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`.
|
`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
|
||||||
|
|
|
||||||
|
|
@ -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. |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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)]) },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue