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

53 KiB
Raw Permalink Blame History

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.tssendEncryptedMessage calls mentions.insertMentions after the row INSERT
  • packages/shared/src/chat/attachments.tsAttachmentHandle 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

-- 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)
cd D:/Programmieren/ChatApp-Electron/chat-app
git add supabase/migrations/20260516000002_pinned_messages.sql
git commit -m "feat(db): pinned_messages table (≤5 per conv via trigger)"

Task 2: shared pinnedMessages.ts — fetch / pin / unpin / subscribe

Files:

  • Create: packages/shared/src/chat/pinnedMessages.ts

  • Create: packages/shared/src/chat/pinnedMessages.test.ts

  • Step 1: Implementation

// packages/shared/src/chat/pinnedMessages.ts
import type { AppSupabaseClient } from '../supabase/client';

export interface PinnedMessage {
  conversationId: string;
  messageId: string;
  pinnedBy: string;
  pinnedAt: string;
}

interface Row {
  conversation_id: string;
  message_id: string;
  pinned_by: string;
  pinned_at: string;
}

function mapRow(r: Row): PinnedMessage {
  return {
    conversationId: r.conversation_id,
    messageId: r.message_id,
    pinnedBy: r.pinned_by,
    pinnedAt: r.pinned_at,
  };
}

export async function listPinnedMessages(
  client: AppSupabaseClient,
  conversationId: string,
): Promise<PinnedMessage[]> {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const { data, error } = await (client as any)
    .from('pinned_messages')
    .select('conversation_id, message_id, pinned_by, pinned_at')
    .eq('conversation_id', conversationId)
    .order('pinned_at', { ascending: false });
  if (error) throw error;
  return (data ?? []).map(mapRow);
}

export async function pinMessage(
  client: AppSupabaseClient,
  conversationId: string,
  messageId: string,
  pinnedBy: string,
): Promise<void> {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const { error } = await (client as any)
    .from('pinned_messages')
    .insert({ conversation_id: conversationId, message_id: messageId, pinned_by: pinnedBy });
  if (error) throw error;
}

export async function unpinMessage(
  client: AppSupabaseClient,
  conversationId: string,
  messageId: string,
): Promise<void> {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const { error } = await (client as any)
    .from('pinned_messages')
    .delete()
    .eq('conversation_id', conversationId)
    .eq('message_id', messageId);
  if (error) throw error;
}
  • Step 2: Test
// packages/shared/src/chat/pinnedMessages.test.ts
import { describe, expect, it } from 'vitest';

import { listPinnedMessages, pinMessage, unpinMessage } from './pinnedMessages';

function fakeClient(rows: Array<Record<string, unknown>>) {
  const calls: Array<{ name: string; payload: unknown }> = [];
  const client = {
    from(_table: string) {
      return {
        select: () => ({
          eq: () => ({
            order: () => Promise.resolve({ data: rows, error: null }),
          }),
        }),
        insert: (payload: unknown) => {
          calls.push({ name: 'insert', payload });
          return Promise.resolve({ error: null });
        },
        delete: () => ({
          eq: () => ({
            eq: () => {
              calls.push({ name: 'delete', payload: null });
              return Promise.resolve({ error: null });
            },
          }),
        }),
      };
    },
  };
  return { client, calls };
}

describe('pinnedMessages', () => {
  it('listPinnedMessages maps DB rows to camelCase', async () => {
    const rows = [
      { conversation_id: 'c1', message_id: 'm1', pinned_by: 'u1', pinned_at: '2026-05-16T10:00:00Z' },
    ];
    const { client } = fakeClient(rows);
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const result = await listPinnedMessages(client as any, 'c1');
    expect(result).toEqual([
      { conversationId: 'c1', messageId: 'm1', pinnedBy: 'u1', pinnedAt: '2026-05-16T10:00:00Z' },
    ]);
  });

  it('pinMessage inserts with the right shape', async () => {
    const { client, calls } = fakeClient([]);
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    await pinMessage(client as any, 'c1', 'm1', 'u1');
    expect(calls).toEqual([
      { name: 'insert', payload: { conversation_id: 'c1', message_id: 'm1', pinned_by: 'u1' } },
    ]);
  });

  it('unpinMessage deletes by conversation_id + message_id', async () => {
    const { client, calls } = fakeClient([]);
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    await unpinMessage(client as any, 'c1', 'm1');
    expect(calls).toEqual([{ name: 'delete', payload: null }]);
  });
});
  • Step 3: Re-export from packages/shared/src/chat/index.ts

Append export * from './pinnedMessages'; to the existing index.ts.

  • Step 4: Verify
pnpm --filter @chat-app/shared exec vitest run src/chat/pinnedMessages.test.ts
pnpm --filter @chat-app/shared typecheck

Expected: 3 tests pass, typecheck clean.

  • Step 5: Commit
git add packages/shared/src/chat/pinnedMessages.ts packages/shared/src/chat/pinnedMessages.test.ts packages/shared/src/chat/index.ts
git commit -m "feat(shared): pinned-messages list/pin/unpin helpers"

Task 3: usePinnedMessages hook (desktop)

Files:

  • Create: apps/desktop/src/lib/usePinnedMessages.ts

  • Step 1: Implementation

// apps/desktop/src/lib/usePinnedMessages.ts
import { listPinnedMessages, type PinnedMessage } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';

import { supabase } from './supabase';

