feat(cordn): unread and join-request badges; persist pending joins
This commit is contained in:
parent
2cec2b4782
commit
7da67cc2c0
8 changed files with 303 additions and 20 deletions
|
|
@ -43,6 +43,8 @@ import { isNativePlatform } from '@/lib/platform';
|
|||
import { formatDistanceToNow } from '@/lib/dateFnsI18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CordnRoom } from '@/lib/cordn/room';
|
||||
import { readCordnSeen, writeCordnSeen } from '@/lib/cordn/unread';
|
||||
import { clearPendingJoin, readPendingJoin, writePendingJoin } from '@/lib/cordn/pendingJoin';
|
||||
|
||||
type RoomScope = 'crew' | 'community';
|
||||
|
||||
|
|
@ -60,6 +62,12 @@ interface CordnNativePanelProps {
|
|||
inviteUrl?: string;
|
||||
/** Community bot pubkey — shown with a friendly label in requests/members. */
|
||||
botPubkey?: string;
|
||||
/** Whether the private-chat tab is the visible tab (drives seen/unread). */
|
||||
active?: boolean;
|
||||
/** Called when the room has inbound messages newer than the last seen cursor. */
|
||||
onUnreadChange?: (hasUnread: boolean) => void;
|
||||
/** Called with the number of pending join requests (founder/admin). */
|
||||
onRequestsChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
type MemberRole = 'founder' | 'admin' | 'member';
|
||||
|
|
@ -82,6 +90,9 @@ export function CordnNativePanel({
|
|||
scope = 'crew',
|
||||
inviteUrl: inviteUrlProp,
|
||||
botPubkey,
|
||||
active = true,
|
||||
onUnreadChange,
|
||||
onRequestsChange,
|
||||
}: CordnNativePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const isCommunity = scope === 'community';
|
||||
|
|
@ -127,7 +138,7 @@ export function CordnNativePanel({
|
|||
],
|
||||
};
|
||||
const { user } = useCurrentUser();
|
||||
const native = useCordnNative({ crew, enabled: !!user && isMember });
|
||||
const native = useCordnNative({ crew, enabled: !!user && isMember, active });
|
||||
// Stable callbacks (the hook's return object is recreated each render).
|
||||
const sendNative = native.send;
|
||||
const checkInviteNative = native.checkInvite;
|
||||
|
|
@ -135,7 +146,9 @@ export function CordnNativePanel({
|
|||
const [copied, setCopied] = useState(false);
|
||||
const [copiedLink, setCopiedLink] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [awaiting, setAwaiting] = useState(false);
|
||||
const [awaiting, setAwaiting] = useState(() =>
|
||||
user?.pubkey ? readPendingJoin(user.pubkey, native.groupId) : false,
|
||||
);
|
||||
const [pending, setPending] = useState<PendingMessage | null>(null);
|
||||
const [sentIds, setSentIds] = useState<Set<string>>(() => new Set());
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
|
|
@ -159,17 +172,80 @@ export function CordnNativePanel({
|
|||
}, [native.messages.length, pending?.state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (native.group && awaiting) setAwaiting(false);
|
||||
}, [native.group, awaiting]);
|
||||
if (!user?.pubkey) return;
|
||||
setAwaiting(readPendingJoin(user.pubkey, native.groupId));
|
||||
}, [user?.pubkey, native.groupId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (native.group && awaiting) {
|
||||
if (user?.pubkey) clearPendingJoin(user.pubkey, native.groupId);
|
||||
setAwaiting(false);
|
||||
}
|
||||
}, [native.group, awaiting, user?.pubkey, native.groupId]);
|
||||
|
||||
// Founders should see waiting requests without hunting for the check button.
|
||||
// One quiet read when the group opens; the manual button handles the rest.
|
||||
const loadRequestsNative = native.loadRequests;
|
||||
// Quiet reads keep the tab badge fresh without toggling the composer's busy
|
||||
// state. Amber is excluded: a background ContextVM request would pop the
|
||||
// signer; those users use the explicit Check requests button.
|
||||
const loadRequestsQuietNative = native.loadRequestsQuiet;
|
||||
const interactive = native.interactiveHintVisible;
|
||||
const groupGid = native.group?.gid;
|
||||
useEffect(() => {
|
||||
if (!canManage || !native.group || !native.ready || loadedRequestsRef.current) return;
|
||||
// Hidden tabs only do this for nsec/bunker. Extension/Amber users either
|
||||
// open the tab once (extension) or use the manual Check requests button.
|
||||
if (!canManage || !groupGid || !native.ready || interactive || (!native.passiveSigner && !active) || loadedRequestsRef.current) return;
|
||||
loadedRequestsRef.current = true;
|
||||
void loadRequestsNative();
|
||||
}, [canManage, native.group, native.ready, loadRequestsNative]);
|
||||
void loadRequestsQuietNative();
|
||||
}, [canManage, groupGid, native.ready, interactive, native.passiveSigner, active, loadRequestsQuietNative]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canManage || !groupGid || !native.ready || interactive || !native.passiveSigner) return;
|
||||
const id = window.setInterval(() => void loadRequestsQuietNative(), 60_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [canManage, groupGid, native.ready, interactive, native.passiveSigner, loadRequestsQuietNative]);
|
||||
|
||||
useEffect(() => {
|
||||
onRequestsChange?.(canManage ? native.requests.length : 0);
|
||||
}, [canManage, native.requests.length, onRequestsChange]);
|
||||
|
||||
// "New since last opened" marker. While the tab is visible we advance the
|
||||
// cursor; while it is hidden we only compare inbound timestamps. The first
|
||||
// observation seeds the cursor so a room's old history is not "unread".
|
||||
const messageCursor = useMemo(() => {
|
||||
const latest = native.messages
|
||||
.filter((m) => m.direction === 'inbound' && m.sender !== user?.pubkey)
|
||||
.reduce((max, m) => Math.max(max, m.createdAt), 0);
|
||||
return { latest };
|
||||
}, [native.messages, user?.pubkey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.pubkey || !groupGid || !native.ready) {
|
||||
onUnreadChange?.(false);
|
||||
return;
|
||||
}
|
||||
const gid = groupGid;
|
||||
const latest = messageCursor.latest;
|
||||
if (active) {
|
||||
writeCordnSeen(user.pubkey, gid, latest);
|
||||
onUnreadChange?.(false);
|
||||
return;
|
||||
}
|
||||
const seen = readCordnSeen(user.pubkey, gid);
|
||||
if (seen === null) {
|
||||
writeCordnSeen(user.pubkey, gid, latest);
|
||||
onUnreadChange?.(false);
|
||||
} else {
|
||||
onUnreadChange?.(latest > seen);
|
||||
}
|
||||
}, [active, groupGid, native.ready, user?.pubkey, messageCursor.latest, onUnreadChange]);
|
||||
|
||||
// Opening the private tab pulls anything missed while it was hidden. Silent
|
||||
// signers also poll in the hook; this makes tab activation immediate.
|
||||
const refreshNative = native.refresh;
|
||||
useEffect(() => {
|
||||
if (!active || !native.ready || !groupGid || interactive) return;
|
||||
void refreshNative();
|
||||
}, [active, native.ready, groupGid, interactive, refreshNative]);
|
||||
|
||||
const markSent = useCallback((id: string) => {
|
||||
if (!id) return;
|
||||
|
|
@ -261,20 +337,22 @@ export function CordnNativePanel({
|
|||
const handleRequestJoin = useCallback(async () => {
|
||||
setNotice(null);
|
||||
const requested = await native.requestJoin();
|
||||
if (!requested) return;
|
||||
if (!requested || !user?.pubkey) return;
|
||||
writePendingJoin(user.pubkey, native.groupId);
|
||||
setAwaiting(true);
|
||||
}, [native]);
|
||||
}, [native, user?.pubkey]);
|
||||
|
||||
const handleCheckInvite = useCallback(async () => {
|
||||
if (native.busy) return;
|
||||
setNotice(null);
|
||||
const joined = await checkNow();
|
||||
if (joined) {
|
||||
if (user?.pubkey) clearPendingJoin(user.pubkey, native.groupId);
|
||||
setAwaiting(false);
|
||||
return;
|
||||
}
|
||||
if (!awaiting) setNotice(t('cordnUi.noInvite'));
|
||||
}, [checkNow, awaiting, t, native.busy]);
|
||||
}, [checkNow, awaiting, t, native.busy, user?.pubkey, native.groupId]);
|
||||
|
||||
// Keep the latest checker in a ref: the poll must not restart just because a
|
||||
// parent re-render gave the hook callbacks new identities.
|
||||
|
|
@ -287,7 +365,7 @@ export function CordnNativePanel({
|
|||
// 30s so a founder who approves later is picked up without a manual tap.
|
||||
// Interactive signers (Amber) are excluded — polling would pop their signer.
|
||||
useEffect(() => {
|
||||
if (!awaiting || native.group || !native.ready || native.interactiveHintVisible) return;
|
||||
if (!awaiting || native.group || !native.ready || native.interactiveHintVisible || (!active && !native.passiveSigner)) return;
|
||||
let cancelled = false;
|
||||
let timer: number | undefined;
|
||||
let delay = 10_000;
|
||||
|
|
@ -310,7 +388,7 @@ export function CordnNativePanel({
|
|||
cancelled = true;
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
};
|
||||
}, [awaiting, native.group, native.ready, native.interactiveHintVisible]);
|
||||
}, [awaiting, native.group, native.ready, native.interactiveHintVisible, active, native.passiveSigner]);
|
||||
|
||||
// "Join our crew chat" invite. The chat needs the person to be a crew member
|
||||
// first, so the link just drops them on this crew's private-chat tab with the
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import type {
|
|||
CordnGroupInfo,
|
||||
CordnSession,
|
||||
} from '@/lib/cordn/native';
|
||||
import { isInteractiveSigner } from '@/lib/signerCapabilities';
|
||||
import { isInteractiveSigner, isPassiveSigner } from '@/lib/signerCapabilities';
|
||||
import type { CordnRoom } from '@/lib/cordn/room';
|
||||
|
||||
/** Resolved coordinator endpoint for the native (ContextVM) client. */
|
||||
|
|
@ -70,10 +70,14 @@ export interface UseCordnNativeResult {
|
|||
/** Founder/admin: pending join requests for this group. */
|
||||
requests: JoinRequest[];
|
||||
loadRequests: () => Promise<void>;
|
||||
/** Background check that never toggles `busy` (for a tab/request badge). */
|
||||
loadRequestsQuiet: () => Promise<void>;
|
||||
/** Founder/admin: add a requester (by pubkey) and deliver a welcome. */
|
||||
approve: (identifier: string) => Promise<boolean>;
|
||||
/** True for interactive signers (Amber) — manual refresh/send, no polling. */
|
||||
interactiveHintVisible: boolean;
|
||||
/** True for nsec/bunker: safe to poll while the private tab is hidden. */
|
||||
passiveSigner: boolean;
|
||||
/** Shareable `cordn1…` reference for the crew's group (once known). */
|
||||
groupRef: string | null;
|
||||
/** True when this device is set up: it holds the group or a published key package. */
|
||||
|
|
@ -93,9 +97,12 @@ const POLL_MS = 6000;
|
|||
export function useCordnNative({
|
||||
crew,
|
||||
enabled,
|
||||
active = true,
|
||||
}: {
|
||||
crew: CordnRoom | null;
|
||||
enabled: boolean;
|
||||
/** Visible private-chat tab; hidden tabs only poll for passive signers. */
|
||||
active?: boolean;
|
||||
}): UseCordnNativeResult {
|
||||
const { user } = useCurrentUser();
|
||||
const { logins } = useNostrLogin();
|
||||
|
|
@ -126,6 +133,8 @@ export function useCordnNative({
|
|||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
const interactive = isInteractiveSigner(user, logins);
|
||||
const passiveSigner = isPassiveSigner(user, logins);
|
||||
const canBackgroundPoll = active || passiveSigner;
|
||||
|
||||
const retry = useCallback(() => {
|
||||
setError(null);
|
||||
|
|
@ -212,10 +221,10 @@ export function useCordnNative({
|
|||
// every request, so those users refresh/send manually (same rule as the DM
|
||||
// and sealed-relay chat hooks).
|
||||
useEffect(() => {
|
||||
if (!ready || !group || interactive) return;
|
||||
if (!ready || !group || interactive || !canBackgroundPoll) return;
|
||||
const id = setInterval(() => void refresh(), POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [ready, group, interactive, refresh]);
|
||||
}, [ready, group, interactive, canBackgroundPoll, refresh]);
|
||||
|
||||
const run = useCallback(async <T,>(fn: () => Promise<T>): Promise<T | undefined> => {
|
||||
setBusy(true);
|
||||
|
|
@ -306,6 +315,17 @@ export function useCordnNative({
|
|||
});
|
||||
}, [groupId, run]);
|
||||
|
||||
const loadRequestsQuiet = useCallback(async () => {
|
||||
const session = sessionRef.current;
|
||||
if (!session || !groupId) return;
|
||||
try {
|
||||
const pending = await session.fetchJoinRequests(groupId);
|
||||
setRequests(pending);
|
||||
} catch {
|
||||
// Keep the previous count; the next quiet poll or manual check retries.
|
||||
}
|
||||
}, [groupId]);
|
||||
|
||||
const approve = useCallback(
|
||||
async (identifier: string) => {
|
||||
const session = sessionRef.current;
|
||||
|
|
@ -336,8 +356,10 @@ export function useCordnNative({
|
|||
checkInvite,
|
||||
requests,
|
||||
loadRequests,
|
||||
loadRequestsQuiet,
|
||||
approve,
|
||||
interactiveHintVisible: interactive,
|
||||
passiveSigner,
|
||||
groupRef,
|
||||
keyPackageReady,
|
||||
retry,
|
||||
|
|
|
|||
36
src/lib/cordn/pendingJoin.test.ts
Normal file
36
src/lib/cordn/pendingJoin.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { clearPendingJoin, readPendingJoin, writePendingJoin } from './pendingJoin';
|
||||
|
||||
const PK = 'a'.repeat(64);
|
||||
const GID = 'skatespots:community';
|
||||
|
||||
describe('pendingJoin', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('round-trips a pending request and scopes it by account + room', () => {
|
||||
expect(readPendingJoin(PK, GID)).toBe(false);
|
||||
writePendingJoin(PK, GID);
|
||||
expect(readPendingJoin(PK, GID)).toBe(true);
|
||||
expect(readPendingJoin('b'.repeat(64), GID)).toBe(false);
|
||||
expect(readPendingJoin(PK, 'other:room')).toBe(false);
|
||||
clearPendingJoin(PK, GID);
|
||||
expect(readPendingJoin(PK, GID)).toBe(false);
|
||||
});
|
||||
|
||||
it('expires stale requests', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-10-28T00:00:00Z'));
|
||||
writePendingJoin(PK, GID);
|
||||
vi.setSystemTime(new Date('2026-12-01T00:00:00Z'));
|
||||
expect(readPendingJoin(PK, GID)).toBe(false);
|
||||
});
|
||||
|
||||
it('does nothing for empty identifiers', () => {
|
||||
writePendingJoin('', GID);
|
||||
writePendingJoin(PK, '');
|
||||
expect(readPendingJoin('', GID)).toBe(false);
|
||||
});
|
||||
});
|
||||
44
src/lib/cordn/pendingJoin.ts
Normal file
44
src/lib/cordn/pendingJoin.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* Remember a join request across reloads so the panel can resume the
|
||||
* coordinator poll instead of losing the “waiting for approval” state.
|
||||
*
|
||||
* Scoped by account + room and expires after 30 days; an old request should not
|
||||
* make the panel poll forever if the user abandoned it.
|
||||
*/
|
||||
|
||||
const KEY_PREFIX = 'skatespots:cordn-pending:';
|
||||
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function key(pubkey: string, gid: string): string {
|
||||
return `${KEY_PREFIX}${pubkey}:${gid}`;
|
||||
}
|
||||
|
||||
export function readPendingJoin(pubkey: string, gid: string): boolean {
|
||||
if (!pubkey || !gid) return false;
|
||||
try {
|
||||
const raw = localStorage.getItem(key(pubkey, gid));
|
||||
if (raw === null) return false;
|
||||
const at = Number(raw);
|
||||
return Number.isFinite(at) && Date.now() - at < MAX_AGE_MS;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function writePendingJoin(pubkey: string, gid: string): void {
|
||||
if (!pubkey || !gid) return;
|
||||
try {
|
||||
localStorage.setItem(key(pubkey, gid), String(Date.now()));
|
||||
} catch {
|
||||
// private mode / quota — polling still works for this session
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPendingJoin(pubkey: string, gid: string): void {
|
||||
if (!pubkey || !gid) return;
|
||||
try {
|
||||
localStorage.removeItem(key(pubkey, gid));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
28
src/lib/cordn/unread.test.ts
Normal file
28
src/lib/cordn/unread.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { clearCordnSeen, readCordnSeen, writeCordnSeen } from './unread';
|
||||
|
||||
const PK = 'a'.repeat(64);
|
||||
const GID = 'skatespots:community';
|
||||
|
||||
describe('cordn unread cursor', () => {
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
it('distinguishes never-seen from seen-at-zero', () => {
|
||||
expect(readCordnSeen(PK, GID)).toBeNull();
|
||||
writeCordnSeen(PK, GID, 0);
|
||||
expect(readCordnSeen(PK, GID)).toBe(0);
|
||||
});
|
||||
|
||||
it('round-trips the newest timestamp per account + room', () => {
|
||||
writeCordnSeen(PK, GID, 1_700_000_000);
|
||||
expect(readCordnSeen(PK, GID)).toBe(1_700_000_000);
|
||||
expect(readCordnSeen('b'.repeat(64), GID)).toBeNull();
|
||||
expect(readCordnSeen(PK, 'other:room')).toBeNull();
|
||||
});
|
||||
|
||||
it('clears a cursor', () => {
|
||||
writeCordnSeen(PK, GID, 42);
|
||||
clearCordnSeen(PK, GID);
|
||||
expect(readCordnSeen(PK, GID)).toBeNull();
|
||||
});
|
||||
});
|
||||
41
src/lib/cordn/unread.ts
Normal file
41
src/lib/cordn/unread.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Per-device "last seen" cursor for a native Cordn room.
|
||||
*
|
||||
* We store the newest inbound message timestamp (seconds), not a message count:
|
||||
* it is stable across reconnects and only requires a tiny localStorage value.
|
||||
* `null` means "never opened this room on this device" — callers seed it on the
|
||||
* first observation so old history does not appear as unread.
|
||||
*/
|
||||
|
||||
const KEY_PREFIX = 'skatespots:cordn-seen:';
|
||||
|
||||
function key(pubkey: string, gid: string): string {
|
||||
return `${KEY_PREFIX}${pubkey}:${gid}`;
|
||||
}
|
||||
|
||||
export function readCordnSeen(pubkey: string, gid: string): number | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(key(pubkey, gid));
|
||||
if (raw === null) return null;
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCordnSeen(pubkey: string, gid: string, at: number): void {
|
||||
try {
|
||||
localStorage.setItem(key(pubkey, gid), String(at));
|
||||
} catch {
|
||||
// private mode / quota — unread is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCordnSeen(pubkey: string, gid: string): void {
|
||||
try {
|
||||
localStorage.removeItem(key(pubkey, gid));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
|
@ -87,6 +87,15 @@ export function isInteractiveSigner(
|
|||
return login.type === 'x-amber';
|
||||
}
|
||||
|
||||
/** True when background signing is silent: nsec / remote bunker (not Amber or a browser extension). */
|
||||
export function isPassiveSigner(
|
||||
user: MaybeUser | null | undefined,
|
||||
logins: readonly MaybeLogin[],
|
||||
): boolean {
|
||||
const login = resolveLogin(user, logins);
|
||||
return login?.type === 'nsec' || login?.type === 'bunker';
|
||||
}
|
||||
|
||||
/** True if the user's signer can do NIP-04 encryption. Never throws. */
|
||||
export function hasNip04(
|
||||
user: MaybeUser | null | undefined,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { useParams, Link, useSearchParams } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
|
@ -71,6 +71,10 @@ export default function CrewDetailPage() {
|
|||
: null;
|
||||
const [activeTab, setActiveTab] = useState(validTab ?? 'events');
|
||||
const tabTouchedRef = useRef(Boolean(validTab));
|
||||
const [cordnUnread, setCordnUnread] = useState(false);
|
||||
const [cordnRequests, setCordnRequests] = useState(0);
|
||||
const handleCordnUnread = useCallback((value: boolean) => setCordnUnread(value), []);
|
||||
const handleCordnRequests = useCallback((count: number) => setCordnRequests(count), []);
|
||||
|
||||
// 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.
|
||||
|
|
@ -362,6 +366,16 @@ export default function CrewDetailPage() {
|
|||
<TabsTrigger value="mls" className="min-w-0 gap-1.5 overflow-hidden text-[13px] data-[state=active]:bg-teal-500/20 data-[state=active]:text-teal-300">
|
||||
<Lock className="h-4 w-4 shrink-0" />{' '}
|
||||
<span className="min-w-0 truncate">{t('cordnUi.title')}</span>
|
||||
{(cordnRequests > 0 || cordnUnread) && (
|
||||
<span
|
||||
className={
|
||||
cordnRequests > 0
|
||||
? 'h-1.5 w-1.5 shrink-0 rounded-full bg-red-400'
|
||||
: 'h-1.5 w-1.5 shrink-0 rounded-full bg-teal-300'
|
||||
}
|
||||
title={cordnRequests > 0 ? t('cordnUi.requests') : t('settings.notifications')}
|
||||
/>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
|
@ -398,8 +412,19 @@ export default function CrewDetailPage() {
|
|||
</TabsContent>
|
||||
|
||||
{/* Private chat — native Cordn / MLS (real end-to-end encryption). */}
|
||||
<TabsContent value="mls" className="mt-4">
|
||||
<CordnNativePanel crew={crew} canManage={canManage} isMember={isCrewMember} />
|
||||
<TabsContent
|
||||
value="mls"
|
||||
forceMount
|
||||
className={activeTab === 'mls' ? 'mt-4' : 'mt-4 hidden'}
|
||||
>
|
||||
<CordnNativePanel
|
||||
crew={crew}
|
||||
canManage={canManage}
|
||||
isMember={isCrewMember}
|
||||
active={activeTab === 'mls'}
|
||||
onUnreadChange={handleCordnUnread}
|
||||
onRequestsChange={handleCordnRequests}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue