From fa0fc1cc15801fdd9abce67502708c477497046d Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Mon, 12 Oct 2026 23:41:29 +0700 Subject: [PATCH] ux(cordn): reconcile the crew chat card with the MLS tab (+ audit) The sidebar card badged 'Private chat - On' while the MLS tab said 'not created yet', and led with external apps. Rewrote CrewCoordinatorCard to show two explicit rows (Chat = NIP-17 always on; Private chat = MLS with real status) and make the in-app chat the primary CTA (?tab=mls); demoted Cordn web / Staircase under 'Open in another app'. Panel now gives join feedback + an admin invite hint. Adds CORDN_UX_AUDIT.md (findings, terminology model, deferred items). --- AGENTS.md | 14 +- CORDN_UX_AUDIT.md | 48 ++++ src/components/crew/CordnNativePanel.tsx | 24 +- src/components/crew/CrewCoordinatorCard.tsx | 267 ++++++++++++-------- src/i18n/en.ts | 10 + src/i18n/es.ts | 10 + src/i18n/legacy/cordnUi.ts | 2 + src/pages/CrewDetailPage.tsx | 7 +- 8 files changed, 272 insertions(+), 110 deletions(-) create mode 100644 CORDN_UX_AUDIT.md diff --git a/AGENTS.md b/AGENTS.md index 0253c65..bc2e902 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2271,11 +2271,15 @@ The community relay moved off the hosted relay.tools instance default; **Settings → Coordinator** shows a "Use SkateSpots coordinator" block and **CrewForm** prefills it for new crews. Disclosure copy lives in `socialUi.coordinator.ourDefault*` + the existing `privacyNote` (coordinators see - ciphertext + metadata, never contents). `CrewCoordinatorCard` offers two real-MLS - entry points in both branches — **"Cordn (web)"** (the cordn.net add-link → real - MLS in the browser) and **"Copy for Staircase"** (copies the nprofile to add in - the native app) — our coordinator by default, a BYO coordinator's when it has a - pubkey. Also fixed a status-badge bug: `STATUS_META` labels in + ciphertext + metadata, never contents). ⚠️ **`CrewCoordinatorCard` was rewritten + (2026-10-12, see `CORDN_UX_AUDIT.md`):** it used to badge **"Private chat · On"** + while the MLS tab said **"not created yet"** (a contradiction) and led with the + external apps. It now shows two explicit rows — **Chat** (NIP-17, always on) and + **Private chat** (MLS; On / Set up / Join / Checking from `nativeGroupExists`) — + with the **in-app chat as the primary CTA** (`?tab=mls`), and "Cordn (web)" / + "Copy for Staircase" demoted under an **"Open in another app"** label. Props are + `{ crew, isFounder, canManage, nativeGroupExists }`. Also fixed a status-badge bug: + `STATUS_META` labels in `CoordinatorSettingsCard` were missing the `socialUi.` prefix and rendered raw keys (e.g. `coordinator.statusStale`). **Verified (2026-10-12):** the reference `@contextvm/sdk` client (encrypted diff --git a/CORDN_UX_AUDIT.md b/CORDN_UX_AUDIT.md new file mode 100644 index 0000000..551889d --- /dev/null +++ b/CORDN_UX_AUDIT.md @@ -0,0 +1,48 @@ +# Cordn / MLS / Staircase — UX audit (2026-10-12) + +Scope: every surface where a skater meets the crew's encrypted chat — +the crew page (Private chat tab + sidebar card), the Messages hub, Settings → +Coordinator, and the CrewForm fields — plus the Staircase / Cordn-web hand-offs. + +The goal was a *coherent* story: two chats exist (built-in NIP-17 "Chat" and the +real-MLS "Private chat"), and the UI must never make them contradict each other. + +## Terminology model (the fix that anchors everything) +| Surface | Name | Protocol | Setup | +|---|---|---|---| +| Crew page tab (existing) | **Chat** | NIP-17 giftwrap | always on | +| Crew page tab (new) | **Private chat** | MLS via coordinator | created once by an admin | +| Sidebar card | **Encrypted chat** | covers both | — | + +Rule: never call the sidebar card "Private chat" (collides with the MLS tab), and +never badge the MLS chat "On" when it hasn't been created. + +## Findings + +| # | Sev | Status | Finding | +|---|---|---|---| +| 1 | High | **Fixed** | The sidebar card badged **"Private chat · On"** (meaning the NIP-17 Chat tab) while the MLS tab said **"not created yet"** — a direct contradiction. The card now shows two explicit rows, **Chat** (always on) and **Private chat** (On / Set up / Join / Checking), reflecting the real MLS status. | +| 2 | High | **Fixed** | Name collision: card "Private Crew Chat" vs tab "Private chat". Card retitled **"Encrypted chat"**; row names are Chat / Private chat. | +| 3 | Med | **Fixed** | The card led with **external** apps ("Cordn (web)", "Copy for Staircase") even though the app now ships its own MLS chat. The **in-app Private chat is now the primary CTA** (one tap → `?tab=mls`); external clients moved under a labelled **"Open in another app"** row. | +| 4 | Med | **Fixed** | Joining gave no feedback. The panel now says **"Request sent…"** after Ask-to-join and **"No invite waiting yet…"** after Check-for-invite. | +| 5 | Med | **Fixed** | An admin who created the chat wasn't told how members join. The panel now shows an invite hint while the group has one member. | +| 6 | Med | **Fixed** | Crews were invisible in the Messages hub. Added a **Crews** section (status badge + deep link to `?tab=mls`). | +| 7 | Low | **Fixed** | The crew page's tabs weren't deep-linkable; now `?tab=events\|chat\|mls` (explicit param wins over the auto-default). | +| 8 | Low | Open | `cordnUi.*` / new `crewCoordinator.*` copy exists only in **en/es**; other locales fall back to English. Acceptable for a new feature, worth translating once it has users. | +| 9 | Low | Open | **Cordn (web) is broken against our coordinator** (upstream issue, drafted). Kept as a secondary link only; prefer the in-app chat or Staircase. | +| 10 | Low | Open | CrewForm's **"Cordn group"** field is Staircase-era jargon; now that the app creates groups natively it mostly serves importing an external group. Consider moving it under an "Advanced" disclosure. | +| 11 | Info | By design | **Interactive signer (Amber):** no live updates — the panel shows a hint and the DM pages have a manual Refresh. This is the deliberate "never pop Amber in the background" posture. | +| 12 | Info | By design | The coordinator sees **ciphertext + metadata only**; the card and the Settings privacy note say so plainly. | + +## Still open / next +- **Onboarding the first member**: the flow works (create → ask → approve) but is + three taps across two people. A shareable "join this crew's chat" link that + pre-fills the join request would remove the guesswork. +- **Unread for the Private chat** : deferred (needs a background signed request). +- **Persisted DM decrypt cache** (from the Amber audit) so the *first* load per + app start stops being a decrypt storm. +- Translate the new keys beyond en/es. + +## Verified +`npm test` + `check-i18n` green; `?tab=mls` deep-link wired through the crew page. +Not click-tested in a real browser this session (no logged-in browser fixture). diff --git a/src/components/crew/CordnNativePanel.tsx b/src/components/crew/CordnNativePanel.tsx index eb1ea68..f0d9b21 100644 --- a/src/components/crew/CordnNativePanel.tsx +++ b/src/components/crew/CordnNativePanel.tsx @@ -33,6 +33,7 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel const native = useCordnNative({ crew, enabled: !!user && isMember }); const [draft, setDraft] = useState(''); const [copied, setCopied] = useState(false); + const [notice, setNotice] = useState(null); const scrollRef = useRef(null); const fileRef = useRef(null); const { mutateAsync: uploadFile, isPending: uploading } = useUploadFile(); @@ -76,6 +77,18 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel } }, [native.groupRef]); + const handleRequestJoin = useCallback(async () => { + setNotice(null); + await native.requestJoin(); + setNotice(t('cordnUi.requested')); + }, [native, t]); + + const handleCheckInvite = useCallback(async () => { + setNotice(null); + const joined = await native.checkInvite(); + if (!joined) setNotice(t('cordnUi.noInvite')); + }, [native, t]); + if (!user) { return } text={t('cordnUi.notLoggedIn')} />; } @@ -142,7 +155,7 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel {native.requests.length === 0 ? ( -