// Live list of pinned messages for one conversation. Subscribes to the
// `pinned_messages` realtime channel for the conv so the header pill +
// side-panel update without a refetch.
export function usePinnedMessages(conversationId: string | undefined): PinnedMessage[] {
  const [pins, setPins] = useState<PinnedMessage[]>([]);

  useEffect(() => {
    if (!conversationId) {
      setPins([]);
      return;
    }
    let cancelled = false;

    void listPinnedMessages(supabase, conversationId).then((rows) => {
      if (!cancelled) setPins(rows);
    });

    const channel = supabase
      .channel('pinned_messages:' + conversationId)
      .on(
        'postgres_changes',
        {
          event: '*',
          schema: 'public',
          table: 'pinned_messages',
          filter: 'conversation_id=eq.' + conversationId,
        },
        () => {
          void listPinnedMessages(supabase, conversationId).then((rows) => {
            if (!cancelled) setPins(rows);
          });
        },
      )
      .subscribe();

    return () => {
      cancelled = true;
      void supabase.removeChannel(channel);
    };
  }, [conversationId]);

  return pins;
}
  • Step 2: Verify + commit
pnpm --filter @chat-app/desktop typecheck
git add apps/desktop/src/lib/usePinnedMessages.ts
git commit -m "feat(desktop): usePinnedMessages live hook"

Task 4: Pinned-pill in conversation header

Files:

  • Create: apps/desktop/src/components/PinnedMessagesPill.tsx

  • Modify: apps/desktop/src/components/ConversationHeader.tsx

  • Step 1: The pill

// apps/desktop/src/components/PinnedMessagesPill.tsx
import { PinIcon } from './icons';

interface Props {
  count: number;
  onClick: () => void;
}

// Compact chip rendered in the conv header that opens the pinned panel.
// Renders nothing when count is 0 so a fresh conv shows no clutter.
export function PinnedMessagesPill({ count, onClick }: Props) {
  if (count === 0) return null;
  return (
    <button
      type="button"
      onClick={onClick}
      className="inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-full border border-line bg-surface-3 px-2.5 text-xs font-medium text-fg-muted transition hover:bg-surface-2 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
      title="Angepinnte Nachrichten anzeigen"
    >
      <PinIcon className="h-3 w-3 text-accent" />
      <span>{count} angepinnt</span>
    </button>
  );
}
  • Step 2: Wire it into the header

In ConversationHeader.tsx add the import and render the pill next to the existing title. Take pinnedCount: number and onOpenPinned: () => void as new optional props. Render <PinnedMessagesPill count={pinnedCount} onClick={onOpenPinned} /> inline after the title text. Use 0 as the default if pinnedCount is undefined.

Add the props to the existing Props interface as optional:

pinnedCount?: number;
onOpenPinned?: () => void;

In the render, alongside the title:

<PinnedMessagesPill count={pinnedCount ?? 0} onClick={() => onOpenPinned?.()} />
  • Step 3: Verify + commit
pnpm --filter @chat-app/desktop typecheck
git add apps/desktop/src/components/PinnedMessagesPill.tsx apps/desktop/src/components/ConversationHeader.tsx
git commit -m "feat(desktop): pinned-messages pill in conv header"

Task 5: Pinned-messages side panel

Files:

  • Create: apps/desktop/src/components/PinnedMessagesPanel.tsx

  • Step 1: Implementation

// apps/desktop/src/components/PinnedMessagesPanel.tsx
import type { PinnedMessage } from '@chat-app/shared/chat';

import { PinIcon, XIcon } from './icons';

interface Props {
  open: boolean;
  pins: PinnedMessage[];
  onClose: () => void;
  onJump: (messageId: string) => void;
  onUnpin: (messageId: string) => void;
  // Optional preview-renderer: parent resolves messageId → short text/snippet
  // since the panel itself doesn't decrypt. If absent, the panel just shows
  // the message-id stub.
  renderPreview?: (messageId: string) => React.ReactNode;
}

export function PinnedMessagesPanel({ open, pins, onClose, onJump, onUnpin, renderPreview }: Props) {
  if (!open) return null;
  return (
    <aside
      role="complementary"
      aria-label="Angepinnte Nachrichten"
      className="absolute right-0 top-0 z-30 flex h-full w-80 flex-col border-l border-line bg-surface-2 shadow-xl"
    >
      <header className="flex items-center justify-between border-b border-line px-4 py-3">
        <div className="flex items-center gap-2">
          <PinIcon className="h-4 w-4 text-accent" />
          <h3 className="text-sm font-semibold text-fg">Angepinnt · {pins.length}</h3>
        </div>
        <button
          type="button"
          onClick={onClose}
          aria-label="Schließen"
          className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
        >
          <XIcon className="h-4 w-4" />
        </button>
      </header>
      {pins.length === 0 ? (
        <p className="p-6 text-center text-xs text-fg-muted">Noch nichts angepinnt.</p>
      ) : (
        <ul className="flex-1 overflow-y-auto">
          {pins.map((p) => (
            <li key={p.messageId} className="border-b border-line/60 px-4 py-3">
              <button
                type="button"
                onClick={() => onJump(p.messageId)}
                className="block w-full cursor-pointer text-left text-sm text-fg hover:text-accent"
              >
                {renderPreview ? renderPreview(p.messageId) : <span className="font-mono text-xs">{p.messageId.slice(0, 8)}</span>}
              </button>
              <div className="mt-1 flex items-center justify-between text-[11px] text-fg-muted">
                <time dateTime={p.pinnedAt}>{new Date(p.pinnedAt).toLocaleString()}</time>
                <button
                  type="button"
                  onClick={() => onUnpin(p.messageId)}
                  className="cursor-pointer text-rose-400 hover:underline"
                >
                  Anheftung entfernen
                </button>
              </div>
            </li>
          ))}
        </ul>
      )}
    </aside>
  );
}
  • Step 2: Verify + commit
