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:
parent
3ae05a0c38
commit
4bdb949f06
7 changed files with 121 additions and 5 deletions
|
|
@ -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/<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
|
||||
(full-bleed), so `components/MapFooterStrip.tsx` surfaces the same links
|
||||
(Newsletter/License/Privacy/Collaborators + "not sponsored by OpenSats") as an
|
||||
|
|
|
|||
|
|
@ -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<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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<Set<string>> {
|
||||
if (!accountPubkey) return new Set();
|
||||
const snapshot = await createCordnStore(accountPubkey).load();
|
||||
return new Set((snapshot?.groups ?? []).map((g) => g.gid));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<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) */}
|
||||
{groups && groups.length > 0 && (
|
||||
<div className="mb-5">
|
||||
|
|
|
|||
Loading…
Reference in a new issue