feedback-free.openapps.xyz

Source

This app’s code and revision history are public. Download a copy or explore the files below.

Public
The app’s code and revision history are public.
Owned app · rev 4a4c54cd95c6
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 54b9c9d7fdc2b68029ce2e57
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
← All files
functions/lib/feedback.ts
// Logic shared by the web app and the public API: statuses, tags, sorting, search and ids.

export const STATUSES = ["new", "review", "planned", "progress", "done", "closed"] as const;
export type Status = (typeof STATUSES)[number];

export const STATUS_LABEL: Record<Status, string> = {
  new: "New",
  review: "Under review",
  planned: "Planned",
  progress: "In progress",
  done: "Done",
  closed: "Closed",
};

/** The roadmap's columns, left to right. */
export const ROADMAP: Status[] = ["review", "planned", "progress", "done"];

/** Statuses still in play: what a board shows by default. */
export const ACTIVE: Status[] = ["new", "review", "planned", "progress"];

export const LIMITS = {
  boardName: 80,
  intro: 1000,
  tagName: 24,
  tags: 20,
  title: 140,
  details: 5000,
  name: 60,
  comment: 3000,
  entryTitle: 140,
  entryBody: 10000,
  entryPosts: 50,
};

export const SORTS = ["top", "trending", "new"] as const;
export type Sort = (typeof SORTS)[number];
export const SORT_LABEL: Record<Sort, string> = { top: "Top", trending: "Trending", new: "New" };

export interface Tag { id: string; name: string }

export interface Board { name: string; intro: string; tags: string; createdBy: string; createdAt: number }

export interface Post {
  id: string;
  title: string;
  details: string;
  tags: string;
  status: Status;
  statusAt: number;
  votes: number;
  comments: number;
  lastComment?: string | null;
  pinned: boolean;
  hidden: boolean;
  merged?: string | null;
  name: string;
  by: string;
  createdAt: number;
}

export interface Comment { id: string; body: string; name: string; team: boolean; by: string; at: number }

export interface Entry { id: string; title: string; body: string; posts: string; at: number; by: string }

export const DEFAULT_TAGS = ["Idea", "Bug"];

const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";

export function randomId(length = 20): string {
  const bytes = crypto.getRandomValues(new Uint8Array(length));
  let out = "";
  for (const b of bytes) out += ALPHABET[b % ALPHABET.length];
  return out;
}

export const isStatus = (s: unknown): s is Status => STATUSES.includes(s as Status);

export function parseTags(raw: string | null | undefined): Tag[] {
  try {
    const list = JSON.parse(raw || "[]");
    return Array.isArray(list) ? list.filter((t) => t && typeof t.id === "string" && typeof t.name === "string") : [];
  } catch {
    return [];
  }
}

export function parseIds(raw: string | null | undefined): string[] {
  try {
    const list = JSON.parse(raw || "[]");
    return Array.isArray(list) ? list.filter((x): x is string => typeof x === "string") : [];
  } catch {
    return [];
  }
}

/** Tag names cleaned up: trimmed, one space between words, no duplicates, within limits. */
export function cleanTagNames(names: string[]): string[] {
  const seen = new Set<string>();
  const out: string[] = [];
  for (const raw of names) {
    const name = String(raw ?? "").replace(/\s+/g, " ").trim().slice(0, LIMITS.tagName);
    if (!name || seen.has(name.toLowerCase())) continue;
    seen.add(name.toLowerCase());
    out.push(name);
  }
  return out.slice(0, LIMITS.tags);
}

/** "Trending": votes and comments, weighed by age, so a newer post gaining votes rises above an old favorite. */
export function trendingScore(p: Pick<Post, "votes" | "comments" | "createdAt">, now: number): number {
  const hours = Math.max(0, now - p.createdAt) / 3600;
  return (p.votes + p.comments / 2) / Math.pow(hours + 2, 1.4);
}

/** Sorts posts for a list: pinned ones first, then by the chosen order. */
export function sortPosts<T extends Post>(posts: T[], sort: Sort, now = Math.floor(Date.now() / 1000)): T[] {
  const key = (p: T) =>
    sort === "top" ? p.votes : sort === "new" ? p.createdAt : trendingScore(p, now);
  return [...posts].sort((a, b) =>
    Number(b.pinned) - Number(a.pinned) || key(b) - key(a) || b.votes - a.votes || b.createdAt - a.createdAt);
}

const words = (s: string) => s.toLowerCase().normalize("NFKD").replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter((w) => w.length > 1);

/** True when every word of the search appears in the post's title, details or tag names. */
export function matchesSearch(p: Pick<Post, "title" | "details">, q: string, tagNames = ""): boolean {
  const want = words(q);
  if (!want.length) return true;
  const hay = words(`${p.title} ${p.details} ${tagNames}`).join(" ");
  return want.every((w) => hay.includes(w));
}

const STOP = new Set(["the", "a", "an", "to", "for", "of", "and", "or", "in", "on", "is", "it", "be", "with", "add", "please", "can", "would", "should", "could", "we", "you", "my", "me", "i", "able", "allow", "support", "option", "feature"]);

/** Posts whose titles share meaningful words with a draft title, to suggest voting instead of posting a duplicate. */
export function similarPosts<T extends Post>(posts: T[], title: string, max = 3): T[] {
  const mine = new Set(words(title).filter((w) => !STOP.has(w) && w.length > 2));
  if (!mine.size) return [];
  return posts
    .map((p) => {
      const theirs = new Set(words(p.title).filter((w) => !STOP.has(w) && w.length > 2));
      let shared = 0;
      for (const w of mine) if (theirs.has(w) || [...theirs].some((t) => t.startsWith(w) || w.startsWith(t))) shared++;
      return { p, score: shared / Math.max(mine.size, 1) };
    })
    .filter((x) => x.score >= 0.5)
    .sort((a, b) => b.score - a.score || b.p.votes - a.p.votes)
    .slice(0, max)
    .map((x) => x.p);
}

/** Posts a list shows: not merged away. Hidden ones only reach owners and their authors (the read rule decides). */
export const listed = <T extends Post>(posts: T[]) => posts.filter((p) => !p.merged);

Change history

Point store links to openapps.free
4a4c54cd95 · 18 hours ago
Release with preview
1d0728818d · 19 hours ago
Retry release
1abc843b3f · 19 hours ago
Bind app agent address in policy
00eb15f50d · 19 hours ago
Use OpenApps service URLs and add contribution strip
347a7b8ca0 · 19 hours ago
Bounded source sync
a0aaceae63 · 1 day ago
Bounded source sync
3125b8113f · 1 day ago
Bounded source sync
882a76ba95 · 1 day ago
Bounded source sync
852d9782ee · 1 day ago
Bounded source sync
a8ee705109 · 1 day ago