pnpm --filter @chat-app/desktop typecheck
git add apps/desktop/src/components/PinnedMessagesPanel.tsx
git commit -m "feat(desktop): pinned-messages side panel"

Task 6: Wire pin/unpin from MessageBubble context-menu + render panel

Files:

  • Modify: apps/desktop/src/components/MessageBubble.tsx (right-click context menu)

  • Modify: apps/desktop/src/pages/ConversationPage.tsx (state + panel render + props to header)

  • Step 1: Add pin/unpin action to MessageBubble's context menu

In MessageBubble.tsx, locate the existing right-click context menu (search for contextMenu state — it's already there). Add two new props to the Props interface:

isPinned?: boolean;
onTogglePin?: (messageId: string) => void;

Inside the context menu's action list (look for entries like canCopy, canEdit, canDelete), add a new entry. After the existing Copy/Edit/Delete buttons, add:

{onTogglePin && (
  <button
    type="button"
    onClick={() => { onTogglePin(message.id); setContextMenu(null); }}
    className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-sm hover:bg-surface-3"
  >
    <PinIcon className="h-4 w-4 text-fg-muted" />
    <span>{isPinned ? 'Anheftung entfernen' : 'Anpinnen'}</span>
  </button>
)}

Add PinIcon to the existing icons import if not present.

  • Step 2: Wire it from ConversationPage

In ConversationPage.tsx:

import { usePinnedMessages } from '../lib/usePinnedMessages';
import { pinMessage, unpinMessage } from '@chat-app/shared/chat';
import { PinnedMessagesPanel } from '../components/PinnedMessagesPanel';

// inside the component
const pins = usePinnedMessages(conversationId);
const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false);
const pinnedIds = new Set(pins.map((p) => p.messageId));

const handleTogglePin = useCallback(
  async (messageId: string) => {
    if (!conversationId || !userId) return;
    try {
      if (pinnedIds.has(messageId)) {
        await unpinMessage(supabase, conversationId, messageId);
      } else {
        await pinMessage(supabase, conversationId, messageId, userId);
      }
    } catch (err) {
      console.warn('pin toggle failed', err);
    }
  },
  [conversationId, userId, pinnedIds],
);

Pass pinnedCount={pins.length} and onOpenPinned={() => setPinnedPanelOpen(true)} to <ConversationHeader>.

For every <MessageBubble> rendered, add:

isPinned={pinnedIds.has(message.id)}
onTogglePin={handleTogglePin}

Render the panel near other modals at the end of the page:

<PinnedMessagesPanel
  open={pinnedPanelOpen}
  pins={pins}
  onClose={() => setPinnedPanelOpen(false)}
  onJump={(messageId) => {
    setPinnedPanelOpen(false);
    // If the conversation page exposes a `jumpToMessage` helper, call it
    // here. If no such helper exists yet, leave the jump as a no-op for
    // this commit — the panel still shows the list + lets users unpin.
  }}
  onUnpin={(messageId) => void handleTogglePin(messageId)}
/>
  • Step 3: Verify + commit
pnpm --filter @chat-app/desktop typecheck
git add apps/desktop/src/components/MessageBubble.tsx apps/desktop/src/pages/ConversationPage.tsx
git commit -m "feat(desktop): pin/unpin from message context menu + side panel"

Task 7: SQL — message_mentions table

Files:

  • Create: supabase/migrations/20260516000003_message_mentions.sql

  • Step 1: Write the migration

-- Per-mention rows so realtime can notify mentioned users without leaking
-- the rest of a conversation. Each mention is the pair (message, user_id).

create table if not exists public.message_mentions (
  message_id        uuid not null references public.messages(id) on delete cascade,
  mentioned_user_id uuid not null references auth.users(id)      on delete cascade,
  conversation_id   uuid not null references public.conversations(id) on delete cascade,
  created_at        timestamptz not null default now(),
  primary key (message_id, mentioned_user_id)
);

create index if not exists message_mentions_user_idx
  on public.message_mentions(mentioned_user_id, created_at desc);

alter table public.message_mentions enable row level security;

-- The mentioned user OR the message author can SELECT. RLS uses messages
-- to confirm the caller is the author.
drop policy if exists message_mentions_select on public.message_mentions;
create policy message_mentions_select
  on public.message_mentions
  for select
  to authenticated
  using (
    mentioned_user_id = auth.uid()
    or exists (
      select 1 from public.messages m
      where m.id = message_id and m.sender_id = auth.uid()
    )
  );

-- INSERT allowed when the caller is the author of the referenced message
-- AND the mentioned user is also a member of the same conversation.
drop policy if exists message_mentions_insert_author on public.message_mentions;
create policy message_mentions_insert_author
  on public.message_mentions
  for insert
  to authenticated
  with check (
    exists (
      select 1 from public.messages m
      where m.id = message_id
        and m.sender_id = auth.uid()
        and m.conversation_id = message_mentions.conversation_id
    )
    and public.is_conversation_member(conversation_id)
    and exists (
      select 1 from public.conversation_members cm
      where cm.conversation_id = message_mentions.conversation_id
        and cm.user_id = mentioned_user_id
        and cm.accepted
    )
  );

alter publication supabase_realtime add table public.message_mentions;
  • Step 2: Commit
git add supabase/migrations/20260516000003_message_mentions.sql
git commit -m "feat(db): message_mentions table (RLS: mentioned user + author can SELECT)"

Task 8: shared mentions.ts — parse + insert

Files:

  • Create: packages/shared/src/chat/mentions.ts

  • Create: packages/shared/src/chat/mentions.test.ts

  • Step 1: Implementation

