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
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
// 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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
-- ============================================================================
|
||||
-- Cached OpenGraph previews. The edge function `og-preview` writes rows
|
||||
-- server-side (service-role). Clients read via RLS for any authenticated
|
||||
-- user. Rows are keyed by URL so hits across conversations deduplicate.
|
||||
-- ============================================================================
|
||||
create table public.link_previews (
|
||||
url text primary key,
|
||||
title text,
|
||||
description text,
|
||||
image_url text,
|
||||
site_name text,
|
||||
fetched_at timestamptz not null default now(),
|
||||
-- Absence of metadata (404, unreachable, parse failure) still produces a
|
||||
-- cache row so we don't spam the fetcher. `ok = false` signals the UI to
|
||||
-- hide the preview.
|
||||
ok boolean not null default true,
|
||||
error text
|
||||
);
|
||||
|
||||
create index link_previews_fetched_at_idx on public.link_previews (fetched_at);
|
||||
|
||||
alter table public.link_previews enable row level security;
|
||||
|
||||
create policy link_previews_select_authenticated on public.link_previews
|
||||
for select to authenticated
|
||||
using (true);
|
||||
|
||||
-- No INSERT/UPDATE policy: writes flow exclusively through the edge function
|
||||
-- which authenticates with the service-role key.
|
||||
Reference in New Issue
Block a user