Files
byGalax 672c8738c7 feat: file variety, user status, DND gate, drag-drop, mentions, link preview, emoji picker, soundboard, ringtone
Attachments
- Video: native <video controls>, decrypted blob, metadata preload
- PDF: collapsible <object> viewer with download + preview toggle
- Generic: file card with lazy decrypt, mime-to-extension map
- MessageBubble dispatch: audio/image/video/pdf/generic by mime
- Composer accept widened from image/* to any; AttachmentPreview renders
  non-images as file cards

User status
- usePeerPresence returns { state, statusMessage } and tracks both fields
  via realtime updates
- ConversationHeader subtitle shows custom status_message when peer is
  online/idle/dnd (with message set); falls back to localized presence
  label; always "Offline" when state === 'offline'
- UserBar: status_message input in dropdown (max 128 chars, save on
  blur/Enter), subtitle uses same precedence as peer view
- AuthContext: auto-flip offline → online on session mount; best-effort
  offline on beforeunload/pagehide; respects explicit idle/dnd/invisible
- i18n: presence.status_placeholder (DE + EN)

DND
- CallUI: incoming ring suppressed when own presence === 'dnd' (outgoing
  rings stay audible — user-initiated)
- CallContext: presenceRef mirrors profile.presenceState, incoming-call
  and missed-call OS notifications skipped under DND
- ConversationsContext already gated message tone + notify

Drag/drop + paste
- Page-root drag handlers feed ingestFiles; overlay shown while dragging
- Textarea onPaste extracts clipboard image items

@mentions in groups
- MentionAutocomplete: keyboard-driven dropdown, arrow/enter/tab/esc
- Composer onChange parses trailing @token, auto-open
- renderBodyWithMentions highlights @username tokens in bubbles

Link previews
- Migration 20260421000003_link_previews.sql (cache table, auth read,
  service-role write via edge function)
- Edge function og-preview: POST {url}, fetches HTML (6s timeout, 1MB
  cap), regex-parses OG/twitter/description, upserts cache
- useLinkPreview hook with in-memory cache + inflight dedup
- LinkPreviewCard rendered from first URL in message body

Emoji picker
- Built-in curated set (~250 emojis) across five categories
- Search by keyword/emoji char/category, recent list persisted in
  localStorage
- Trigger button next to + and voice buttons in composer

Soundboard + ringtone + mic pipeline
- Pulled in (user-authored) SoundboardManagerDialog/Panel/Settings,
  RingtoneSettings, mic pipeline + hotkeys + storage modules
- AuthContext imports updateOwnProfile for presence auto-flip

Fixes
- AttachmentAudio min-width so bubbles don't collapse to 0px on peer
  side
- Focus flicker: visibility/online wake refresh throttled to 30s,
  focus listener dropped, loading flag only on first fetch
2026-04-21 09:13:30 +02:00

214 lines
5.9 KiB
TypeScript

// Edge function — fetch OpenGraph/meta tags for a URL and cache the result.
//
// POST /og-preview { "url": "..." }
//
// Returns: { url, title, description, imageUrl, siteName, ok }
//
// Caching: rows live in public.link_previews keyed by URL. Repeat calls
// return the cached row without re-fetching (unless older than MAX_CACHE_AGE_MS).
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const SUPABASE_URL = Deno.env.get('SUPABASE_URL') ?? '';
const SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '';
const MAX_CACHE_AGE_MS = 7 * 24 * 3600 * 1000; // 7 days
const FETCH_TIMEOUT_MS = 6_000;
const MAX_HTML_BYTES = 1_000_000;
const corsHeaders = {
'access-control-allow-origin': '*',
'access-control-allow-headers': 'authorization, x-client-info, apikey, content-type',
'access-control-allow-methods': 'POST, OPTIONS',
};
function json(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json', ...corsHeaders },
});
}
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') return new Response(null, { headers: corsHeaders });
if (req.method !== 'POST') return json({ error: 'method' }, 405);
let body: { url?: string };
try {
body = await req.json();
} catch {
return json({ error: 'bad json' }, 400);
}
const url = body.url;
if (!url || typeof url !== 'string') return json({ error: 'missing url' }, 400);
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return json({ error: 'invalid url' }, 400);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return json({ error: 'unsupported scheme' }, 400);
}
const client = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
// Cache lookup.
const { data: cached } = await client
.from('link_previews')
.select('*')
.eq('url', url)
.maybeSingle();
if (
cached &&
Date.now() - new Date(cached.fetched_at).getTime() < MAX_CACHE_AGE_MS
) {
return json(toClientShape(cached));
}
// Fetch HTML with timeout.
let html = '';
let ok = true;
let error: string | null = null;
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
const res = await fetch(url, {
signal: ctrl.signal,
headers: {
'user-agent': 'Mozilla/5.0 (compatible; LinkPreviewBot/1.0)',
accept: 'text/html,application/xhtml+xml',
},
redirect: 'follow',
});
clearTimeout(timer);
if (!res.ok) throw new Error('status ' + res.status);
const ct = res.headers.get('content-type') ?? '';
if (!ct.includes('html')) throw new Error('not html');
const reader = res.body?.getReader();
if (!reader) throw new Error('no body');
const decoder = new TextDecoder();
let total = 0;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
if (!value) continue;
total += value.byteLength;
html += decoder.decode(value, { stream: true });
if (total >= MAX_HTML_BYTES) {
await reader.cancel();
break;
}
}
} catch (err) {
ok = false;
error = err instanceof Error ? err.message : 'fetch failed';
}
const meta = ok ? parseMeta(html, parsed) : null;
// Upsert cache row so even failures cache briefly.
const row = {
url,
title: meta?.title ?? null,
description: meta?.description ?? null,
image_url: meta?.imageUrl ?? null,
site_name: meta?.siteName ?? null,
ok,
error,
fetched_at: new Date().toISOString(),
};
await client.from('link_previews').upsert(row);
return json(toClientShape(row));
});
interface MetaRow {
url: string;
title: string | null;
description: string | null;
image_url: string | null;
site_name: string | null;
ok: boolean;
}
function toClientShape(row: MetaRow) {
return {
url: row.url,
title: row.title,
description: row.description,
imageUrl: row.image_url,
siteName: row.site_name,
ok: row.ok,
};
}
function parseMeta(html: string, baseUrl: URL): {
title: string | null;
description: string | null;
imageUrl: string | null;
siteName: string | null;
} {
// Cheap regex-based parser — no DOM in Deno runtime without extra deps.
// Extracts <meta property|name="..."> values + <title>.
const getMeta = (...keys: string[]): string | null => {
for (const key of keys) {
const re = new RegExp(
'<meta[^>]+(?:property|name)=[\'"]' +
escapeRegex(key) +
'[\'"][^>]+content=[\'"]([^\'"]+)[\'"]',
'i',
);
const m = re.exec(html);
if (m?.[1]) return decodeHtmlEntities(m[1]);
const re2 = new RegExp(
'<meta[^>]+content=[\'"]([^\'"]+)[\'"][^>]+(?:property|name)=[\'"]' +
escapeRegex(key) +
'[\'"]',
'i',
);
const m2 = re2.exec(html);
if (m2?.[1]) return decodeHtmlEntities(m2[1]);
}
return null;
};
const title =
getMeta('og:title', 'twitter:title') ??
/<title[^>]*>([^<]*)<\/title>/i.exec(html)?.[1]?.trim() ??
null;
const description = getMeta('og:description', 'twitter:description', 'description');
const imageRaw = getMeta('og:image', 'twitter:image', 'twitter:image:src');
const siteName = getMeta('og:site_name') ?? baseUrl.hostname;
let imageUrl: string | null = null;
if (imageRaw) {
try {
imageUrl = new URL(imageRaw, baseUrl).toString();
} catch {
imageUrl = null;
}
}
return {
title: title ? decodeHtmlEntities(title) : null,
description,
imageUrl,
siteName,
};
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function decodeHtmlEntities(s: string): string {
return s
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'");
}