~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
143 lines · 4.8 KB Raw History
import { writable, get } from "svelte/store";
import { callXrpc } from "$hatk/client";
import { Images, Users, Camera, MapPin, Hash, Pin, Sparkles, UsersRound } from "lucide-svelte";
import { applyStoredTheme } from "./theme";

export interface PinnedFeed {
  id: string;
  label: string;
  type: string;
  path: string;
}

/**
 * Feeds that only work on a PDS serving permissioned spaces.
 *
 * Groups are pools, pools are spaces, and every read of one mints a delegation
 * token on the viewer's own PDS — so a viewer whose server has no spaces cannot
 * see a pool however they arrive at it. Hidden rather than shown-and-broken.
 *
 * Filtered where feeds are rendered, never removed from the stored pins: a
 * server can gain spaces, and someone who had Groups pinned before should find
 * it there again rather than having quietly lost it.
 */
export const SPACES_ONLY_FEEDS = new Set(["groups"]);

export const DEFAULT_PINNED: PinnedFeed[] = [
  { id: "recent", label: "Recent", type: "feed", path: "/feeds/recent" },
  { id: "following", label: "Following", type: "feed", path: "/feeds/following" },
  { id: "foryou", label: "For You", type: "feed", path: "/feeds/for-you" },
  { id: "groups", label: "Groups", type: "feed", path: "/feeds/groups" },
];

/* eslint-disable @typescript-eslint/no-explicit-any */
const CORE_ICONS: Record<string, any> = {
  recent: Images,
  following: Users,
  foryou: Sparkles,
  groups: UsersRound,
};

const TYPE_ICONS: Record<string, any> = {
  camera: Camera,
  location: MapPin,
  hashtag: Hash,
};

export function feedIcon(feed: { id: string; type: string }): any {
  return CORE_ICONS[feed.id] ?? TYPE_ICONS[feed.type] ?? Pin;
}

export const pinnedFeeds = writable<PinnedFeed[]>(DEFAULT_PINNED);
export const includeExif = writable(true);
export const includeLocation = writable(true);

function isValidFeed(f: unknown): f is PinnedFeed {
  return (
    !!f &&
    typeof f === "object" &&
    typeof (f as any).id === "string" &&
    typeof (f as any).label === "string" &&
    typeof (f as any).path === "string"
  );
}

export function loadPreferences(prefs: Record<string, unknown> | null): void {
  if (!prefs) return;
  if (Array.isArray(prefs.pinnedFeeds)) {
    const valid = prefs.pinnedFeeds
      .filter(isValidFeed)
      .map((f) => (f.id === "recent" && f.path === "/" ? { ...f, path: "/feeds/recent" } : f));
    // Dedupe by id. pinFeed refuses to add a duplicate, but nothing validates
    // the stored blob — an older client, the native app, or a racing write
    // could persist one. Sidebar, MobileDrawer and /feeds all key their
    // {#each} on feed.id, and the sidebar renders on every page, so a repeat
    // would throw each_key_duplicate and blank the entire app.
    const seen = new Set<string>();
    const unique = valid.filter((f) => (seen.has(f.id) ? false : (seen.add(f.id), true)));
    if (unique.length > 0) pinnedFeeds.set(unique);
  }
  if (typeof prefs.includeExif === "boolean") includeExif.set(prefs.includeExif);
  if (typeof prefs.includeLocation === "boolean") includeLocation.set(prefs.includeLocation);
  applyStoredTheme(prefs.theme);
}

export async function setIncludeExif(value: boolean): Promise<void> {
  includeExif.set(value);
  await callXrpc("dev.hatk.putPreference", { key: "includeExif", value });
}

export async function setIncludeLocation(value: boolean): Promise<void> {
  includeLocation.set(value);
  await callXrpc("dev.hatk.putPreference", { key: "includeLocation", value });
}

export async function pinFeed(feed: PinnedFeed): Promise<boolean> {
  const previous = get(pinnedFeeds);
  if (previous.some((f) => f.id === feed.id)) return false;
  const updated = [...previous, feed];
  pinnedFeeds.set(updated);
  try {
    await callXrpc("dev.hatk.putPreference", { key: "pinnedFeeds", value: updated });
  } catch {
    pinnedFeeds.set(previous);
  }
  return true;
}

export async function unpinFeed(id: string): Promise<boolean> {
  const previous = get(pinnedFeeds);
  const updated = previous.filter((f) => f.id !== id);
  if (updated.length === previous.length) return false;
  if (updated.length === 0) return false;
  pinnedFeeds.set(updated);
  try {
    await callXrpc("dev.hatk.putPreference", { key: "pinnedFeeds", value: updated });
  } catch {
    pinnedFeeds.set(previous);
  }
  return true;
}

export async function reorderFeeds(feeds: PinnedFeed[]): Promise<void> {
  const previous = get(pinnedFeeds);
  pinnedFeeds.set(feeds);
  try {
    await callXrpc("dev.hatk.putPreference", { key: "pinnedFeeds", value: feeds });
  } catch {
    pinnedFeeds.set(previous);
  }
}

export function resetPreferences(): void {
  pinnedFeeds.set(DEFAULT_PINNED);
  includeExif.set(true);
  includeLocation.set(true);
}

export async function markNotificationsSeen(): Promise<void> {
  await callXrpc("dev.hatk.putPreference", {
    key: "lastSeenNotifications",
    value: new Date().toISOString(),
  });
}