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`.
③ 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

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

View file

@ -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<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
// 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({
<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')}
</CardTitle>
{usingCordn ? (
<Badge
variant="outline"
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.cordnBadgeTitle')}
>
<Server className="h-3 w-3" />
Cordn
</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>
)}
<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>
<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" />
{usingCordn
? t('socialUi.chat.encryptedCordn')
: t('socialUi.chat.encryptedNip17')}
{t('socialUi.chat.encryptedNip17')}
{canModerate && t('socialUi.chat.moderatorNote')}
</p>
</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.
*
* 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';
/**
* ⚠️ 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.
*

View file

@ -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)]) },
);

View file

@ -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() {
</TabsTrigger>
<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')}
{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 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')}
@ -376,7 +367,6 @@ export default function CrewDetailPage() {
{/* Chat */}
<TabsContent value="chat" className="mt-4">
<CrewChat
crew={crew}
crewName={crew.name}
founderPubkey={crew.pubkey}
members={crew.members}