// packages/shared/src/chat/mentions.ts
import type { AppSupabaseClient } from '../supabase/client';

// `@anna_b` style — letters, digits, underscore, dot, dash, 2-32 chars.
// Conservative on purpose: false negatives (a real username we don't match)
// are recoverable (no notification fires); false positives (matching a
// non-username) just become an INSERT that the FK check rejects.
const MENTION_RE = /(?:^|[\s,;:!?(])@([a-zA-Z0-9_.-]{2,32})/g;

export function parseMentionUsernames(plaintext: string): string[] {
  const out = new Set<string>();
  for (const m of plaintext.matchAll(MENTION_RE)) {
    if (m[1]) out.add(m[1].toLowerCase());
  }
  return [...out];
}

export interface MentionResolver {
  // Resolves an array of @usernames in this conversation to user-ids.
  // Returns only memberships that exist + are accepted.
  resolveUsernames(conversationId: string, usernames: string[]): Promise<Map<string, string>>;
}

export function makeMentionResolver(client: AppSupabaseClient): MentionResolver {
  return {
    async resolveUsernames(conversationId, usernames) {
      if (usernames.length === 0) return new Map();
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      const { data, error } = await (client as any)
        .from('conversation_members')
        .select('user_id, accepted, profiles!inner(username)')
        .eq('conversation_id', conversationId)
        .eq('accepted', true)
        .in('profiles.username', usernames);
      if (error) throw error;
      const out = new Map<string, string>();
      for (const row of (data ?? []) as Array<{ user_id: string; profiles: { username: string } }>) {
        out.set(row.profiles.username.toLowerCase(), row.user_id);
      }
      return out;
    },
  };
}

export async function insertMentions(
  client: AppSupabaseClient,
  messageId: string,
  conversationId: string,
  mentionedUserIds: string[],
): Promise<void> {
  if (mentionedUserIds.length === 0) return;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const { error } = await (client as any).from('message_mentions').insert(
    mentionedUserIds.map((uid) => ({
      message_id: messageId,
      mentioned_user_id: uid,
      conversation_id: conversationId,
    })),
  );
  if (error) throw error;
}
  • Step 2: Test
// packages/shared/src/chat/mentions.test.ts
import { describe, expect, it } from 'vitest';

import { parseMentionUsernames } from './mentions';

describe('parseMentionUsernames', () => {
  it('extracts a leading mention', () => {
    expect(parseMentionUsernames('@anna hi')).toEqual(['anna']);
  });
  it('extracts mid-sentence', () => {
    expect(parseMentionUsernames('hey @ben_c what do you think')).toEqual(['ben_c']);
  });
  it('lowercases usernames', () => {
    expect(parseMentionUsernames('hi @Anna')).toEqual(['anna']);
  });
  it('deduplicates', () => {
    expect(parseMentionUsernames('@x and @x again')).toEqual(['x']);
  });
  it('ignores emails (no preceding boundary)', () => {
    expect(parseMentionUsernames('mail me at foo@bar.com')).toEqual([]);
  });
  it('rejects 1-char names', () => {
    expect(parseMentionUsernames('@a')).toEqual([]);
  });
  it('handles multiple in one message', () => {
    expect(parseMentionUsernames('@anna, @ben and @cara')).toEqual(['anna', 'ben', 'cara']);
  });
});
  • Step 3: Re-export + verify + commit

Add export * from './mentions'; to packages/shared/src/chat/index.ts.

pnpm --filter @chat-app/shared exec vitest run src/chat/mentions.test.ts
pnpm --filter @chat-app/shared typecheck
git add packages/shared/src/chat/mentions.ts packages/shared/src/chat/mentions.test.ts packages/shared/src/chat/index.ts
git commit -m "feat(shared): parseMentionUsernames + insertMentions helpers"

Task 9: Hook mentions into sendEncryptedMessage

Files:

  • Modify: packages/shared/src/chat/messages.ts

  • Step 1: Parse + insert after the INSERT

In messages.ts, find the existing sendEncryptedMessage. After the message row is INSERTed and you have messageRow.id, add:

import { insertMentions, makeMentionResolver, parseMentionUsernames } from './mentions';

// after the message INSERT, before return:
const usernames = parseMentionUsernames(params.plaintext);
if (usernames.length > 0) {
  try {
    const resolver = makeMentionResolver(params.client);
    const resolved = await resolver.resolveUsernames(params.conversationId, usernames);
    if (resolved.size > 0) {
      await insertMentions(
        params.client,
        (messageRow as { id: string }).id,
        params.conversationId,
        [...resolved.values()],
      );
    }
  } catch (err) {
    // Mention-insert failure must NOT block the send. Worst case: the user
    // who was @-ed doesn't get a notification; the message itself is fine.
    console.warn('mention insert failed', err);
  }
}

parseMentionUsernames runs on params.plaintext (the user-typed text), so it still works regardless of attachment-payload JSON-wrapping done later.

  • Step 2: Typecheck + commit
pnpm --filter @chat-app/shared typecheck
git add packages/shared/src/chat/messages.ts
git commit -m "feat(shared): sendEncryptedMessage inserts mention rows after the message row"

Task 10: Desktop subscriber useMentionNotifications

Files:

  • Create: apps/desktop/src/lib/useMentionNotifications.ts

  • Modify: apps/desktop/src/components/AppShell.tsx

  • Step 1: Implementation

// apps/desktop/src/lib/useMentionNotifications.ts
import { useEffect } from 'react';

import { notify } from './osNotify';
import { supabase } from './supabase';

interface MentionRow {
  message_id: string;
  mentioned_user_id: string;
  conversation_id: string;
}

// Subscribes to my own message_mentions inserts and fires an OS notification
// for each one. Bypasses per-conv mute (mentions override mute by design).
//
// We don't decrypt the body here — the notification just says "Du wurdest
// erwähnt". The conv list highlight + the in-app navigation reveal context.
export function useMentionNotifications(userId: string | undefined): void {
  useEffect(() => {
    if (!userId) return;
    const channel = supabase
      .channel('mentions:' + userId)
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'message_mentions',
          filter: 'mentioned_user_id=eq.' + userId,
        },
        (payload) => {
          const row = payload.new as MentionRow | null;
          if (!row) return;
          void notify({
            title: 'Du wurdest erwähnt',
            body: 'Tippe um die Nachricht zu lesen.',
            force: true,
          });
        },
      )
      .subscribe();
    return () => {
      void supabase.removeChannel(channel);
    };
  }, [userId]);
}
  • Step 2: Mount the hook once per session

