diff --git a/AGENTS.md b/AGENTS.md index 884a92d..0253c65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1402,7 +1402,14 @@ session with **no other memory of this project**. So: nudge** — the crew page defaults to the Private-chat tab when this device already holds the group (`useCordnGroupExists` reads the IndexedDB snapshot: local only, no signer), and a founder/admin banner ("Set up") points at the tab - when it doesn't. A manual tab click always wins (`tabTouchedRef`). + when it doesn't. A manual tab click always wins (`tabTouchedRef`). ⑤ **Messages + hub entry (2026-10-12):** `DirectMessagesPage` has a **"Crews"** section listing + the viewer's crews (`useUserCrews`) with an Open/Set-up badge (from + `useCordnGroupGids` — one local IndexedDB read) linking into + `/crew//?tab=mls`. The crew page's tabs are now **URL-addressable** + (`?tab=events|chat|mls`; an explicit param wins over the auto-default). This is + the cheap "pointer" integration — the two transports are NOT merged (DMs stay + NIP-17; the coordinator is only the crew MLS group). - **Map site-links strip (2026-10-05):** the desktop footer is hidden on the map (full-bleed), so `components/MapFooterStrip.tsx` surfaces the same links (Newsletter/License/Privacy/Collaborators + "not sponsored by OpenSats") as an diff --git a/src/hooks/useCordnNative.ts b/src/hooks/useCordnNative.ts index af7c7b4..79c5772 100644 --- a/src/hooks/useCordnNative.ts +++ b/src/hooks/useCordnNative.ts @@ -9,7 +9,7 @@ import { resolveCordnEndpoint } from '@/lib/cordn/endpoint'; // Dexie-only (light); the heavy MLS/ContextVM module is imported lazily so it // loads only when the private-chat tab is opened, and stays out of the crew-page // chunk. -import { createCordnStore, cordnGroupExists } from '@/lib/cordn/native/idbStore'; +import { createCordnStore, cordnGroupExists, cordnGroupGids } from '@/lib/cordn/native/idbStore'; import type { CordnChatMessage, CordnGroupInfo, @@ -327,3 +327,32 @@ export function useCordnGroupExists( }, [accountPubkey, gid, refreshKey]); return exists; } + +/** + * All MLS group ids this account holds (one IndexedDB read). Lets a list page + * mark which crews already have a private chat without a per-crew read. + */ +export function useCordnGroupGids( + accountPubkey: string | undefined, + refreshKey?: unknown, +): Set | undefined { + const [gids, setGids] = useState | undefined>(undefined); + useEffect(() => { + let cancelled = false; + if (!accountPubkey) { + setGids(undefined); + return; + } + cordnGroupGids(accountPubkey) + .then((value) => { + if (!cancelled) setGids(value); + }) + .catch(() => { + if (!cancelled) setGids(new Set()); + }); + return () => { + cancelled = true; + }; + }, [accountPubkey, refreshKey]); + return gids; +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 3e5ee8a..3780656 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -4076,6 +4076,11 @@ socialUi: { newMessage: 'New Message', groups: 'Groups', refresh: 'Refresh', + crews: 'Crews', + crewFounder: 'Founder', + crewMember: 'Member', + crewOpen: 'Open', + crewSetUp: 'Set up', loadFailed: 'Failed to load messages', emptyTitle: 'No messages yet', emptyDesc: 'Start a conversation with someone in the community', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index ff35870..04a7b48 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -4076,6 +4076,11 @@ socialUi: { newMessage: 'Nuevo mensaje', groups: 'Grupos', refresh: 'Actualizar', + crews: 'Peñas', + crewFounder: 'Fundador', + crewMember: 'Miembro', + crewOpen: 'Abrir', + crewSetUp: 'Configurar', loadFailed: 'No se pudieron cargar los mensajes', emptyTitle: 'Aún no hay mensajes', emptyDesc: 'Empieza una conversación con alguien de la comunidad', diff --git a/src/lib/cordn/native/idbStore.ts b/src/lib/cordn/native/idbStore.ts index a7c7036..c17e8af 100644 --- a/src/lib/cordn/native/idbStore.ts +++ b/src/lib/cordn/native/idbStore.ts @@ -63,3 +63,10 @@ export async function cordnGroupExists(accountPubkey: string, gid: string): Prom const snapshot = await createCordnStore(accountPubkey).load(); return Boolean(snapshot?.groups.some((g) => g.gid === gid)); } + +/** All MLS group ids saved for this account (one IndexedDB read). */ +export async function cordnGroupGids(accountPubkey: string): Promise> { + if (!accountPubkey) return new Set(); + const snapshot = await createCordnStore(accountPubkey).load(); + return new Set((snapshot?.groups ?? []).map((g) => g.gid)); +} diff --git a/src/pages/CrewDetailPage.tsx b/src/pages/CrewDetailPage.tsx index 4d67ab4..2c0aef1 100644 --- a/src/pages/CrewDetailPage.tsx +++ b/src/pages/CrewDetailPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { useSeoMeta } from '@unhead/react'; -import { useParams, Link } from 'react-router-dom'; +import { useParams, Link, useSearchParams } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { PageError } from '@/components/ErrorState'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -62,8 +62,15 @@ export default function CrewDetailPage() { const { isFounder, isAdmin, role, canManage } = useCrewRole(crew); const { toast } = useToast(); const [agentOpen, setAgentOpen] = useState(false); - const [activeTab, setActiveTab] = useState('events'); - const tabTouchedRef = useRef(false); + // The crew's tabs are URL-addressable (`?tab=mls`) so other surfaces (e.g. + // the Messages hub) can deep-link straight into the private chat. + const [searchParams] = useSearchParams(); + const requestedTab = searchParams.get('tab'); + const validTab = requestedTab === 'events' || requestedTab === 'chat' || requestedTab === 'mls' + ? requestedTab + : null; + const [activeTab, setActiveTab] = useState(validTab ?? 'events'); + const tabTouchedRef = useRef(Boolean(validTab)); // The crew's native MLS group id (the crew coordinate) and whether this device // already holds the group — used to default the tab and to nudge the founder. @@ -72,6 +79,13 @@ export default function CrewDetailPage() { useEffect(() => { if (nativeGroupExists && !tabTouchedRef.current) setActiveTab('mls'); }, [nativeGroupExists]); + // Honor an explicit `?tab=` even when the page is already mounted. + useEffect(() => { + if (validTab) { + tabTouchedRef.current = true; + setActiveTab(validTab); + } + }, [validTab]); useSeoMeta({ title: crew ? t('socialUi.crew.seoTitleNamed', { name: crew.name }) : t('socialUi.crew.seoTitle'), diff --git a/src/pages/DirectMessagesPage.tsx b/src/pages/DirectMessagesPage.tsx index 6b083b0..3785211 100644 --- a/src/pages/DirectMessagesPage.tsx +++ b/src/pages/DirectMessagesPage.tsx @@ -14,6 +14,9 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrLogin } from '@nostrify/react/login'; import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities'; import { useConversations, useGroupConversations, type Conversation } from '@/hooks/useDirectMessages'; +import { useUserCrews } from '@/hooks/useCrews'; +import { useCordnGroupGids } from '@/hooks/useCordnNative'; +import { SKATE_CREW_KIND } from '@/lib/types'; import { useDMRead } from '@/hooks/useDMRead'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; @@ -218,6 +221,11 @@ export default function DirectMessagesPage() { const undecryptable = inbox?.undecryptable ?? 0; const { data: groups, refetch: refetchGroups } = useGroupConversations(); const { getLastRead } = useDMRead(); + // The viewer's crews — surfaced here as entry points to their private (MLS) + // chat. `cordnGids` is a local IndexedDB read (no signer prompt) used to mark + // which crews already have a chat on this device. + const { memberships: myCrews } = useUserCrews(user?.pubkey); + const cordnGids = useCordnGroupGids(user?.pubkey); const [showNewMessage, setShowNewMessage] = useState(false); // A conversation is unread when its latest message is from the OTHER person @@ -354,6 +362,47 @@ export default function DirectMessagesPage() { } /> + {/* Crews — entry points into each crew's private (MLS) chat */} + {myCrews.length > 0 && ( +
+

+ {t('socialUi.dms.crews')} +

+
+ {myCrews.map(({ crew, role }) => { + const hasGroup = cordnGids?.has(`${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}`) ?? false; + return ( + + + +
+
+ +
+
+

{crew.name}

+

+ {role === 'founder' ? t('socialUi.dms.crewFounder') : t('socialUi.dms.crewMember')} +

+
+ + {hasGroup ? t('socialUi.dms.crewOpen') : t('socialUi.dms.crewSetUp')} + +
+
+
+ + ); + })} +
+
+ )} + {/* Group conversations (NIP-17 group DMs) */} {groups && groups.length > 0 && (