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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue