ux(cordn): reconcile the crew chat card with the MLS tab (+ audit)

The sidebar card badged 'Private chat - On' while the MLS tab said 'not created
yet', and led with external apps. Rewrote CrewCoordinatorCard to show two
explicit rows (Chat = NIP-17 always on; Private chat = MLS with real status) and
make the in-app chat the primary CTA (?tab=mls); demoted Cordn web / Staircase
under 'Open in another app'. Panel now gives join feedback + an admin invite
hint. Adds CORDN_UX_AUDIT.md (findings, terminology model, deferred items).
This commit is contained in:
SkateSpots 2026-10-12 23:41:29 +07:00
parent 4bdb949f06
commit fa0fc1cc15
8 changed files with 272 additions and 110 deletions

View file

@ -2271,11 +2271,15 @@ The community relay moved off the hosted relay.tools instance
default; **Settings → Coordinator** shows a "Use SkateSpots coordinator" block
and **CrewForm** prefills it for new crews. Disclosure copy lives in
`socialUi.coordinator.ourDefault*` + the existing `privacyNote` (coordinators see
ciphertext + metadata, never contents). `CrewCoordinatorCard` offers two real-MLS
entry points in both branches — **"Cordn (web)"** (the cordn.net add-link → real
MLS in the browser) and **"Copy for Staircase"** (copies the nprofile to add in
the native app) — our coordinator by default, a BYO coordinator's when it has a
pubkey. Also fixed a status-badge bug: `STATUS_META` labels in
ciphertext + metadata, never contents). ⚠️ **`CrewCoordinatorCard` was rewritten
(2026-10-12, see `CORDN_UX_AUDIT.md`):** it used to badge **"Private chat · On"**
while the MLS tab said **"not created yet"** (a contradiction) and led with the
external apps. It now shows two explicit rows — **Chat** (NIP-17, always on) and
**Private chat** (MLS; On / Set up / Join / Checking from `nativeGroupExists`) —
with the **in-app chat as the primary CTA** (`?tab=mls`), and "Cordn (web)" /
"Copy for Staircase" demoted under an **"Open in another app"** label. Props are
`{ crew, isFounder, canManage, nativeGroupExists }`. Also fixed a status-badge bug:
`STATUS_META` labels in
`CoordinatorSettingsCard` were missing the `socialUi.` prefix and rendered raw
keys (e.g. `coordinator.statusStale`).
**Verified (2026-10-12):** the reference `@contextvm/sdk` client (encrypted

48
CORDN_UX_AUDIT.md Normal file
View file

@ -0,0 +1,48 @@
# Cordn / MLS / Staircase — UX audit (2026-10-12)
Scope: every surface where a skater meets the crew's encrypted chat —
the crew page (Private chat tab + sidebar card), the Messages hub, Settings →
Coordinator, and the CrewForm fields — plus the Staircase / Cordn-web hand-offs.
The goal was a *coherent* story: two chats exist (built-in NIP-17 "Chat" and the
real-MLS "Private chat"), and the UI must never make them contradict each other.
## Terminology model (the fix that anchors everything)
| Surface | Name | Protocol | Setup |
|---|---|---|---|
| Crew page tab (existing) | **Chat** | NIP-17 giftwrap | always on |
| Crew page tab (new) | **Private chat** | MLS via coordinator | created once by an admin |
| Sidebar card | **Encrypted chat** | covers both | — |
Rule: never call the sidebar card "Private chat" (collides with the MLS tab), and
never badge the MLS chat "On" when it hasn't been created.
## Findings
| # | Sev | Status | Finding |
|---|---|---|---|
| 1 | High | **Fixed** | The sidebar card badged **"Private chat · On"** (meaning the NIP-17 Chat tab) while the MLS tab said **"not created yet"** — a direct contradiction. The card now shows two explicit rows, **Chat** (always on) and **Private chat** (On / Set up / Join / Checking), reflecting the real MLS status. |
| 2 | High | **Fixed** | Name collision: card "Private Crew Chat" vs tab "Private chat". Card retitled **"Encrypted chat"**; row names are Chat / Private chat. |
| 3 | Med | **Fixed** | The card led with **external** apps ("Cordn (web)", "Copy for Staircase") even though the app now ships its own MLS chat. The **in-app Private chat is now the primary CTA** (one tap → `?tab=mls`); external clients moved under a labelled **"Open in another app"** row. |
| 4 | Med | **Fixed** | Joining gave no feedback. The panel now says **"Request sent…"** after Ask-to-join and **"No invite waiting yet…"** after Check-for-invite. |
| 5 | Med | **Fixed** | An admin who created the chat wasn't told how members join. The panel now shows an invite hint while the group has one member. |
| 6 | Med | **Fixed** | Crews were invisible in the Messages hub. Added a **Crews** section (status badge + deep link to `?tab=mls`). |
| 7 | Low | **Fixed** | The crew page's tabs weren't deep-linkable; now `?tab=events\|chat\|mls` (explicit param wins over the auto-default). |
| 8 | Low | Open | `cordnUi.*` / new `crewCoordinator.*` copy exists only in **en/es**; other locales fall back to English. Acceptable for a new feature, worth translating once it has users. |
| 9 | Low | Open | **Cordn (web) is broken against our coordinator** (upstream issue, drafted). Kept as a secondary link only; prefer the in-app chat or Staircase. |
| 10 | Low | Open | CrewForm's **"Cordn group"** field is Staircase-era jargon; now that the app creates groups natively it mostly serves importing an external group. Consider moving it under an "Advanced" disclosure. |
| 11 | Info | By design | **Interactive signer (Amber):** no live updates — the panel shows a hint and the DM pages have a manual Refresh. This is the deliberate "never pop Amber in the background" posture. |
| 12 | Info | By design | The coordinator sees **ciphertext + metadata only**; the card and the Settings privacy note say so plainly. |
## Still open / next
- **Onboarding the first member**: the flow works (create → ask → approve) but is
three taps across two people. A shareable "join this crew's chat" link that
pre-fills the join request would remove the guesswork.
- **Unread for the Private chat** : deferred (needs a background signed request).
- **Persisted DM decrypt cache** (from the Amber audit) so the *first* load per
app start stops being a decrypt storm.
- Translate the new keys beyond en/es.
## Verified
`npm test` + `check-i18n` green; `?tab=mls` deep-link wired through the crew page.
Not click-tested in a real browser this session (no logged-in browser fixture).

View file

@ -33,6 +33,7 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
const native = useCordnNative({ crew, enabled: !!user && isMember });
const [draft, setDraft] = useState('');
const [copied, setCopied] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const { mutateAsync: uploadFile, isPending: uploading } = useUploadFile();
@ -76,6 +77,18 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
}
}, [native.groupRef]);
const handleRequestJoin = useCallback(async () => {
setNotice(null);
await native.requestJoin();
setNotice(t('cordnUi.requested'));
}, [native, t]);
const handleCheckInvite = useCallback(async () => {
setNotice(null);
const joined = await native.checkInvite();
if (!joined) setNotice(t('cordnUi.noInvite'));
}, [native, t]);
if (!user) {
return <Notice icon={<Lock className="h-5 w-5" />} text={t('cordnUi.notLoggedIn')} />;
}
@ -142,7 +155,7 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
<Button
size="sm"
disabled={!native.ready || native.busy}
onClick={() => void native.requestJoin()}
onClick={() => void handleRequestJoin()}
className="gap-1.5 bg-teal-500 font-semibold text-black hover:bg-teal-400"
>
<UserPlus className="h-3.5 w-3.5" /> {t('cordnUi.requestJoin')}
@ -151,7 +164,7 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
size="sm"
variant="outline"
disabled={!native.ready || native.busy}
onClick={() => void native.checkInvite()}
onClick={() => void handleCheckInvite()}
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" /> {t('cordnUi.checkInvite')}
@ -159,6 +172,9 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
</>
)}
</div>
{notice && (
<p className="text-center text-xs text-teal-300">{notice}</p>
)}
</div>
) : (
<>
@ -288,7 +304,9 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
</Button>
</div>
{native.requests.length === 0 ? (
<p className="px-1 text-[11px] text-gray-500">{t('cordnUi.noRequests')}</p>
<p className="px-1 text-[11px] text-gray-500">
{native.group.members.length <= 1 ? t('cordnUi.inviteHint') : t('cordnUi.noRequests')}
</p>
) : (
native.requests.map((req) => (
<div key={`${req.pk}-${req.kp_ref}`} className="flex items-center justify-between gap-2">

View file

@ -1,5 +1,5 @@
import { Link } from 'react-router-dom';
import { Lock, ShieldCheck, Server, ExternalLink, Settings, Copy } from 'lucide-react';
import { Lock, ShieldCheck, Server, ExternalLink, Settings, Copy, MessagesSquare, Users } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useAuthor } from '@/hooks/useAuthor';
@ -9,28 +9,35 @@ import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
import { resolveCordnEndpoint } from '@/lib/cordn/endpoint';
import { CORDN_RELAY_URL, coordinatorAddUrl, CORDN_HOME, SKATESPOTS_COORDINATOR } from '@/lib/coordinator';
import { cn } from '@/lib/utils';
import type { CrewCoordinator, SkateCrew } from '@/lib/types';
import { nip19 } from 'nostr-tools';
/**
* Shows the crew's encrypted-chat coordinator (Cordn / MLS Delivery Service).
* The crew's encrypted-chat card (sidebar).
*
* Every crew already has built-in end-to-end encrypted group chat (NIP-17
* giftwrap to the roster) — no setup required. This card:
* - by default, reassures the crew that private chat is already ON; and
* - if a founder has opted into routing chat through their own self-hosted
* Cordn coordinator, surfaces which coordinator is in use.
* A crew has **two** chats and the card must not conflate them:
* - the **Chat** tab — built-in NIP-17 group chat, always on, no setup; and
* - the **Private chat** tab — real MLS (forward secrecy, member removal),
* created once by an admin and routed through a coordinator.
*
* Note: the coordinator path currently provides NIP-44 group encryption routed
* over ContextVM (nothing crew-specific hits a public relay). It is NOT yet a
* full MLS ratchet, so we deliberately avoid claiming forward secrecy here.
* It leads with an honest status for each and a one-tap entry into the private
* chat; the coordinator details and any external clients (Staircase / Cordn web)
* are secondary. ⚠️ The status must match the tab: an earlier version badged
* "Private chat · On" while the MLS tab said "not created yet".
*/
export function CrewCoordinatorCard({
crew,
isFounder,
canManage,
nativeGroupExists,
}: {
crew: SkateCrew;
isFounder: boolean;
/** Founder/admin — may create the MLS group. */
canManage: boolean;
/** Whether this device already holds the crew's MLS group (local check). */
nativeGroupExists: boolean | undefined;
}) {
const { t } = useTranslation();
const { coordinator } = crew;
@ -41,106 +48,164 @@ export function CrewCoordinatorCard({
const endpoint = resolveCordnEndpoint(crew);
const overPublicRelay = endpoint?.relayUrl === CORDN_RELAY_URL;
if (!coordinator) {
// Default state: private chat is ALREADY on for every crew (built-in NIP-17
// giftwrap group chat, no setup). We say so plainly rather than implying a
// coordinator is required. Founders get a small, honestly-labeled link to
// the experimental self-hosted-coordinator option.
return (
<Card className="border-2 border-teal-500/25 bg-gradient-to-br from-teal-500/[0.06] to-black/90">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
<Lock className="h-5 w-5 text-teal-400" /> {t('socialUi.crewCoordinator.privateChat')}
<span className="ml-auto inline-flex items-center gap-1 rounded-full bg-teal-500/20 px-2 py-0.5 text-[10px] font-black uppercase tracking-wide text-teal-300">
<ShieldCheck className="h-3 w-3" /> {t('socialUi.crewCoordinator.on')}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm leading-relaxed text-gray-400">
{t('socialUi.crewCoordinator.defaultA')}{' '}
<span className="font-semibold text-gray-200">{t('socialUi.crewCoordinator.chatWord')}</span> {t('socialUi.crewCoordinator.defaultB')}{' '}
<span className="font-bold text-teal-300">{t('socialUi.crewCoordinator.e2ee')}</span> {t('socialUi.crewCoordinator.defaultC')}
</p>
<StaircaseLink coordinator={SKATESPOTS_COORDINATOR} />
{crew.cordnGroup && <CordnGroupLink group={crew.cordnGroup} />}
{isFounder && (
<p className="text-[11px] leading-relaxed text-gray-600">
{t('socialUi.crewCoordinator.advancedA')}{' '}
<a
href="https://github.com/Cordn-msg/cordn"
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 underline underline-offset-2 hover:text-teal-300"
>
{t('socialUi.crewCoordinator.cordnCoordinator')}
</a>
?{' '}
<Link
to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}
className="inline-flex items-center gap-1 font-semibold text-gray-400 hover:text-teal-300"
>
<Settings className="h-3 w-3" /> {t('socialUi.crewCoordinator.routeThrough')}
</Link>
</p>
)}
</CardContent>
</Card>
);
}
const privateChatTab = `/crew/${crew.pubkey}/${crew.identifier}?tab=mls`;
const mlsLabel =
nativeGroupExists === true
? t('socialUi.crewCoordinator.on')
: nativeGroupExists === undefined
? t('socialUi.crewCoordinator.checking')
: canManage
? t('socialUi.crewCoordinator.setUpChat')
: t('socialUi.crewCoordinator.joinChat');
const mlsCta =
nativeGroupExists === true
? t('socialUi.crewCoordinator.openChat')
: canManage
? t('socialUi.crewCoordinator.setUpChat')
: t('socialUi.crewCoordinator.joinChat');
return (
<Card className="border-2 border-emerald-500/30 bg-gradient-to-br from-emerald-500/[0.08] to-black/90">
<Card className="border-2 border-teal-500/25 bg-gradient-to-br from-teal-500/[0.06] to-black/90">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
<Lock className="h-5 w-5 text-emerald-400" /> {t('socialUi.crewCoordinator.privateChat')}
<span className="ml-auto inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2 py-0.5 text-[10px] font-black uppercase tracking-wide text-emerald-300">
<Server className="h-3 w-3" /> {t('socialUi.crewCoordinator.coordinator')}
</span>
<Lock className="h-5 w-5 text-teal-400" /> {t('socialUi.crewCoordinator.encryptedChat')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<CoordinatorEndpoint coordinator={coordinator} />
{coordinator.operator && <CoordinatorOperator pubkey={coordinator.operator} />}
<StaircaseLink
coordinator={{
pubkey: coordinator.pubkey,
relays: /^wss?:\/\//i.test(coordinator.url) ? [coordinator.url] : [],
}}
/>
{crew.cordnGroup && <CordnGroupLink group={crew.cordnGroup} />}
{endpoint && (
<p className="text-[11px] leading-relaxed text-gray-500">
{t('socialUi.crewCoordinator.deliveredOver')}{' '}
<span className="font-mono text-gray-400">{urlDomain(endpoint.relayUrl) || endpoint.relayUrl}</span>
{overPublicRelay ? ` ${t('socialUi.crewCoordinator.sharedRelay')}` : ` ${t('socialUi.crewCoordinator.ownRelay')}`}
</p>
)}
<div className="rounded-xl border border-emerald-500/15 bg-emerald-500/[0.04] p-3">
<p className="flex items-center gap-1.5 text-xs font-bold text-emerald-300">
<Lock className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.chatLive')}
</p>
<p className="mt-1 text-[11px] leading-relaxed text-gray-500">
{t('socialUi.crewCoordinator.routesA')}{' '}
<span className="font-semibold text-gray-300">{t('socialUi.crewCoordinator.chatWord')}</span> {t('socialUi.crewCoordinator.routesB')}{' '}
{overPublicRelay
? t('socialUi.crewCoordinator.publicWarning')
: t('socialUi.crewCoordinator.ownRelaySafe')}
</p>
{/* Chat (NIP-17) — always on */}
<div className="flex items-center gap-3 rounded-xl border border-white/10 bg-black/40 p-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-teal-500/15">
<MessagesSquare className="h-4 w-4 text-teal-400" />
</div>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 text-sm font-bold text-white">
{t('socialUi.crewCoordinator.chatWord')}
<span className="inline-flex items-center gap-1 rounded-full bg-teal-500/20 px-2 py-0.5 text-[10px] font-black uppercase tracking-wide text-teal-300">
<ShieldCheck className="h-3 w-3" /> {t('socialUi.crewCoordinator.on')}
</span>
</p>
<p className="mt-0.5 text-[11px] leading-relaxed text-gray-500">
{t('socialUi.crewCoordinator.chatRowDesc')}
</p>
</div>
</div>
{isFounder && (
{/* Private chat (MLS) — created once, then joined */}
<div className="flex items-center gap-3 rounded-xl border border-white/10 bg-black/40 p-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-teal-500/15">
<Lock className="h-4 w-4 text-teal-400" />
</div>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 text-sm font-bold text-white">
{t('socialUi.crewCoordinator.privateChatName')}
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-black uppercase tracking-wide',
nativeGroupExists === true ? 'bg-teal-500/20 text-teal-300' : 'bg-white/10 text-gray-400',
)}
>
{mlsLabel}
</span>
</p>
<p className="mt-0.5 text-[11px] leading-relaxed text-gray-500">
{t('socialUi.crewCoordinator.privateChatDesc')}
</p>
</div>
<Link
to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}
className="inline-flex items-center gap-1.5 text-xs font-bold text-emerald-400 hover:text-emerald-300"
to={privateChatTab}
className="shrink-0 rounded-lg border border-teal-500/30 bg-teal-500/10 px-2.5 py-1.5 text-xs font-bold text-teal-300 transition-colors hover:bg-teal-500/20"
>
<Settings className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.manage')}
{mlsCta}
</Link>
</div>
{/* Coordinator details (only when one is configured) */}
{coordinator && (
<div className="space-y-3 border-t border-white/5 pt-3">
<CoordinatorEndpoint coordinator={coordinator} />
{coordinator.operator && <CoordinatorOperator pubkey={coordinator.operator} />}
{endpoint && (
<p className="text-[11px] leading-relaxed text-gray-500">
{t('socialUi.crewCoordinator.deliveredOver')}{' '}
<span className="font-mono text-gray-400">{urlDomain(endpoint.relayUrl) || endpoint.relayUrl}</span>
{overPublicRelay ? ` ${t('socialUi.crewCoordinator.sharedRelay')}` : ` ${t('socialUi.crewCoordinator.ownRelay')}`}
</p>
)}
<div className="rounded-xl border border-emerald-500/15 bg-emerald-500/[0.04] p-3">
<p className="flex items-center gap-1.5 text-xs font-bold text-emerald-300">
<Lock className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.chatLive')}
</p>
<p className="mt-1 text-[11px] leading-relaxed text-gray-500">
{t('socialUi.crewCoordinator.chatRowDesc')}{' '}
{overPublicRelay
? t('socialUi.crewCoordinator.publicWarning')
: t('socialUi.crewCoordinator.ownRelaySafe')}
</p>
</div>
{isFounder && (
<Link
to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}
className="inline-flex items-center gap-1.5 text-xs font-bold text-emerald-400 hover:text-emerald-300"
>
<Settings className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.manage')}
</Link>
)}
</div>
)}
{/* External clients — secondary, clearly labelled */}
{(coordinator || crew.cordnGroup) && (
<div className="space-y-2 border-t border-white/5 pt-3">
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">
{t('socialUi.crewCoordinator.openInAnotherApp')}
</p>
<div className="flex flex-wrap items-center gap-2">
<StaircaseLink
coordinator={
coordinator
? {
pubkey: coordinator.pubkey,
relays: /^wss?:\/\//i.test(coordinator.url) ? [coordinator.url] : [],
}
: SKATESPOTS_COORDINATOR
}
/>
{crew.cordnGroup && <CordnGroupLink group={crew.cordnGroup} />}
</div>
</div>
)}
{/* Advanced (founders, no coordinator configured) */}
{!coordinator && isFounder && (
<p className="text-[11px] leading-relaxed text-gray-600">
{t('socialUi.crewCoordinator.advancedA')}{' '}
<a
href="https://github.com/Cordn-msg/cordn"
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 underline underline-offset-2 hover:text-teal-300"
>
{t('socialUi.crewCoordinator.cordnCoordinator')}
</a>
?{' '}
<Link
to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}
className="inline-flex items-center gap-1 font-semibold text-gray-400 hover:text-teal-300"
>
<Settings className="h-3 w-3" /> {t('socialUi.crewCoordinator.routeThrough')}
</Link>
</p>
)}
<p className="pt-1 text-[11px] leading-relaxed text-gray-600">
<Users className="mr-1 inline h-3 w-3" />
{t('socialUi.crewCoordinator.privateChatDescFull')}
</p>
</CardContent>
</Card>
);
@ -172,7 +237,7 @@ function StaircaseLink({ coordinator }: { coordinator: { pubkey?: string; relays
const btn =
'inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-bold transition-colors';
return (
<div className="flex flex-wrap items-center gap-2">
<>
<a
href={webUrl}
target="_blank"
@ -190,7 +255,7 @@ function StaircaseLink({ coordinator }: { coordinator: { pubkey?: string; relays
<Copy className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.copyForStaircase')}
</button>
)}
</div>
</>
);
}
@ -214,7 +279,7 @@ function CordnGroupLink({ group }: { group: string }) {
const btn =
'inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-bold transition-colors';
return (
<div className="flex flex-wrap items-center gap-2">
<>
{url && (
<a
href={url}
@ -232,7 +297,7 @@ function CordnGroupLink({ group }: { group: string }) {
>
<Copy className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.copyForStaircase')}
</button>
</div>
</>
);
}

View file

@ -4025,6 +4025,16 @@ socialUi: {
},
crewCoordinator: {
privateChat: 'Private Crew Chat',
encryptedChat: 'Encrypted chat',
privateChatName: 'Private chat',
privateChatDesc: 'Forward-secret group chat (MLS) through a coordinator.',
privateChatDescFull: 'Private chat is created once by an admin, then members join.',
chatRowDesc: "Everyone in the crew — sealed to each member's key (NIP-17).",
openChat: 'Open',
setUpChat: 'Set up',
joinChat: 'Join',
checking: 'Checking…',
openInAnotherApp: 'Open in another app',
on: 'On',
defaultA: 'The',
chatWord: 'Chat',

View file

@ -4025,6 +4025,16 @@ socialUi: {
},
crewCoordinator: {
privateChat: 'Chat privado de la peña',
encryptedChat: 'Chat cifrado',
privateChatName: 'Chat privado',
privateChatDesc: 'Chat de grupo con secreto futuro (MLS) mediante un coordinador.',
privateChatDescFull: 'Un administrador crea el chat privado una vez; luego los miembros se unen.',
chatRowDesc: 'Todos en la peña: sellado con la clave de cada miembro (NIP-17).',
openChat: 'Abrir',
setUpChat: 'Configurar',
joinChat: 'Unirse',
checking: 'Comprobando…',
openInAnotherApp: 'Abrir en otra app',
on: 'Activado',
defaultA: 'La',
chatWord: 'Chat',

View file

@ -26,6 +26,7 @@ const cordnUi = {
checkRequests: 'Check requests',
approve: 'Add',
noRequests: 'No pending join requests.',
inviteHint: 'Only you are in this chat so far — crew members can tap “Ask to join”, then you approve them here.',
memberCount: '{{count}} in the group',
you: 'You',
groupRef: 'Group link',
@ -64,6 +65,7 @@ const cordnUi = {
checkRequests: 'Ver solicitudes',
approve: 'Añadir',
noRequests: 'No hay solicitudes pendientes.',
inviteHint: 'De momento solo estás tú en este chat: los miembros pueden pulsar «Pedir entrar» y tú los apruebas aquí.',
memberCount: '{{count}} en el grupo',
you: 'Tú',
groupRef: 'Enlace del grupo',

View file

@ -399,7 +399,12 @@ export default function CrewDetailPage() {
<CrewLeadership crew={crew} confirmedAdmins={confirmedAdmins} rolesByPubkey={rolesByPubkey} />
{/* Private encrypted chat coordinator (Cordn / MLS) */}
<CrewCoordinatorCard crew={crew} isFounder={isFounder} />
<CrewCoordinatorCard
crew={crew}
isFounder={isFounder}
canManage={canManage}
nativeGroupExists={nativeGroupExists}
/>
{/* Members (with per-member visibility respected) */}
{hasMembers && (