diff --git a/AGENTS.md b/AGENTS.md index 79bcc96..b7f73e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -535,9 +535,14 @@ session with **no other memory of this project**. So: `useConversations`/`useConversation`/`useGroupConversations`/`useGroupConversation`. ③ threads dedup by rumor id; ④ `CrewChat` guards double-send with a ref; ⑤ the Cordn path optimistically inserts too and invalidates only for silent - signers. ⚠️ **Open:** a crew with a `coordinator` tag silently uses the - sealed-relay Cordn transport instead of NIP-17 (separate history — a product - decision, not a bug); the 200-wrap read window can hide older messages. + signers. **Standardised the Chat tab on NIP-17 (2026-10-13):** a crew with a + `coordinator` tag no longer silently reroutes the Chat tab — coordinator + routing lives **only** in the Private chat (MLS). `CrewChat` no longer takes a + `crew` prop or the Cordn branch; the tab's unread dot (Cordn-only metadata peek) + was dropped. `useCordnChat.ts`, `useCrewChatUnread.ts` and + `lib/cordn/{client,crypto}.ts` are **kept but unwired** (marked in their + headers) — re-wiring is a deliberate decision, not a one-liner, because it + split chat history. Group reads widened to **500** wraps (1:1 stays 200). (Still not done: persisting the decrypt cache across app restarts, so the first load per launch still decrypts everything.) - **Sasquatch AI guide** (`src/components/SasquatchGuide.tsx`): the chat @@ -1344,6 +1349,12 @@ session with **no other memory of this project**. So: renders it as a **Coordinator** row with a one-tap **Add** (sets the viewer's `useCoordinator`, with toast) plus an "Open in Cordn" link — falling back to the old plain-link row when the value doesn't parse. + ⚠️ **STATUS (2026-10-13): the sealed-relay chat path described in this bullet + is now UNWIRED** — the crew **Chat** tab is always **NIP-17**, and coordinator + routing lives only in the crew's **Private chat** (MLS). `useCordnChat` + + `lib/cordn/{client,crypto}` + `useCrewChatUnread` are kept but unused (headers + marked); see `CREW_CHAT_AUDIT.md`. Do not re-wire the old path without reading + that. The rest of the bullet is the historical design. **⚠️ Crew-level Cordn chat ALREADY EXISTS — do NOT rebuild it (2026-09-29):** `src/lib/cordn/{client,crypto,endpoint,types}.ts` + `useCordnChat.ts` + `CrewChat.tsx` + `crew/CrewCoordinatorCard.tsx`, driven by diff --git a/CREW_CHAT_AUDIT.md b/CREW_CHAT_AUDIT.md index 739d70f..fba6f3c 100644 --- a/CREW_CHAT_AUDIT.md +++ b/CREW_CHAT_AUDIT.md @@ -14,8 +14,8 @@ built-in **NIP-17** group chat and the **Cordn sealed-relay** path (`useCordnCha | 4 | Med | **Fixed** | No dedup by rumor id in a thread — a re-sent rumor (same id, two wraps) could render twice. Threads now dedup by id before sorting. | | 5 | Med | **Fixed** | **Double-send**: pressing Enter twice before React re-rendered `isPending` sent twice. Added an in-flight ref guard. | | 6 | Med | **Fixed** | The **Cordn** path invalidated its query on every send → an Amber refetch storm. Now optimistically inserts the sent message and only invalidates for silent signers. | -| 7 | Med | Open | **Transport switches silently.** A crew with a `coordinator` tag uses the **sealed-relay Cordn** path; without one it uses **NIP-17**. They have separate histories, so a crew that later gains a coordinator appears to "lose" its chat. New crews now get the SkateSpots coordinator by default, so most new crews take the Cordn path. Needs a product decision: label it clearly, or standardise on one. | -| 8 | Med | Open | **Read limit 200.** Threads fetch the N most recent wraps addressed to the viewer and filter client-side (the wrap hides its participants, so the relay can't filter). A busy inbox can push older crew messages out of the window → the chat looks empty. Could raise the limit for group threads. | +| 7 | Med | **Fixed** | **Transport switched silently.** A crew with a `coordinator` tag used the **sealed-relay Cordn** path; without one, **NIP-17**. They have separate histories, so a crew that later gained a coordinator looked like it "lost" its chat — and new crews default to a coordinator. **The Chat tab is now always NIP-17**; coordinator routing lives only in the **Private chat** (MLS). The sealed-relay modules (`useCordnChat`, `lib/cordn/{client,crypto}`, `useCrewChatUnread`) are kept but **unwired**. | +| 8 | Med | **Fixed** | **Read limit 200.** Group reads (the Groups list + a crew thread) now use **500** (client-side filter is unavoidable — a gift wrap hides its participants — and the shared decrypt cache makes it affordable); the 1:1 list stays at 200. | | 9 | Low | Open | No images/attachments in the crew **Chat** (text only). The MLS **Private chat** got images; the NIP-17 path didn't. | | 10 | Info | — | With the cache, an Amber read is now **one decrypt per wrap per session** instead of one per view. Sends still cost 2 approvals per member (NIP-17) vs ~1 per message on the MLS path. | diff --git a/src/components/CrewChat.tsx b/src/components/CrewChat.tsx index 844c1bd..9a1c852 100644 --- a/src/components/CrewChat.tsx +++ b/src/components/CrewChat.tsx @@ -18,20 +18,11 @@ import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities'; import { AmberApprovalHint } from '@/components/AmberApprovalHint'; import { useAuthor } from '@/hooks/useAuthor'; import { useToast } from '@/hooks/useToast'; -import { - useGroupConversation, - useSendDirectMessage, - type DirectMessage, -} from '@/hooks/useDirectMessages'; -import { useCordnChat } from '@/hooks/useCordnChat'; +import { useGroupConversation, useSendDirectMessage, type DirectMessage } from '@/hooks/useDirectMessages'; import { useCrewChatRead } from '@/hooks/useCrewChatRead'; -import { resolveCordnEndpoint } from '@/lib/cordn/endpoint'; -import type { CordnMessage } from '@/lib/cordn/types'; import { genUserName } from '@/lib/genUserName'; import { useCrewModeration, useHideCrewPost } from '@/hooks/useCrewModeration'; import { cn } from '@/lib/utils'; -import { Server } from 'lucide-react'; -import type { SkateCrew } from '@/lib/types'; interface CrewChatProps { crewName: string; @@ -45,23 +36,6 @@ interface CrewChatProps { crewCoord?: string; /** Whether the viewer can moderate (founder or admin). */ canModerate?: boolean; - /** - * Full crew — when present AND it has a `coordinator` configured, chat runs - * over the Cordn coordinator (encrypted, coordinator-routed). Otherwise it - * falls back to the built-in NIP-17 group DM. - */ - crew?: SkateCrew; -} - -/** Normalise a Cordn message into the shared DirectMessage bubble shape. */ -function cordnToDirect(m: CordnMessage, groupId: string): DirectMessage { - return { - id: m.id, - pubkey: m.pubkey, - content: m.content, - createdAt: m.createdAt, - conversationWith: groupId, - }; } /** One message bubble, resolving the author's profile. */ @@ -138,7 +112,6 @@ export function CrewChat({ admins = [], crewCoord = '', canModerate = false, - crew, }: CrewChatProps) { const { t } = useTranslation(); const { user } = useCurrentUser(); @@ -147,13 +120,6 @@ export function CrewChat({ const [draft, setDraft] = useState(''); const scrollRef = useRef(null); - // Cordn transport when the crew advertises a coordinator; else NIP-17. - const cordnEndpoint = useMemo( - () => (crew ? resolveCordnEndpoint(crew) : null), - [crew], - ); - const usingCordn = !!cordnEndpoint; - // Moderation — founder + admins can hide chat messages (client-side, since // NIP-17 messages are E2EE and can't be deleted from relays). const moderators = useMemo( @@ -190,30 +156,15 @@ export function CrewChat({ // because group messages are end-to-end encrypted to the participants. const isMember = !!user && participants.includes(user.pubkey); - // ── NIP-17 fallback transport ── - const { data: nip17Messages, isLoading: nip17Loading } = useGroupConversation( - !usingCordn && isMember ? participants : [], + // The crew Chat is **always NIP-17** — a standard, coordinator-independent + // group DM. (A coordinator no longer silently reroutes this tab; coordinator + // routing lives in the crew's Private chat / MLS. See CREW_CHAT_AUDIT.md.) + const { data: nip17Messages, isLoading } = useGroupConversation( + isMember ? participants : [], ); - const { mutateAsync: sendNip17, isPending: nip17Sending } = useSendDirectMessage(); + const { mutateAsync: sendNip17, isPending } = useSendDirectMessage(); - // ── Cordn coordinator transport ── - const cordn = useCordnChat({ - endpoint: cordnEndpoint, - roster: participants, - enabled: isMember, - }); - - // Unify whichever transport is active into one message list + send + state. - // Memoised so its identity is stable for the useMemo/useEffect deps below. - const messages: DirectMessage[] = useMemo( - () => - usingCordn - ? cordn.messages.map((m) => cordnToDirect(m, cordnEndpoint!.groupId)) - : (nip17Messages ?? []), - [usingCordn, cordn.messages, cordnEndpoint, nip17Messages], - ); - const isLoading = usingCordn ? cordn.isLoading : nip17Loading; - const isPending = usingCordn ? cordn.isSending : nip17Sending; + const messages: DirectMessage[] = useMemo(() => nip17Messages ?? [], [nip17Messages]); // ── "New messages" divider ── // Snapshot the last-seen time ONCE per open so the divider stays put while the @@ -260,11 +211,10 @@ export function CrewChat({ // How many signer approvals one Send will cost — used to warn Amber users so // the repeated Approve screens don't look like a bug. Roster = founder + all - // members (deduped, includes self). Cordn v2: one author-proof signature plus - // one encryption per roster member. NIP-17: each target (others + self) is an + // members (deduped, includes self). NIP-17: each target (others + self) is an // encrypt + a seal signature, i.e. two ops per member. const rosterSize = Math.max(participants.length, 1); - const approvalCount = usingCordn ? rosterSize + 1 : rosterSize * 2; + const approvalCount = rosterSize * 2; const sendingRef = useRef(false); const handleSend = async (e: React.FormEvent) => { @@ -286,7 +236,7 @@ export function CrewChat({ // NIP-17 recipients = everyone except yourself (the hook wraps the self copy). const recipients = participants.filter((p) => p !== user.pubkey); - if (!usingCordn && recipients.length === 0) { + if (recipients.length === 0) { toast({ title: t('socialUi.chat.noOneToMessage'), description: t('socialUi.chat.noOneToMessageDesc'), @@ -296,15 +246,11 @@ export function CrewChat({ sendingRef.current = true; try { - if (usingCordn) { - await cordn.send(text); - } else { - await sendNip17({ - recipients, - content: text, - subject: t('socialUi.chat.subject', { crew: crewName }), - }); - } + await sendNip17({ + recipients, + content: text, + subject: t('socialUi.chat.subject', { crew: crewName }), + }); setDraft(''); } catch (err) { toast({ @@ -324,31 +270,18 @@ export function CrewChat({ {t('socialUi.chat.title')} - {usingCordn ? ( - - - Cordn - - ) : ( - - - {t('socialUi.chat.private')} - - )} + + + {t('socialUi.chat.private')} +

- {usingCordn - ? t('socialUi.chat.encryptedCordn') - : t('socialUi.chat.encryptedNip17')} + {t('socialUi.chat.encryptedNip17')} {canModerate && t('socialUi.chat.moderatorNote')}

diff --git a/src/hooks/useCordnChat.ts b/src/hooks/useCordnChat.ts index 13aabff..237ad90 100644 --- a/src/hooks/useCordnChat.ts +++ b/src/hooks/useCordnChat.ts @@ -23,6 +23,12 @@ interface UseCordnChatParams { } /** + * ⚠️ UNWIRED (2026-10-13). The crew **Chat** tab is now always NIP-17 (`CrewChat`); + * coordinator routing lives in the crew's **Private chat** (MLS). This sealed-relay + * transport (pre-MLS NIP-44 group delivery over the coordinator's relay) is kept as + * a reference but is no longer rendered — see `CREW_CHAT_AUDIT.md`. Re-wiring it is + * a deliberate, separate decision (it split chat history per transport). + * * Crew encrypted group chat over a Cordn coordinator. * * Fetch-then-poll: an initial bounded backlog fetch, then a light poll for live diff --git a/src/hooks/useCrewChatUnread.ts b/src/hooks/useCrewChatUnread.ts index cb5f303..4556eb0 100644 --- a/src/hooks/useCrewChatUnread.ts +++ b/src/hooks/useCrewChatUnread.ts @@ -8,6 +8,11 @@ import { CORDN_APP_TAG, CORDN_MESSAGE_KIND } from '@/lib/cordn/types'; import { SKATE_CREW_KIND, type SkateCrew } from '@/lib/types'; /** + * ⚠️ UNWIRED (2026-10-13). Cordn-only unread metadata peek for the crew Chat tab. + * The Chat tab is now always NIP-17, so there is no cheap metadata-only peek + * (gift wraps randomise timestamps and hide participants) — the in-chat "New" + * divider covers "what did I miss". Kept for reference; see `CREW_CHAT_AUDIT.md`. + * * A cheap, honest "is there new crew chat since I last looked?" signal for the * Chat tab's unread dot. * diff --git a/src/hooks/useDirectMessages.ts b/src/hooks/useDirectMessages.ts index 63171ff..c0a744c 100644 --- a/src/hooks/useDirectMessages.ts +++ b/src/hooks/useDirectMessages.ts @@ -811,7 +811,11 @@ export function useGroupConversations() { try { const relay = nostr.relay(relayUrl); return await relay.query( - [{ kinds: [GIFT_WRAP_KIND], '#p': [user.pubkey], limit: 200 }], + // Groups list: a wider window than the 1:1 list — group wraps sit + // among all the viewer's wraps, and the relay can't filter by group + // (a gift wrap hides its participants), so we filter client-side. + // The shared decrypt cache makes the extra reads affordable. + [{ kinds: [GIFT_WRAP_KIND], '#p': [user.pubkey], limit: 500 }], { signal: AbortSignal.any([c.signal, AbortSignal.timeout(10000)]) }, ); } catch (error) { @@ -905,7 +909,9 @@ export function useGroupConversation(participants: string[]) { queryFn: async (c) => { if (!user?.signer?.nip44 || participants.length === 0) return []; - // Query for gift wraps addressed to us + // Query for gift wraps addressed to us. Wider window than the 1:1 list so + // older crew messages aren't pushed out by unrelated DMs (client-side group + // filter; the shared decrypt cache keeps this affordable). const batches = await Promise.all( readRelays.map(async (relayUrl) => { try { @@ -914,7 +920,7 @@ export function useGroupConversation(participants: string[]) { [{ kinds: [GIFT_WRAP_KIND], '#p': [user.pubkey], - limit: 200, + limit: 500, }], { signal: AbortSignal.any([c.signal, AbortSignal.timeout(10000)]) }, ); diff --git a/src/pages/CrewDetailPage.tsx b/src/pages/CrewDetailPage.tsx index 718637a..7c53cc6 100644 --- a/src/pages/CrewDetailPage.tsx +++ b/src/pages/CrewDetailPage.tsx @@ -20,7 +20,6 @@ import { CrewCoordinatorCard } from '@/components/crew/CrewCoordinatorCard'; import { CrewVideoSection } from '@/components/crew/CrewVideoSection'; import { crewAccent } from '@/lib/crewHelpers'; import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity'; -import { useCrewChatUnread } from '@/hooks/useCrewChatUnread'; import { CrewChat } from '@/components/CrewChat'; import { CordnNativePanel } from '@/components/crew/CordnNativePanel';import { CrewAgentPanel } from '@/components/CrewAgentPanel'; import { CREW_AGENT_AVATAR } from '@/lib/crewAgent'; @@ -103,10 +102,9 @@ export default function CrewDetailPage() { const crewCoord = crew ? `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}` : ''; const { mutateAsync: hidePost } = useHideCrewPost(crewCoord); - // Unread dot on the Chat tab — cheap, member-only, no background decryption. + // Membership (used by the Chat/Private-chat tabs). const isCrewMember = !!user && !!crew && (user.pubkey === crew.pubkey || crew.members.includes(user.pubkey)); - const { hasUnread: chatHasUnread } = useCrewChatUnread(crew ?? undefined, isCrewMember); const _handleHidePost = async (ev: NostrEvent) => { try { @@ -340,13 +338,6 @@ export default function CrewDetailPage() { {t('socialUi.crew.chatTab')} - {chatHasUnread && activeTab !== 'chat' && ( - - )} {t('cordnUi.title')} @@ -376,7 +367,6 @@ export default function CrewDetailPage() { {/* Chat */}