refactor(crew-chat): Chat tab is always NIP-17; widen group read window

Per the crew-chat audit recommendations:
- The crew Chat tab no longer silently switches transport on a coordinator tag.
  It is always NIP-17; coordinator routing lives only in the Private chat (MLS).
  useCordnChat / useCrewChatUnread / lib/cordn client+crypto are kept but unwired
  (headers marked). Drops the tab's Cordn-only unread dot.
- Group reads (Groups list + crew thread) widened 200 -> 500 wraps; the shared
  decrypt cache makes the extra reads affordable. 1:1 list stays 200.
This commit is contained in:
SkateSpots 2026-10-13 18:35:03 +07:00
parent c7be44dfb3
commit 707dcaf644
7 changed files with 62 additions and 111 deletions

View file

@ -535,9 +535,14 @@ session with **no other memory of this project**. So:
`useConversations`/`useConversation`/`useGroupConversations`/`useGroupConversation`. `useConversations`/`useConversation`/`useGroupConversations`/`useGroupConversation`.
③ threads dedup by rumor id; ④ `CrewChat` guards double-send with a ref; ③ threads dedup by rumor id; ④ `CrewChat` guards double-send with a ref;
⑤ the Cordn path optimistically inserts too and invalidates only for silent ⑤ the Cordn path optimistically inserts too and invalidates only for silent
signers. ⚠️ **Open:** a crew with a `coordinator` tag silently uses the signers. **Standardised the Chat tab on NIP-17 (2026-10-13):** a crew with a
sealed-relay Cordn transport instead of NIP-17 (separate history — a product `coordinator` tag no longer silently reroutes the Chat tab — coordinator
decision, not a bug); the 200-wrap read window can hide older messages. 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 (Still not done: persisting the decrypt cache across app restarts, so the first
load per launch still decrypts everything.) load per launch still decrypts everything.)
- **Sasquatch AI guide** (`src/components/SasquatchGuide.tsx`): the chat - **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 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 `useCoordinator`, with toast) plus an "Open in Cordn" link — falling back to
the old plain-link row when the value doesn't parse. 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 **⚠️ Crew-level Cordn chat ALREADY EXISTS — do NOT rebuild it
(2026-09-29):** `src/lib/cordn/{client,crypto,endpoint,types}.ts` + (2026-09-29):** `src/lib/cordn/{client,crypto,endpoint,types}.ts` +
`useCordnChat.ts` + `CrewChat.tsx` + `crew/CrewCoordinatorCard.tsx`, driven by `useCordnChat.ts` + `CrewChat.tsx` + `crew/CrewCoordinatorCard.tsx`, driven by

View file

@ -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. | | 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. | | 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. | | 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. | | 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 | 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. | | 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. | | 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. | | 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. |

View file

