53 KiB
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.sqlsupabase/migrations/20260516000003_message_mentions.sqlsupabase/migrations/20260516000004_view_once_attachments.sql
New shared package files:
packages/shared/src/chat/pinnedMessages.ts— fetch / pin / unpin / subscribepackages/shared/src/chat/pinnedMessages.test.tspackages/shared/src/chat/mentions.ts— parse@username+ insert mention rowspackages/shared/src/chat/mentions.test.tspackages/shared/src/chat/viewOnceAttachments.ts— mark-viewed RPC wrapper
Modified shared package files:
packages/shared/src/chat/messages.ts—sendEncryptedMessagecalls mentions.insertMentions after the row INSERTpackages/shared/src/chat/attachments.ts—AttachmentHandlegets optionalviewOnce: boolean; insert helper writes the columnpackages/shared/src/chat/index.ts— re-export new modules
New desktop files:
apps/desktop/src/components/PinnedMessagesPill.tsx—📌 N angepinntheader chipapps/desktop/src/components/PinnedMessagesPanel.tsx— right-side panel listing pinsapps/desktop/src/lib/usePinnedMessages.ts— hook bundling fetch + realtimeapps/desktop/src/lib/useMentionNotifications.ts— global subscriber that fires OS notification on incoming mentionapps/desktop/src/components/GifPicker.tsx— Tenor search popoverapps/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<PinnedMessagesPill>after the titleapps/desktop/src/pages/ConversationPage.tsx— toggle / render<PinnedMessagesPanel>, mountuseMentionNotificationsapps/desktop/src/components/AttachmentImage.tsx— swap to<ViewOnceImage>whenattachment.viewOnce- composer wherever attachment-picker lives (likely inside
ConversationPage.tsxor a child) — add the👁 Einmal ansehentoggle, 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
-- 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:migratelater)
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
// 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<PinnedMessage[]> {
// 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<void> {
// 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<void> {
// 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
// packages/shared/src/chat/pinnedMessages.test.ts
import { describe, expect, it } from 'vitest';
import { listPinnedMessages, pinMessage, unpinMessage } from './pinnedMessages';
function fakeClient(rows: Array<Record<string, unknown>>) {
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
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
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
// 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<PinnedMessage[]>([]);
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
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
// 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 (
<button
type="button"
onClick={onClick}
className="inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-full border border-line bg-surface-3 px-2.5 text-xs font-medium text-fg-muted transition hover:bg-surface-2 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
title="Angepinnte Nachrichten anzeigen"
>
<PinIcon className="h-3 w-3 text-accent" />
<span>{count} angepinnt</span>
</button>
);
}
- 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 <PinnedMessagesPill count={pinnedCount} onClick={onOpenPinned} /> inline after the title text. Use 0 as the default if pinnedCount is undefined.
Add the props to the existing Props interface as optional:
pinnedCount?: number;
onOpenPinned?: () => void;
In the render, alongside the title:
<PinnedMessagesPill count={pinnedCount ?? 0} onClick={() => onOpenPinned?.()} />
- Step 3: Verify + commit
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
// 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 (
<aside
role="complementary"
aria-label="Angepinnte Nachrichten"
className="absolute right-0 top-0 z-30 flex h-full w-80 flex-col border-l border-line bg-surface-2 shadow-xl"
>
<header className="flex items-center justify-between border-b border-line px-4 py-3">
<div className="flex items-center gap-2">
<PinIcon className="h-4 w-4 text-accent" />
<h3 className="text-sm font-semibold text-fg">Angepinnt · {pins.length}</h3>
</div>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
>
<XIcon className="h-4 w-4" />
</button>
</header>
{pins.length === 0 ? (
<p className="p-6 text-center text-xs text-fg-muted">Noch nichts angepinnt.</p>
) : (
<ul className="flex-1 overflow-y-auto">
{pins.map((p) => (
<li key={p.messageId} className="border-b border-line/60 px-4 py-3">
<button
type="button"
onClick={() => onJump(p.messageId)}
className="block w-full cursor-pointer text-left text-sm text-fg hover:text-accent"
>
{renderPreview ? renderPreview(p.messageId) : <span className="font-mono text-xs">{p.messageId.slice(0, 8)}</span>}
</button>
<div className="mt-1 flex items-center justify-between text-[11px] text-fg-muted">
<time dateTime={p.pinnedAt}>{new Date(p.pinnedAt).toLocaleString()}</time>
<button
type="button"
onClick={() => onUnpin(p.messageId)}
className="cursor-pointer text-rose-400 hover:underline"
>
Anheftung entfernen
</button>
</div>
</li>
))}
</ul>
)}
</aside>
);
}
- Step 2: Verify + commit
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:
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:
{onTogglePin && (
<button
type="button"
onClick={() => { onTogglePin(message.id); setContextMenu(null); }}
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-sm hover:bg-surface-3"
>
<PinIcon className="h-4 w-4 text-fg-muted" />
<span>{isPinned ? 'Anheftung entfernen' : 'Anpinnen'}</span>
</button>
)}
Add PinIcon to the existing icons import if not present.
- Step 2: Wire it from ConversationPage
In ConversationPage.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 <ConversationHeader>.
For every <MessageBubble> rendered, add:
isPinned={pinnedIds.has(message.id)}
onTogglePin={handleTogglePin}
Render the panel near other modals at the end of the page:
<PinnedMessagesPanel
open={pinnedPanelOpen}
pins={pins}
onClose={() => 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
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
-- 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
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
// 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<string>();
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<Map<string, string>>;
}
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<string, string>();
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<void> {
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
// 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.
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:
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
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
// 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:
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
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
// 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/<path>. 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<string, { url: string; dims?: [number, number] }>;
}
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<string, string>): Promise<GifResult[]> {
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<GifResult[]> {
if (!query.trim()) return featuredGifs(locale);
return fetchTenor('search', { q: query, limit: '30', locale });
}
export async function featuredGifs(locale: string = 'de_DE'): Promise<GifResult[]> {
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
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
// 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<Tab>('trending');
const [query, setQuery] = useState('');
const [results, setResults] = useState<GifResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="absolute bottom-full left-0 z-30 mb-2 w-[440px] rounded-2xl border border-line bg-surface-2 shadow-xl">
<header className="flex items-center justify-between border-b border-line px-3 py-2">
<div className="flex items-center gap-1">
{(['trending', 'search', 'recent'] as Tab[]).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={
'cursor-pointer rounded-md px-2 py-1 text-xs font-medium transition ' +
(t === tab ? 'bg-accent/15 text-accent' : 'text-fg-muted hover:text-fg')
}
>
{t === 'trending' ? 'Trending' : t === 'search' ? 'Suche' : 'Zuletzt'}
</button>
))}
</div>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
>
<XIcon className="h-3.5 w-3.5" />
</button>
</header>
{tab === 'search' && (
<div className="border-b border-line px-3 py-2">
<input
type="text"
value={query}
onChange={(e) => 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"
/>
</div>
)}
<div className="grid max-h-[360px] grid-cols-3 gap-1.5 overflow-y-auto p-2">
{loading && (
<div className="col-span-3 flex justify-center py-6 text-fg-muted">
<SpinnerIcon className="h-5 w-5" />
</div>
)}
{error && <p className="col-span-3 px-2 py-4 text-center text-xs text-rose-300">{error}</p>}
{!loading && !error && visible.length === 0 && (
<p className="col-span-3 px-2 py-6 text-center text-xs text-fg-muted">
{tab === 'recent' ? 'Noch keine zuletzt verwendeten GIFs.' : 'Keine Treffer.'}
</p>
)}
{!loading && !error && visible.map((g) => (
<button
key={g.id}
type="button"
onClick={() => { rememberRecentGif(g); onPick(g); onClose(); }}
className="overflow-hidden rounded-md border border-line bg-surface-3 hover:border-accent/40"
title={g.description}
>
<img src={g.previewUrl} alt={g.description || 'GIF'} className="h-24 w-full object-cover" loading="lazy" />
</button>
))}
</div>
</div>
);
}
- Step 2: Verify + commit
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:
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:
<button
type="button"
onClick={() => setGifPickerOpen((v) => !v)}
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
aria-label="GIF"
title="GIF einfügen"
>
<span className="text-xs font-bold">GIF</span>
</button>
<GifPicker
open={gifPickerOpen}
onClose={() => 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
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
-- 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
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
// 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<ViewOnceResult> {
// 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<string, unknown>;
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
AttachmentHandleto carry the flag
In packages/shared/src/chat/attachments.ts, find the AttachmentHandle interface. Add an optional viewOnce field:
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.
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
// 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<string | null>(viewedAt);
const [fullscreen, setFullscreen] = useState(false);
const burned = revealedAt !== null;
if (burned && !isSender) {
return (
<div className="flex h-32 w-48 items-center justify-center rounded-lg border border-dashed border-line bg-surface-3 text-xs text-fg-muted">
<EyeOffIcon className="mr-2 h-4 w-4" />
Angesehen am {new Date(revealedAt).toLocaleString()}
</div>
);
}
if (isSender) {
return (
<div className="relative">
<img src={src} alt="" className="max-h-72 rounded-lg" />
<span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-black/60 px-2 py-0.5 text-[10px] font-semibold text-white">
<EyeOffIcon className="h-3 w-3" /> Einmal ansehen
</span>
{burned && (
<span className="absolute right-2 bottom-2 rounded-full bg-emerald-500/80 px-2 py-0.5 text-[10px] font-semibold text-white">
Angesehen
</span>
)}
</div>
);
}
// Recipient, not yet viewed.
const handleOpen = async (): Promise<void> => {
try {
const res = await markAttachmentViewed(supabase, attachmentId);
if (res.viewedAt) setRevealedAt(res.viewedAt);
} catch (err) {
console.warn('mark-viewed failed', err);
}
setFullscreen(true);
};
return (
<>
<button
type="button"
onClick={() => void handleOpen()}
className="relative flex h-48 w-64 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-3 text-fg-muted hover:border-accent/40"
>
<LockIcon className="h-6 w-6 text-accent" />
<span className="text-xs font-medium">Einmal ansehen — antippen</span>
</button>
{fullscreen && (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-6"
onClick={() => setFullscreen(false)}
>
<img src={src} alt="" className="max-h-full max-w-full rounded-lg" />
</div>
)}
</>
);
}
- Step 2: Render
ViewOnceImagewhen 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):
import { ViewOnceImage } from './ViewOnceImage';
// where the image is currently rendered:
if (attachment.viewOnce) {
return (
<ViewOnceImage
attachmentId={attachment.id}
viewedAt={attachment.viewedAt ?? null}
isSender={mine}
src={decryptedObjectUrl}
/>
);
}
// 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:
import { EyeOffIcon } from '../components/icons';
const [viewOnceNext, setViewOnceNext] = useState(false);
// in JSX, after the attachment plus button:
<button
type="button"
onClick={() => setViewOnceNext((v) => !v)}
aria-pressed={viewOnceNext}
title={viewOnceNext ? 'Nächstes Bild: einmal ansehen' : 'Nächstes Bild: normal'}
className={
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-md transition ' +
(viewOnceNext
? 'bg-accent/20 text-accent'
: 'text-fg-muted hover:bg-surface-3 hover:text-fg')
}
>
<EyeOffIcon className="h-4 w-4" />
</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
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
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 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<ViewOnceResult>(Task 15) consumed in Task 16.AttachmentHandle.viewOnceintroduced Task 15, set in Task 16.useMentionNotifications(userId)(Task 10) — single consumer, AppShell.usePinnedMessages(conversationId)(Task 3) consumed in Task 6.<PinnedMessagesPill>(Task 4) consumed in Task 6 via ConversationHeader.<PinnedMessagesPanel>(Task 5) consumed in Task 6.<GifPicker>(Task 12) consumed in Task 13.<ViewOnceImage>(Task 16, step 1) consumed in step 2 of the same task.
All consistent.