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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
73 lines · 2.9 KB Raw History
/** Build a blob URL for a profile image. Returns pre-resolved URLs as-is. */
export function blobUrl(did: string, blobRef: unknown): string | null {
  if (!blobRef) return null;
  if (typeof blobRef === "string") {
    if (blobRef.startsWith("http")) return blobRef;
    try {
      blobRef = JSON.parse(blobRef);
    } catch {
      return null;
    }
  }
  const p = blobRef as any;
  if (!p?.ref?.$link) return null;
  return `https://cdn.bsky.app/img/avatar/plain/${did}/${p.ref.$link}@jpeg`;
}

/** Truncate a DID for display. */
export function truncDid(did: string): string {
  if (did.length <= 24) return did;
  return did.slice(0, 12) + "\u2026" + did.slice(-6);
}

/** Compact relative time matching grain-native: now, 2m, 3h, 4d, 1w, then Mon DD. */
export function relativeTime(iso: string): string {
  const date = new Date(iso);
  const diff = (Date.now() - date.getTime()) / 1000;

  if (diff < 60) return "now";
  if (diff < 3600) return `${Math.floor(diff / 60)}m`;
  if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
  if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
  if (diff < 2592000) return `${Math.floor(diff / 604800)}w`;

  return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}

/** Compact number formatting: 1K, 1.2M, etc. */
export function compactCount(n: number): string {
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
  if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}K`;
  return String(n);
}

// ─── Links ──────────────────────────────────────────────────────────

type Actor = { did?: string | null; handle?: string | null } | null | undefined;

const rkeyOf = (uri: string) => uri.split("/").pop() ?? "";

/**
 * The segment that names an account in a URL. Handles are what people read
 * and share, so an account with one is linked by it. An account without one,
 * or with Bluesky's `handle.invalid` placeholder, or whose view fell back to
 * echoing its DID as the handle, is linked by DID instead. Every profile route
 * accepts either.
 */
export function actorSegment(p: Actor): string {
  const handle = p?.handle;
  if (handle && handle !== "handle.invalid" && !handle.startsWith("did:")) return handle;
  return p?.did ?? "";
}

export const profilePath = (p: Actor) => `/profile/${actorSegment(p)}`;

/** `/profile/:actor/gallery/:rkey`. Falls back to the DID in the URI when no creator view came along. */
export function galleryPath(g: { uri: string; creator?: Actor }): string {
  return `${profilePath(g.creator ?? { did: g.uri.split("/")[2] })}/gallery/${rkeyOf(g.uri)}`;
}

/** `/profile/:actor/story/:rkey`, same shape as `galleryPath`. */
export function storyPath(s: { uri: string; creator?: Actor }): string {
  return `${profilePath(s.creator ?? { did: s.uri.split("/")[2] })}/story/${rkeyOf(s.uri)}`;
}