@ -18,20 +18,11 @@ import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
import { AmberApprovalHint } from '@/components/AmberApprovalHint'; import { AmberApprovalHint } from '@/components/AmberApprovalHint';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { import { useGroupConversation, useSendDirectMessage, type DirectMessage } from '@/hooks/useDirectMessages';
useGroupConversation,
useSendDirectMessage,
type DirectMessage,
} from '@/hooks/useDirectMessages';
import { useCordnChat } from '@/hooks/useCordnChat';
import { useCrewChatRead } from '@/hooks/useCrewChatRead'; import { useCrewChatRead } from '@/hooks/useCrewChatRead';
import { resolveCordnEndpoint } from '@/lib/cordn/endpoint';
import type { CordnMessage } from '@/lib/cordn/types';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { useCrewModeration, useHideCrewPost } from '@/hooks/useCrewModeration'; import { useCrewModeration, useHideCrewPost } from '@/hooks/useCrewModeration';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Server } from 'lucide-react';
import type { SkateCrew } from '@/lib/types';
interface CrewChatProps { interface CrewChatProps {
crewName: string; crewName: string;
@ -45,23 +36,6 @@ interface CrewChatProps {
crewCoord?: string; crewCoord?: string;
/** Whether the viewer can moderate (founder or admin). */ /** Whether the viewer can moderate (founder or admin). */
canModerate?: boolean; 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. */ /** One message bubble, resolving the author's profile. */
@ -138,7 +112,6 @@ export function CrewChat({
admins = [], admins = [],
crewCoord = '', crewCoord = '',
canModerate = false, canModerate = false,
crew,
}: CrewChatProps) { }: CrewChatProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
@ -147,13 +120,6 @@ export function CrewChat({
const [draft, setDraft] = useState(''); const [draft, setDraft] = useState('');
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(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 // Moderation — founder + admins can hide chat messages (client-side, since
// NIP-17 messages are E2EE and can't be deleted from relays). // NIP-17 messages are E2EE and can't be deleted from relays).
const moderators = useMemo( const moderators = useMemo(
@ -190,30 +156,15 @@ export function CrewChat({
// because group messages are end-to-end encrypted to the participants. // because group messages are end-to-end encrypted to the participants.
const isMember = !!user && participants.includes(user.pubkey); const isMember = !!user && participants.includes(user.pubkey);
// ── NIP-17 fallback transport ── // The crew Chat is **always NIP-17** — a standard, coordinator-independent
const { data: nip17Messages, isLoading: nip17Loading } = useGroupConversation( // group DM. (A coordinator no longer silently reroutes this tab; coordinator
!usingCordn && isMember ? participants : [], // 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 messages: DirectMessage[] = useMemo(() => nip17Messages ?? [], [nip17Messages]);
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;
// ── "New messages" divider ── // ── "New messages" divider ──
// Snapshot the last-seen time ONCE per open so the divider stays put while the // 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 // 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 // the repeated Approve screens don't look like a bug. Roster = founder + all
// members (deduped, includes self). Cordn v2: one author-proof signature plus // members (deduped, includes self). NIP-17: each target (others + self) is an
// one encryption per roster member. NIP-17: each target (others + self) is an
// encrypt + a seal signature, i.e. two ops per member. // encrypt + a seal signature, i.e. two ops per member.
const rosterSize = Math.max(participants.length, 1); const rosterSize = Math.max(participants.length, 1);
const approvalCount = usingCordn ? rosterSize + 1 : rosterSize * 2; const approvalCount = rosterSize * 2;
const sendingRef = useRef(false); const sendingRef = useRef(false);
const handleSend = async (e: React.FormEvent) => { 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). // NIP-17 recipients = everyone except yourself (the hook wraps the self copy).
const recipients = participants.filter((p) => p !== user.pubkey); const recipients = participants.filter((p) => p !== user.pubkey);
if (!usingCordn && recipients.length === 0) { if (recipients.length === 0) {
toast({ toast({
title: t('socialUi.chat.noOneToMessage'), title: t('socialUi.chat.noOneToMessage'),
description: t('socialUi.chat.noOneToMessageDesc'), description: t('socialUi.chat.noOneToMessageDesc'),
@ -296,15 +246,11 @@ export function CrewChat({
sendingRef.current = true; sendingRef.current = true;
try { try {
if (usingCordn) { await sendNip17({
await cordn.send(text); recipients,
} else { content: text,
await sendNip17({ subject: t('socialUi.chat.subject', { crew: crewName }),
recipients, });
content: text,
subject: t('socialUi.chat.subject', { crew: crewName }),
});
}
setDraft(''); setDraft('');
} catch (err) { } catch (err) {
toast({ toast({
@ -324,31 +270,18 @@ export function CrewChat({
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white"> <CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
<MessageSquare className="h-5 w-5 text-teal-400" /> {t('socialUi.chat.title')} <MessageSquare className="h-5 w-5 text-teal-400" /> {t('socialUi.chat.title')}
</CardTitle> </CardTitle>
{usingCordn ? ( <Badge
<Badge variant="outline"
variant="outline" className="gap-1 border-teal-500/40 bg-teal-500/10 text-teal-400 text-[10px] font-bold uppercase tracking-wide"
className="gap-1 border-emerald-500/40 bg-emerald-500/10 text-emerald-300 text-[10px] font-bold uppercase tracking-wide" title={t('socialUi.chat.privateBadgeTitle')}
title={t('socialUi.chat.cordnBadgeTitle')} >
> <Lock className="h-3 w-3" />
<Server className="h-3 w-3" /> {t('socialUi.chat.private')}
Cordn </Badge>
</Badge>
) : (
<Badge
variant="outline"
className="gap-1 border-teal-500/40 bg-teal-500/10 text-teal-400 text-[10px] font-bold uppercase tracking-wide"
title={t('socialUi.chat.privateBadgeTitle')}
>
<Lock className="h-3 w-3" />
{t('socialUi.chat.private')}
</Badge>
)}
</div> </div>
<p className="text-xs text-muted-foreground flex items-center gap-1 pt-0.5"> <p className="text-xs text-muted-foreground flex items-center gap-1 pt-0.5">
<ShieldCheck className="h-3 w-3 text-teal-400 shrink-0" /> <ShieldCheck className="h-3 w-3 text-teal-400 shrink-0" />
{usingCordn {t('socialUi.chat.encryptedNip17')}
? t('socialUi.chat.encryptedCordn')
: t('socialUi.chat.encryptedNip17')}
{canModerate && t('socialUi.chat.moderatorNote')} {canModerate && t('socialUi.chat.moderatorNote')}
</p> </p>
</CardHeader> </CardHeader>

View file

@ -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. * Crew encrypted group chat over a Cordn coordinator.
* *
* Fetch-then-poll: an initial bounded backlog fetch, then a light poll for live * Fetch-then-poll: an initial bounded backlog fetch, then a light poll for live

View file

@ -8,6 +8,11 @@ import { CORDN_APP_TAG, CORDN_MESSAGE_KIND } from '@/lib/cordn/types';
import { SKATE_CREW_KIND, type SkateCrew } from '@/lib/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 * A cheap, honest "is there new crew chat since I last looked?" signal for the
* Chat tab's unread dot. * Chat tab's unread dot.
* *

View file

@ -811,7 +811,11 @@ export function useGroupConversations() {
try { try {
const relay = nostr.relay(relayUrl); const relay = nostr.relay(relayUrl);
return await relay.query( 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)]) }, { signal: AbortSignal.any([c.signal, AbortSignal.timeout(10000)]) },
); );
} catch (error) { } catch (error) {
@ -905,7 +909,9 @@ export function useGroupConversation(participants: string[]) {
queryFn: async (c) => { queryFn: async (c) => {
if (!user?.signer?.nip44 || participants.length === 0) return []; 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( const batches = await Promise.all(
readRelays.map(async (relayUrl) => { readRelays.map(async (relayUrl) => {
try { try {
@ -914,7 +920,7 @@ export function useGroupConversation(participants: string[]) {
[{ [{
kinds: [GIFT_WRAP_KIND], kinds: [GIFT_WRAP_KIND],
'#p': [user.pubkey], '#p': [user.pubkey],
limit: 200, limit: 500,
}], }],
{ signal: AbortSignal.any([c.signal, AbortSignal.timeout(10000)]) }, { signal: AbortSignal.any([c.signal, AbortSignal.timeout(10000)]) },
); );

View file

@ -20,7 +20,6 @@ import { CrewCoordinatorCard } from '@/components/crew/CrewCoordinatorCard';
import { CrewVideoSection } from '@/components/crew/CrewVideoSection'; import { CrewVideoSection } from '@/components/crew/CrewVideoSection';
import { crewAccent } from '@/lib/crewHelpers'; import { crewAccent } from '@/lib/crewHelpers';
import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity'; import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity';
import { useCrewChatUnread } from '@/hooks/useCrewChatUnread';
import { CrewChat } from '@/components/CrewChat'; import { CrewChat } from '@/components/CrewChat';
import { CordnNativePanel } from '@/components/crew/CordnNativePanel';import { CrewAgentPanel } from '@/components/CrewAgentPanel'; import { CordnNativePanel } from '@/components/crew/CordnNativePanel';import { CrewAgentPanel } from '@/components/CrewAgentPanel';
import { CREW_AGENT_AVATAR } from '@/lib/crewAgent'; 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 crewCoord = crew ? `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}` : '';
const { mutateAsync: hidePost } = useHideCrewPost(crewCoord); 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 && const isCrewMember = !!user && !!crew &&
(user.pubkey === crew.pubkey || crew.members.includes(user.pubkey)); (user.pubkey === crew.pubkey || crew.members.includes(user.pubkey));
const { hasUnread: chatHasUnread } = useCrewChatUnread(crew ?? undefined, isCrewMember);
const _handleHidePost = async (ev: NostrEvent) => { const _handleHidePost = async (ev: NostrEvent) => {
try { try {
@ -340,13 +338,6 @@ export default function CrewDetailPage() {
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="chat" className="gap-1.5 data-[state=active]:bg-teal-500/20 data-[state=active]:text-teal-300"> <TabsTrigger value="chat" className="gap-1.5 data-[state=active]:bg-teal-500/20 data-[state=active]:text-teal-300">
<MessageSquare className="h-4 w-4" /> {t('socialUi.crew.chatTab')} <MessageSquare className="h-4 w-4" /> {t('socialUi.crew.chatTab')}
{chatHasUnread && activeTab !== 'chat' && (
<span
className="ml-0.5 h-2 w-2 rounded-full bg-teal-400 shadow-[0_0_6px_rgba(45,212,191,0.9)]"
aria-label={t('socialUi.crew.newMessages')}
title={t('socialUi.crew.newMessages')}
/>
)}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="mls" className="gap-1.5 data-[state=active]:bg-teal-500/20 data-[state=active]:text-teal-300"> <TabsTrigger value="mls" className="gap-1.5 data-[state=active]:bg-teal-500/20 data-[state=active]:text-teal-300">
<Lock className="h-4 w-4" /> {t('cordnUi.title')} <Lock className="h-4 w-4" /> {t('cordnUi.title')}
@ -376,7 +367,6 @@ export default function CrewDetailPage() {
{/* Chat */} {/* Chat */}
<TabsContent value="chat" className="mt-4"> <TabsContent value="chat" className="mt-4">
<CrewChat <CrewChat
crew={crew}
crewName={crew.name} crewName={crew.name}
founderPubkey={crew.pubkey} founderPubkey={crew.pubkey}
members={crew.members} members={crew.members}