# 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 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.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.
