Files
ChatApp/docs/superpowers/plans/2026-05-16-phase2-messaging.md

1607 lines
53 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<PinnedMessagesPill>` after the title
- `apps/desktop/src/pages/ConversationPage.tsx` — toggle / render `<PinnedMessagesPanel>`, mount `useMentionNotifications`
- `apps/desktop/src/components/AttachmentImage.tsx` — swap to `<ViewOnceImage>` 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<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**
```ts
// 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**
```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<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**
```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 (
<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:
```ts
pinnedCount?: number;
onOpenPinned?: () => void;
```
In the render, alongside the title:
```tsx
<PinnedMessagesPill count={pinnedCount ?? 0} onClick={() => 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 (
<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**
```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 && (
<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`:
```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:
```tsx
isPinned={pinnedIds.has(message.id)}
onTogglePin={handleTogglePin}
```
Render the panel near other modals at the end of the page:
```tsx
<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**
```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<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**
```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/<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**
```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<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**
```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
<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**
```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<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 `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<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 `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 (
<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:
```tsx
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**
```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 16 (SQL, helpers, hook, pill, panel, wire-in) ✓
- Mentions-Notifications: Tasks 710 (SQL, parser, send-side hook, recv subscriber) ✓
- GIF-Picker: Tasks 1113 (Tenor client, picker, composer wire) ✓
- View-Once Media: Tasks 1416 (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.viewOnce` introduced 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.