1607 lines
53 KiB
Markdown
1607 lines
53 KiB
Markdown
# 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.
|