feat(messages): Crews section in the Messages hub -> each crew's private chat

- DirectMessagesPage gains a 'Crews' block listing the viewer's crews with an
  Open/Set-up badge (from one local IndexedDB read via useCordnGroupGids),
  linking into /crew/<pk>/<id>?tab=mls.
- Crew page tabs are now URL-addressable (?tab=events|chat|mls); an explicit
  param wins over the auto-default. useUserCrews + cordnGroupGids added.
This commit is contained in:
SkateSpots 2026-10-12 23:35:35 +07:00
parent 3ae05a0c38
commit 4bdb949f06
7 changed files with 121 additions and 5 deletions

View file

@ -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 nudge** — the crew page defaults to the Private-chat tab when this device
already holds the group (`useCordnGroupExists` reads the IndexedDB snapshot: already holds the group (`useCordnGroupExists` reads the IndexedDB snapshot:
local only, no signer), and a founder/admin banner ("Set up") points at the tab 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/<pk>/<id>?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 - **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 (full-bleed), so `components/MapFooterStrip.tsx` surfaces the same links
(Newsletter/License/Privacy/Collaborators + "not sponsored by OpenSats") as an (Newsletter/License/Privacy/Collaborators + "not sponsored by OpenSats") as an

View file

@ -9,7 +9,7 @@ import { resolveCordnEndpoint } from '@/lib/cordn/endpoint';
// Dexie-only (light); the heavy MLS/ContextVM module is imported lazily so it // 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 // loads only when the private-chat tab is opened, and stays out of the crew-page
// chunk. // chunk.
import { createCordnStore, cordnGroupExists } from '@/lib/cordn/native/idbStore'; import { createCordnStore, cordnGroupExists, cordnGroupGids } from '@/lib/cordn/native/idbStore';
import type { import type {
CordnChatMessage, CordnChatMessage,
CordnGroupInfo, CordnGroupInfo,
@ -327,3 +327,32 @@ export function useCordnGroupExists(
}, [accountPubkey, gid, refreshKey]); }, [accountPubkey, gid, refreshKey]);
return exists; 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<string> | undefined {
const [gids, setGids] = useState<Set<string> | 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;
}

View file

@ -4076,6 +4076,11 @@ socialUi: {
newMessage: 'New Message', newMessage: 'New Message',
groups: 'Groups', groups: 'Groups',
refresh: 'Refresh', refresh: 'Refresh',
crews: 'Crews',
crewFounder: 'Founder',
crewMember: 'Member',
crewOpen: 'Open',
crewSetUp: 'Set up',
loadFailed: 'Failed to load messages', loadFailed: 'Failed to load messages',
emptyTitle: 'No messages yet', emptyTitle: 'No messages yet',
emptyDesc: 'Start a conversation with someone in the community', emptyDesc: 'Start a conversation with someone in the community',

View file

@ -4076,6 +4076,11 @@ socialUi: {
newMessage: 'Nuevo mensaje', newMessage: 'Nuevo mensaje',
groups: 'Grupos', groups: 'Grupos',
refresh: 'Actualizar', refresh: 'Actualizar',
crews: 'Peñas',
crewFounder: 'Fundador',
crewMember: 'Miembro',
crewOpen: 'Abrir',
crewSetUp: 'Configurar',
loadFailed: 'No se pudieron cargar los mensajes', loadFailed: 'No se pudieron cargar los mensajes',
emptyTitle: 'Aún no hay mensajes', emptyTitle: 'Aún no hay mensajes',
emptyDesc: 'Empieza una conversación con alguien de la comunidad', emptyDesc: 'Empieza una conversación con alguien de la comunidad',

View file

@ -63,3 +63,10 @@ export async function cordnGroupExists(accountPubkey: string, gid: string): Prom
const snapshot = await createCordnStore(accountPubkey).load(); const snapshot = await createCordnStore(accountPubkey).load();
return Boolean(snapshot?.groups.some((g) => g.gid === gid)); 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<Set<string>> {
if (!accountPubkey) return new Set();
const snapshot = await createCordnStore(accountPubkey).load();
return new Set((snapshot?.groups ?? []).map((g) => g.gid));
}

View file

@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useSeoMeta } from '@unhead/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 { nip19 } from 'nostr-tools';
import { PageError } from '@/components/ErrorState'; import { PageError } from '@/components/ErrorState';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 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 { isFounder, isAdmin, role, canManage } = useCrewRole(crew);
const { toast } = useToast(); const { toast } = useToast();
const [agentOpen, setAgentOpen] = useState(false); const [agentOpen, setAgentOpen] = useState(false);
const [activeTab, setActiveTab] = useState('events'); // The crew's tabs are URL-addressable (`?tab=mls`) so other surfaces (e.g.
const tabTouchedRef = useRef(false); // 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 // 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. // already holds the group — used to default the tab and to nudge the founder.
@ -72,6 +79,13 @@ export default function CrewDetailPage() {
useEffect(() => { useEffect(() => {
if (nativeGroupExists && !tabTouchedRef.current) setActiveTab('mls'); if (nativeGroupExists && !tabTouchedRef.current) setActiveTab('mls');
}, [nativeGroupExists]); }, [nativeGroupExists]);
// Honor an explicit `?tab=` even when the page is already mounted.
useEffect(() => {
if (validTab) {
tabTouchedRef.current = true;
setActiveTab(validTab);
}
}, [validTab]);
useSeoMeta({ useSeoMeta({
title: crew ? t('socialUi.crew.seoTitleNamed', { name: crew.name }) : t('socialUi.crew.seoTitle'), title: crew ? t('socialUi.crew.seoTitleNamed', { name: crew.name }) : t('socialUi.crew.seoTitle'),

View file

@ -14,6 +14,9 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrLogin } from '@nostrify/react/login'; import { useNostrLogin } from '@nostrify/react/login';
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities'; import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
import { useConversations, useGroupConversations, type Conversation } from '@/hooks/useDirectMessages'; 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 { useDMRead } from '@/hooks/useDMRead';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
@ -218,6 +221,11 @@ export default function DirectMessagesPage() {
const undecryptable = inbox?.undecryptable ?? 0; const undecryptable = inbox?.undecryptable ?? 0;
const { data: groups, refetch: refetchGroups } = useGroupConversations(); const { data: groups, refetch: refetchGroups } = useGroupConversations();
const { getLastRead } = useDMRead(); 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); const [showNewMessage, setShowNewMessage] = useState(false);
// A conversation is unread when its latest message is from the OTHER person // 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 && (
<div className="mb-5">
<h2 className="mb-2 flex items-center gap-1.5 text-xs font-bold uppercase tracking-widest text-gray-500">
<Lock className="h-3.5 w-3.5" /> {t('socialUi.dms.crews')}
</h2>
<div className="space-y-3">
{myCrews.map(({ crew, role }) => {
const hasGroup = cordnGids?.has(`${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}`) ?? false;
return (
<Link key={crew.pubkey} to={`/crew/${crew.pubkey}/${crew.identifier}?tab=mls`}>
<Card className="hover:bg-gray-900/50 transition-colors cursor-pointer border-gray-800 hover:border-teal-500/50 active:scale-[0.99]">
<CardContent className="p-3">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full bg-teal-500/15 text-teal-400">
<Lock className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate font-bold text-white">{crew.name}</h3>
<p className="mt-0.5 truncate text-xs text-gray-500">
{role === 'founder' ? t('socialUi.dms.crewFounder') : t('socialUi.dms.crewMember')}
</p>
</div>
<span
className={cn(
'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide',
hasGroup ? 'bg-teal-500/15 text-teal-300' : 'bg-white/10 text-gray-400',
)}
>
{hasGroup ? t('socialUi.dms.crewOpen') : t('socialUi.dms.crewSetUp')}
</span>
</div>
</CardContent>
</Card>
</Link>
);
})}
</div>
</div>
)}
{/* Group conversations (NIP-17 group DMs) */} {/* Group conversations (NIP-17 group DMs) */}
{groups && groups.length > 0 && ( {groups && groups.length > 0 && (
<div className="mb-5"> <div className="mb-5">