In apps/desktop/src/components/AppShell.tsx, at the top of the component, call:

import { useMentionNotifications } from '../lib/useMentionNotifications';

// inside the component, near other hooks:
const { session } = useAuth();
useMentionNotifications(session?.user.id);

If AppShell already destructures useAuth(), reuse the existing variable.

  • Step 3: Verify + commit
pnpm --filter @chat-app/desktop typecheck
git add apps/desktop/src/lib/useMentionNotifications.ts apps/desktop/src/components/AppShell.tsx
git commit -m "feat(desktop): mention notifications via realtime + osNotify"

Task 11: Tenor client lib/tenor.ts

Files:

  • Create: apps/desktop/src/lib/tenor.ts

  • Step 1: Implementation

// apps/desktop/src/lib/tenor.ts
//
// Tenor v2 client — Google's free GIF API. Public read endpoints accept any
// `client_key` so we don't ship a per-user API key. Trending + Search both
// hit https://tenor.googleapis.com/v2/<path>. Cache recent picks (last 24
// URLs) in localStorage so the picker has a "Zuletzt" tab.

const ENDPOINT = 'https://tenor.googleapis.com/v2';
const CLIENT_KEY = 'netralax-chat';
const RECENT_KEY = 'chatapp.gifRecent.v1';
const RECENT_MAX = 24;
// Tenor's public key for browser-side reads. Documented as "anonymous" and
// usable without account binding. Rate-limited at ~3k/day per IP which is
// plenty for a chat app's picker traffic.
const PUBLIC_API_KEY = 'AIzaSyAyimkuYQYF_FXVALexPuGQctUWRURdCYQ';

export interface GifResult {
  id: string;
  // Animated full-size URL (typically <2 MB).
  url: string;
  // Small preview shown in the picker grid.
  previewUrl: string;
  width: number;
  height: number;
  description: string;
}

interface TenorApiResult {
  id: string;
  content_description?: string;
  media_formats?: Record<string, { url: string; dims?: [number, number] }>;
}

function mapResult(r: TenorApiResult): GifResult | null {
  const full = r.media_formats?.gif ?? r.media_formats?.mediumgif ?? r.media_formats?.tinygif;
  const preview = r.media_formats?.tinygif ?? r.media_formats?.gif;
  if (!full?.url || !preview?.url) return null;
  return {
    id: r.id,
    url: full.url,
    previewUrl: preview.url,
    width: full.dims?.[0] ?? 0,
    height: full.dims?.[1] ?? 0,
    description: r.content_description ?? '',
  };
}

async function fetchTenor(path: string, params: Record<string, string>): Promise<GifResult[]> {
  const search = new URLSearchParams({ key: PUBLIC_API_KEY, client_key: CLIENT_KEY, ...params });
  const res = await fetch(`${ENDPOINT}/${path}?${search.toString()}`);
  if (!res.ok) throw new Error('tenor http ' + res.status);
  const json = (await res.json()) as { results?: TenorApiResult[] };
  return (json.results ?? []).map(mapResult).filter((x): x is GifResult => x !== null);
}

export async function searchGifs(query: string, locale: string = 'de_DE'): Promise<GifResult[]> {
  if (!query.trim()) return featuredGifs(locale);
  return fetchTenor('search', { q: query, limit: '30', locale });
}

export async function featuredGifs(locale: string = 'de_DE'): Promise<GifResult[]> {
  return fetchTenor('featured', { limit: '30', locale });
}

export function getRecentGifs(): GifResult[] {
  try {
    const raw = window.localStorage.getItem(RECENT_KEY);
    if (!raw) return [];
    const parsed = JSON.parse(raw) as unknown;
    if (!Array.isArray(parsed)) return [];
    return parsed.filter(
      (x): x is GifResult =>
        x != null &&
        typeof x === 'object' &&
        typeof (x as GifResult).url === 'string',
    );
  } catch {
    return [];
  }
}

export function rememberRecentGif(gif: GifResult): void {
  const current = getRecentGifs().filter((g) => g.id !== gif.id);
  const next = [gif, ...current].slice(0, RECENT_MAX);
  try {
    window.localStorage.setItem(RECENT_KEY, JSON.stringify(next));
  } catch {
    /* quota */
  }
}
  • Step 2: Verify + commit
pnpm --filter @chat-app/desktop typecheck
git add apps/desktop/src/lib/tenor.ts
git commit -m "feat(desktop): Tenor v2 GIF search/featured/recent client"

Task 12: GIF picker popover

Files:

  • Create: apps/desktop/src/components/GifPicker.tsx

  • Step 1: Implementation

// apps/desktop/src/components/GifPicker.tsx
import { useEffect, useMemo, useState } from 'react';

