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:
parent
c7be44dfb3
commit
707dcaf644
7 changed files with 62 additions and 111 deletions
17
AGENTS.md
17
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
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
});
|
||||
}
|
||||
setDraft('');
|
||||
} catch (err) {
|
||||
toast({
|
||||
|
|
@ -324,16 +270,6 @@ 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"
|
||||
|
|
@ -342,13 +278,10 @@ export function CrewChat({
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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)]) },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue