docs(P5C): Phase 5C polish plan (4 spec sub-items)
This commit is contained in:
@@ -0,0 +1,361 @@
|
|||||||
|
# Phase 5C — Spec-Polish (4 leftover sub-items)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Close the 4 documented but never-built sub-items from the fifteen-features spec:
|
||||||
|
1. Empty-state for empty search results in chat list (Phase 1 spec line 52).
|
||||||
|
2. Per-conversation "Nur bei @Mentions benachrichtigen" toggle (Phase 2 spec line 72).
|
||||||
|
3. Mentions-on-edit recompute (Phase 2 spec line 141).
|
||||||
|
4. Confetti animation on game-win (Phase 5 spec line 133).
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
- (1) Drops the existing `EmptyState` primitive into the `ChatsPage` search results when the filter produces zero rows.
|
||||||
|
- (2) Adds a `mentions_only boolean` column to `conversation_members` + a toggle in `ConversationRowMenu` next to the mute submenu. The notification gate suppresses non-mention notifications when set. `useMentionNotifications` is untouched — mentions fire regardless.
|
||||||
|
- (3) Extends `editEncryptedMessage()` in `packages/shared/src/chat/messages.ts` to re-run `parseMentionUsernames` + `insertMentions` after the text changes. Old mention rows are deleted first.
|
||||||
|
- (4) Adds `canvas-confetti` dep, fires it in `GameModal` when `winnerIdx === myPlayerIdx`.
|
||||||
|
|
||||||
|
**Tech Stack:** No new infra. Adds one runtime dep (`canvas-confetti` + types).
|
||||||
|
|
||||||
|
**Non-goals:**
|
||||||
|
- Cron-based items (7-day view-once purge, 12h watch-together auto-end, 24h game auto-draw) — server-side per spec, out of scope.
|
||||||
|
- New in-conversation message search (only chat-list search empty state).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-flight
|
||||||
|
|
||||||
|
- [ ] **Verify clean working tree on `main`**
|
||||||
|
|
||||||
|
Run: `cd "D:\Programmieren\ChatApp-Electron\chat-app" && git status`
|
||||||
|
Expected: clean.
|
||||||
|
|
||||||
|
- [ ] **Confirm tooling is green**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck && pnpm --filter @chat-app/shared test -- --run`
|
||||||
|
Expected: all green; 71 shared tests pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Empty-state for empty search results
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/desktop/src/pages/ChatsPage.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Locate the search-filter render**
|
||||||
|
|
||||||
|
```
|
||||||
|
Read apps/desktop/src/pages/ChatsPage.tsx (offset 1, limit 80)
|
||||||
|
```
|
||||||
|
|
||||||
|
Find:
|
||||||
|
- Search input + `query` state (~lines 29-48).
|
||||||
|
- `queryFiltered` (or similarly named) memo.
|
||||||
|
- The render loop over the filtered list.
|
||||||
|
- The existing `<EmptyState>` import — add `import { EmptyState } from '../components/EmptyState';` if missing.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Render an empty-state when search yields zero results**
|
||||||
|
|
||||||
|
Wrap the rendered list with a length check:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{query.trim().length > 0 && filteredList.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t('app:chats.search_empty_title', { defaultValue: 'Keine Treffer' })}
|
||||||
|
description={t('app:chats.search_empty_desc', {
|
||||||
|
defaultValue: 'Keine Chats passen zu "' + query.trim() + '".',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
/* existing list render */
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `EmptyState`'s prop shape differs (`icon`/`action` required), match the existing chat-list call site (P1.T6). Grep first: `Grep -n "EmptyState" apps/desktop/src/pages/ChatsPage.tsx apps/desktop/src/components/EmptyState.tsx`.
|
||||||
|
|
||||||
|
Substitute the real variable names from the file (`query` vs `searchText`, `filteredList` vs `queryFiltered`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Typecheck**
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm --filter @chat-app/desktop typecheck
|
||||||
|
```
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "D:\Programmieren\ChatApp-Electron\chat-app"
|
||||||
|
git add apps/desktop/src/pages/ChatsPage.tsx
|
||||||
|
git commit -m "feat(P5C.T1): empty-state for empty chat-list search results"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Per-conv "Nur bei @Mentions benachrichtigen" toggle
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `supabase/migrations/20260516000010_mentions_only.sql`
|
||||||
|
- Modify: `packages/db-types/src/index.ts`
|
||||||
|
- Modify: `packages/shared/src/chat/conversations.ts` (or wherever conversation_members helpers live)
|
||||||
|
- Modify: `apps/desktop/src/components/ConversationRowMenu.tsx`
|
||||||
|
- Modify: the notification gate (likely `apps/desktop/src/lib/osNotify.ts` callers, e.g. `useConversationMessages.ts`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: SQL migration + prod push**
|
||||||
|
|
||||||
|
Create `supabase/migrations/20260516000010_mentions_only.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Phase 5C: per-conversation "notify only on @mentions" toggle.
|
||||||
|
-- Lives alongside the existing muted_until column on conversation_members.
|
||||||
|
-- When true: the renderer's incoming-message notification gate suppresses
|
||||||
|
-- the alert unless the message contains an @-mention of the local user.
|
||||||
|
-- Mentions always fire regardless (override-by-design).
|
||||||
|
|
||||||
|
alter table public.conversation_members
|
||||||
|
add column if not exists mentions_only boolean not null default false;
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "D:\Programmieren\ChatApp-Electron\chat-app"
|
||||||
|
git add supabase/migrations/20260516000010_mentions_only.sql
|
||||||
|
git commit -m "feat(P5C.T2-sql): conversation_members.mentions_only column"
|
||||||
|
bash scripts/prod/push-migrations.sh mentions_only
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: db-types extension**
|
||||||
|
|
||||||
|
In `packages/db-types/src/index.ts`, find the `conversation_members` entry. Add `mentions_only: boolean` to `Row`, `mentions_only?: boolean` to `Insert`, and `mentions_only?: boolean` to `Update`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Shared wrapper for the toggle**
|
||||||
|
|
||||||
|
Find where `conversation_members` mutation helpers live (likely a `setConversationMuted` exists):
|
||||||
|
```
|
||||||
|
Grep -rn "conversation_members" packages/shared/src/
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to that same file (or the most-fitting chat helper):
|
||||||
|
```ts
|
||||||
|
export async function setConversationMentionsOnly(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
params: { conversationId: string; mentionsOnly: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
const { data: session } = await client.auth.getUser();
|
||||||
|
if (!session.user) throw new Error('not authenticated');
|
||||||
|
const { error } = await client
|
||||||
|
.from('conversation_members')
|
||||||
|
.update({ mentions_only: params.mentionsOnly })
|
||||||
|
.eq('conversation_id', params.conversationId)
|
||||||
|
.eq('user_id', session.user.id);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `AppSupabaseClient` import if missing.
|
||||||
|
|
||||||
|
Also: if the conversation-read wrapper (`listConversations` or similar) projects `muted_until` into a camelCase `mutedUntil`, add `mentionsOnly` alongside it in the same mapper.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Render the toggle in `ConversationRowMenu.tsx`**
|
||||||
|
|
||||||
|
```
|
||||||
|
Read apps/desktop/src/components/ConversationRowMenu.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Find the mute entry (~lines 31-42). Add a sibling menu item below it:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void setConversationMentionsOnly(supabase, {
|
||||||
|
conversationId: conv.id,
|
||||||
|
mentionsOnly: !conv.mentionsOnly,
|
||||||
|
}).catch((err) => console.warn('mentions-only toggle failed', err));
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="..." // copy from the existing mute-entry className
|
||||||
|
>
|
||||||
|
<span>{conv.mentionsOnly ? '✓ ' : ''}Nur bei @Mentions benachrichtigen</span>
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
If the `conv` prop type doesn't yet expose `mentionsOnly`, extend the type in the source (the `Conversation` interface in shared) and the mapper in the read wrapper from Step 3.
|
||||||
|
|
||||||
|
Add imports:
|
||||||
|
```ts
|
||||||
|
import { setConversationMentionsOnly } from '@chat-app/shared/chat';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
```
|
||||||
|
(Adapt the `@chat-app/shared/chat` path if Step 3's helper lives in a different sub-path.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Suppress non-mention notifications when `mentions_only` is true**
|
||||||
|
|
||||||
|
```
|
||||||
|
Grep -rn "useMentionNotifications\|osNotify" apps/desktop/src/
|
||||||
|
```
|
||||||
|
|
||||||
|
Find the message-incoming notification path. It's the place that calls `osNotify(...)` on inbound non-self messages. The current shape likely:
|
||||||
|
```ts
|
||||||
|
if (!isAppFocused() && !isMuted(conv)) {
|
||||||
|
osNotify(...);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Extend it (the cleanest path — assumes `useMentionNotifications` independently fires for every mention, which the recon confirmed):
|
||||||
|
```ts
|
||||||
|
if (!isAppFocused() && !isMuted(conv)) {
|
||||||
|
if (conv.mentionsOnly) {
|
||||||
|
// Non-mention messages are silenced here. The mention case is handled
|
||||||
|
// by useMentionNotifications (which subscribes to message_mentions
|
||||||
|
// INSERT independently) so we don't lose the @-alert.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
osNotify(...);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `conv` isn't in scope at the gate (some hooks only have `conversationId`), look up the conv via the `ConversationsContext` cache. Pattern: `Grep -n "useConversations\|conversations.find\|conversationsById" apps/desktop/src/`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Typecheck**
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck
|
||||||
|
```
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/db-types/src/index.ts packages/shared/src/chat/ apps/desktop/src/
|
||||||
|
# verify with git status that only intended files are staged before committing
|
||||||
|
git commit -m "feat(P5C.T2): per-conv 'mentions only' toggle + notification gate"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Mentions-on-edit recompute
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/shared/src/chat/messages.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the existing edit + send paths**
|
||||||
|
|
||||||
|
```
|
||||||
|
Read packages/shared/src/chat/messages.ts (offset 140, limit 100)
|
||||||
|
```
|
||||||
|
|
||||||
|
Find:
|
||||||
|
- `insertMessage` (~line 145) — calls `parseMentionUsernames` + `insertMentions`.
|
||||||
|
- `editEncryptedMessage` (~lines 208-229) — no mention re-extraction.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Extend `editEncryptedMessage`**
|
||||||
|
|
||||||
|
After the existing `UPDATE` on `messages`, add:
|
||||||
|
```ts
|
||||||
|
// Recompute mentions: edit can add/remove @-tokens. Drop old, insert new.
|
||||||
|
await client.from('message_mentions').delete().eq('message_id', messageId);
|
||||||
|
const mentionUsernames = parseMentionUsernames(plaintext);
|
||||||
|
if (mentionUsernames.length > 0) {
|
||||||
|
await insertMentions(client, {
|
||||||
|
messageId,
|
||||||
|
conversationId,
|
||||||
|
usernames: mentionUsernames,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Adapt to the exact arg shapes used by `insertMessage` (precedent).
|
||||||
|
|
||||||
|
If `editEncryptedMessage`'s signature doesn't accept `plaintext` and `conversationId`, extend the signature and fix all callers. Grep first: `Grep -rn "editEncryptedMessage" apps/desktop/src/ packages/shared/src/`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Typecheck + tests**
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck
|
||||||
|
pnpm --filter @chat-app/shared test -- --run
|
||||||
|
```
|
||||||
|
Expected: all green.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/shared/src/chat/messages.ts apps/desktop/src/
|
||||||
|
git commit -m "feat(P5C.T3): recompute message_mentions on edit"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Confetti on game win
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/desktop/package.json` (deps)
|
||||||
|
- Modify: `apps/desktop/src/components/GameModal.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the dep**
|
||||||
|
|
||||||
|
```
|
||||||
|
cd "D:\Programmieren\ChatApp-Electron\chat-app"
|
||||||
|
pnpm --filter @chat-app/desktop add canvas-confetti
|
||||||
|
pnpm --filter @chat-app/desktop add -D @types/canvas-confetti
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Fire confetti when the local player wins**
|
||||||
|
|
||||||
|
In `apps/desktop/src/components/GameModal.tsx` (P5B.T5 commit `cd59ee3`), add an effect near the existing keyboard-Esc effect:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import confetti from 'canvas-confetti';
|
||||||
|
// ...
|
||||||
|
useEffect(() => {
|
||||||
|
if (finished && winnerIdx !== null && winnerIdx === myPlayerIdx) {
|
||||||
|
void confetti({
|
||||||
|
particleCount: 80,
|
||||||
|
spread: 60,
|
||||||
|
origin: { x: 0.2, y: 0.9 },
|
||||||
|
});
|
||||||
|
void confetti({
|
||||||
|
particleCount: 80,
|
||||||
|
spread: 60,
|
||||||
|
origin: { x: 0.8, y: 0.9 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [finished, winnerIdx, myPlayerIdx]);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Typecheck**
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm --filter @chat-app/desktop typecheck
|
||||||
|
```
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/desktop/package.json apps/desktop/src/components/GameModal.tsx
|
||||||
|
# include pnpm-lock.yaml if changed at the repo root
|
||||||
|
git add pnpm-lock.yaml
|
||||||
|
git commit -m "feat(P5C.T4): confetti burst on game win"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final gate
|
||||||
|
|
||||||
|
- [ ] **Step 1: Typecheck both packages**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck`
|
||||||
|
Expected: both PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run shared tests**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @chat-app/shared test -- --run`
|
||||||
|
Expected: PASS — 71 tests (same as baseline; no new tests added).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify no uncommitted changes**
|
||||||
|
|
||||||
|
Run: `git status`
|
||||||
|
Expected: clean.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Report**
|
||||||
|
|
||||||
|
Report: "Phase 5C (Polish) code-complete on `main`; mentions_only migration applied to prod. Fifteen-features spec is now 100 % implemented. Smoke: (1) search 'xyz' in chat list → 'Keine Treffer' card. (2) Conv menu → 'Nur bei @Mentions' → DM with normal text → silent; DM with '@<dein-name>' → notify. (3) Edit a sent message to add @someone → that someone gets a notification. (4) Win a TTT or C4 game → confetti."
|
||||||
Reference in New Issue
Block a user