diff --git a/PRODUCT_PLAN.md b/PRODUCT_PLAN.md index 952aca6..83984eb 100644 --- a/PRODUCT_PLAN.md +++ b/PRODUCT_PLAN.md @@ -1,6 +1,6 @@ # Cove Product Plan -> Last updated: 2026-07-03 | Cycle: 38 | Phase: 2b — Polish & Identity | Build health: verified; 308 tests passing (126 core + 144 web + 24 contracts + 14 desktop); strict TypeScript clean +> Last updated: 2026-07-04 | Cycle: 39 | Phase: 2b — Polish & Identity | Build health: verified; 330 tests passing (131 core + 161 web + 24 contracts + 14 desktop); strict TypeScript clean > > Current objective: Build an app you and your friends actually want to use. Phase 2b pivots from platform infrastructure to product feel — landing page, UI bug fixes, custom server emoji, user profiles, rich embeds, file upload previews, and appearance customization. Deploying to cove.demonbox360.net on Oracle Cloud via Caddy. Migrating monorepo from npm to pnpm. > @@ -241,14 +241,14 @@ Native mobile, supported self-hosting, federation, knowledge channels, curated d | Area | Current | Notes | | ---------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Install | in-progress | Migrating from npm to pnpm (queued Cycle 35+, not started). pnpm install will replace npm install across monorepo. | -| Unit tests | verified | 308 total: 126 core + 144 web + 24 contracts + 14 desktop, confirmed Cycle 38 (2026-07-03). Rate-limit tests, ChannelTopicPanel, NotificationPreferencePanel, AttachmentApprovalPanel, MessageAttachment suites added this cycle. | +| Unit tests | verified | 330 total: 131 core + 161 web + 24 contracts + 14 desktop, confirmed Cycle 38→39 (2026-07-04). Member-list endpoint tests, CommunitySettingsModal and DmConversationList suites, attention-persistence and operator-auth test updates added this cycle. | | Type safety | verified | All five workspaces pass strict TypeScript (confirmed Cycle 30). | | Production build | verified | 215 kB gzip JS (livekit-client bundled) / 5.09 kB gzip CSS; multi-stage non-root container present. | | API integration | verified | All Phase 1–2 endpoints covered. Cycle 30 added snapshot persistence (`SNAPSHOT_FILE`). Operator endpoints auth guard in progress (Codex Cycle 30). | -| Frontend UX | partial | Community switching, reactions, replies, WS reconnect, error toasts now wired. Attention panel, create/join modal, loading state still missing (P0 queue). | +| Frontend UX | partial | Community switching, reactions, replies, WS reconnect, error toasts, attention panel, channel creation, member list, and appearance settings now wired (2026-07-04). Still unwired: emoji picker, message-list/composer/sidebar extraction, voice panel, passkey flow, user profile panel, channel topic panel, notification preferences, attachment approval, pinned messages, community settings modal, DM conversation list — see WORKLOG.md. | | Accessibility | partial | Semantic UI tests exist; real-browser review blocked. | | Performance | partial | Electron renderer 2.392 s; 464 MB working-set risk noted. PTT/soak unmeasured (session-blocked). | -| Security | partial | CSP, headers, runtime schemas, exact-origin CORS, 0 production vulns. Operator endpoint auth guard in progress. 5 high build-time `tar` findings via electron-rebuild (no fix available). | +| Security | partial | CSP, headers, runtime schemas, exact-origin CORS, 0 production vulns. Operator backup/restore now fails closed (503) by default when `OPERATOR_KEY` is unset (2026-07-04). 5 high build-time `tar` findings via electron-rebuild (no fix available). | | Reliability | partial | WS reconnects with backoff (Cycle 30). Snapshot persistence for local dev (Cycle 30). Container execution and deployment SLO validation environment-blocked. | | Cost | modelled | Initial cost model exists; real-world telemetry pending. | diff --git a/WORKLOG.md b/WORKLOG.md index 9f3fda7..489b6ca 100644 --- a/WORKLOG.md +++ b/WORKLOG.md @@ -1,20 +1,51 @@ # Cove — Work Log > Maintained by Claude (lead/orchestrator). Updated each work cycle. -> Last updated: 2026-07-03 (daily orchestration review) | Current cycle: 38 | PR: [#7](https://github.com/Vornecs/dsc-competitor/pull/7) (open) +> Last updated: 2026-07-05 (Codex roster change) | Current cycle: 39 | PR: [#7](https://github.com/Vornecs/dsc-competitor/pull/7) merged; [#8](https://github.com/Vornecs/dsc-competitor/pull/8) (open) covers this cycle's remaining work > > Older cycle detail (full "Completed — Cycle N" writeups, superseded sync notes, resolved flags) lives in [`WORKLOG_ARCHIVE.md`](WORKLOG_ARCHIVE.md) — this file only keeps what's still actionable. The Cycle history table at the bottom is the permanent one-line-per-cycle index. --- -## Orchestration sync note — 2026-07-03 (daily review) +## Orchestration sync note — 2026-07-05 (Codex off the team) -- **Five items landed since the last review, all reviewed against Definition of Done and passing:** Codex-16 (rate limiting on `POST /v1/communities`, `POST /v1/communities/:communityId/channels`, `POST /v1/reports` — 5/min, keyed on bearer token, `8a305f2`), AG-17/18/19/21 (ChannelTopicPanel, NotificationPreferencePanel, AttachmentApprovalPanel, MessageAttachment — all match spec, full unit test coverage, none touch `App.tsx`). AG-19 correctly re-flagged that no backend approve/reject route exists yet — tracked as Codex-19. -- **Codex-17 unblocked:** landed `communityMemberSchema`/`communityMembersResponseSchema` in `protocol.ts` — member-list route returns each member's **full live `Account`** (including `customStatus`), not a stripped projection. This is the smaller fix for cross-member custom-status visibility vs. making message-author snapshots refresh live (still deferred indefinitely as a design choice). -- **Health checks green:** typecheck clean across all 5 workspaces; 308/308 tests passing. -- **PR opened:** [#7](https://github.com/Vornecs/dsc-competitor/pull/7) (`cycle-38-batch` branch) bundling the above. -- **Queue minimums refreshed:** Codex refilled to 3 (Codex-17/19/20). Antigravity refilled to 3 (AG-22/23/24). P4 has 6 unclaimed items. P0–P3 combined has well over 10 remaining. -- **`App.tsx` wiring backlog escalated for explicit operator input** (see Flags below) — the "hard requirement" set last cycle to open with wiring didn't hold, because a genuine blocker (Codex-17) needed unblocking first. This has now been flagged 13 cycles running without the backlog shrinking once. +**Operator: Codex is off the roster (subscription status uncertain) — no longer an active implementer.** Reconciling Codex's last output and adjusting queues accordingly: + +- **Codex-20 (DM channel scoping investigation) landed** (`status/Codex.md`) — read-only, no source edits. Recommendation: do **not** use a synthetic `communityId` for DM channels; the non-null community FK leaks false membership/`@everyone`/bootstrap/audit/export/gateway semantics. Prefer a nullable/absent community ownership model for `kind: 'dm'`, backed by an explicit two-participant relation and a discriminated channel contract. Full call-site list and permission model recorded in Codex's status file. **This is now an open schema decision for Claude** (P3 item 32) — still blocks wiring `DmConversationList.tsx` (AG-23). +- **Codex-21 (pre-auth rate limiting) and Codex-22 (rate-limit audit)** were queued but never started. Moved to the Priority Queue below as unassigned backend items — no implementer currently owns backend/`app.ts` work. Claude may pick these up directly or hold them for a replacement backend agent; flagging Codex-21 (unauthenticated email-verify brute-force surface) as the more time-sensitive of the two. +- **Hot-files table and per-agent sections below updated** to drop Codex as an active roster entry. Antigravity remains active; the CLAUDE.md orchestration routine's "3+ queued tasks per active agent" now applies only to Antigravity until a backend implementer is reassigned. + +**Daily orchestration check — 2026-07-05 (health re-verification, no new agent output):** Re-ran `npm run typecheck && npm test` — clean, 330/330 tests passing (131 core + 161 web + 24 contracts + 14 desktop), matching the count already recorded in PRODUCT_PLAN.md. No `status/*.md` files have changed since the Codex-20/roster reconciliation above — Antigravity's AG-24/25/26 queue and the P4 queue (6 items) are untouched and still meet the backlog minimums, so no new task assignments were needed this pass. PR #8 remains open covering the full `cycle-38-batch` commit range; no additional feature commits landed today to fold in, so no new PR action taken. Committing this pass's reconciliation now (it was prepared but left uncommitted after the prior run). + +--- + +## Orchestration sync note — 2026-07-04 (wiring push, same-day follow-up) + +**Operator directive: make App.tsx wiring the #1 priority, above all else, until it's dealt with.** Response: Claude (owner of App.tsx) wired four backlog items directly this pass; Codex/Antigravity queues below are reordered so every near-term task either unblocks or directly feeds the remaining wiring work. + +- **Wired directly by Claude** (`e53a3b7`), verified live in the browser (created a community, created a channel through the new modal, confirmed member panel shows real data, confirmed accent/mode changes apply): + - `AttentionPanel.tsx` swapped in for the inline JSX (removes ~50 duplicated lines) + - `NewChannelModal.tsx` wired to the previously-dead "+" button on each channel category header — **closes P0 item 8d** — and to `POST /v1/communities/:communityId/channels` + - `MemberListPanel.tsx` wired into the right panel, fetching Codex-17's members route + the existing roles route + - Settings icon now opens a modal hosting `ThemeSettingsPanel.tsx` (accent color + mode) — **closes P0 item 7** (button was dead before). Accent persists to `localStorage`, applies via `--accent` on `:root`. Note: `ThemeSettingsPanel`'s mode enum (`light`/`dark`/`system`) doesn't have a `contrast` option, so `'system'` is mapped onto the app's existing `'contrast'` theme as a stopgap — cosmetically the label says "System" for what is actually contrast mode. Flagging this as a known rough edge, not a bug: a cleaner fix would add `contrast` to `ThemeSettingsPanel`'s own enum, which touches AG-owned code, or accept the mislabel. No action needed unless it bothers you. + - **Backlog now 12 unwired components** (down from 17): `EmojiPicker`, `UserAvatar` (message list/user dock only — its use inside `MemberListPanel` now ships), `Skeleton`, `MessageList`/`Composer`/`ChannelSidebar` (extraction), `VoicePanel`, `PasskeyFlow`, `UserProfilePanel`, `ChannelTopicPanel`, `NotificationPreferencePanel`, `MessageAttachment`, `CommunitySettingsModal`, plus the new `DmConversationList` (AG-23, landed this pass, blocked on Codex-20's schema decision) and pinned-messages UI (still no component built). +- **AG-23 (`DmConversationList.tsx`) landed** (`3097521`) while the above was in progress — reviewed, matches spec, doesn't touch App.tsx. Adds one item to the backlog (can't wire until Codex-20's DM scoping decision lands). +- **Codex-19 (attachment quarantine route, `3f6969e`) also landed autonomously while the above was in progress** — reviewed, matches spec (uploads now start `'pending'`, `PATCH /v1/attachments/:attachmentId/quarantine` gated on `message.manage`, tests cover approve/reject/repeat-moderation). Flagged one consequence: this flips every upload to pending with **no moderation surface reachable anywhere**, so Claude wired `AttachmentApprovalPanel.tsx` immediately after (`20c6d60`) — a warning icon in the channel header (shown when the active channel has pending attachments, derived from already-fetched message data) opens approve/reject cards. Not fully exercisable end-to-end yet since the composer's own upload button is still unwired (P3 item 17) — verified via typecheck/tests/clean empty-state render instead, ready to activate once upload lands. +- **Queues reordered around the wiring push** (see Codex/Antigravity sections below): Codex-20 (DM scoping) is now first since Codex-19 landed; Antigravity's next task is now the previously-missing **pinned-messages UI** component, since that's the one wiring item with no component to wire yet. +- **Nothing here looked too hard for the current agent roster** — no need for a Fable/Deepseek swarm on this pass. The remaining items are mechanical (swap inline JSX for existing components) except the `MessageList`/`Composer`/`ChannelSidebar` extraction, which is higher-risk (large diff surface, easy to regress message rendering) and is reserved for Claude to do carefully in a dedicated pass rather than rushed. +- Health: typecheck clean, 330/330 tests passing (131 core + 161 web + 24 contracts + 14 desktop), up from 322. + +--- + +## Orchestration sync note — 2026-07-04 (daily review) + +- **Codex-17 landed and reviewed** (`e4c749c`) — `GET /v1/communities/:communityId/members`, membership-gated, returns each member's full live `Account` (incl. `customStatus`) plus `roleIds`; tests cover multi-member, 403/404, empty-community. Matches spec, passes DoD. Resolves the backend half of P2 item 13. +- **AG-22 landed and reviewed** (`f7d56fe`) — `CommunitySettingsModal.tsx` shell: Overview tab wired to `onRename`/`onClose` with unsaved-changes gating, Channels/Members tabs are "coming soon" placeholders. Matches spec, full unit coverage, doesn't touch `App.tsx`. Adds a 17th item to the wiring backlog (see Hot files). +- **Claude landed three fixes directly this cycle** (`394664e`, `977dd35`): (1) attention "mark all read"/"dismiss" now call the real backend routes with optimistic update + error toast instead of mutating client-only state — **closes P0 item 8g**; (2) `OPERATOR_KEY` fail-open closed — backup/restore now returns 503 when the key is unset instead of exposing unauthenticated DB export/restore — **resolves the security flag from Cycle 37/38**; (3) message list was rendering newest-first (server cursor order) instead of oldest-first, breaking author-grouping — now sorted ascending by `createdAt`; (4) WS reconnect now no-ops on a cleanup-triggered close (StrictMode remount) instead of flashing the preview banner and racing a reconnect against the next socket's effect instance; (5) user-dock footer controls grouped into one flex row so the settings icon isn't visually orphaned — this is layout only, the icon still has no `onClick`, so **P0 item 7 stays open**. +- **Health checks green:** typecheck clean across all 5 workspaces; 322/322 tests passing (129 core + 155 web + 24 contracts + 14 desktop), up from 308. +- **Correction: PR #7 was already merged to `main`** (merge commit `017f14e`, prior to this review starting) — `cycle-38-batch` had 5 new commits on top of that merge (`e4c749c`, `394664e`, `f7d56fe`, `977dd35`, plus this review's reconciliation commit). Opened [PR #8](https://github.com/Vornecs/dsc-competitor/pull/8) for this new batch rather than assuming #7 was still open. +- **Queue minimums refreshed:** Codex refilled to 3 (Codex-19/20/21). Antigravity refilled to 3 (AG-23/24/25). P4 still has 6 unclaimed items. P0–P3 combined has well over 10 remaining. +- **`App.tsx` wiring backlog still not started as its own effort** — 17 components now unwired (AG-22 added one). Claude has been chipping at *adjacent* bugs (WS reconnect, attention persistence, message ordering) rather than the structural wiring/extraction pass itself. Recommend the operator either explicitly greenlight a dedicated wiring cycle (block other App.tsx work until the backlog clears) or deprioritize it in writing so this stops recurring as a no-op flag every cycle. --- @@ -39,11 +70,11 @@ These files are actively in flux or architecturally owned by the orchestrator. T | File | Reason | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `apps/web/src/App.tsx` | **Claude — flagged 14 cycles running as not yet started.** Wiring backlog: 16 built-but-unwired components (see Flags below for the full list) + 2 backend features (pinned messages, attention persistence), channel-creation UI, first-run empty-space onboarding view. Escalated for operator input. | -| `services/core/src/app.ts` | **Codex active Cycle 38** — Codex-17 (member-list route, now unblocked). Pull latest before starting. | +| `apps/web/src/App.tsx` | **Claude — operator-directed #1 priority as of 2026-07-04.** AttentionPanel/NewChannelModal/MemberListPanel/ThemeSettingsPanel/AttachmentApprovalPanel wired this pass (`e53a3b7`, `20c6d60`). 12 components remain unwired — see the sync note above and Claude's in-progress list below. This stays hot and stays top priority until the backlog clears. | +| `services/core/src/app.ts` | No active implementer as of 2026-07-05 (Codex off the roster). Codex-21/22 rate-limiting tasks sit unassigned in the Priority Queue — pull latest before starting if picked up. | | `services/core/src/index.ts` | Snapshot persistence settled — no active edits | -| `services/core/schema/` | No pending migration — member-list (Codex-17) is a read-only projection over existing data, no new persistence needed. | -| `packages/contracts/src/protocol.ts` | Schema changes require Claude — most recent addition: `communityMemberSchema`/`communityMembersResponseSchema` (2026-07-03) to unblock Codex-17. | +| `services/core/schema/` | No pending migration | +| `packages/contracts/src/protocol.ts` | Schema changes require Claude — most recent addition: `communityMemberSchema`/`communityMembersResponseSchema` (2026-07-03), now consumed by Codex-17. | | `PRODUCT_PLAN.md` | Authoritative delivery record | | `CLAUDE.md` | Orchestration rules | @@ -51,35 +82,39 @@ These files are actively in flux or architecturally owned by the orchestrator. T --- -## In progress — Cycle 38 (2026-07-03) +## In progress — Cycle 39 (2026-07-04) ### Claude — in progress -**Wire built-but-unwired components into App.tsx (carried over, growing list — now 16 components + 2 backend features):** +**Wire built-but-unwired components into App.tsx — operator-directed #1 priority as of 2026-07-04. Now 13 remaining (down from 17):** - [ ] `EmojiPicker.tsx` → message reaction/composer flow -- [ ] `UserAvatar.tsx` → message author display + user dock +- [ ] `UserAvatar.tsx` → message author display + user dock (already wired inside `MemberListPanel.tsx` as of this cycle — this item is now just the message-list/user-dock uses) - [ ] `Skeleton.tsx` → loading placeholders (replace ad-hoc loading markup) -- [ ] `MessageList.tsx`, `Composer.tsx`, `ChannelSidebar.tsx` → extract corresponding inline JSX out of `App.tsx` in favor of the built components (shrinks the 2100+ line file substantially) -- [ ] `NewChannelModal.tsx` → wire to a "+ create channel" affordance in the channel sidebar, call `POST /v1/communities/:communityId/channels` -- [ ] `AttentionPanel.tsx` → swap in for the inline attention section in `App.tsx`, wire to the Codex-9 endpoints +- [ ] `MessageList.tsx`, `Composer.tsx`, `ChannelSidebar.tsx` → extract corresponding inline JSX out of `App.tsx` in favor of the built components (shrinks the 2100+ line file substantially). **Deliberately reserved for a dedicated pass** — highest-risk item in the backlog (largest diff surface, easy to regress message rendering); not something to rush alongside other wiring. - [ ] `VoicePanel.tsx` → swap in for the inline voice/stage section in `App.tsx` - [ ] `PasskeyFlow.tsx` → wire into the sign-in modal as an alternate/additional auth path alongside the existing email-code flow -- [ ] `MemberListPanel.tsx` → wire into the community view as a right-side panel, sourced from the member-list route once Codex-17 lands -- [ ] `ThemeSettingsPanel.tsx` → wire into a settings surface, apply `data-theme` + `--accent` on `:root` when mode/accent change - [ ] `UserProfilePanel.tsx` → wire into message author / avatar click to open the panel - [ ] `ChannelTopicPanel.tsx` → wire into the channel header, call `PATCH /v1/channels/:channelId` - [ ] `NotificationPreferencePanel.tsx` → wire into a per-channel settings surface once attention/notification persistence is wired -- [ ] `AttachmentApprovalPanel.tsx` → **no backend route to call yet** (see Codex-19 below); wiring blocked until that lands - [ ] `MessageAttachment.tsx` → wire into `MessageList.tsx`'s per-message render (both AG-20 composer upload and this are now built) -- [ ] **Attention persistence wiring** — replace client-only `markAllAttentionRead`/`dismissAttentionItem` state mutations with calls to `GET/POST /v1/accounts/me/attention*` -- [ ] **Pinned messages UI** — pin/unpin action on messages (permission-gated) + a pinned-messages view in the channel header +- [ ] `CommunitySettingsModal.tsx` (AG-22) → wire to the community header `` menu, call `PATCH /v1/communities/:id` on rename +- [ ] `DmConversationList.tsx` (new, AG-23) → blocked on Codex-20's DM channel scoping decision before there's a real data source to wire it to +- [ ] **Pinned messages UI** — no component built yet; assigned to Antigravity as AG-24 (see below) since it's the one wiring item with nothing to wire + +**Done this cycle (2026-07-04):** + +- [x] **Attention persistence wiring** — "Mark all read"/"Dismiss" now call `POST /v1/accounts/me/attention/read-all` and `/attention/:id/dismiss` with optimistic update + error toast (closes P0 item 8g) +- [x] **`AttentionPanel.tsx`** swapped in for the inline attention JSX +- [x] **`NewChannelModal.tsx`** wired to the channel-category "+" button and `POST /v1/communities/:communityId/channels` — closes P0 item 8d +- [x] **`MemberListPanel.tsx`** wired into the right panel using Codex-17's members route + the existing roles route +- [x] **Settings button** now opens a modal hosting `ThemeSettingsPanel.tsx` — closes P0 item 7 (button was dead before); accent color persists to `localStorage` +- [x] **`AttachmentApprovalPanel.tsx`** wired — warning icon in the channel header when pending attachments exist, opens approve/reject cards calling Codex-19's quarantine route. Not fully E2E-exercisable yet since composer upload (P3 item 17) is still unwired. **Carried over:** - [ ] **First-run empty-space onboarding view** — `GET /v1/bootstrap` correctly returns `communities: []` for new accounts; frontend still needs an actual "create or join your first space" screen for that state - [ ] Fix attention panel orphaned × button layout -- [ ] Fix settings button placement (currently buried under user profile area) - [ ] Remove hardcoded "Practice run · Tonight · 6 interested" event card **pnpm migration:** @@ -88,55 +123,39 @@ These files are actively in flux or architecturally owned by the orchestrator. T --- -### Codex — ready for next task - -**Codex-16 (rate limiting) landed** (`8a305f2`) — reviewed, matches spec. No revision needed. **Codex-17 was blocked, now unblocked** — schema landed by Claude this pass (see sync note above). - -**Current task:** Codex-17 (pick this up next) - -**Queue — pick in order after current task:** - -**Codex-17:** Member-list endpoint (P2 item 13, follow-up) -Files: `services/core/src/app.ts` + test (schema already landed by Claude, do not edit `protocol.ts`) -Details: Add `GET /v1/communities/:communityId/members`, gated on membership (any member can list, not just admins). Response shape is `communityMembersResponseSchema` (`{ members: CommunityMember[] }`) — each member is a full live `Account` lookup (including current `customStatus`/`status`) plus `roleIds` from their membership record and an optional `joinedAt`. Read-only projection over existing membership + account data, no new persistence needed. Tests: full member list for a multi-member community, 403/404 for non-members, empty-community edge case. +### Codex — off the roster (2026-07-05) -**Codex-19:** Attachment moderation approve/reject route (unblocks AG-19's `AttachmentApprovalPanel.tsx`, P3 item 27 follow-up) -Files: `services/core/src/app.ts` + test -Details: Today's upload endpoint (`app.ts:2005`) auto-approves every upload — nothing stays `'pending'`. Add a `PATCH /v1/attachments/:attachmentId/quarantine` route (your call on the exact path), gated on a moderation permission (reuse `message.manage` or propose a narrower one — flag Claude if so), accepting `{ status: 'approved' | 'rejected' }`, validating the current status is `'pending'` before transitioning. Tests: approve path, reject path, rejecting a non-pending attachment. - -**Codex-20:** DM channels scoping pass (P3 item 32) -Files: read-only this task — no edits -Details: `communityId` is required on every existing channel/message code path today, which makes DMs (`kind: 'dm'`, private to two participants) a bigger lift than normal. Produce a scoping note (in your status file) covering: which call sites in `app.ts` assume a non-null `communityId`, whether a DM channel should get a synthetic `communityId` sentinel or whether the schema needs it optional, and the permission model for a 2-participant channel (no roles, no `@everyone`). Investigation only — Claude makes the actual schema call from your findings. +Codex is no longer an active implementer (operator call, subscription status uncertain). Last landed work: Codex-17 (member-list endpoint, `e4c749c`), Codex-19 (attachment quarantine route, `3f6969e`), and Codex-20 (DM scoping investigation, read-only — see sync note above), all reviewed and reconciled. Codex-21 and Codex-22 (queued, never started) have been moved to the unassigned Priority Queue below. No new tasks should be drafted for Codex until/unless the operator reactivates the seat. If a replacement backend implementer joins, hand them the Priority Queue backend items directly rather than recreating a Codex-numbered queue. --- ### Antigravity — ready for next task -**AG-17/18/19/21 all landed** (`f6de55e`, `608ef2c`, `9c75fd1`, `f0870e7`) — reviewed, all match spec, none touch `App.tsx`/`MessageList.tsx`. No revisions needed. +**AG-22 (CommunitySettingsModal shell) landed** (`f7d56fe`) and **AG-23 (DmConversationList) landed** (`3097521`) — both reviewed, match spec, no revisions needed. -**Current task:** AG-22 (pick this up next) +**Current task: AG-24 — this is the wiring-critical one, pick it up first.** Pinned messages is the one item in the App.tsx wiring backlog with no component built yet; everything else on the backlog is a mechanical swap Claude can do directly. Building this unblocks Claude's next wiring pass. **Queue — pick in order after current task:** -**AG-22:** Community settings modal shell (P3 item 25, follow-up) -Files: `apps/web/src/CommunitySettingsModal.tsx` (new), unit tests -Details: The `` on the community header needs a settings modal. Backend rename (`PATCH /v1/communities/:id`) already shipped. Build the modal shell: a tabbed/sectioned layout with an "Overview" tab (name edit field wired to `onRename: (name: string) => void`) and placeholder "Channels"/"Members" tabs rendering a "coming soon" state — those need full channel-list-management and member-role-assignment UI as separate follow-ups. Props: `community: { id, name }`, `onRename: (name: string) => void`, `onClose: () => void`. Do NOT edit App.tsx. - -**AG-23:** DM conversation list placeholder (P3 item 32, frontend scoping companion to Codex-20) -Files: `apps/web/src/DmConversationList.tsx` (new), unit tests -Details: Codex is producing a backend scoping note for DM channels this cycle; build the frontend counterpart in isolation. A list of DM conversations (avatar/display name/presence dot/last-message preview per conversation, reusing `UserAvatar.tsx`). Accept `conversations: Array<{ id, participant: { id, displayName, initials, status }, lastMessagePreview: string | undefined }>` and `onSelect: (id: string) => void` — a reasonable guess at the eventual contract, adjustable once Codex-20 lands. Do NOT edit App.tsx. +**AG-24:** Pinned messages panel (new, P3 item 18, wiring-critical) +Files: `apps/web/src/PinnedMessagesPanel.tsx` (new), unit tests +Details: Backend is fully built (`services/core/src/app.ts`): `POST`/`DELETE /v1/channels/:channelId/messages/:messageId/pin` (pin/unpin, gated on `message.manage` permission) and `GET /v1/channels/:channelId/pinned` returning `{ items: Message[] }` (fully materialized messages, same shape as the channel message list). Build: (1) a panel component listing pinned messages for a channel — author, content preview, timestamp, and an "Unpin" action per item, empty state when there are none; (2) since there's no dedicated "pin" component elsewhere, also export a small `PinButton`-style affordance (icon + label, toggles based on a `pinned: boolean` prop) that Claude can drop into the per-message action row in `MessageList.tsx`/the inline message actions in `App.tsx`. Props for the panel: `items: Message[]`, `onUnpin: (messageId: string) => void`, `onClose: () => void`, `canManage: boolean` (hide the unpin action when false). Props for the button: `pinned: boolean`, `canManage: boolean`, `onToggle: () => void`. Do NOT edit App.tsx or MessageList.tsx — Claude wires both pieces in once they land. -**AG-24:** Member list role-assignment control (P3 item 29, follow-up) +**AG-25:** Member list role-assignment control (P3 item 29, follow-up) Files: `apps/web/src/MemberRoleAssigner.tsx` (new), unit tests Details: `MemberListPanel.tsx` displays members grouped by role but has no way to assign/remove roles. Build a control (checkbox list/multi-select of a community's roles) that lets an admin toggle role membership for a member. Props: `roles: Role[]` (id/name/color), `assignedRoleIds: string[]`, `canManage: boolean`, `onToggleRole: (roleId: string, assign: boolean) => void`. Disable all controls when `canManage` is false. Do NOT edit App.tsx or MemberListPanel.tsx. +**AG-26:** Guided report flow modal (P3 item 26) +Files: `apps/web/src/GuidedReportFlow.tsx` (new), unit tests +Details: Backend `POST /v1/reports` already validates against `createReportRequestSchema` (`communityId`, `targetType: 'message'|'user'|'channel'`, `targetId`, `reason` string ≤200 chars, optional `details` ≤1000 chars) — see `packages/contracts/src/protocol.ts`. Build a low-friction, multi-step wizard (per CLAUDE.md product principle 5 — guided, never intimidating): step 1 pick a reason from a short fixed list of common reasons (e.g. harassment, spam, explicit content, other) as large tappable options rather than a dropdown; step 2 optional free-text details textarea; step 3 a calm confirmation state after submit. Props: `targetType: 'message' | 'user' | 'channel'`, `targetId: string`, `onSubmit: (input: { reason: string; details?: string }) => void`, `onClose: () => void`. Do NOT edit App.tsx. + --- ## Flags for orchestrator -- **App.tsx wiring backlog needs an explicit operator call.** 16 components built-but-unwired (EmojiPicker, UserAvatar, Skeleton, MessageList, Composer, ChannelSidebar, NewChannelModal, AttentionPanel, VoicePanel, PasskeyFlow, MemberListPanel, ThemeSettingsPanel, UserProfilePanel, ChannelTopicPanel, NotificationPreferencePanel, AttachmentApprovalPanel, MessageAttachment) plus 2 backend features (pinned messages, attention persistence) with no frontend consumer. This has grown every cycle since Cycle 32; the "hard requirement" set 2026-07-02 to open the next cycle with wiring didn't hold this pass either (a genuine blocker needed unblocking first). Recommend the operator either commit to blocking on wiring before any other work next cycle, or explicitly deprioritize it so this stops being a recurring no-op flag. +- ~~App.tsx wiring backlog needs an explicit operator call~~ — **operator made the call 2026-07-04: wiring is now the #1 priority above all else.** Claude wired 5 items directly this pass (AttentionPanel, NewChannelModal, MemberListPanel, ThemeSettingsPanel/Settings button, AttachmentApprovalPanel — see sync note above), closing P0 items 7 and 8d. 12 components remain: EmojiPicker, UserAvatar (message-list/user-dock uses), Skeleton, MessageList/Composer/ChannelSidebar (extraction, deliberately deferred to its own pass), VoicePanel, PasskeyFlow, UserProfilePanel, ChannelTopicPanel, NotificationPreferencePanel, MessageAttachment, CommunitySettingsModal, DmConversationList (blocked on Codex-20) — plus pinned-messages UI, now assigned to Antigravity as AG-24 since the component doesn't exist yet. No remaining item looked too hard for the current roster; no Fable/Deepseek swarm needed unless the operator wants the `MessageList`/`Composer`/`ChannelSidebar` extraction parallelized. - **`/register` route and path-based channel URLs are both unresolved routing decisions.** Both need the same underlying call: adopt React Router (recommended — resolves deep-linkable channels and a real `/register` path at once) vs. continue modal/state-only. Awaiting explicit operator go-ahead before starting a router migration unprompted. -- **`OPERATOR_KEY` fail-open is a security posture question, not a silent fix.** `requireOperatorAuth` (`services/core/src/app.ts`, search for the function) returns `true` whenever `OPERATOR_KEY` is unset, so a deployment that forgets to set it exposes full backup export _and_ restore-overwrite with zero authentication. An existing test codifies this as intentional (presumably for zero-config local dev). Recommend failing closed by default (503 until `OPERATOR_KEY` is set) with the permissive behavior kept only in test/dev builds — but this changes production security posture, so it needs an explicit operator call rather than a silent change. +- ~~`OPERATOR_KEY` fail-open~~ — **resolved 2026-07-04.** `requireOperatorAuth` now returns 503 ("operator backup and restore are unavailable") whenever `OPERATOR_KEY` is unset, in all environments including local/test — no permissive dev-mode carve-out. The existing test that codified fail-open behavior was updated to assert the 503 instead. --- @@ -147,11 +166,11 @@ Claude pulls from here when agent work is integrated. Agents work from their own ### P0 — UI bugs and broken flows 6. **Attention panel orphaned × buttons** — layout needs a proper pass -7. **Settings button buried under user profile** — needs to be accessible +~~7. Settings button buried under user profile~~ — **done 2026-07-04**: button now opens a modal hosting `ThemeSettingsPanel.tsx` 8. **Hardcoded event card** — remove the "Practice run" static fallback - 8d. **No UI to create channels** — backend route exists; `NewChannelModal.tsx` built but not wired into App.tsx + ~~8d. No UI to create channels~~ — **done 2026-07-04**: `NewChannelModal.tsx` wired to the channel-category "+" button and `POST /v1/communities/:communityId/channels` 8f. **No first-run onboarding for zero-community accounts** - 8g. **Attention mark-as-read/dismiss don't persist** — backend shipped; frontend still mutates client state only + ~~8g. Attention mark-as-read/dismiss don't persist~~ — **done 2026-07-04**, now calls the real backend routes with optimistic update + error toast ### P1 — Landing page & routing @@ -160,15 +179,24 @@ Claude pulls from here when agent work is integrated. Agents work from their own ### P2 — Identity & customization 12. **User avatars** — `UserAvatar.tsx` built but not wired into message authors / user dock yet -13. **Custom status** — self-visibility done (Codex-12). Cross-member visibility: schema landed, route in progress (Codex-17). Message-author snapshot refresh deferred indefinitely as a design choice. +13. **Custom status** — self-visibility done (Codex-12). Cross-member visibility: ~~schema + route~~ **and now the UI, all done 2026-07-04** — `MemberListPanel.tsx` wired into the community right panel, sourced from Codex-17's members route. Message-author snapshot refresh deferred indefinitely as a design choice. 14. **Custom server emoji** — backend + Repository persistence done; still needs Claude to wire message renderer + `EmojiPicker.tsx` -15. **Themes & appearance** — `ThemeSettingsPanel.tsx` built, not yet wired into App.tsx +~~15. Themes & appearance~~ — **done 2026-07-04**: `ThemeSettingsPanel.tsx` wired behind the Settings button; accent persists to `localStorage`. Known rough edge: the panel's mode enum has no `contrast` option, so "System" in the UI currently maps onto the app's existing `contrast` theme rather than true OS-preference detection — cosmetic mislabel, not a functional bug. + +### P3 — Backend (unassigned — Codex off the roster, 2026-07-05) + +35. **Rate-limit the pre-auth endpoints** (security follow-up to Codex-16, was Codex-21) + Files: `services/core/src/app.ts` + test + Details: Two pre-auth routes have no rate limiting today, unlike the community/channel/report routes Codex-16 covered: `POST /v1/auth/email/send-code` (`app.ts:846`, spam/probing risk) and — more urgent — `POST /v1/auth/email/verify` (a 6-digit code is only 1,000,000 possibilities; with no throttling it's brute-forceable). Add `config.rateLimit` blocks matching the Codex-16 pattern (same `keyGenerator`/`max`/`timeWindow` shape, key on IP since there's no bearer token pre-auth) to both: send-code tuned to ~5/min per IP+email, verify tuned tighter (~10/min per IP+challenge). Tests: exceeding either limit returns 429, distinct emails/IPs/challenges aren't cross-throttled. **More time-sensitive of the two — brute-forceable auth surface.** +36. **Rate-limit audit for remaining mutating routes** (security follow-up, was Codex-22) + Files: `services/core/src/app.ts` + test + Details: Codex-16 covered community/channel creation and reports; item 35 above covers the two pre-auth routes. Audit the rest of `app.ts` for mutating routes (POST/PATCH/PUT/DELETE) with no `config.rateLimit` block — likely candidates: reactions, message edit/delete, channel topic/rename (`PATCH /v1/channels/:channelId`), role CRUD, ban/unban, invite creation. Apply the same rate-limit pattern to any that look abusable. List what was added vs. deliberately skipped and why. Tests: one per newly-limited route confirming 429 past the limit. ### P3 — Core messaging polish 16. **Rich link embeds** — backend unfurl endpoint done; frontend inline rendering not yet assigned 17. **File & image uploads** — byte-cap bump, composer upload UI (AG-20), and inline message rendering (AG-21) all built; still needs `App.tsx`/`MessageList.tsx` wiring -18. **Pinned messages** — backend done (Codex-8); UI (pin button + panel) not yet wired +18. **Pinned messages** — backend done (Codex-8); UI (pin button + panel) assigned AG-24 (queued, wiring-critical — no component exists yet) 19. **Message search** — keyword search within channel (deferred — see P5) 20. **Bootstrap failure UX** — component built (AG-15/BootstrapError.tsx), not yet wired 21. **Frontend error reporting** — `window.onerror` → `showToast()` @@ -176,14 +204,14 @@ Claude pulls from here when agent work is integrated. Agents work from their own ### P3 — Features 24. **Passkey auth UI** — `PasskeyFlow.tsx` built, Claude wires into sign-in modal -25. **Community settings panel** — name-edit backend done; shell UI assigned AG-22 (queued); full channel-list-management and member-role-assignment tabs are further follow-ups -26. **Guided report flow** — backend done; frontend modal not yet assigned -27. **Attachment approval UX** — `AttachmentApprovalPanel.tsx` built (AG-19); backend route assigned Codex-19 (queued) +25. **Community settings panel** — name-edit backend done; shell UI landed (AG-22, `CommunitySettingsModal.tsx`, 2026-07-04) but not yet wired into App.tsx; full channel-list-management and member-role-assignment tabs are further follow-ups +26. **Guided report flow** — backend done; frontend modal assigned AG-26 (queued) +~~27. Attachment approval UX~~ — **done 2026-07-04**: backend route (Codex-19) and frontend wiring both landed; not yet exercisable end-to-end pending composer upload wiring (P3 item 17) 28. **User profile panel** — `UserProfilePanel.tsx` built (AG-16), not yet wired into App.tsx -29. **Member list sidebar** — member-list route in progress (Codex-17); `MemberListPanel.tsx` built; role-assignment control assigned AG-24 (queued) +29. **Member list sidebar** — ~~member-list route in progress~~ **done 2026-07-04**: `MemberListPanel.tsx` wired into the community right panel using Codex-17's route + the existing roles route; role-assignment control assigned AG-25 (queued) 30. **Channel topic/description** — backend + UI both done (`ChannelTopicPanel.tsx`, AG-17); not yet wired into App.tsx 31. **Notification preference panel** — UI done (`NotificationPreferencePanel.tsx`, AG-18); not yet wired into App.tsx -32. **DM channels** — backend scoping pass assigned Codex-20 (queued); frontend placeholder list assigned AG-23 (queued); schema decision pending Codex-20's findings +32. **DM channels** — backend scoping investigation landed (Codex-20, 2026-07-05): recommends nullable/absent community ownership for `kind: 'dm'` over a synthetic `communityId`, backed by a two-participant relation and discriminated channel contract (full findings in `status/Codex.md` / sync note above). **Open schema decision now sits with Claude** — no implementer to hand the follow-through to with Codex off the roster. Frontend placeholder list landed (AG-23, `DmConversationList.tsx`, 2026-07-04) but can't be wired until the schema decision lands. 33. **Server-side notification mute persistence** — backend done; needs the fetch wired into bootstrap and synced with localStorage 34. **Staff/admin user type** — long-term operator want, not urgent; needs a role-model decision before scoping @@ -225,7 +253,7 @@ Full narrative detail for any cycle below lives in [`WORKLOG_ARCHIVE.md`](WORKLO | Cycle | What shipped | | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 38 | (in progress — Codex-16 rate limiting, AG-17/18/19/21 components (ChannelTopicPanel, NotificationPreferencePanel, AttachmentApprovalPanel, MessageAttachment) all reviewed and landed; dev-port pin fix; landed `communityMemberSchema` to unblock Codex-17; 308 tests green; PR #7 opened. App.tsx wiring still not started — 16 components now unwired, escalated for operator input.) | +| 38 | Codex-16 rate limiting, AG-17/18/19/21 components (ChannelTopicPanel, NotificationPreferencePanel, AttachmentApprovalPanel, MessageAttachment), Codex-17 member-list endpoint, AG-22 CommunitySettingsModal shell all reviewed and landed; `communityMemberSchema` unblocked Codex-17; attention persistence wired to real backend calls (closes P0 8g); `OPERATOR_KEY` fail-open closed (503 by default); message-list ordering bug fixed; WS reconnect no longer storms on cleanup-triggered closes; 322 tests green (up from 207 at cycle start); PR #7 open, 4 new commits pushed. App.tsx structural wiring pass itself still not started — 17 components now unwired, escalated for operator input again. | | 37 | Daytime: PR #3/main reconciliation, Codex-10/11/AG-15 review, customStatus/report schemas landed, PR #4 opened. Evening: 4-task Grok CLI trial (link embeds, guided reports, community rename, themes panel) + Codex-12 custom status + AG-16 user profile panel, all reviewed and landed; 261 tests green. | | 36 | Custom-emoji Repository persistence (Codex-10), channel topic edit endpoint (Codex-11), BootstrapError.tsx (AG-15); reconciled a local/`origin` `main` divergence from the PR #3 merge; landed `customStatus` and `report` schemas to unblock Codex-12/13; 230 tests green. | | 35 | PasskeyFlow.tsx + MemberListPanel.tsx built (AG-13/14); custom-emoji migration (`006_server_emoji.sql`) landed to unblock Codex-10; 222 tests green. | diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 836da33..435f7b3 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,6 +2,7 @@ import { bootstrapStateSchema, attentionItemSchema, communityStatsSchema, + communityMembersResponseSchema, gatewayServerFrameSchema, messageReactionUpdateSchema, messageSchema, @@ -13,18 +14,23 @@ import { screenShareEndedSchema, stageSpeakingStateSchema, type AuditEvent, + channelSchema, type BootstrapState, type AttentionItem, type Channel, + type CommunityMember, type CommunityStats, + type CreateChannelRequest, + type Attachment, type Message, type Participant, + type Role, type VoiceSession, type StageParticipants, } from '@cove/contracts'; import { Avatar, IconButton, StatusPill } from '@cove/ui'; import { - Bell, + AlertTriangle, ChevronDown, ClipboardList, Download, @@ -49,7 +55,6 @@ import { Volume2, MicOff, VolumeX, - BellOff, X, } from 'lucide-react'; import { Room, RoomEvent, Track } from 'livekit-client'; @@ -57,6 +62,12 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import type { FormEvent } from 'react'; import { resolveRuntimeConfig } from './runtime-config'; import { Landing } from './Landing'; +import { AttentionPanel } from './AttentionPanel'; +import NewChannelModal from './NewChannelModal'; +import { ThemeSettingsPanel, ACCENT_PRESETS } from './ThemeSettingsPanel'; +import type { ThemeMode } from './ThemeSettingsPanel'; +import { MemberListPanel, type MemberWithRoles } from './MemberListPanel'; +import { AttachmentApprovalPanel } from './AttachmentApprovalPanel'; type ConnectionState = 'connecting' | 'live' | 'preview'; type Density = 'compact' | 'comfortable' | 'touch'; @@ -166,6 +177,13 @@ export function markAllAttentionRead(current: AttentionItem[]): AttentionItem[] return current.map((item) => (item.unread ? { ...item, unread: false } : item)); } +export function toMemberWithRoles(member: CommunityMember): MemberWithRoles { + const { customStatus, ...rest } = member; + if (!customStatus) return rest; + const { text, emoji } = customStatus; + return { ...rest, customStatus: emoji ? { text, emoji } : { text } }; +} + export function reconcileVoiceJoin( channels: Channel[], channelId: string, @@ -326,6 +344,21 @@ export function App() { const [density, setDensity] = useState('comfortable'); const [theme, setTheme] = useState<'dark' | 'light' | 'contrast'>('dark'); const [communityStats, setCommunityStats] = useState(null); + const [communityMembers, setCommunityMembers] = useState([]); + const [communityRoles, setCommunityRoles] = useState([]); + const [accentColor, setAccentColor] = useState(() => { + if (import.meta.env.MODE === 'test') return '#ff7759'; + try { + return localStorage.getItem('cove_accent_color') || '#ff7759'; + } catch { + return '#ff7759'; + } + }); + const [showUserSettingsModal, setShowUserSettingsModal] = useState(false); + const [showAttachmentModeration, setShowAttachmentModeration] = useState(false); + const [showNewChannelModal, setShowNewChannelModal] = useState(false); + const [newChannelLoading, setNewChannelLoading] = useState(false); + const [newChannelError, setNewChannelError] = useState(null); const [activeVoiceChannelId, setActiveVoiceChannelId] = useState(null); const [voiceSession, setVoiceSession] = useState(null); const [auditLog, setAuditLog] = useState([]); @@ -408,7 +441,9 @@ export function App() { const parsed = data.items .map((m) => messageSchema.safeParse(m)) .filter((r) => r.success) - .map((r) => r.data!); + .map((r) => r.data!) + // Server returns newest-first for cursor pagination; the timeline renders oldest-first + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); setMessages((current) => { const others = current.filter((m) => m.channelId !== channelId); return [...others, ...parsed]; @@ -444,6 +479,71 @@ export function App() { } } + async function handleMarkAllAttentionRead() { + setBootstrap((current) => ({ + ...current, + attention: markAllAttentionRead(current.attention), + })); + if (!sessionToken) return; + try { + const res = await fetch(`${API_BASE}/v1/accounts/me/attention/read-all`, { + method: 'POST', + headers: { authorization: `Bearer ${sessionToken}` }, + }); + if (!res.ok) throw new Error(`${res.status}`); + } catch { + showToast('Failed to mark notifications as read'); + } + } + + async function handleDismissAttention(attentionId: string) { + setBootstrap((current) => ({ + ...current, + attention: dismissAttentionItem(current.attention, attentionId), + })); + if (!sessionToken) return; + try { + const res = await fetch(`${API_BASE}/v1/accounts/me/attention/${attentionId}/dismiss`, { + method: 'POST', + headers: { authorization: `Bearer ${sessionToken}` }, + }); + if (!res.ok) throw new Error(`${res.status}`); + } catch { + showToast('Failed to dismiss notification'); + } + } + + async function handleModerateAttachment( + attachmentId: string, + status: 'approved' | 'rejected', + ) { + if (!sessionToken) { + showToast('Sign in to moderate attachments'); + return; + } + try { + const res = await fetch(`${API_BASE}/v1/attachments/${attachmentId}/quarantine`, { + method: 'PATCH', + headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify({ status }), + }); + if (!res.ok) { + const problem = await res.json().catch(() => null); + throw new Error(problem?.detail ?? `Failed to update attachment (${res.status})`); + } + setMessages((current) => + current.map((message) => ({ + ...message, + attachments: message.attachments.map((attachment) => + attachment.id === attachmentId ? { ...attachment, quarantineStatus: status } : attachment, + ), + })), + ); + } catch (err) { + showToast(err instanceof Error ? err.message : 'Failed to update attachment'); + } + } + async function switchCommunity(communityId: string) { setBootstrap((prev) => { const firstTextChannel = prev.channels.find( @@ -531,6 +631,40 @@ export function App() { } } + async function handleCreateChannel(payload: CreateChannelRequest) { + if (!sessionToken) { + setShowNewChannelModal(false); + setShowLoginModal(true); + return; + } + setNewChannelLoading(true); + setNewChannelError(null); + try { + const res = await fetch( + `${API_BASE}/v1/communities/${bootstrap.activeCommunityId}/channels`, + { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify(payload), + }, + ); + if (!res.ok) { + const problem = await res.json().catch(() => null); + throw new Error(problem?.detail ?? `Failed to create channel (${res.status})`); + } + const parsed = channelSchema.safeParse(await res.json()); + if (!parsed.success) throw new Error('Server returned an unexpected channel shape.'); + setBootstrap((prev) => ({ ...prev, channels: [...prev.channels, parsed.data] })); + setShowNewChannelModal(false); + setActiveChannelId(parsed.data.id); + showToast(`#${parsed.data.name} created!`); + } catch (err) { + setNewChannelError(err instanceof Error ? err.message : 'Failed to create channel.'); + } finally { + setNewChannelLoading(false); + } + } + function toggleChannelMute(channelId: string) { setMutedChannelIds((prev) => { const next = new Set(prev); @@ -711,12 +845,29 @@ export function App() { return true; }); }, [communityChannels]); + const pendingAttachments = useMemo( + () => + channelMessages.flatMap((message) => + message.attachments + .filter((attachment) => attachment.quarantineStatus === 'pending') + .map((attachment) => ({ attachment, uploadedBy: message.author.displayName })), + ), + [channelMessages], + ); useEffect(() => { document.documentElement.dataset.theme = theme; document.documentElement.dataset.density = density; }, [density, theme]); + useEffect(() => { + document.documentElement.style.setProperty('--accent', accentColor); + if (import.meta.env.MODE === 'test') return; + try { + localStorage.setItem('cove_accent_color', accentColor); + } catch {} + }, [accentColor]); + useEffect(() => { if (import.meta.env.MODE === 'test' || !sessionToken) return; const controller = new AbortController(); @@ -732,7 +883,10 @@ export function App() { setMessages(parsed.messages); setActiveChannelId(parsed.activeChannelId); }) - .catch(() => setConnection('preview')); + .catch((error) => { + if (error?.name === 'AbortError') return; + setConnection('preview'); + }); return () => controller.abort(); }, [sessionToken]); @@ -742,6 +896,8 @@ export function App() { const url = `${runtimeConfig.gatewayUrl}?token=${encodeURIComponent(sessionToken)}`; const socket = new WebSocket(url); let heartbeat: number | undefined; + let reconnectTimeout: number | undefined; + let cleanedUp = false; socket.addEventListener('open', () => setConnection('connecting')); socket.addEventListener('message', (event) => { const parsed = gatewayServerFrameSchema.safeParse(JSON.parse(String(event.data))); @@ -887,18 +1043,25 @@ export function App() { } }); socket.addEventListener('close', () => { + // A cleanup-triggered close (e.g. React StrictMode's mount/cleanup/remount, or an + // intentional teardown when deps change) is not a real disconnect — don't flash the + // banner or schedule a reconnect that would tear down the *next* socket. + if (cleanedUp) return; setConnection('preview'); const attempt = wsReconnectAttemptRef.current; wsReconnectAttemptRef.current = attempt + 1; const delay = Math.min(1000 * Math.pow(2, attempt), 30_000); - setTimeout(() => setWsConnectSignal((s) => s + 1), delay); + reconnectTimeout = window.setTimeout(() => setWsConnectSignal((s) => s + 1), delay); }); socket.addEventListener('error', () => { // 'close' fires after 'error', reconnect is handled there + if (cleanedUp) return; setConnection('preview'); }); return () => { + cleanedUp = true; if (heartbeat) window.clearInterval(heartbeat); + if (reconnectTimeout) window.clearTimeout(reconnectTimeout); socket.close(); }; }, [sessionToken, bootstrap.account.id, wsConnectSignal]); @@ -999,6 +1162,34 @@ export function App() { return () => controller.abort(); }, [sessionToken, bootstrap.activeCommunityId]); + useEffect(() => { + if (!sessionToken || !bootstrap.activeCommunityId) return; + const controller = new AbortController(); + const headers = { authorization: `Bearer ${sessionToken}` }; + void fetch(`${API_BASE}/v1/communities/${bootstrap.activeCommunityId}/members`, { + headers, + signal: controller.signal, + }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!data) return; + const parsed = communityMembersResponseSchema.safeParse(data); + if (parsed.success) setCommunityMembers(parsed.data.members); + }) + .catch(() => {}); + void fetch(`${API_BASE}/v1/communities/${bootstrap.activeCommunityId}/roles`, { + headers, + signal: controller.signal, + }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!data || !Array.isArray(data.roles)) return; + setCommunityRoles(data.roles); + }) + .catch(() => {}); + return () => controller.abort(); + }, [sessionToken, bootstrap.activeCommunityId]); + useEffect(() => { if (import.meta.env.MODE === 'test' || !activeChannelId) return; void fetchChannelMessages(activeChannelId); @@ -1527,7 +1718,14 @@ export function App() {
{category} -
@@ -1675,68 +1873,70 @@ export function App() { @{bootstrap.account.handle} )} - {sessionToken ? ( - - +
+ {activeVoiceChannelId && ( + <> + leaveVoice(activeVoiceChannelId)}> + + + + + + + )} + setIsMuted((m) => !m)} + > + {isMuted ? ( + + ) : ( + + )} - ) : ( - - )} - {activeVoiceChannelId && ( - <> - leaveVoice(activeVoiceChannelId)}> - - - - + {isDeafened ? ( + + ) : ( + + )} + + {sessionToken ? ( + + - - )} - setIsMuted((m) => !m)} - > - {isMuted ? ( - ) : ( - - )} - - setIsDeafened((d) => !d)} - > - {isDeafened ? ( - - ) : ( - + )} - - - - + setShowUserSettingsModal(true)}> + + +
@@ -1780,6 +1980,14 @@ export function App() { + {pendingAttachments.length > 0 && ( + setShowAttachmentModeration((open) => !open)} + > + + + )} setContextOpen(!contextOpen)} @@ -1788,6 +1996,35 @@ export function App() { + {showAttachmentModeration && pendingAttachments.length > 0 && ( +
+ {pendingAttachments.map(({ attachment, uploadedBy }) => ( + void handleModerateAttachment(id, 'approved')} + onReject={(id) => void handleModerateAttachment(id, 'rejected')} + /> + ))} +
+ )} +
@@ -2060,77 +2297,16 @@ export function App() { ))}
-
-
- Attention - -
- {bootstrap.attention.map((item) => ( -
- - - {item.channelId && ( - - )} - - -
- ))} -
+ void handleMarkAllAttentionRead()} + onDismiss={(id) => void handleDismissAttention(id)} + onToggleMute={toggleChannelMute} + /> + {communityMembers.length > 0 && ( + + )}
Space health @@ -2352,6 +2528,49 @@ export function App() { )} + setShowNewChannelModal(false)} + onCreate={handleCreateChannel} + loading={newChannelLoading} + error={newChannelError} + parentChannels={communityChannels + .filter((channel) => channel.kind === 'stage' && !channel.parentChannelId) + .map((channel) => ({ id: channel.id, name: channel.name }))} + /> + {showUserSettingsModal && ( +
{ + if (e.target === e.currentTarget) setShowUserSettingsModal(false); + }} + > +
+
+

Settings

+ setShowUserSettingsModal(false)}> + + +
+ setTheme(mode === 'system' ? 'contrast' : mode)} + onAccentChange={setAccentColor} + /> +
+
+ )} {hoveredChannel && (
{ + const defaultProps = { + community: { + id: 'comm-123', + name: 'Testing Community', + }, + onRename: vi.fn(), + onClose: vi.fn(), + }; + + // Mock clipboard API + const writeTextMock = vi.fn(); + beforeEach(() => { + vi.clearAllMocks(); + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: writeTextMock, + }, + writable: true, + configurable: true, + }); + }); + + it('renders the modal overlay and initial Overview state', () => { + render(); + + expect(screen.getByTestId('community-settings-overlay')).toBeInTheDocument(); + expect(screen.getByTestId('settings-sidebar')).toBeInTheDocument(); + + // Check headings + expect(screen.getByRole('heading', { name: 'Community Overview' })).toBeInTheDocument(); + expect(screen.getByText('Update your space profile and metadata.')).toBeInTheDocument(); + + // Check name input + const input = screen.getByTestId('community-name-input') as HTMLInputElement; + expect(input).toBeInTheDocument(); + expect(input.value).toBe('Testing Community'); + + // Check community id display + expect(screen.getByTestId('community-id-val')).toHaveTextContent('comm-123'); + + // Check buttons are disabled initially (no unsaved changes) + expect(screen.getByTestId('save-btn')).toBeDisabled(); + expect(screen.getByTestId('reset-btn')).toBeDisabled(); + }); + + it('allows changing the community name and enables action buttons', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByTestId('community-name-input') as HTMLInputElement; + + await user.clear(input); + await user.type(input, 'New Community Name'); + + expect(input.value).toBe('New Community Name'); + expect(screen.getByTestId('save-btn')).toBeEnabled(); + expect(screen.getByTestId('reset-btn')).toBeEnabled(); + }); + + it('resets the name value and disables buttons when Reset is clicked', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByTestId('community-name-input') as HTMLInputElement; + + await user.clear(input); + await user.type(input, 'New Community Name'); + + const resetBtn = screen.getByTestId('reset-btn'); + await user.click(resetBtn); + + expect(input.value).toBe('Testing Community'); + expect(screen.getByTestId('save-btn')).toBeDisabled(); + expect(screen.getByTestId('reset-btn')).toBeDisabled(); + }); + + it('calls onRename with trimmed name when Save Changes is clicked', async () => { + const onRename = vi.fn(); + const user = userEvent.setup(); + render(); + + const input = screen.getByTestId('community-name-input') as HTMLInputElement; + + await user.clear(input); + await user.type(input, ' My Awesome Space '); + + const saveBtn = screen.getByTestId('save-btn'); + await user.click(saveBtn); + + expect(onRename).toHaveBeenCalledWith('My Awesome Space'); + expect(onRename).toHaveBeenCalledTimes(1); + }); + + it('calls onRename on form submission', async () => { + const onRename = vi.fn(); + const user = userEvent.setup(); + render(); + + const input = screen.getByTestId('community-name-input') as HTMLInputElement; + + await user.clear(input); + await user.type(input, 'Submitting Space'); + + // Pressing Enter inside input triggers submit + fireEvent.submit(input); + + expect(onRename).toHaveBeenCalledWith('Submitting Space'); + }); + + it('switches tabs and displays placeholder content', async () => { + const user = userEvent.setup(); + render(); + + // Click Channels tab + const channelsTab = screen.getByTestId('tab-channels'); + await user.click(channelsTab); + + expect(screen.getByRole('heading', { name: 'Channels Management' })).toBeInTheDocument(); + expect(screen.getByTestId('channels-placeholder')).toBeInTheDocument(); + expect(screen.queryByTestId('community-name-input')).not.toBeInTheDocument(); + expect(screen.queryByTestId('settings-footer')).not.toBeInTheDocument(); + + // Click Members tab + const membersTab = screen.getByTestId('tab-members'); + await user.click(membersTab); + + expect(screen.getByRole('heading', { name: 'Members & Roles' })).toBeInTheDocument(); + expect(screen.getByTestId('members-placeholder')).toBeInTheDocument(); + expect(screen.queryByTestId('channels-placeholder')).not.toBeInTheDocument(); + + // Click Overview tab back + const overviewTab = screen.getByTestId('tab-overview'); + await user.click(overviewTab); + + expect(screen.getByRole('heading', { name: 'Community Overview' })).toBeInTheDocument(); + expect(screen.getByTestId('community-name-input')).toBeInTheDocument(); + }); + + it('calls onClose when clicking close button', async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + render(); + + const closeBtn = screen.getByTestId('close-settings-btn'); + await user.click(closeBtn); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when clicking the modal overlay', async () => { + const onClose = vi.fn(); + render(); + + const overlay = screen.getByTestId('community-settings-overlay'); + fireEvent.click(overlay); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not call onClose when clicking inside the modal container', async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + render(); + + const contentBody = screen.getByTestId('settings-content-body'); + await user.click(contentBody); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it('calls onClose when pressing the Escape key', () => { + const onClose = vi.fn(); + render(); + + fireEvent.keyDown(window, { key: 'Escape', code: 'Escape' }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('copies community ID to clipboard and shows copied state', async () => { + writeTextMock.mockResolvedValueOnce(undefined); + + // Use fake timers to check temporary "copied" state transition + vi.useFakeTimers(); + + render(); + + const copyBtn = screen.getByTestId('copy-id-btn'); + expect(copyBtn.getAttribute('aria-label')).toBe('Copy ID'); + + // Click copy button using fireEvent to avoid userEvent/fake-timers conflict + await act(async () => { + fireEvent.click(copyBtn); + }); + + expect(writeTextMock).toHaveBeenCalledWith('comm-123'); + expect(copyBtn.getAttribute('aria-label')).toBe('Copied ID'); + + // Fast-forward timers by 2 seconds + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(copyBtn.getAttribute('aria-label')).toBe('Copy ID'); + vi.useRealTimers(); + }); +}); diff --git a/apps/web/src/CommunitySettingsModal.tsx b/apps/web/src/CommunitySettingsModal.tsx new file mode 100644 index 0000000..25e788e --- /dev/null +++ b/apps/web/src/CommunitySettingsModal.tsx @@ -0,0 +1,649 @@ +import React, { useState, useEffect } from 'react'; +import { Settings, Users, Sliders, X, Hash, Info, Copy, Check } from 'lucide-react'; + +export interface CommunitySettingsModalProps { + community: { + id: string; + name: string; + }; + onRename: (name: string) => void; + onClose: () => void; +} + +type TabId = 'overview' | 'channels' | 'members'; + +export function CommunitySettingsModal({ + community, + onRename, + onClose, +}: CommunitySettingsModalProps) { + const [activeTab, setActiveTab] = useState('overview'); + const [name, setName] = useState(community.name); + const [copied, setCopied] = useState(false); + const [animate, setAnimate] = useState(false); + + // Sync state if community prop changes + useEffect(() => { + setName(community.name); + }, [community.name]); + + // Handle ESC key press to close + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + // Handle opening animation transition + useEffect(() => { + const timer = setTimeout(() => setAnimate(true), 20); + return () => clearTimeout(timer); + }, []); + + const handleCopyId = async () => { + try { + await navigator.clipboard.writeText(community.id); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy ID to clipboard', err); + } + }; + + const handleReset = () => { + setName(community.name); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (name.trim() && name.trim() !== community.name) { + onRename(name.trim()); + } + }; + + const hasUnsavedChanges = name.trim() !== community.name && name.trim().length > 0; + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + data-testid="community-settings-overlay" + > + + +
+ {/* Sidebar Tabs */} + + + {/* Content Area */} +
+ {/* Header */} +
+
+

+ {activeTab === 'overview' && 'Community Overview'} + {activeTab === 'channels' && 'Channels Management'} + {activeTab === 'members' && 'Members & Roles'} +

+

+ {activeTab === 'overview' && 'Update your space profile and metadata.'} + {activeTab === 'channels' && 'Manage your text, voice, and stage channels.'} + {activeTab === 'members' && 'View your roster and manage roles.'} +

+
+ +
+ + {/* Body */} +
+ {activeTab === 'overview' && ( +
+
+ + setName(e.target.value)} + placeholder="Enter community name" + required + data-testid="community-name-input" + /> +
+ +
+ +

+ Renaming the community updates how it is displayed in the navigation rail and sidebar headers. +

+
+ +
+
+ Community ID +
+ + {community.id} + + +
+
+
+
+ )} + + {activeTab === 'channels' && ( +
+
+ +
+
+

Channel Administration

+

+ Reorder lists, manage categories, and customize permissions. +

+
+ Coming Soon +
+ )} + + {activeTab === 'members' && ( +
+
+ +
+
+

Roster & Role Assignments

+

+ Assign custom roles, manage moderation permissions, and invite members. +

+
+ Coming Soon +
+ )} +
+ + {/* Footer (only visible when in Overview to save/reset) */} + {activeTab === 'overview' && ( +
+ + +
+ )} +
+
+
+ ); +} + +export default CommunitySettingsModal; diff --git a/apps/web/src/DmConversationList.test.tsx b/apps/web/src/DmConversationList.test.tsx new file mode 100644 index 0000000..f55fefa --- /dev/null +++ b/apps/web/src/DmConversationList.test.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import DmConversationList, { type DMConversation } from './DmConversationList'; + +describe('DmConversationList', () => { + const mockOnSelect = vi.fn(); + + const mockConversations: DMConversation[] = [ + { + id: 'conv-1', + participant: { + id: 'user-1', + displayName: 'Alice Smith', + initials: 'AS', + status: 'online', + avatarUrl: 'https://example.com/alice.jpg', + }, + lastMessagePreview: 'Hey, are we still on for lunch?', + }, + { + id: 'conv-2', + participant: { + id: 'user-2', + displayName: 'Bob Jones', + initials: 'BJ', + status: 'idle', + }, + lastMessagePreview: 'I sent the files over.', + }, + { + id: 'conv-3', + participant: { + id: 'user-3', + displayName: 'Charlie Brown', + initials: 'CB', + status: 'do-not-disturb', + }, + lastMessagePreview: '', + }, + { + id: 'conv-4', + participant: { + id: 'user-4', + displayName: 'David Miller', + initials: 'DM', + status: 'offline', + }, + lastMessagePreview: undefined, // No preview should be rendered + }, + ]; + + it('renders correctly and shows title and count', () => { + render( + , + ); + + expect(screen.getByTestId('dm-conversation-list')).toBeInTheDocument(); + expect(screen.getByText('Direct Messages')).toBeInTheDocument(); + expect(screen.getByText('4')).toBeInTheDocument(); // Count + }); + + it('displays empty state when conversations list is empty', () => { + render(); + + expect(screen.getByTestId('dm-list-empty')).toBeInTheDocument(); + expect(screen.getByText('No direct messages yet.')).toBeInTheDocument(); + expect(screen.queryByTestId('dm-list-content')).not.toBeInTheDocument(); + }); + + it('renders all conversations with their names and previews', () => { + render(); + + expect(screen.getByTestId('dm-list-content')).toBeInTheDocument(); + + // Verify Display Names + expect(screen.getByText('Alice Smith')).toBeInTheDocument(); + expect(screen.getByText('Bob Jones')).toBeInTheDocument(); + expect(screen.getByText('Charlie Brown')).toBeInTheDocument(); + expect(screen.getByText('David Miller')).toBeInTheDocument(); + + // Verify Message Previews + expect(screen.getByText('Hey, are we still on for lunch?')).toBeInTheDocument(); + expect(screen.getByText('I sent the files over.')).toBeInTheDocument(); + expect(screen.getByText('No messages yet')).toBeInTheDocument(); // Empty string should render fallback + expect(screen.queryByTestId('dm-preview-conv-4')).not.toBeInTheDocument(); // undefined preview should not render preview element at all + }); + + it('triggers onSelect when a conversation is clicked', () => { + render(); + + const aliceBtn = screen.getByTestId('dm-item-conv-1'); + fireEvent.click(aliceBtn); + + expect(mockOnSelect).toHaveBeenCalledTimes(1); + expect(mockOnSelect).toHaveBeenCalledWith('conv-1'); + }); + + it('applies is-active class to the active conversation', () => { + render( + , + ); + + const aliceBtn = screen.getByTestId('dm-item-conv-1'); + const bobBtn = screen.getByTestId('dm-item-conv-2'); + + expect(aliceBtn.className).not.toContain('is-active'); + expect(bobBtn.className).toContain('is-active'); + }); + + it('handles offline opacity class', () => { + render(); + + const davidBtn = screen.getByTestId('dm-item-conv-4'); + expect(davidBtn.className).toContain('is-offline'); + + const aliceBtn = screen.getByTestId('dm-item-conv-1'); + expect(aliceBtn.className).not.toContain('is-offline'); + }); +}); diff --git a/apps/web/src/DmConversationList.tsx b/apps/web/src/DmConversationList.tsx new file mode 100644 index 0000000..1af9af4 --- /dev/null +++ b/apps/web/src/DmConversationList.tsx @@ -0,0 +1,261 @@ +import React from 'react'; +import { UserAvatar, type UserAvatarProps } from './UserAvatar'; + +export interface DMParticipant { + id: string; + displayName: string; + initials: string; + status: 'online' | 'idle' | 'dnd' | 'offline' | string; + src?: string; + avatarUrl?: string; +} + +export interface DMConversation { + id: string; + participant: DMParticipant; + lastMessagePreview: string | undefined; +} + +export interface DmConversationListProps { + conversations: DMConversation[]; + activeConversationId?: string | null; + onSelect: (id: string) => void; +} + +export function DmConversationList({ + conversations = [], + activeConversationId = null, + onSelect, +}: DmConversationListProps) { + // Helper to map status strings if needed (e.g. do-not-disturb -> dnd) + const mapStatus = (status: string): 'online' | 'idle' | 'dnd' | 'offline' | undefined => { + const s = status.toLowerCase(); + if (s === 'online') return 'online'; + if (s === 'idle') return 'idle'; + if (s === 'dnd' || s === 'do-not-disturb') return 'dnd'; + if (s === 'offline') return 'offline'; + return undefined; + }; + + return ( +
+ + +
+ Direct Messages + {conversations.length} +
+ + {conversations.length === 0 ? ( +
+ 💬 +

No direct messages yet.

+
+ ) : ( +
+ {conversations.map((conv) => { + const participant = conv.participant; + const isOffline = participant.status === 'offline'; + const avatarStatus = mapStatus(participant.status); + const isActive = activeConversationId === conv.id; + + // Strict exactOptionalPropertyTypes compilation fix + const avatarProps: UserAvatarProps = { + name: participant.displayName, + size: 'md', + }; + if (avatarStatus) { + avatarProps.status = avatarStatus; + } + const srcVal = participant.src || participant.avatarUrl; + if (srcVal) { + avatarProps.src = srcVal; + } + + return ( + + ); + })} +
+ )} +
+ ); +} + +export default DmConversationList; diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 7cbd194..8c84526 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -398,7 +398,7 @@ button { .user-dock { display: grid; - grid-template-columns: auto minmax(0, 1fr) auto auto auto; + grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 4px; padding: 7px 8px; @@ -406,12 +406,18 @@ button { background: color-mix(in srgb, var(--rail) 30%, var(--sidebar)); flex-shrink: 0; } -.user-dock > div { +.user-dock > div:not(.user-dock-actions) { display: flex; flex-direction: column; min-width: 0; margin-left: 4px; } +.user-dock-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 2px; +} .user-dock strong, .user-dock span { overflow: hidden; diff --git a/package.json b/package.json index 697629d..42748bf 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "scripts": { "dev": "npm run build -w @cove/contracts && npm run build -w @cove/ui && concurrently -n core,web -c magenta,cyan \"npm run dev -w @cove/core\" \"npm run dev -w @cove/web\"", "build": "npm run build -w @cove/contracts && npm run build -w @cove/ui && npm run build -w @cove/core && npm run build -w @cove/web", - "typecheck": "npm run typecheck --workspaces --if-present", - "test": "npm run test --workspaces --if-present", + "typecheck": "npm run build -w @cove/contracts && npm run build -w @cove/ui && npm run typecheck --workspaces --if-present", + "test": "npm run build -w @cove/contracts && npm run build -w @cove/ui && npm run test --workspaces --if-present", "format:check": "prettier --check .", "format": "prettier --write ." }, diff --git a/services/core/src/app.test.ts b/services/core/src/app.test.ts index be6aebc..9900642 100644 --- a/services/core/src/app.test.ts +++ b/services/core/src/app.test.ts @@ -525,8 +525,10 @@ describe('authentication API', () => { }); describe('community API', () => { - async function getAuth(app: Awaited>) { - const email = `user-${crypto.randomUUID()}@test.cove.chat`; + async function getAuth( + app: Awaited>, + email = `user-${crypto.randomUUID()}@test.cove.chat`, + ) { const sendRes = await app.inject({ method: 'POST', url: '/v1/auth/email/send-code', @@ -628,6 +630,109 @@ describe('community API', () => { expect(getRes.statusCode).toBe(403); }); + it('lists full live accounts for every community member', async () => { + const app = await buildApp(); + apps.push(app); + const { token: ownerToken, account: owner } = await getAuth(app, 'member-owner@test.cove.chat'); + const { token: memberToken, account: member } = await getAuth( + app, + 'member-user@test.cove.chat', + ); + + const createRes = await app.inject({ + method: 'POST', + url: '/v1/communities', + headers: { authorization: `Bearer ${ownerToken}` }, + payload: { name: 'Member List Space' }, + }); + const communityId = createRes.json().id; + const inviteRes = await app.inject({ + method: 'POST', + url: `/v1/communities/${communityId}/invites`, + headers: { authorization: `Bearer ${ownerToken}` }, + payload: {}, + }); + await app.inject({ + method: 'POST', + url: `/v1/invites/${inviteRes.json().invite.code}`, + headers: { authorization: `Bearer ${memberToken}` }, + }); + await app.inject({ + method: 'PATCH', + url: '/v1/accounts/me/status', + headers: { authorization: `Bearer ${memberToken}` }, + payload: { text: 'Reviewing member lists', emoji: 'eyes' }, + }); + + const listRes = await app.inject({ + method: 'GET', + url: `/v1/communities/${communityId}/members`, + headers: { authorization: `Bearer ${ownerToken}` }, + }); + + expect(listRes.statusCode, listRes.body).toBe(200); + expect(listRes.json().members).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: owner.id, roleIds: expect.any(Array) }), + expect.objectContaining({ + id: member.id, + roleIds: [], + customStatus: { text: 'Reviewing member lists', emoji: 'eyes' }, + }), + ]), + ); + }); + + it('rejects member listing for non-members and unknown communities', async () => { + const app = await buildApp(); + apps.push(app); + const { token: ownerToken } = await getAuth(app); + const { token: otherToken } = await getAuth(app); + const createRes = await app.inject({ + method: 'POST', + url: '/v1/communities', + headers: { authorization: `Bearer ${ownerToken}` }, + payload: { name: 'Private Member List' }, + }); + + const forbiddenRes = await app.inject({ + method: 'GET', + url: `/v1/communities/${createRes.json().id}/members`, + headers: { authorization: `Bearer ${otherToken}` }, + }); + const missingRes = await app.inject({ + method: 'GET', + url: '/v1/communities/missing-community/members', + headers: { authorization: `Bearer ${ownerToken}` }, + }); + + expect(forbiddenRes.statusCode).toBe(403); + expect(missingRes.statusCode).toBe(404); + }); + + it('returns an empty member list when the membership projection is empty', async () => { + const repo = createMemoryRepository(); + const app = await buildApp({ repo }); + apps.push(app); + const { token } = await getAuth(app); + const createRes = await app.inject({ + method: 'POST', + url: '/v1/communities', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Empty Projection Space' }, + }); + vi.spyOn(repo, 'getMemberships').mockResolvedValueOnce([]); + + const listRes = await app.inject({ + method: 'GET', + url: `/v1/communities/${createRes.json().id}/members`, + headers: { authorization: `Bearer ${token}` }, + }); + + expect(listRes.statusCode).toBe(200); + expect(listRes.json()).toEqual({ members: [] }); + }); + it('joins and leaves a community', async () => { const app = await buildApp(); apps.push(app); @@ -1831,6 +1936,15 @@ describe('attachment pipeline', () => { }); expect(uploadRes.statusCode).toBe(204); + const approveRes = await app.inject({ + method: 'PATCH', + url: '/v1/attachments/' + attachmentId + '/quarantine', + headers: { authorization: 'Bearer ' + token }, + payload: { status: 'approved' }, + }); + expect(approveRes.statusCode).toBe(200); + expect(approveRes.json()).toEqual({ attachmentId, status: 'approved' }); + const serveRes = await app.inject({ method: 'GET', url: `/v1/attachments/${attachmentId}/content`, @@ -1935,6 +2049,13 @@ describe('attachment pipeline', () => { headers: { authorization: `Bearer ${token}`, 'content-type': 'application/octet-stream' }, body: Buffer.from('png-data'), }); + const approveRes = await app.inject({ + method: 'PATCH', + url: '/v1/attachments/' + attachmentId + '/quarantine', + headers: { authorization: 'Bearer ' + token }, + payload: { status: 'approved' }, + }); + expect(approveRes.statusCode).toBe(200); const msgRes = await app.inject({ method: 'POST', @@ -1953,6 +2074,76 @@ describe('attachment pipeline', () => { expect(msg.attachments[0].filename).toBe('screenshot.png'); expect(msg.attachments[0].quarantineStatus).toBe('approved'); }); + + it('rejects a pending uploaded attachment', async () => { + const app = await buildApp(); + apps.push(app); + const { token, channelId } = await setupChannelAndAuth(app); + const { attachmentId } = ( + await app.inject({ + method: 'POST', + url: '/v1/channels/' + channelId + '/attachments/initiate', + headers: { authorization: 'Bearer ' + token }, + payload: { filename: 'unsafe.png', mimeType: 'image/png', size: 64 }, + }) + ).json(); + await app.inject({ + method: 'PUT', + url: '/v1/channels/' + channelId + '/attachments/' + attachmentId + '/upload', + headers: { authorization: 'Bearer ' + token, 'content-type': 'application/octet-stream' }, + body: Buffer.from('unsafe-content'), + }); + + const rejectRes = await app.inject({ + method: 'PATCH', + url: '/v1/attachments/' + attachmentId + '/quarantine', + headers: { authorization: 'Bearer ' + token }, + payload: { status: 'rejected' }, + }); + expect(rejectRes.statusCode).toBe(200); + expect(rejectRes.json()).toEqual({ attachmentId, status: 'rejected' }); + + const serveRes = await app.inject({ + method: 'GET', + url: '/v1/attachments/' + attachmentId + '/content', + headers: { authorization: 'Bearer ' + token }, + }); + expect(serveRes.statusCode).toBe(451); + }); + + it('rejects moderation of a non-pending attachment', async () => { + const app = await buildApp(); + apps.push(app); + const { token, channelId } = await setupChannelAndAuth(app); + const { attachmentId } = ( + await app.inject({ + method: 'POST', + url: '/v1/channels/' + channelId + '/attachments/initiate', + headers: { authorization: 'Bearer ' + token }, + payload: { filename: 'reviewed.pdf', mimeType: 'application/pdf', size: 64 }, + }) + ).json(); + await app.inject({ + method: 'PUT', + url: '/v1/channels/' + channelId + '/attachments/' + attachmentId + '/upload', + headers: { authorization: 'Bearer ' + token, 'content-type': 'application/octet-stream' }, + body: Buffer.from('pdf-content'), + }); + await app.inject({ + method: 'PATCH', + url: '/v1/attachments/' + attachmentId + '/quarantine', + headers: { authorization: 'Bearer ' + token }, + payload: { status: 'approved' }, + }); + + const secondReview = await app.inject({ + method: 'PATCH', + url: '/v1/attachments/' + attachmentId + '/quarantine', + headers: { authorization: 'Bearer ' + token }, + payload: { status: 'rejected' }, + }); + expect(secondReview.statusCode).toBe(409); + }); }); describe('managed message lifecycle', () => { @@ -3201,23 +3392,23 @@ describe('operator audit log', () => { }); it('rejects operator backup and restore without the configured operator key', async () => { - // Phase 1: no OPERATOR_KEY — endpoints are unguarded - const unguardedApp = await buildApp(); - apps.push(unguardedApp); + // Phase 1: no OPERATOR_KEY — endpoints fail closed + const unconfiguredApp = await buildApp(); + apps.push(unconfiguredApp); - const unguardedBackup = await unguardedApp.inject({ + const unconfiguredBackup = await unconfiguredApp.inject({ method: 'POST', url: '/v1/operator/backup', }); - expect(unguardedBackup.statusCode).toBe(200); + expect(unconfiguredBackup.statusCode).toBe(503); - const unguardedRestore = await unguardedApp.inject({ + const unconfiguredRestore = await unconfiguredApp.inject({ method: 'POST', url: '/v1/operator/restore', headers: { 'content-type': 'application/json' }, - payload: unguardedBackup.body, + payload: '{}', }); - expect(unguardedRestore.statusCode).toBe(204); + expect(unconfiguredRestore.statusCode).toBe(503); // Phase 2: OPERATOR_KEY set — build a NEW app so the key is captured at startup vi.stubEnv('OPERATOR_KEY', 'test-operator-key'); diff --git a/services/core/src/app.ts b/services/core/src/app.ts index 2fe6ed4..1aff72a 100644 --- a/services/core/src/app.ts +++ b/services/core/src/app.ts @@ -29,6 +29,7 @@ import { permissionSimulatorRequestSchema, resolvePermission, communityStatsSchema, + communityMembersResponseSchema, banMemberRequestSchema, banSchema, communityExportSchema, @@ -2002,12 +2003,75 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise( + '/v1/attachments/:attachmentId/quarantine', + async (request, reply) => { + const ok = await requireAuth(request, reply); + if (!ok) return; + + const body = (request.body ?? {}) as { status?: unknown }; + if (body.status !== 'approved' && body.status !== 'rejected') { + return problem( + reply, + 400, + 'Invalid quarantine status', + 'Status must be either approved or rejected.', + request.url, + ); + } + + const attachment = await repo.getAttachment(request.params.attachmentId); + if (!attachment) { + return problem(reply, 404, 'Attachment not found', 'Attachment not found.', request.url); + } + const channel = await repo.findChannelById(attachment.channelId); + if (!channel) { + return problem(reply, 404, 'Channel not found', 'Channel not found.', request.url); + } + + const { account: actor } = (request as any).user; + const membership = await requireMembership(reply, channel.communityId, actor.id, request.url); + if (!membership) return; + const permission = await requirePermission( + reply, + channel.communityId, + membership, + actor.id, + 'message.manage', + request.url, + ); + if (!permission) return; + + if (attachment.quarantineStatus !== 'pending') { + return problem( + reply, + 409, + 'Attachment already moderated', + 'Only pending attachments can be moderated.', + request.url, + ); + } + if (!attachment.uploadedAt) { + return problem( + reply, + 409, + 'Attachment not uploaded', + 'The attachment must be uploaded before it can be moderated.', + request.url, + ); + } + + await repo.updateAttachmentStatus(attachment.id, body.status); + return reply.code(200).send({ attachmentId: attachment.id, status: body.status }); + }, + ); + app.get<{ Params: { attachmentId: string } }>( '/v1/attachments/:attachmentId/content', async (request, reply) => { @@ -2351,6 +2415,40 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise( + '/v1/communities/:communityId/members', + async (request, reply) => { + const ok = await requireAuth(request, reply); + if (!ok) return; + + const { account } = (request as any).user as { account: Account }; + const community = await repo.getCommunity(request.params.communityId); + if (!community) { + return problem( + reply, + 404, + 'Community not found', + 'The requested community does not exist.', + request.url, + ); + } + const membership = await requireMembership(reply, community.id, account.id, request.url); + if (!membership) return; + + const memberships = await repo.getMemberships(community.id); + const members = ( + await Promise.all( + memberships.map(async (item) => { + const memberAccount = await repo.getAccountById(item.accountId); + return memberAccount ? { ...memberAccount, roleIds: item.roleIds } : undefined; + }), + ) + ).filter((member): member is NonNullable => member !== undefined); + + return reply.code(200).send(communityMembersResponseSchema.parse({ members })); + }, + ); + app.patch<{ Params: { communityId: string }; Body: { name?: unknown } }>( '/v1/communities/:communityId', async (request, reply) => { @@ -4384,11 +4482,20 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise` elements linked to their content URL. -- Other files render as compact download chips with names, human-readable sizes, file-specific icons, and download links. -- Respects quarantine status by displaying distinct verification indicators for `'pending'` and `'rejected'` attachments. +- Component built according to specifications: accepts `conversations: Array<{ id, participant: { id, displayName, initials, status }, lastMessagePreview: string | undefined }>` and `onSelect: (id: string) => void`. +- Added support for `activeConversationId` prop to style the selected/active DM conversation item. +- Built to handle strict `exactOptionalPropertyTypes: true` compiler rules (handling conditional props for `UserAvatar` and explicitly typing `lastMessagePreview` as `string | undefined` to match the requested API contract exactly). +- Reuses `UserAvatar` for participant status rendering (remapping `'do-not-disturb'` status text to `'dnd'` safely) and supports optional `src` / `avatarUrl` properties. +- Includes a premium, responsive CSS design in an inline `