{t('cordnUi.noRequests')}

+

+ {native.group.members.length <= 1 ? t('cordnUi.inviteHint') : t('cordnUi.noRequests')} +

) : ( native.requests.map((req) => (
diff --git a/src/components/crew/CrewCoordinatorCard.tsx b/src/components/crew/CrewCoordinatorCard.tsx index e021ec6..944317b 100644 --- a/src/components/crew/CrewCoordinatorCard.tsx +++ b/src/components/crew/CrewCoordinatorCard.tsx @@ -1,5 +1,5 @@ import { Link } from 'react-router-dom'; -import { Lock, ShieldCheck, Server, ExternalLink, Settings, Copy } from 'lucide-react'; +import { Lock, ShieldCheck, Server, ExternalLink, Settings, Copy, MessagesSquare, Users } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { useAuthor } from '@/hooks/useAuthor'; @@ -9,28 +9,35 @@ import { genUserName } from '@/lib/genUserName'; import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl'; import { resolveCordnEndpoint } from '@/lib/cordn/endpoint'; import { CORDN_RELAY_URL, coordinatorAddUrl, CORDN_HOME, SKATESPOTS_COORDINATOR } from '@/lib/coordinator'; +import { cn } from '@/lib/utils'; import type { CrewCoordinator, SkateCrew } from '@/lib/types'; import { nip19 } from 'nostr-tools'; /** - * Shows the crew's encrypted-chat coordinator (Cordn / MLS Delivery Service). + * The crew's encrypted-chat card (sidebar). * - * Every crew already has built-in end-to-end encrypted group chat (NIP-17 - * giftwrap to the roster) — no setup required. This card: - * - by default, reassures the crew that private chat is already ON; and - * - if a founder has opted into routing chat through their own self-hosted - * Cordn coordinator, surfaces which coordinator is in use. + * A crew has **two** chats and the card must not conflate them: + * - the **Chat** tab — built-in NIP-17 group chat, always on, no setup; and + * - the **Private chat** tab — real MLS (forward secrecy, member removal), + * created once by an admin and routed through a coordinator. * - * Note: the coordinator path currently provides NIP-44 group encryption routed - * over ContextVM (nothing crew-specific hits a public relay). It is NOT yet a - * full MLS ratchet, so we deliberately avoid claiming forward secrecy here. + * It leads with an honest status for each and a one-tap entry into the private + * chat; the coordinator details and any external clients (Staircase / Cordn web) + * are secondary. ⚠️ The status must match the tab: an earlier version badged + * "Private chat · On" while the MLS tab said "not created yet". */ export function CrewCoordinatorCard({ crew, isFounder, + canManage, + nativeGroupExists, }: { crew: SkateCrew; isFounder: boolean; + /** Founder/admin — may create the MLS group. */ + canManage: boolean; + /** Whether this device already holds the crew's MLS group (local check). */ + nativeGroupExists: boolean | undefined; }) { const { t } = useTranslation(); const { coordinator } = crew; @@ -41,106 +48,164 @@ export function CrewCoordinatorCard({ const endpoint = resolveCordnEndpoint(crew); const overPublicRelay = endpoint?.relayUrl === CORDN_RELAY_URL; - if (!coordinator) { - // Default state: private chat is ALREADY on for every crew (built-in NIP-17 - // giftwrap group chat, no setup). We say so plainly rather than implying a - // coordinator is required. Founders get a small, honestly-labeled link to - // the experimental self-hosted-coordinator option. - return ( - - - - {t('socialUi.crewCoordinator.privateChat')} - - {t('socialUi.crewCoordinator.on')} - - - - -

- {t('socialUi.crewCoordinator.defaultA')}{' '} - {t('socialUi.crewCoordinator.chatWord')} {t('socialUi.crewCoordinator.defaultB')}{' '} - {t('socialUi.crewCoordinator.e2ee')} {t('socialUi.crewCoordinator.defaultC')} -

- - {crew.cordnGroup && } - {isFounder && ( -

- {t('socialUi.crewCoordinator.advancedA')}{' '} - - {t('socialUi.crewCoordinator.cordnCoordinator')} - - ?{' '} - - {t('socialUi.crewCoordinator.routeThrough')} - -

- )} -
-
- ); - } + const privateChatTab = `/crew/${crew.pubkey}/${crew.identifier}?tab=mls`; + + const mlsLabel = + nativeGroupExists === true + ? t('socialUi.crewCoordinator.on') + : nativeGroupExists === undefined + ? t('socialUi.crewCoordinator.checking') + : canManage + ? t('socialUi.crewCoordinator.setUpChat') + : t('socialUi.crewCoordinator.joinChat'); + const mlsCta = + nativeGroupExists === true + ? t('socialUi.crewCoordinator.openChat') + : canManage + ? t('socialUi.crewCoordinator.setUpChat') + : t('socialUi.crewCoordinator.joinChat'); return ( - + - {t('socialUi.crewCoordinator.privateChat')} - - {t('socialUi.crewCoordinator.coordinator')} - + {t('socialUi.crewCoordinator.encryptedChat')} + - - - {coordinator.operator && } - - - {crew.cordnGroup && } - - {endpoint && ( -

- {t('socialUi.crewCoordinator.deliveredOver')}{' '} - {urlDomain(endpoint.relayUrl) || endpoint.relayUrl} - {overPublicRelay ? ` ${t('socialUi.crewCoordinator.sharedRelay')}` : ` ${t('socialUi.crewCoordinator.ownRelay')}`} -

- )} - -
-

- {t('socialUi.crewCoordinator.chatLive')} -

-

- {t('socialUi.crewCoordinator.routesA')}{' '} - {t('socialUi.crewCoordinator.chatWord')} {t('socialUi.crewCoordinator.routesB')}{' '} - {overPublicRelay - ? t('socialUi.crewCoordinator.publicWarning') - : t('socialUi.crewCoordinator.ownRelaySafe')} -

+ {/* Chat (NIP-17) — always on */} +
+
+ +
+
+

+ {t('socialUi.crewCoordinator.chatWord')} + + {t('socialUi.crewCoordinator.on')} + +

+

+ {t('socialUi.crewCoordinator.chatRowDesc')} +

+
- {isFounder && ( + {/* Private chat (MLS) — created once, then joined */} +
+
+ +
+
+

+ {t('socialUi.crewCoordinator.privateChatName')} + + {mlsLabel} + +

+

+ {t('socialUi.crewCoordinator.privateChatDesc')} +

+
- {t('socialUi.crewCoordinator.manage')} + {mlsCta} +
+ + {/* Coordinator details (only when one is configured) */} + {coordinator && ( +
+ + {coordinator.operator && } + + {endpoint && ( +

+ {t('socialUi.crewCoordinator.deliveredOver')}{' '} + {urlDomain(endpoint.relayUrl) || endpoint.relayUrl} + {overPublicRelay ? ` ${t('socialUi.crewCoordinator.sharedRelay')}` : ` ${t('socialUi.crewCoordinator.ownRelay')}`} +

+ )} + +
+

+ {t('socialUi.crewCoordinator.chatLive')} +

+

+ {t('socialUi.crewCoordinator.chatRowDesc')}{' '} + {overPublicRelay + ? t('socialUi.crewCoordinator.publicWarning') + : t('socialUi.crewCoordinator.ownRelaySafe')} +

+
+ + {isFounder && ( + + {t('socialUi.crewCoordinator.manage')} + + )} +
)} + + {/* External clients — secondary, clearly labelled */} + {(coordinator || crew.cordnGroup) && ( +
+

+ {t('socialUi.crewCoordinator.openInAnotherApp')} +

+
+ + {crew.cordnGroup && } +
+
+ )} + + {/* Advanced (founders, no coordinator configured) */} + {!coordinator && isFounder && ( +

+ {t('socialUi.crewCoordinator.advancedA')}{' '} + + {t('socialUi.crewCoordinator.cordnCoordinator')} + + ?{' '} + + {t('socialUi.crewCoordinator.routeThrough')} + +

+ )} + +

+ + {t('socialUi.crewCoordinator.privateChatDescFull')} +

); @@ -172,7 +237,7 @@ function StaircaseLink({ coordinator }: { coordinator: { pubkey?: string; relays const btn = 'inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-bold transition-colors'; return ( - + ); } @@ -214,7 +279,7 @@ function CordnGroupLink({ group }: { group: string }) { const btn = 'inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-bold transition-colors'; return ( - + ); } diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 3780656..a6968ac 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -4025,6 +4025,16 @@ socialUi: { }, crewCoordinator: { privateChat: 'Private Crew Chat', + encryptedChat: 'Encrypted chat', + privateChatName: 'Private chat', + privateChatDesc: 'Forward-secret group chat (MLS) through a coordinator.', + privateChatDescFull: 'Private chat is created once by an admin, then members join.', + chatRowDesc: "Everyone in the crew — sealed to each member's key (NIP-17).", + openChat: 'Open', + setUpChat: 'Set up', + joinChat: 'Join', + checking: 'Checking…', + openInAnotherApp: 'Open in another app', on: 'On', defaultA: 'The', chatWord: 'Chat', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 04a7b48..bcb4080 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -4025,6 +4025,16 @@ socialUi: { }, crewCoordinator: { privateChat: 'Chat privado de la peña', + encryptedChat: 'Chat cifrado', + privateChatName: 'Chat privado', + privateChatDesc: 'Chat de grupo con secreto futuro (MLS) mediante un coordinador.', + privateChatDescFull: 'Un administrador crea el chat privado una vez; luego los miembros se unen.', + chatRowDesc: 'Todos en la peña: sellado con la clave de cada miembro (NIP-17).', + openChat: 'Abrir', + setUpChat: 'Configurar', + joinChat: 'Unirse', + checking: 'Comprobando…', + openInAnotherApp: 'Abrir en otra app', on: 'Activado', defaultA: 'La', chatWord: 'Chat', diff --git a/src/i18n/legacy/cordnUi.ts b/src/i18n/legacy/cordnUi.ts index 025928f..64bca59 100644 --- a/src/i18n/legacy/cordnUi.ts +++ b/src/i18n/legacy/cordnUi.ts @@ -26,6 +26,7 @@ const cordnUi = { checkRequests: 'Check requests', approve: 'Add', noRequests: 'No pending join requests.', + inviteHint: 'Only you are in this chat so far — crew members can tap “Ask to join”, then you approve them here.', memberCount: '{{count}} in the group', you: 'You', groupRef: 'Group link', @@ -64,6 +65,7 @@ const cordnUi = { checkRequests: 'Ver solicitudes', approve: 'Añadir', noRequests: 'No hay solicitudes pendientes.', + inviteHint: 'De momento solo estás tú en este chat: los miembros pueden pulsar «Pedir entrar» y tú los apruebas aquí.', memberCount: '{{count}} en el grupo', you: 'Tú', groupRef: 'Enlace del grupo', diff --git a/src/pages/CrewDetailPage.tsx b/src/pages/CrewDetailPage.tsx index 2c0aef1..718637a 100644 --- a/src/pages/CrewDetailPage.tsx +++ b/src/pages/CrewDetailPage.tsx @@ -399,7 +399,12 @@ export default function CrewDetailPage() { {/* Private encrypted chat coordinator (Cordn / MLS) */} - + {/* Members (with per-member visibility respected) */} {hasMembers && (