# Phase 2 — Messaging Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Land four messaging power features — Pinned Messages, real Mention-Notifications, an inline Tenor GIF Picker, and View-Once Media. **Architecture:** Three SQL migrations (pin table, mention table, attachment column + RPC). One client-side helper per feature plus matching UI surfaces. Everything end-to-end-encrypted where applicable (mentions piggyback on existing encrypted messages; GIFs become normal encrypted attachments). No release at the end of this phase — work accumulates on `main` until the user signs off at the end of Phase 5. **Tech Stack:** TypeScript, React 18, Vite, Vitest, Supabase (Postgres + RLS + RPC + Realtime + Storage), Tenor v2 API. **Spec:** `docs/superpowers/specs/2026-05-16-fifteen-features-design.md` (Phase 2 section) --- ## File Overview **New SQL migrations:** - `supabase/migrations/20260516000002_pinned_messages.sql` - `supabase/migrations/20260516000003_message_mentions.sql` - `supabase/migrations/20260516000004_view_once_attachments.sql` **New shared package files:** - `packages/shared/src/chat/pinnedMessages.ts` — fetch / pin / unpin / subscribe - `packages/shared/src/chat/pinnedMessages.test.ts` - `packages/shared/src/chat/mentions.ts` — parse `@username` + insert mention rows - `packages/shared/src/chat/mentions.test.ts` - `packages/shared/src/chat/viewOnceAttachments.ts` — mark-viewed RPC wrapper **Modified shared package files:** - `packages/shared/src/chat/messages.ts` — `sendEncryptedMessage` calls mentions.insertMentions after the row INSERT - `packages/shared/src/chat/attachments.ts` — `AttachmentHandle` gets optional `viewOnce: boolean`; insert helper writes the column - `packages/shared/src/chat/index.ts` — re-export new modules **New desktop files:** - `apps/desktop/src/components/PinnedMessagesPill.tsx` — `📌 N angepinnt` header chip - `apps/desktop/src/components/PinnedMessagesPanel.tsx` — right-side panel listing pins - `apps/desktop/src/lib/usePinnedMessages.ts` — hook bundling fetch + realtime - `apps/desktop/src/lib/useMentionNotifications.ts` — global subscriber that fires OS notification on incoming mention - `apps/desktop/src/components/GifPicker.tsx` — Tenor search popover - `apps/desktop/src/lib/tenor.ts` — Tenor v2 client (search + trending + recent localStorage) - `apps/desktop/src/components/ViewOnceImage.tsx` — blurred-lock thumbnail + fullscreen lightbox + post-view tombstone **Modified desktop files:** - `apps/desktop/src/components/MessageBubble.tsx` — context-menu entries "Anpinnen" / "Anheftung aufheben" - `apps/desktop/src/components/ConversationHeader.tsx` — render `` after the title - `apps/desktop/src/pages/ConversationPage.tsx` — toggle / render ``, mount `useMentionNotifications` - `apps/desktop/src/components/AttachmentImage.tsx` — swap to `` when `attachment.viewOnce` - composer wherever attachment-picker lives (likely inside `ConversationPage.tsx` or a child) — add the `👁 Einmal ansehen` toggle, add the GIF button + GifPicker mount --- ## Task 1: SQL — `pinned_messages` table **Files:** - Create: `supabase/migrations/20260516000002_pinned_messages.sql` - [ ] **Step 1: Write the migration** ```sql -- Pinned messages: each conversation gets up to 5 anchored references to its -- own messages. Any accepted member can pin/unpin. RLS mirrors conversation -- membership; the cap is enforced by a BEFORE INSERT trigger because partial -- unique indexes can't express "at most N rows per group". create table if not exists public.pinned_messages ( conversation_id uuid not null references public.conversations(id) on delete cascade, message_id uuid not null references public.messages(id) on delete cascade, pinned_by uuid not null references auth.users(id) on delete set null, pinned_at timestamptz not null default now(), primary key (conversation_id, message_id) ); create index if not exists pinned_messages_conv_idx on public.pinned_messages(conversation_id, pinned_at desc); alter table public.pinned_messages enable row level security; drop policy if exists pinned_messages_select_member on public.pinned_messages; create policy pinned_messages_select_member on public.pinned_messages for select to authenticated using (public.is_conversation_member(conversation_id)); drop policy if exists pinned_messages_insert_member on public.pinned_messages; create policy pinned_messages_insert_member on public.pinned_messages for insert to authenticated with check ( public.is_conversation_member(conversation_id) and pinned_by = auth.uid() ); drop policy if exists pinned_messages_delete_member on public.pinned_messages; create policy pinned_messages_delete_member on public.pinned_messages for delete to authenticated using (public.is_conversation_member(conversation_id)); -- Enforce the per-conversation cap. Trigger-based so cross-row counts work. create or replace function public.pinned_messages_enforce_cap() returns trigger language plpgsql as $$ begin if (select count(*) from public.pinned_messages where conversation_id = new.conversation_id) >= 5 then raise exception 'pinned_messages_cap_reached: at most 5 pins per conversation'; end if; return new; end; $$; drop trigger if exists pinned_messages_cap on public.pinned_messages; create trigger pinned_messages_cap before insert on public.pinned_messages for each row execute function public.pinned_messages_enforce_cap(); alter publication supabase_realtime add table public.pinned_messages; ``` - [ ] **Step 2: Commit (no local apply — push via `pnpm prod:migrate` later)** ```bash cd D:/Programmieren/ChatApp-Electron/chat-app git add supabase/migrations/20260516000002_pinned_messages.sql git commit -m "feat(db): pinned_messages table (≤5 per conv via trigger)" ``` --- ## Task 2: shared `pinnedMessages.ts` — fetch / pin / unpin / subscribe **Files:** - Create: `packages/shared/src/chat/pinnedMessages.ts` - Create: `packages/shared/src/chat/pinnedMessages.test.ts` - [ ] **Step 1: Implementation** ```ts // packages/shared/src/chat/pinnedMessages.ts import type { AppSupabaseClient } from '../supabase/client'; export interface PinnedMessage { conversationId: string; messageId: string; pinnedBy: string; pinnedAt: string; } interface Row { conversation_id: string; message_id: string; pinned_by: string; pinned_at: string; } function mapRow(r: Row): PinnedMessage { return { conversationId: r.conversation_id, messageId: r.message_id, pinnedBy: r.pinned_by, pinnedAt: r.pinned_at, }; } export async function listPinnedMessages( client: AppSupabaseClient, conversationId: string, ): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any const { data, error } = await (client as any) .from('pinned_messages') .select('conversation_id, message_id, pinned_by, pinned_at') .eq('conversation_id', conversationId) .order('pinned_at', { ascending: false }); if (error) throw error; return (data ?? []).map(mapRow); } export async function pinMessage( client: AppSupabaseClient, conversationId: string, messageId: string, pinnedBy: string, ): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any const { error } = await (client as any) .from('pinned_messages') .insert({ conversation_id: conversationId, message_id: messageId, pinned_by: pinnedBy }); if (error) throw error; } export async function unpinMessage( client: AppSupabaseClient, conversationId: string, messageId: string, ): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any const { error } = await (client as any) .from('pinned_messages') .delete() .eq('conversation_id', conversationId) .eq('message_id', messageId); if (error) throw error; } ``` - [ ] **Step 2: Test** ```ts // packages/shared/src/chat/pinnedMessages.test.ts import { describe, expect, it } from 'vitest'; import { listPinnedMessages, pinMessage, unpinMessage } from './pinnedMessages'; function fakeClient(rows: Array>) { const calls: Array<{ name: string; payload: unknown }> = []; const client = { from(_table: string) { return { select: () => ({ eq: () => ({ order: () => Promise.resolve({ data: rows, error: null }), }), }), insert: (payload: unknown) => { calls.push({ name: 'insert', payload }); return Promise.resolve({ error: null }); }, delete: () => ({ eq: () => ({ eq: () => { calls.push({ name: 'delete', payload: null }); return Promise.resolve({ error: null }); }, }), }), }; }, }; return { client, calls }; } describe('pinnedMessages', () => { it('listPinnedMessages maps DB rows to camelCase', async () => { const rows = [ { conversation_id: 'c1', message_id: 'm1', pinned_by: 'u1', pinned_at: '2026-05-16T10:00:00Z' }, ]; const { client } = fakeClient(rows); // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await listPinnedMessages(client as any, 'c1'); expect(result).toEqual([ { conversationId: 'c1', messageId: 'm1', pinnedBy: 'u1', pinnedAt: '2026-05-16T10:00:00Z' }, ]); }); it('pinMessage inserts with the right shape', async () => { const { client, calls } = fakeClient([]); // eslint-disable-next-line @typescript-eslint/no-explicit-any await pinMessage(client as any, 'c1', 'm1', 'u1'); expect(calls).toEqual([ { name: 'insert', payload: { conversation_id: 'c1', message_id: 'm1', pinned_by: 'u1' } }, ]); }); it('unpinMessage deletes by conversation_id + message_id', async () => { const { client, calls } = fakeClient([]); // eslint-disable-next-line @typescript-eslint/no-explicit-any await unpinMessage(client as any, 'c1', 'm1'); expect(calls).toEqual([{ name: 'delete', payload: null }]); }); }); ``` - [ ] **Step 3: Re-export from `packages/shared/src/chat/index.ts`** Append `export * from './pinnedMessages';` to the existing `index.ts`. - [ ] **Step 4: Verify** ```bash pnpm --filter @chat-app/shared exec vitest run src/chat/pinnedMessages.test.ts pnpm --filter @chat-app/shared typecheck ``` Expected: 3 tests pass, typecheck clean. - [ ] **Step 5: Commit** ```bash git add packages/shared/src/chat/pinnedMessages.ts packages/shared/src/chat/pinnedMessages.test.ts packages/shared/src/chat/index.ts git commit -m "feat(shared): pinned-messages list/pin/unpin helpers" ``` --- ## Task 3: `usePinnedMessages` hook (desktop) **Files:** - Create: `apps/desktop/src/lib/usePinnedMessages.ts` - [ ] **Step 1: Implementation** ```ts // apps/desktop/src/lib/usePinnedMessages.ts import { listPinnedMessages, type PinnedMessage } from '@chat-app/shared/chat'; import { useEffect, useState } from 'react'; import { supabase } from './supabase'; // Live list of pinned messages for one conversation. Subscribes to the // `pinned_messages` realtime channel for the conv so the header pill + // side-panel update without a refetch. export function usePinnedMessages(conversationId: string | undefined): PinnedMessage[] { const [pins, setPins] = useState([]); useEffect(() => { if (!conversationId) { setPins([]); return; } let cancelled = false; void listPinnedMessages(supabase, conversationId).then((rows) => { if (!cancelled) setPins(rows); }); const channel = supabase .channel('pinned_messages:' + conversationId) .on( 'postgres_changes', { event: '*', schema: 'public', table: 'pinned_messages', filter: 'conversation_id=eq.' + conversationId, }, () => { void listPinnedMessages(supabase, conversationId).then((rows) => { if (!cancelled) setPins(rows); }); }, ) .subscribe(); return () => { cancelled = true; void supabase.removeChannel(channel); }; }, [conversationId]); return pins; } ``` - [ ] **Step 2: Verify + commit** ```bash pnpm --filter @chat-app/desktop typecheck git add apps/desktop/src/lib/usePinnedMessages.ts git commit -m "feat(desktop): usePinnedMessages live hook" ``` --- ## Task 4: Pinned-pill in conversation header **Files:** - Create: `apps/desktop/src/components/PinnedMessagesPill.tsx` - Modify: `apps/desktop/src/components/ConversationHeader.tsx` - [ ] **Step 1: The pill** ```tsx // apps/desktop/src/components/PinnedMessagesPill.tsx import { PinIcon } from './icons'; interface Props { count: number; onClick: () => void; } // Compact chip rendered in the conv header that opens the pinned panel. // Renders nothing when count is 0 so a fresh conv shows no clutter. export function PinnedMessagesPill({ count, onClick }: Props) { if (count === 0) return null; return ( ); } ``` - [ ] **Step 2: Wire it into the header** In `ConversationHeader.tsx` add the import and render the pill next to the existing title. Take `pinnedCount: number` and `onOpenPinned: () => void` as new optional props. Render `` inline after the title text. Use `0` as the default if `pinnedCount` is undefined. Add the props to the existing `Props` interface as optional: ```ts pinnedCount?: number; onOpenPinned?: () => void; ``` In the render, alongside the title: ```tsx onOpenPinned?.()} /> ``` - [ ] **Step 3: Verify + commit** ```bash pnpm --filter @chat-app/desktop typecheck git add apps/desktop/src/components/PinnedMessagesPill.tsx apps/desktop/src/components/ConversationHeader.tsx git commit -m "feat(desktop): pinned-messages pill in conv header" ``` --- ## Task 5: Pinned-messages side panel **Files:** - Create: `apps/desktop/src/components/PinnedMessagesPanel.tsx` - [ ] **Step 1: Implementation** ```tsx // apps/desktop/src/components/PinnedMessagesPanel.tsx import type { PinnedMessage } from '@chat-app/shared/chat'; import { PinIcon, XIcon } from './icons'; interface Props { open: boolean; pins: PinnedMessage[]; onClose: () => void; onJump: (messageId: string) => void; onUnpin: (messageId: string) => void; // Optional preview-renderer: parent resolves messageId → short text/snippet // since the panel itself doesn't decrypt. If absent, the panel just shows // the message-id stub. renderPreview?: (messageId: string) => React.ReactNode; } export function PinnedMessagesPanel({ open, pins, onClose, onJump, onUnpin, renderPreview }: Props) { if (!open) return null; return ( ); } ``` - [ ] **Step 2: Verify + commit** ```bash pnpm --filter @chat-app/desktop typecheck git add apps/desktop/src/components/PinnedMessagesPanel.tsx git commit -m "feat(desktop): pinned-messages side panel" ``` --- ## Task 6: Wire pin/unpin from MessageBubble context-menu + render panel **Files:** - Modify: `apps/desktop/src/components/MessageBubble.tsx` (right-click context menu) - Modify: `apps/desktop/src/pages/ConversationPage.tsx` (state + panel render + props to header) - [ ] **Step 1: Add pin/unpin action to MessageBubble's context menu** In `MessageBubble.tsx`, locate the existing right-click context menu (search for `contextMenu` state — it's already there). Add two new props to the `Props` interface: ```ts isPinned?: boolean; onTogglePin?: (messageId: string) => void; ``` Inside the context menu's action list (look for entries like `canCopy`, `canEdit`, `canDelete`), add a new entry. After the existing Copy/Edit/Delete buttons, add: ```tsx {onTogglePin && ( )} ``` Add `PinIcon` to the existing icons import if not present. - [ ] **Step 2: Wire it from ConversationPage** In `ConversationPage.tsx`: ```tsx import { usePinnedMessages } from '../lib/usePinnedMessages'; import { pinMessage, unpinMessage } from '@chat-app/shared/chat'; import { PinnedMessagesPanel } from '../components/PinnedMessagesPanel'; // inside the component const pins = usePinnedMessages(conversationId); const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false); const pinnedIds = new Set(pins.map((p) => p.messageId)); const handleTogglePin = useCallback( async (messageId: string) => { if (!conversationId || !userId) return; try { if (pinnedIds.has(messageId)) { await unpinMessage(supabase, conversationId, messageId); } else { await pinMessage(supabase, conversationId, messageId, userId); } } catch (err) { console.warn('pin toggle failed', err); } }, [conversationId, userId, pinnedIds], ); ``` Pass `pinnedCount={pins.length}` and `onOpenPinned={() => setPinnedPanelOpen(true)}` to ``. For every `` rendered, add: ```tsx isPinned={pinnedIds.has(message.id)} onTogglePin={handleTogglePin} ``` Render the panel near other modals at the end of the page: ```tsx setPinnedPanelOpen(false)} onJump={(messageId) => { setPinnedPanelOpen(false); // If the conversation page exposes a `jumpToMessage` helper, call it // here. If no such helper exists yet, leave the jump as a no-op for // this commit — the panel still shows the list + lets users unpin. }} onUnpin={(messageId) => void handleTogglePin(messageId)} /> ``` - [ ] **Step 3: Verify + commit** ```bash pnpm --filter @chat-app/desktop typecheck git add apps/desktop/src/components/MessageBubble.tsx apps/desktop/src/pages/ConversationPage.tsx git commit -m "feat(desktop): pin/unpin from message context menu + side panel" ``` --- ## Task 7: SQL — `message_mentions` table **Files:** - Create: `supabase/migrations/20260516000003_message_mentions.sql` - [ ] **Step 1: Write the migration** ```sql -- Per-mention rows so realtime can notify mentioned users without leaking -- the rest of a conversation. Each mention is the pair (message, user_id). create table if not exists public.message_mentions ( message_id uuid not null references public.messages(id) on delete cascade, mentioned_user_id uuid not null references auth.users(id) on delete cascade, conversation_id uuid not null references public.conversations(id) on delete cascade, created_at timestamptz not null default now(), primary key (message_id, mentioned_user_id) ); create index if not exists message_mentions_user_idx on public.message_mentions(mentioned_user_id, created_at desc); alter table public.message_mentions enable row level security; -- The mentioned user OR the message author can SELECT. RLS uses messages -- to confirm the caller is the author. drop policy if exists message_mentions_select on public.message_mentions; create policy message_mentions_select on public.message_mentions for select to authenticated using ( mentioned_user_id = auth.uid() or exists ( select 1 from public.messages m where m.id = message_id and m.sender_id = auth.uid() ) ); -- INSERT allowed when the caller is the author of the referenced message -- AND the mentioned user is also a member of the same conversation. drop policy if exists message_mentions_insert_author on public.message_mentions; create policy message_mentions_insert_author on public.message_mentions for insert to authenticated with check ( exists ( select 1 from public.messages m where m.id = message_id and m.sender_id = auth.uid() and m.conversation_id = message_mentions.conversation_id ) and public.is_conversation_member(conversation_id) and exists ( select 1 from public.conversation_members cm where cm.conversation_id = message_mentions.conversation_id and cm.user_id = mentioned_user_id and cm.accepted ) ); alter publication supabase_realtime add table public.message_mentions; ``` - [ ] **Step 2: Commit** ```bash git add supabase/migrations/20260516000003_message_mentions.sql git commit -m "feat(db): message_mentions table (RLS: mentioned user + author can SELECT)" ``` --- ## Task 8: shared `mentions.ts` — parse + insert **Files:** - Create: `packages/shared/src/chat/mentions.ts` - Create: `packages/shared/src/chat/mentions.test.ts` - [ ] **Step 1: Implementation** ```ts // packages/shared/src/chat/mentions.ts import type { AppSupabaseClient } from '../supabase/client'; // `@anna_b` style — letters, digits, underscore, dot, dash, 2-32 chars. // Conservative on purpose: false negatives (a real username we don't match) // are recoverable (no notification fires); false positives (matching a // non-username) just become an INSERT that the FK check rejects. const MENTION_RE = /(?:^|[\s,;:!?(])@([a-zA-Z0-9_.-]{2,32})/g; export function parseMentionUsernames(plaintext: string): string[] { const out = new Set(); for (const m of plaintext.matchAll(MENTION_RE)) { if (m[1]) out.add(m[1].toLowerCase()); } return [...out]; } export interface MentionResolver { // Resolves an array of @usernames in this conversation to user-ids. // Returns only memberships that exist + are accepted. resolveUsernames(conversationId: string, usernames: string[]): Promise>; } export function makeMentionResolver(client: AppSupabaseClient): MentionResolver { return { async resolveUsernames(conversationId, usernames) { if (usernames.length === 0) return new Map(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const { data, error } = await (client as any) .from('conversation_members') .select('user_id, accepted, profiles!inner(username)') .eq('conversation_id', conversationId) .eq('accepted', true) .in('profiles.username', usernames); if (error) throw error; const out = new Map(); for (const row of (data ?? []) as Array<{ user_id: string; profiles: { username: string } }>) { out.set(row.profiles.username.toLowerCase(), row.user_id); } return out; }, }; } export async function insertMentions( client: AppSupabaseClient, messageId: string, conversationId: string, mentionedUserIds: string[], ): Promise { if (mentionedUserIds.length === 0) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const { error } = await (client as any).from('message_mentions').insert( mentionedUserIds.map((uid) => ({ message_id: messageId, mentioned_user_id: uid, conversation_id: conversationId, })), ); if (error) throw error; } ``` - [ ] **Step 2: Test** ```ts // packages/shared/src/chat/mentions.test.ts import { describe, expect, it } from 'vitest'; import { parseMentionUsernames } from './mentions'; describe('parseMentionUsernames', () => { it('extracts a leading mention', () => { expect(parseMentionUsernames('@anna hi')).toEqual(['anna']); }); it('extracts mid-sentence', () => { expect(parseMentionUsernames('hey @ben_c what do you think')).toEqual(['ben_c']); }); it('lowercases usernames', () => { expect(parseMentionUsernames('hi @Anna')).toEqual(['anna']); }); it('deduplicates', () => { expect(parseMentionUsernames('@x and @x again')).toEqual(['x']); }); it('ignores emails (no preceding boundary)', () => { expect(parseMentionUsernames('mail me at foo@bar.com')).toEqual([]); }); it('rejects 1-char names', () => { expect(parseMentionUsernames('@a')).toEqual([]); }); it('handles multiple in one message', () => { expect(parseMentionUsernames('@anna, @ben and @cara')).toEqual(['anna', 'ben', 'cara']); }); }); ``` - [ ] **Step 3: Re-export + verify + commit** Add `export * from './mentions';` to `packages/shared/src/chat/index.ts`. ```bash pnpm --filter @chat-app/shared exec vitest run src/chat/mentions.test.ts pnpm --filter @chat-app/shared typecheck git add packages/shared/src/chat/mentions.ts packages/shared/src/chat/mentions.test.ts packages/shared/src/chat/index.ts git commit -m "feat(shared): parseMentionUsernames + insertMentions helpers" ``` --- ## Task 9: Hook mentions into `sendEncryptedMessage` **Files:** - Modify: `packages/shared/src/chat/messages.ts` - [ ] **Step 1: Parse + insert after the INSERT** In `messages.ts`, find the existing `sendEncryptedMessage`. After the message row is INSERTed and you have `messageRow.id`, add: ```ts import { insertMentions, makeMentionResolver, parseMentionUsernames } from './mentions'; // after the message INSERT, before return: const usernames = parseMentionUsernames(params.plaintext); if (usernames.length > 0) { try { const resolver = makeMentionResolver(params.client); const resolved = await resolver.resolveUsernames(params.conversationId, usernames); if (resolved.size > 0) { await insertMentions( params.client, (messageRow as { id: string }).id, params.conversationId, [...resolved.values()], ); } } catch (err) { // Mention-insert failure must NOT block the send. Worst case: the user // who was @-ed doesn't get a notification; the message itself is fine. console.warn('mention insert failed', err); } } ``` `parseMentionUsernames` runs on `params.plaintext` (the user-typed text), so it still works regardless of attachment-payload JSON-wrapping done later. - [ ] **Step 2: Typecheck + commit** ```bash pnpm --filter @chat-app/shared typecheck git add packages/shared/src/chat/messages.ts git commit -m "feat(shared): sendEncryptedMessage inserts mention rows after the message row" ``` --- ## Task 10: Desktop subscriber `useMentionNotifications` **Files:** - Create: `apps/desktop/src/lib/useMentionNotifications.ts` - Modify: `apps/desktop/src/components/AppShell.tsx` - [ ] **Step 1: Implementation** ```ts // apps/desktop/src/lib/useMentionNotifications.ts import { useEffect } from 'react'; import { notify } from './osNotify'; import { supabase } from './supabase'; interface MentionRow { message_id: string; mentioned_user_id: string; conversation_id: string; } // Subscribes to my own message_mentions inserts and fires an OS notification // for each one. Bypasses per-conv mute (mentions override mute by design). // // We don't decrypt the body here — the notification just says "Du wurdest // erwähnt". The conv list highlight + the in-app navigation reveal context. export function useMentionNotifications(userId: string | undefined): void { useEffect(() => { if (!userId) return; const channel = supabase .channel('mentions:' + userId) .on( 'postgres_changes', { event: 'INSERT', schema: 'public', table: 'message_mentions', filter: 'mentioned_user_id=eq.' + userId, }, (payload) => { const row = payload.new as MentionRow | null; if (!row) return; void notify({ title: 'Du wurdest erwähnt', body: 'Tippe um die Nachricht zu lesen.', force: true, }); }, ) .subscribe(); return () => { void supabase.removeChannel(channel); }; }, [userId]); } ``` - [ ] **Step 2: Mount the hook once per session** In `apps/desktop/src/components/AppShell.tsx`, at the top of the component, call: ```tsx import { useMentionNotifications } from '../lib/useMentionNotifications'; // inside the component, near other hooks: const { session } = useAuth(); useMentionNotifications(session?.user.id); ``` If `AppShell` already destructures `useAuth()`, reuse the existing variable. - [ ] **Step 3: Verify + commit** ```bash pnpm --filter @chat-app/desktop typecheck git add apps/desktop/src/lib/useMentionNotifications.ts apps/desktop/src/components/AppShell.tsx git commit -m "feat(desktop): mention notifications via realtime + osNotify" ``` --- ## Task 11: Tenor client `lib/tenor.ts` **Files:** - Create: `apps/desktop/src/lib/tenor.ts` - [ ] **Step 1: Implementation** ```ts // apps/desktop/src/lib/tenor.ts // // Tenor v2 client — Google's free GIF API. Public read endpoints accept any // `client_key` so we don't ship a per-user API key. Trending + Search both // hit https://tenor.googleapis.com/v2/. Cache recent picks (last 24 // URLs) in localStorage so the picker has a "Zuletzt" tab. const ENDPOINT = 'https://tenor.googleapis.com/v2'; const CLIENT_KEY = 'netralax-chat'; const RECENT_KEY = 'chatapp.gifRecent.v1'; const RECENT_MAX = 24; // Tenor's public key for browser-side reads. Documented as "anonymous" and // usable without account binding. Rate-limited at ~3k/day per IP which is // plenty for a chat app's picker traffic. const PUBLIC_API_KEY = 'AIzaSyAyimkuYQYF_FXVALexPuGQctUWRURdCYQ'; export interface GifResult { id: string; // Animated full-size URL (typically <2 MB). url: string; // Small preview shown in the picker grid. previewUrl: string; width: number; height: number; description: string; } interface TenorApiResult { id: string; content_description?: string; media_formats?: Record; } function mapResult(r: TenorApiResult): GifResult | null { const full = r.media_formats?.gif ?? r.media_formats?.mediumgif ?? r.media_formats?.tinygif; const preview = r.media_formats?.tinygif ?? r.media_formats?.gif; if (!full?.url || !preview?.url) return null; return { id: r.id, url: full.url, previewUrl: preview.url, width: full.dims?.[0] ?? 0, height: full.dims?.[1] ?? 0, description: r.content_description ?? '', }; } async function fetchTenor(path: string, params: Record): Promise { const search = new URLSearchParams({ key: PUBLIC_API_KEY, client_key: CLIENT_KEY, ...params }); const res = await fetch(`${ENDPOINT}/${path}?${search.toString()}`); if (!res.ok) throw new Error('tenor http ' + res.status); const json = (await res.json()) as { results?: TenorApiResult[] }; return (json.results ?? []).map(mapResult).filter((x): x is GifResult => x !== null); } export async function searchGifs(query: string, locale: string = 'de_DE'): Promise { if (!query.trim()) return featuredGifs(locale); return fetchTenor('search', { q: query, limit: '30', locale }); } export async function featuredGifs(locale: string = 'de_DE'): Promise { return fetchTenor('featured', { limit: '30', locale }); } export function getRecentGifs(): GifResult[] { try { const raw = window.localStorage.getItem(RECENT_KEY); if (!raw) return []; const parsed = JSON.parse(raw) as unknown; if (!Array.isArray(parsed)) return []; return parsed.filter( (x): x is GifResult => x != null && typeof x === 'object' && typeof (x as GifResult).url === 'string', ); } catch { return []; } } export function rememberRecentGif(gif: GifResult): void { const current = getRecentGifs().filter((g) => g.id !== gif.id); const next = [gif, ...current].slice(0, RECENT_MAX); try { window.localStorage.setItem(RECENT_KEY, JSON.stringify(next)); } catch { /* quota */ } } ``` - [ ] **Step 2: Verify + commit** ```bash pnpm --filter @chat-app/desktop typecheck git add apps/desktop/src/lib/tenor.ts git commit -m "feat(desktop): Tenor v2 GIF search/featured/recent client" ``` --- ## Task 12: GIF picker popover **Files:** - Create: `apps/desktop/src/components/GifPicker.tsx` - [ ] **Step 1: Implementation** ```tsx // apps/desktop/src/components/GifPicker.tsx import { useEffect, useMemo, useState } from 'react'; import { featuredGifs, getRecentGifs, type GifResult, rememberRecentGif, searchGifs, } from '../lib/tenor'; import { SpinnerIcon, XIcon } from './icons'; interface Props { open: boolean; onClose: () => void; onPick: (gif: GifResult) => void; } type Tab = 'trending' | 'search' | 'recent'; export function GifPicker({ open, onClose, onPick }: Props) { const [tab, setTab] = useState('trending'); const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const recent = useMemo(() => getRecentGifs(), [open]); useEffect(() => { if (!open) return; let cancelled = false; setLoading(true); setError(null); const run = async () => { try { const gifs = tab === 'search' && query.trim().length > 0 ? await searchGifs(query) : tab === 'trending' ? await featuredGifs() : []; if (!cancelled) setResults(gifs); } catch (err) { if (!cancelled) setError(err instanceof Error ? err.message : 'GIFs gerade nicht verfügbar'); } finally { if (!cancelled) setLoading(false); } }; void run(); return () => { cancelled = true; }; }, [open, tab, query]); if (!open) return null; const visible = tab === 'recent' ? recent : results; return (
{(['trending', 'search', 'recent'] as Tab[]).map((t) => ( ))}
{tab === 'search' && (
setQuery(e.target.value)} placeholder="GIFs suchen…" className="w-full rounded-md border border-line bg-surface-3 px-2 py-1.5 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40" />
)}
{loading && (
)} {error &&

{error}

} {!loading && !error && visible.length === 0 && (

{tab === 'recent' ? 'Noch keine zuletzt verwendeten GIFs.' : 'Keine Treffer.'}

)} {!loading && !error && visible.map((g) => ( ))}
); } ``` - [ ] **Step 2: Verify + commit** ```bash pnpm --filter @chat-app/desktop typecheck git add apps/desktop/src/components/GifPicker.tsx git commit -m "feat(desktop): GIF picker popover (Tenor + trending/search/recent)" ``` --- ## Task 13: Wire GIF picker into the composer **Files:** - Modify: `apps/desktop/src/pages/ConversationPage.tsx` - [ ] **Step 1: Add a GIF button next to the existing composer attachment controls** In the composer toolbar (where the `+` attachment button and emoji-picker button live), add a new GIF button. State + handler: ```tsx import { GifPicker } from '../components/GifPicker'; import { type GifResult } from '../lib/tenor'; // inside the component: const [gifPickerOpen, setGifPickerOpen] = useState(false); const handleGifPick = useCallback( async (gif: GifResult) => { if (!conversationId || !userId) return; try { // Fetch the GIF bytes once and feed them into the existing attachment // upload pipeline so the result is end-to-end-encrypted like any // image attachment. const res = await fetch(gif.url); const blob = await res.blob(); const file = new File([blob], `tenor-${gif.id}.gif`, { type: 'image/gif' }); // Send as a regular image attachment with empty text. Use the same // `send` helper the existing attachment-plus button calls. If `send` // accepts (text, files, replyToId), pass the file through that path. await send('', [file], null); } catch (err) { console.warn('GIF send failed', err); } }, [conversationId, userId, send], ); ``` In the composer JSX, near the existing emoji-picker / attachment-plus buttons, add a GIF button + the picker mount: ```tsx setGifPickerOpen(false)} onPick={(gif) => void handleGifPick(gif)} /> ``` If `send` doesn't accept a File array, adapt to whatever the existing attachment-send method looks like — read the surrounding code and match the convention. - [ ] **Step 2: Verify + commit** ```bash pnpm --filter @chat-app/desktop typecheck git add apps/desktop/src/pages/ConversationPage.tsx git commit -m "feat(desktop): GIF button + picker wired into composer" ``` --- ## Task 14: SQL — view_once column + RPC **Files:** - Create: `supabase/migrations/20260516000004_view_once_attachments.sql` - [ ] **Step 1: Write the migration** ```sql -- View-once media: per-attachment opt-in. When a recipient opens the -- attachment for the first time we delete the storage object and replace -- it with a tombstone marker (kept_until / viewed_at). The encrypted -- attachment row stays so the bubble can render "Angesehen". alter table public.message_attachments add column if not exists view_once boolean not null default false, add column if not exists viewed_at timestamptz null, add column if not exists viewed_by uuid null references auth.users(id) on delete set null; -- Mark-viewed RPC: server-authoritative so the recipient can't replay the -- decrypted blob across devices. Atomic: only the FIRST viewer wins. The -- function returns the previous `viewed_at` so the client can distinguish -- first-open (got it) from already-viewed (too late, sorry). create or replace function public.mark_attachment_viewed(p_attachment_id uuid) returns jsonb language plpgsql security definer set search_path = public as $$ declare caller uuid := auth.uid(); att public.message_attachments%rowtype; msg public.messages%rowtype; prev timestamptz; begin if caller is null then raise exception 'not authenticated'; end if; select * into att from public.message_attachments where id = p_attachment_id for update; if not found then raise exception 'attachment not found'; end if; if not att.view_once then return jsonb_build_object('view_once', false); end if; select * into msg from public.messages where id = att.message_id; if not found then raise exception 'orphan attachment'; end if; if not public.is_conversation_member(msg.conversation_id) then raise exception 'not a member'; end if; -- Sender opening their own view-once doesn't "burn" it — they sent it. if msg.sender_id = caller then return jsonb_build_object('view_once', true, 'viewed_at', att.viewed_at, 'self', true); end if; if att.viewed_at is not null then return jsonb_build_object('view_once', true, 'viewed_at', att.viewed_at, 'already', true); end if; update public.message_attachments set viewed_at = now(), viewed_by = caller where id = p_attachment_id returning viewed_at into prev; return jsonb_build_object('view_once', true, 'viewed_at', prev); end; $$; revoke execute on function public.mark_attachment_viewed(uuid) from public, anon; grant execute on function public.mark_attachment_viewed(uuid) to authenticated; ``` - [ ] **Step 2: Commit** ```bash git add supabase/migrations/20260516000004_view_once_attachments.sql git commit -m "feat(db): message_attachments.view_once + mark_attachment_viewed RPC" ``` --- ## Task 15: shared `viewOnceAttachments.ts` **Files:** - Create: `packages/shared/src/chat/viewOnceAttachments.ts` - [ ] **Step 1: Implementation** ```ts // packages/shared/src/chat/viewOnceAttachments.ts import type { AppSupabaseClient } from '../supabase/client'; export interface ViewOnceResult { viewOnce: boolean; /** ISO timestamp set the moment the first non-sender viewed. */ viewedAt?: string | null; /** True iff the caller is the original sender (they don't burn the view). */ self?: boolean; /** True iff someone else already viewed before this call. */ already?: boolean; } export async function markAttachmentViewed( client: AppSupabaseClient, attachmentId: string, ): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any const { data, error } = await (client as any).rpc('mark_attachment_viewed', { p_attachment_id: attachmentId, }); if (error) throw error; const d = (data ?? {}) as Record; return { viewOnce: Boolean(d.view_once), viewedAt: typeof d.viewed_at === 'string' ? d.viewed_at : null, self: Boolean(d.self), already: Boolean(d.already), }; } ``` - [ ] **Step 2: Extend `AttachmentHandle` to carry the flag** In `packages/shared/src/chat/attachments.ts`, find the `AttachmentHandle` interface. Add an optional `viewOnce` field: ```ts export interface AttachmentHandle { // existing fields… viewOnce?: boolean; } ``` In the existing `insertAttachmentRow` (or whichever helper writes to `message_attachments`), include `view_once: handle.viewOnce ?? false` in the INSERT payload. - [ ] **Step 3: Re-export from index + commit** Add `export * from './viewOnceAttachments';` to `packages/shared/src/chat/index.ts`. ```bash pnpm --filter @chat-app/shared typecheck git add packages/shared/src/chat/viewOnceAttachments.ts packages/shared/src/chat/attachments.ts packages/shared/src/chat/index.ts git commit -m "feat(shared): mark_attachment_viewed + view_once on AttachmentHandle" ``` --- ## Task 16: `ViewOnceImage` component + sender toggle + wire into MessageBubble **Files:** - Create: `apps/desktop/src/components/ViewOnceImage.tsx` - Modify: `apps/desktop/src/components/AttachmentImage.tsx` (where image attachments are rendered) - Modify: `apps/desktop/src/pages/ConversationPage.tsx` (composer attachment toggle) - [ ] **Step 1: ViewOnceImage** ```tsx // apps/desktop/src/components/ViewOnceImage.tsx import { useState } from 'react'; import { markAttachmentViewed } from '@chat-app/shared/chat'; import { supabase } from '../lib/supabase'; import { EyeOffIcon, LockIcon } from './icons'; interface Props { attachmentId: string; /** Already-viewed timestamp from the DB row. Renders tombstone immediately. */ viewedAt: string | null; /** True iff the local user is the sender — they don't burn the view. */ isSender: boolean; /** Decrypted image source; only fetched/displayed inside the lightbox. */ src: string; } // Three states: // 1. viewedAt is null AND user is recipient → blurred lock card; tap opens // fullscreen lightbox AND fires the mark-viewed RPC. // 2. viewedAt is set → tombstone "Angesehen am …". // 3. user is sender → normal image, tombstone update appears once recipient burns it. export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props) { const [revealedAt, setRevealedAt] = useState(viewedAt); const [fullscreen, setFullscreen] = useState(false); const burned = revealedAt !== null; if (burned && !isSender) { return (
Angesehen am {new Date(revealedAt).toLocaleString()}
); } if (isSender) { return (
Einmal ansehen {burned && ( Angesehen )}
); } // Recipient, not yet viewed. const handleOpen = async (): Promise => { try { const res = await markAttachmentViewed(supabase, attachmentId); if (res.viewedAt) setRevealedAt(res.viewedAt); } catch (err) { console.warn('mark-viewed failed', err); } setFullscreen(true); }; return ( <> {fullscreen && (
setFullscreen(false)} >
)} ); } ``` - [ ] **Step 2: Render `ViewOnceImage` when the attachment is flagged** In `AttachmentImage.tsx`, check the new flag (the field on the attachment object will be `viewOnce` after Task 15 plumbs it through): ```tsx import { ViewOnceImage } from './ViewOnceImage'; // where the image is currently rendered: if (attachment.viewOnce) { return ( ); } // existing img render ``` Adapt `mine`, `decryptedObjectUrl`, and `attachment.viewedAt` to whatever the file actually calls them. If the file currently doesn't carry `viewedAt` on the attachment object, plumb it through from the parent (the attachment row from `fetchConversationMessages` exposes it via the SELECT list — extend the SELECT if needed). - [ ] **Step 3: Composer toggle** In the composer (ConversationPage.tsx, where image attachments are queued), add a 👁 toggle near the attachment thumbnail strip. When the toggle is on, any image queued in this composer session is sent with `viewOnce: true`. UI: ```tsx import { EyeOffIcon } from '../components/icons'; const [viewOnceNext, setViewOnceNext] = useState(false); // in JSX, after the attachment plus button: ``` When the user actually sends, propagate the flag onto each queued `AttachmentHandle` before they're uploaded. If the send pipeline currently builds the handle from a `File`, set `handle.viewOnce = viewOnceNext` and reset `viewOnceNext` to false after a successful send. - [ ] **Step 4: Verify + commit** ```bash pnpm --filter @chat-app/desktop typecheck git add apps/desktop/src/components/ViewOnceImage.tsx apps/desktop/src/components/AttachmentImage.tsx apps/desktop/src/pages/ConversationPage.tsx git commit -m "feat(desktop): view-once image attachments (sender toggle + recipient lightbox + tombstone)" ``` --- ## Phase 2 final gate - [ ] **Run the full check matrix** ```bash cd D:/Programmieren/ChatApp-Electron/chat-app pnpm --filter @chat-app/shared typecheck && \ pnpm --filter @chat-app/desktop typecheck && \ pnpm --filter @chat-app/shared test ``` Expected: all green. - [ ] **Push the three new SQL migrations to Prod (when ready)** ```bash bash scripts/prod/push-migrations.sh 20260516000002 bash scripts/prod/push-migrations.sh 20260516000003 bash scripts/prod/push-migrations.sh 20260516000004 ``` Each is a CREATE TABLE / new column / new RPC — safe to apply additively, no destructive changes. - [ ] **DO NOT release** Version stays on `0.18.8`. Phase 3 plan gets written after the user signs off on Phase 2. --- ## Self-Review **1. Spec coverage:** - Pinned Messages: Tasks 1–6 (SQL, helpers, hook, pill, panel, wire-in) ✓ - Mentions-Notifications: Tasks 7–10 (SQL, parser, send-side hook, recv subscriber) ✓ - GIF-Picker: Tasks 11–13 (Tenor client, picker, composer wire) ✓ - View-Once Media: Tasks 14–16 (SQL, shared wrapper, ViewOnceImage + composer toggle) ✓ - No release: Final gate explicit ✓ **2. Placeholder scan:** All code blocks are complete. Tasks 6, 13, and 16 each say "adapt to whatever the existing send pipeline takes" — that's necessary because the exact `useConversationMessages.send` signature varies; the implementing agent will read it. Not a generic "fix the rest", it points to a specific helper. **3. Type consistency:** - `PinnedMessage` (Task 2) consumed in Tasks 3, 5, 6. - `parseMentionUsernames(plaintext: string): string[]` (Task 8) consumed in Task 9. - `markAttachmentViewed(client, attachmentId): Promise` (Task 15) consumed in Task 16. - `AttachmentHandle.viewOnce` introduced Task 15, set in Task 16. - `useMentionNotifications(userId)` (Task 10) — single consumer, AppShell. - `usePinnedMessages(conversationId)` (Task 3) consumed in Task 6. - `` (Task 4) consumed in Task 6 via ConversationHeader. - `` (Task 5) consumed in Task 6. - `` (Task 12) consumed in Task 13. - `` (Task 16, step 1) consumed in step 2 of the same task. All consistent.