import { ref, onMounted } from 'vue'; import { useRealtime } from './useRealtime'; import { useWallet, applyWalletFrame } from './useWallet'; import { setPerks, applyPerksFrame, type Perks } from './usePerks'; import { bumpAdsRevision } from './useAds'; import { handleAlertFrame } from './useAlert'; // Module-level singleton so any component can read the viewer's own perks // without prop-drilling (e.g. SendButton, AdBand). export const myPerks = ref({}); export function useMyPerks() { return { myPerks }; } export interface GeoInfo { country: string; countryCode: string; city: string; lat?: number; lon?: number; } export interface Reply { id: string; content: string; authorIp: string; createdAt: string; parentId?: string | null; authorPerks?: Perks; authorGeo?: GeoInfo | null; richMode?: 'none' | 'htmlcss' | 'js'; richContent?: string | null; attachments?: Attachment[]; } export interface Attachment { id: string; filename: string; mimeType: string; size: number; } export interface Message extends Reply { parentId: string | null; replies: Reply[]; } const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000'; /** * Refresh the viewer's own perks from the server (callable from anywhere). * The backend computes the perks (entitlement.kind → Perks) and returns them * precomputed as `myPerks`, so we just adopt them — no client-side re-derivation. */ export async function refreshMyPerks(): Promise { try { const res = await fetch(`${API_URL}/api/shop/me`); if (!res.ok) return; const { myPerks: p } = (await res.json()) as { myPerks?: Perks }; myPerks.value = p ?? {}; const { ip } = useWallet(); if (ip.value) setPerks(ip.value, myPerks.value); } catch { /* ignore */ } } export function useMessages() { const messages = ref([]); const loading = ref(false); const sending = ref(false); /** Seed the perks store from a message + its replies. */ function harvestPerks(m: Message): void { setPerks(m.authorIp, m.authorPerks); for (const r of m.replies ?? []) setPerks(r.authorIp, r.authorPerks); } async function fetchMessages(): Promise { loading.value = true; try { const res = await fetch(`${API_URL}/api/messages`); if (res.ok) { // API returns newest→oldest; reverse for chronological display. const list = ((await res.json()) as Message[]).reverse(); list.forEach(harvestPerks); messages.value = list; } } finally { loading.value = false; } } /** Add a message pushed over the WebSocket (new thread or reply), with dedup. */ function addIncoming(raw: Message & { parentId: string | null }): void { if (!raw || !raw.id) return; // Always record the author's perks, even for replies. setPerks(raw.authorIp, raw.authorPerks); if (raw.parentId == null) { // New top-level thread. if (messages.value.some((m) => m.id === raw.id)) return; messages.value.push({ ...raw, replies: raw.replies ?? [] }); return; } // Reply: attach to its parent thread if we have it. const parent = messages.value.find((m) => m.id === raw.parentId); if (!parent) return; // thread not loaded; reconnect-resync will reconcile if (parent.replies.some((r) => r.id === raw.id)) return; parent.replies.push({ id: raw.id, content: raw.content, authorIp: raw.authorIp, createdAt: raw.createdAt, parentId: raw.parentId, authorPerks: raw.authorPerks, authorGeo: raw.authorGeo, richMode: raw.richMode, richContent: raw.richContent, attachments: raw.attachments, }); } const { fetchWallet, ip: myIp } = useWallet(); // The viewer's own perks (drives NoAds gating, rich-composer unlocks, etc.). // myPerks is module-level; this ref is the same reference. async function fetchMyPerks(): Promise { return refreshMyPerks(); } const { stats, connected, sendTyping } = useRealtime({ onMessage: addIncoming, onReconnect: () => { fetchMessages(); fetchWallet(); fetchMyPerks(); }, onWallet: applyWalletFrame, onPerks: (data: { ip: string; perks: Perks }) => { applyPerksFrame(data); // If it's about us, update myPerks too (viewer-scoped perks like NoAds). if (myIp.value && data.ip === myIp.value) myPerks.value = data.perks ?? {}; }, onAds: () => bumpAdsRevision(), // a user ad entered rotation → refetch onAlert: (data) => handleAlertFrame(data), // paid global audio alert }); interface PostExtras { parentId?: string; richMode?: 'htmlcss' | 'js'; richContent?: string; attachmentIds?: string[]; } async function postMessage(content: string, extras: PostExtras = {}): Promise { const hasRich = !!extras.richContent && !!extras.richMode; const hasFiles = !!extras.attachmentIds?.length; // Allow empty text only when there's rich content or an attachment. if (!content.trim() && !hasRich && !hasFiles) return false; sending.value = true; try { const res = await fetch(`${API_URL}/api/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: content.trim(), parentId: extras.parentId, richMode: extras.richMode, richContent: extras.richContent, attachmentIds: extras.attachmentIds, }), }); if (!res.ok) return false; // The created message comes back via the WebSocket broadcast, so no // re-fetch here. Fallback: if the socket is down, add it locally. if (!connected.value) { const created = (await res.json()) as Message; addIncoming( created.parentId == null ? { ...created, replies: [] } : created ); } return true; } finally { sending.value = false; } } onMounted(() => { fetchMessages(); fetchWallet(); fetchMyPerks(); }); // Note: viewer-own perks live in the module-level `myPerks` singleton; read // them via `useMyPerks()` rather than off this return (consistency rule). return { messages, loading, sending, postMessage, stats, connected, sendTyping, myIp, fetchMyPerks, }; }