import {
  featuredGifs,
  getRecentGifs,
  type GifResult,
  rememberRecentGif,
  searchGifs,
} from '../lib/tenor';
import { SpinnerIcon, XIcon } from './icons';

interface Props {
  open: boolean;
  onClose: () => void;
  onPick: (gif: GifResult) => void;
}

type Tab = 'trending' | 'search' | 'recent';

export function GifPicker({ open, onClose, onPick }: Props) {
  const [tab, setTab] = useState<Tab>('trending');
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<GifResult[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const recent = useMemo(() => getRecentGifs(), [open]);

  useEffect(() => {
    if (!open) return;
    let cancelled = false;
    setLoading(true);
    setError(null);
    const run = async () => {
      try {
        const gifs = tab === 'search' && query.trim().length > 0
          ? await searchGifs(query)
          : tab === 'trending'
            ? await featuredGifs()
            : [];
        if (!cancelled) setResults(gifs);
      } catch (err) {
        if (!cancelled) setError(err instanceof Error ? err.message : 'GIFs gerade nicht verfügbar');
      } finally {
        if (!cancelled) setLoading(false);
      }
    };
    void run();
    return () => { cancelled = true; };
  }, [open, tab, query]);

  if (!open) return null;

  const visible = tab === 'recent' ? recent : results;

  return (
    <div className="absolute bottom-full left-0 z-30 mb-2 w-[440px] rounded-2xl border border-line bg-surface-2 shadow-xl">
      <header className="flex items-center justify-between border-b border-line px-3 py-2">
        <div className="flex items-center gap-1">
          {(['trending', 'search', 'recent'] as Tab[]).map((t) => (
            <button
              key={t}
              type="button"
              onClick={() => setTab(t)}
              className={
                'cursor-pointer rounded-md px-2 py-1 text-xs font-medium transition ' +
                (t === tab ? 'bg-accent/15 text-accent' : 'text-fg-muted hover:text-fg')
              }
            >
              {t === 'trending' ? 'Trending' : t === 'search' ? 'Suche' : 'Zuletzt'}
            </button>
          ))}
        </div>
        <button
          type="button"
          onClick={onClose}
          aria-label="Schließen"
          className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
        >
          <XIcon className="h-3.5 w-3.5" />
        </button>
      </header>
      {tab === 'search' && (
        <div className="border-b border-line px-3 py-2">
          <input
            type="text"
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="GIFs suchen…"
            className="w-full rounded-md border border-line bg-surface-3 px-2 py-1.5 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40"
          />
        </div>
      )}
      <div className="grid max-h-[360px] grid-cols-3 gap-1.5 overflow-y-auto p-2">
        {loading && (
          <div className="col-span-3 flex justify-center py-6 text-fg-muted">
            <SpinnerIcon className="h-5 w-5" />
          </div>
        )}
        {error && <p className="col-span-3 px-2 py-4 text-center text-xs text-rose-300">{error}</p>}
        {!loading && !error && visible.length === 0 && (
          <p className="col-span-3 px-2 py-6 text-center text-xs text-fg-muted">
            {tab === 'recent' ? 'Noch keine zuletzt verwendeten GIFs.' : 'Keine Treffer.'}
          </p>
        )}
        {!loading && !error && visible.map((g) => (
          <button
            key={g.id}
            type="button"
            onClick={() => { rememberRecentGif(g); onPick(g); onClose(); }}
            className="overflow-hidden rounded-md border border-line bg-surface-3 hover:border-accent/40"
            title={g.description}
          >
            <img src={g.previewUrl} alt={g.description || 'GIF'} className="h-24 w-full object-cover" loading="lazy" />
          </button>
        ))}
      </div>
    </div>
  );
}
  • Step 2: Verify + commit
pnpm --filter @chat-app/desktop typecheck
git add apps/desktop/src/components/GifPicker.tsx
git commit -m "feat(desktop): GIF picker popover (Tenor + trending/search/recent)"

Task 13: Wire GIF picker into the composer

Files:

  • Modify: apps/desktop/src/pages/ConversationPage.tsx

  • Step 1: Add a GIF button next to the existing composer attachment controls

In the composer toolbar (where the + attachment button and emoji-picker button live), add a new GIF button. State + handler:

import { GifPicker } from '../components/GifPicker';
import { type GifResult } from '../lib/tenor';

// inside the component:
const [gifPickerOpen, setGifPickerOpen] = useState(false);

const handleGifPick = useCallback(
  async (gif: GifResult) => {
    if (!conversationId || !userId) return;
    try {
      // Fetch the GIF bytes once and feed them into the existing attachment
      // upload pipeline so the result is end-to-end-encrypted like any
      // image attachment.
      const res = await fetch(gif.url);
      const blob = await res.blob();
      const file = new File([blob], `tenor-${gif.id}.gif`, { type: 'image/gif' });
      // Send as a regular image attachment with empty text. Use the same
      // `send` helper the existing attachment-plus button calls. If `send`
      // accepts (text, files, replyToId), pass the file through that path.
      await send('', [file], null);
    } catch (err) {
      console.warn('GIF send failed', err);
    }
  },
  [conversationId, userId, send],
);

In the composer JSX, near the existing emoji-picker / attachment-plus buttons, add a GIF button + the picker mount:

<button
  type="button"
  onClick={() => setGifPickerOpen((v) => !v)}
  className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
  aria-label="GIF"
  title="GIF einfügen"
>
  <span className="text-xs font-bold">GIF</span>
</button>
<GifPicker
  open={gifPickerOpen}
  onClose={() => setGifPickerOpen(false)}
  onPick={(gif) => void handleGifPick(gif)}
/>

If send doesn't accept a File array, adapt to whatever the existing attachment-send method looks like — read the surrounding code and match the convention.

  • Step 2: Verify + commit
pnpm --filter @chat-app/desktop typecheck
git add apps/desktop/src/pages/ConversationPage.tsx
git commit -m "feat(desktop): GIF button + picker wired into composer"

Task 14: SQL — view_once column + RPC

Files:

  • Create: supabase/migrations/20260516000004_view_once_attachments.sql

  • Step 1: Write the migration

-- View-once media: per-attachment opt-in. When a recipient opens the
-- attachment for the first time we delete the storage object and replace
-- it with a tombstone marker (kept_until / viewed_at). The encrypted
-- attachment row stays so the bubble can render "Angesehen".

alter table public.message_attachments
  add column if not exists view_once boolean not null default false,
  add column if not exists viewed_at timestamptz null,
  add column if not exists viewed_by uuid null references auth.users(id) on delete set null;

-- Mark-viewed RPC: server-authoritative so the recipient can't replay the
-- decrypted blob across devices. Atomic: only the FIRST viewer wins. The
-- function returns the previous `viewed_at` so the client can distinguish
-- first-open (got it) from already-viewed (too late, sorry).
create or replace function public.mark_attachment_viewed(p_attachment_id uuid)
returns jsonb
language plpgsql
security definer
set search_path = public
as $$
declare
  caller uuid := auth.uid();
  att    public.message_attachments%rowtype;
  msg    public.messages%rowtype;
  prev   timestamptz;
begin
  if caller is null then raise exception 'not authenticated'; end if;

  select * into att from public.message_attachments where id = p_attachment_id for update;
  if not found then raise exception 'attachment not found'; end if;
  if not att.view_once then return jsonb_build_object('view_once', false); end if;

  select * into msg from public.messages where id = att.message_id;
  if not found then raise exception 'orphan attachment'; end if;
  if not public.is_conversation_member(msg.conversation_id) then
    raise exception 'not a member';
  end if;
  -- Sender opening their own view-once doesn't "burn" it — they sent it.
  if msg.sender_id = caller then
    return jsonb_build_object('view_once', true, 'viewed_at', att.viewed_at, 'self', true);
  end if;

  if att.viewed_at is not null then
    return jsonb_build_object('view_once', true, 'viewed_at', att.viewed_at, 'already', true);
  end if;

  update public.message_attachments
     set viewed_at = now(),
         viewed_by = caller
   where id = p_attachment_id
   returning viewed_at into prev;

  return jsonb_build_object('view_once', true, 'viewed_at', prev);
end;
$$;

revoke execute on function public.mark_attachment_viewed(uuid) from public, anon;
grant  execute on function public.mark_attachment_viewed(uuid) to authenticated;
  • Step 2: Commit
git add supabase/migrations/20260516000004_view_once_attachments.sql
git commit -m "feat(db): message_attachments.view_once + mark_attachment_viewed RPC"

Task 15: shared viewOnceAttachments.ts

Files:

  • Create: packages/shared/src/chat/viewOnceAttachments.ts

  • Step 1: Implementation

// packages/shared/src/chat/viewOnceAttachments.ts
import type { AppSupabaseClient } from '../supabase/client';

export interface ViewOnceResult {
  viewOnce: boolean;
  /** ISO timestamp set the moment the first non-sender viewed. */
  viewedAt?: string | null;
  /** True iff the caller is the original sender (they don't burn the view). */
  self?: boolean;
  /** True iff someone else already viewed before this call. */
  already?: boolean;
}

export async function markAttachmentViewed(
  client: AppSupabaseClient,
  attachmentId: string,
): Promise<ViewOnceResult> {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const { data, error } = await (client as any).rpc('mark_attachment_viewed', {
    p_attachment_id: attachmentId,
  });
  if (error) throw error;
  const d = (data ?? {}) as Record<string, unknown>;
  return {
    viewOnce: Boolean(d.view_once),
    viewedAt: typeof d.viewed_at === 'string' ? d.viewed_at : null,
    self: Boolean(d.self),
    already: Boolean(d.already),
  };
}
  • Step 2: Extend AttachmentHandle to carry the flag

In packages/shared/src/chat/attachments.ts, find the AttachmentHandle interface. Add an optional viewOnce field:

export interface AttachmentHandle {
  // existing fields…
  viewOnce?: boolean;
}

In the existing insertAttachmentRow (or whichever helper writes to message_attachments), include view_once: handle.viewOnce ?? false in the INSERT payload.

  • Step 3: Re-export from index + commit

Add export * from './viewOnceAttachments'; to packages/shared/src/chat/index.ts.

pnpm --filter @chat-app/shared typecheck
git add packages/shared/src/chat/viewOnceAttachments.ts packages/shared/src/chat/attachments.ts packages/shared/src/chat/index.ts
git commit -m "feat(shared): mark_attachment_viewed + view_once on AttachmentHandle"

Task 16: ViewOnceImage component + sender toggle + wire into MessageBubble

Files:

  • Create: apps/desktop/src/components/ViewOnceImage.tsx

  • Modify: apps/desktop/src/components/AttachmentImage.tsx (where image attachments are rendered)

  • Modify: apps/desktop/src/pages/ConversationPage.tsx (composer attachment toggle)

  • Step 1: ViewOnceImage

// apps/desktop/src/components/ViewOnceImage.tsx
import { useState } from 'react';

import { markAttachmentViewed } from '@chat-app/shared/chat';

import { supabase } from '../lib/supabase';
import { EyeOffIcon, LockIcon } from './icons';

interface Props {
  attachmentId: string;
  /** Already-viewed timestamp from the DB row. Renders tombstone immediately. */
  viewedAt: string | null;
  /** True iff the local user is the sender — they don't burn the view. */
  isSender: boolean;
  /** Decrypted image source; only fetched/displayed inside the lightbox. */
  src: string;
}

// Three states:
//   1. viewedAt is null AND user is recipient → blurred lock card; tap opens
//      fullscreen lightbox AND fires the mark-viewed RPC.
//   2. viewedAt is set → tombstone "Angesehen am …".
//   3. user is sender → normal image, tombstone update appears once recipient burns it.
export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props) {
  const [revealedAt, setRevealedAt] = useState<string | null>(viewedAt);
  const [fullscreen, setFullscreen] = useState(false);

  const burned = revealedAt !== null;

  if (burned && !isSender) {
    return (
      <div className="flex h-32 w-48 items-center justify-center rounded-lg border border-dashed border-line bg-surface-3 text-xs text-fg-muted">
        <EyeOffIcon className="mr-2 h-4 w-4" />
        Angesehen am {new Date(revealedAt).toLocaleString()}
      </div>
    );
  }

  if (isSender) {
    return (
      <div className="relative">
        <img src={src} alt="" className="max-h-72 rounded-lg" />
        <span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-black/60 px-2 py-0.5 text-[10px] font-semibold text-white">
          <EyeOffIcon className="h-3 w-3" /> Einmal ansehen
        </span>
        {burned && (
          <span className="absolute right-2 bottom-2 rounded-full bg-emerald-500/80 px-2 py-0.5 text-[10px] font-semibold text-white">
            Angesehen
          </span>
        )}
      </div>
    );
  }

  // Recipient, not yet viewed.
  const handleOpen = async (): Promise<void> => {
    try {
      const res = await markAttachmentViewed(supabase, attachmentId);
      if (res.viewedAt) setRevealedAt(res.viewedAt);
    } catch (err) {
      console.warn('mark-viewed failed', err);
    }
    setFullscreen(true);
  };

  return (
    <>
      <button
        type="button"
        onClick={() => void handleOpen()}
        className="relative flex h-48 w-64 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-3 text-fg-muted hover:border-accent/40"
      >
        <LockIcon className="h-6 w-6 text-accent" />
        <span className="text-xs font-medium">Einmal ansehen  antippen</span>
      </button>
      {fullscreen && (
        <div
          role="dialog"
          aria-modal="true"
          className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-6"
          onClick={() => setFullscreen(false)}
        >
          <img src={src} alt="" className="max-h-full max-w-full rounded-lg" />
        </div>
      )}
    </>
  );
}
  • Step 2: Render 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):

