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:
parent
4bdb949f06
commit
fa0fc1cc15
8 changed files with 272 additions and 110 deletions
14
AGENTS.md
14
AGENTS.md
|
|
@ -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
48
CORDN_UX_AUDIT.md
Normal 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).
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,30 +48,140 @@ 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.
|
||||
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-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>
|
||||
<Lock className="h-5 w-5 text-teal-400" /> {t('socialUi.crewCoordinator.encryptedChat')}
|
||||
</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')}
|
||||
{/* 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>
|
||||
<StaircaseLink coordinator={SKATESPOTS_COORDINATOR} />
|
||||
{crew.cordnGroup && <CordnGroupLink group={crew.cordnGroup} />}
|
||||
<p className="mt-0.5 text-[11px] leading-relaxed text-gray-500">
|
||||
{t('socialUi.crewCoordinator.chatRowDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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={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"
|
||||
>
|
||||
{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
|
||||
|
|
@ -84,63 +201,11 @@ export function CrewCoordinatorCard({
|
|||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-2 border-emerald-500/30 bg-gradient-to-br from-emerald-500/[0.08] 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>
|
||||
</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 className="pt-1 text-[11px] leading-relaxed text-gray-600">
|
||||
<Users className="mr-1 inline h-3 w-3" />
|
||||
{t('socialUi.crewCoordinator.privateChatDescFull')}
|
||||
</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>
|
||||
</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>
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
Loading…
Reference in a new issue