import { ViewOnceImage } from './ViewOnceImage';

// where the image is currently rendered:
if (attachment.viewOnce) {
  return (
    <ViewOnceImage
      attachmentId={attachment.id}
      viewedAt={attachment.viewedAt ?? null}
      isSender={mine}
      src={decryptedObjectUrl}
    />
  );
}
// existing img render

Adapt mine, decryptedObjectUrl, and attachment.viewedAt to whatever the file actually calls them. If the file currently doesn't carry viewedAt on the attachment object, plumb it through from the parent (the attachment row from fetchConversationMessages exposes it via the SELECT list — extend the SELECT if needed).

  • Step 3: Composer toggle

In the composer (ConversationPage.tsx, where image attachments are queued), add a 👁 toggle near the attachment thumbnail strip. When the toggle is on, any image queued in this composer session is sent with viewOnce: true. UI:

import { EyeOffIcon } from '../components/icons';

const [viewOnceNext, setViewOnceNext] = useState(false);

// in JSX, after the attachment plus button:
<button
  type="button"
  onClick={() => setViewOnceNext((v) => !v)}
  aria-pressed={viewOnceNext}
  title={viewOnceNext ? 'Nächstes Bild: einmal ansehen' : 'Nächstes Bild: normal'}
  className={
    'flex h-9 w-9 cursor-pointer items-center justify-center rounded-md transition ' +
    (viewOnceNext
      ? 'bg-accent/20 text-accent'
      : 'text-fg-muted hover:bg-surface-3 hover:text-fg')
  }
>
  <EyeOffIcon className="h-4 w-4" />
</button>

When the user actually sends, propagate the flag onto each queued AttachmentHandle before they're uploaded. If the send pipeline currently builds the handle from a File, set handle.viewOnce = viewOnceNext and reset viewOnceNext to false after a successful send.

  • Step 4: Verify + commit
pnpm --filter @chat-app/desktop typecheck
git add apps/desktop/src/components/ViewOnceImage.tsx apps/desktop/src/components/AttachmentImage.tsx apps/desktop/src/pages/ConversationPage.tsx
git commit -m "feat(desktop): view-once image attachments (sender toggle + recipient lightbox + tombstone)"

Phase 2 final gate

  • Run the full check matrix
cd D:/Programmieren/ChatApp-Electron/chat-app
pnpm --filter @chat-app/shared typecheck && \
  pnpm --filter @chat-app/desktop typecheck && \
  pnpm --filter @chat-app/shared test

Expected: all green.

  • Push the three new SQL migrations to Prod (when ready)
bash scripts/prod/push-migrations.sh 20260516000002
bash scripts/prod/push-migrations.sh 20260516000003
bash scripts/prod/push-migrations.sh 20260516000004

Each is a CREATE TABLE / new column / new RPC — safe to apply additively, no destructive changes.

  • DO NOT release

Version stays on 0.18.8. Phase 3 plan gets written after the user signs off on Phase 2.


Self-Review

1. Spec coverage:

  • Pinned Messages: Tasks 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.