feat(crew-chat): shareable 'join this crew's chat' invite

The MLS private-chat panel gets an admin 'Invite' action that shares the crew's
private-chat link (…?tab=mls) via the OS share sheet or clipboard, landing the
invitee on the Ask-to-join flow. Added cordnUi.shareInvite/shareInviteText/
inviteCopied to en/es and backfilled them into all 20 locale drift fragments so
i18n stays at 100%.
This commit is contained in:
SkateSpots 2026-10-13 19:18:14 +07:00
parent 9a5b706b1a
commit a9669c17b8
22 changed files with 104 additions and 10 deletions

View file

@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { KeyRound, Lock, Plus, RefreshCw, Send, UserPlus, Copy, Check, ImagePlus, Loader2 } from 'lucide-react'; import { KeyRound, Lock, Plus, RefreshCw, Send, UserPlus, Copy, Check, ImagePlus, Loader2, Share2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -89,6 +89,24 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
if (!joined) setNotice(t('cordnUi.noInvite')); if (!joined) setNotice(t('cordnUi.noInvite'));
}, [native, t]); }, [native, t]);
// "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
// "Ask to join" flow ready. Uses the OS share sheet where available.
const shareInvite = useCallback(async () => {
const url = `${window.location.origin}/#/crew/${crew.pubkey}/${crew.identifier}?tab=mls`;
try {
if (navigator.share) {
await navigator.share({ title: crew.name, text: t('cordnUi.shareInviteText', { crew: crew.name }), url });
return;
}
await navigator.clipboard.writeText(url);
setNotice(t('cordnUi.inviteCopied'));
window.setTimeout(() => setNotice(null), 2500);
} catch {
// share cancelled or clipboard blocked — nothing to do
}
}, [crew, t]);
if (!user) { if (!user) {
return <Notice icon={<Lock className="h-5 w-5" />} text={t('cordnUi.notLoggedIn')} />; return <Notice icon={<Lock className="h-5 w-5" />} text={t('cordnUi.notLoggedIn')} />;
} }
@ -293,15 +311,25 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
<div className="space-y-2 rounded-md border border-white/10 bg-black/20 p-2"> <div className="space-y-2 rounded-md border border-white/10 bg-black/20 p-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-300">{t('cordnUi.requests')}</span> <span className="text-xs font-medium text-gray-300">{t('cordnUi.requests')}</span>
<Button <div className="flex items-center gap-1">
size="sm" <Button
variant="ghost" size="sm"
disabled={native.busy} variant="ghost"
onClick={() => void native.loadRequests()} onClick={() => void shareInvite()}
className="h-7 gap-1 text-xs" className="h-7 gap-1 text-xs"
> >
<RefreshCw className="h-3 w-3" /> {t('cordnUi.checkRequests')} <Share2 className="h-3 w-3" /> {t('cordnUi.shareInvite')}
</Button> </Button>
<Button
size="sm"
variant="ghost"
disabled={native.busy}
onClick={() => void native.loadRequests()}
className="h-7 gap-1 text-xs"
>
<RefreshCw className="h-3 w-3" /> {t('cordnUi.checkRequests')}
</Button>
</div>
</div> </div>
{native.requests.length === 0 ? ( {native.requests.length === 0 ? (
<p className="px-1 text-[11px] text-gray-500"> <p className="px-1 text-[11px] text-gray-500">

View file

@ -278,6 +278,9 @@ const drift = {
"legendWeatherOnly": "Jen počasí" "legendWeatherOnly": "Jen počasí"
}, },
"cordnUi": { "cordnUi": {
"shareInvite": 'Pozvat',
"shareInviteText": 'Přidej se k {{crew}} — soukromý chat crew',
"inviteCopied": 'Odkaz s pozvánkou zkopírován',
"title": "Soukromý chat", "title": "Soukromý chat",
"subtitle": "End-to-end šifrované (MLS) mezi členy crew. Koordinátor přenáší pouze šifrovaný text a metadata — nikdy nevidí obsah zpráv.", "subtitle": "End-to-end šifrované (MLS) mezi členy crew. Koordinátor přenáší pouze šifrovaný text a metadata — nikdy nevidí obsah zpráv.",
"notLoggedIn": "Přihlas se, abys mohl použít soukromý chat crew.", "notLoggedIn": "Přihlas se, abys mohl použít soukromý chat crew.",

View file

@ -280,6 +280,9 @@ const drift = {
legendWeatherOnly: "Nur Wetter", legendWeatherOnly: "Nur Wetter",
}, },
cordnUi: { cordnUi: {
shareInvite: 'Einladen',
shareInviteText: 'Komm zu {{crew}} — privater Crew-Chat',
inviteCopied: 'Einladungslink kopiert',
title: "Privater Chat", title: "Privater Chat",
subtitle: "Ende-zu-Ende-verschlüsselt (MLS) zwischen den Crew-Mitgliedern. Der Koordinator leitet nur Chiffretext und Metadaten weiter – er sieht nie den Inhalt der Nachrichten.", subtitle: "Ende-zu-Ende-verschlüsselt (MLS) zwischen den Crew-Mitgliedern. Der Koordinator leitet nur Chiffretext und Metadaten weiter – er sieht nie den Inhalt der Nachrichten.",
notLoggedIn: "Melde dich an, um den privaten Chat der Crew zu nutzen.", notLoggedIn: "Melde dich an, um den privaten Chat der Crew zu nutzen.",

View file

@ -282,6 +282,9 @@ const drift = {
legendWeatherOnly: "Μόνο καιρός", legendWeatherOnly: "Μόνο καιρός",
}, },
cordnUi: { cordnUi: {
shareInvite: 'Πρόσκληση',
shareInviteText: 'Μπες στο {{crew}} — ιδιωτική συνομιλία',
inviteCopied: 'Ο σύνδεσμος πρόσκλησης αντιγράφηκε',
title: "Ιδιωτική συνομιλία", title: "Ιδιωτική συνομιλία",
subtitle: "End-to-end κρυπτογραφημένη (MLS) μεταξύ των μελών του crew. Ο συντονιστής δρομολογεί μόνο κρυπτογραφημένα δεδομένα και metadata — δεν βλέπει ποτέ το περιεχόμενο των μηνυμάτων.", subtitle: "End-to-end κρυπτογραφημένη (MLS) μεταξύ των μελών του crew. Ο συντονιστής δρομολογεί μόνο κρυπτογραφημένα δεδομένα και metadata — δεν βλέπει ποτέ το περιεχόμενο των μηνυμάτων.",
notLoggedIn: "Συνδέσου για να χρησιμοποιήσεις την ιδιωτική συνομιλία του crew.", notLoggedIn: "Συνδέσου για να χρησιμοποιήσεις την ιδιωτική συνομιλία του crew.",

View file

@ -280,6 +280,9 @@ const drift = {
legendWeatherOnly: "Météo uniquement", legendWeatherOnly: "Météo uniquement",
}, },
cordnUi: { cordnUi: {
shareInvite: 'Inviter',
shareInviteText: 'Rejoins {{crew}} — chat privé du crew',
inviteCopied: 'Lien d’invitation copié',
title: "Chat privé", title: "Chat privé",
subtitle: "Chiffré de bout en bout (MLS) entre les membres de la Crew. Le coordinateur ne route que du texte chiffré et des métadonnées — il ne voit jamais le contenu des messages.", subtitle: "Chiffré de bout en bout (MLS) entre les membres de la Crew. Le coordinateur ne route que du texte chiffré et des métadonnées — il ne voit jamais le contenu des messages.",
notLoggedIn: "Connectez-vous pour utiliser le chat privé de la Crew.", notLoggedIn: "Connectez-vous pour utiliser le chat privé de la Crew.",

View file

@ -274,6 +274,9 @@ const driftStrings = {
legendWeatherOnly: 'Cuaca saja', legendWeatherOnly: 'Cuaca saja',
}, },
cordnUi: { cordnUi: {
shareInvite: 'Undang',
shareInviteText: 'Gabung ke {{crew}} — obrolan pribadi kru',
inviteCopied: 'Tautan undangan disalin',
title: 'Chat privat', title: 'Chat privat',
subtitle: 'Terenkripsi ujung-ke-ujung (MLS) antar anggota crew. Koordinator hanya merutekan ciphertext dan metadata — tidak pernah melihat isi pesan.', subtitle: 'Terenkripsi ujung-ke-ujung (MLS) antar anggota crew. Koordinator hanya merutekan ciphertext dan metadata — tidak pernah melihat isi pesan.',
notLoggedIn: 'Masuk untuk menggunakan chat privat crew.', notLoggedIn: 'Masuk untuk menggunakan chat privat crew.',

View file

@ -280,6 +280,9 @@ const drift = {
legendWeatherOnly: "Solo meteo", legendWeatherOnly: "Solo meteo",
}, },
cordnUi: { cordnUi: {
shareInvite: 'Invita',
shareInviteText: 'Unisciti a {{crew}} — chat privata del crew',
inviteCopied: 'Link d’invito copiato',
title: "Chat privata", title: "Chat privata",
subtitle: "Crittografata end-to-end (MLS) tra i membri della crew. Il coordinatore instrada solo testo cifrato e metadati — non vede mai il contenuto dei messaggi.", subtitle: "Crittografata end-to-end (MLS) tra i membri della crew. Il coordinatore instrada solo testo cifrato e metadati — non vede mai il contenuto dei messaggi.",
notLoggedIn: "Accedi per usare la chat privata della crew.", notLoggedIn: "Accedi per usare la chat privata della crew.",

View file

@ -290,6 +290,9 @@ const drift: Dict = {
legendWeatherOnly: '天気のみ', legendWeatherOnly: '天気のみ',
}, },
cordnUi: { cordnUi: {
shareInvite: '招待',
shareInviteText: '{{crew}} に参加 — クルーのプライベートチャット',
inviteCopied: '招待リンクをコピーしました',
title: 'プライベートチャット', title: 'プライベートチャット',
subtitle: 'クルーメンバー間のエンドツーエンド暗号化(MLS)。コーディネーターは暗号文とメタデータのみをルーティングし、メッセージの内容を見ることはありません。', subtitle: 'クルーメンバー間のエンドツーエンド暗号化(MLS)。コーディネーターは暗号文とメタデータのみをルーティングし、メッセージの内容を見ることはありません。',
notLoggedIn: 'クルーのプライベートチャットを使うにはログインしてください。', notLoggedIn: 'クルーのプライベートチャットを使うにはログインしてください。',

View file

@ -290,6 +290,9 @@ const drift: Dict = {
legendWeatherOnly: '날씨만', legendWeatherOnly: '날씨만',
}, },
cordnUi: { cordnUi: {
shareInvite: '초대',
shareInviteText: '{{crew}} 참여 — 크루 비공개 채팅',
inviteCopied: '초대 링크를 복사했습니다',
title: '비공개 채팅', title: '비공개 채팅',
subtitle: '크루 멤버 간 종단 간 암호화(MLS). 코디네이터는 암호문과 메타데이터만 라우팅하며 메시지 내용은 절대 보지 않습니다.', subtitle: '크루 멤버 간 종단 간 암호화(MLS). 코디네이터는 암호문과 메타데이터만 라우팅하며 메시지 내용은 절대 보지 않습니다.',
notLoggedIn: '크루의 비공개 채팅을 사용하려면 로그인하세요.', notLoggedIn: '크루의 비공개 채팅을 사용하려면 로그인하세요.',

View file

@ -27,6 +27,9 @@ const cordnUi = {
approve: 'Add', approve: 'Add',
noRequests: 'No pending join requests.', 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.', inviteHint: 'Only you are in this chat so far — crew members can tap “Ask to join”, then you approve them here.',
shareInvite: 'Invite',
shareInviteText: 'Join {{crew}} — private crew chat',
inviteCopied: 'Invite link copied',
memberCount: '{{count}} in the group', memberCount: '{{count}} in the group',
you: 'You', you: 'You',
groupRef: 'Group link', groupRef: 'Group link',
@ -66,6 +69,9 @@ const cordnUi = {
approve: 'Añadir', approve: 'Añadir',
noRequests: 'No hay solicitudes pendientes.', 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í.', inviteHint: 'De momento solo estás tú en este chat: los miembros pueden pulsar «Pedir entrar» y tú los apruebas aquí.',
shareInvite: 'Invitar',
shareInviteText: 'Únete a {{crew}} — chat privado de la peña',
inviteCopied: 'Enlace de invitación copiado',
memberCount: '{{count}} en el grupo', memberCount: '{{count}} en el grupo',
you: 'Tú', you: 'Tú',
groupRef: 'Enlace del grupo', groupRef: 'Enlace del grupo',

View file

@ -278,6 +278,9 @@ const drift = {
"legendWeatherOnly": "Alleen weer" "legendWeatherOnly": "Alleen weer"
}, },
"cordnUi": { "cordnUi": {
"shareInvite": 'Uitnodigen',
"shareInviteText": 'Sluit je aan bij {{crew}} — privéchat van de crew',
"inviteCopied": 'Uitnodigingslink gekopieerd',
"title": "Privéchat", "title": "Privéchat",
"subtitle": "End-to-end versleuteld (MLS) tussen crewleden. De coördinator routeert alleen ciphertext en metadata — hij ziet nooit de inhoud van berichten.", "subtitle": "End-to-end versleuteld (MLS) tussen crewleden. De coördinator routeert alleen ciphertext en metadata — hij ziet nooit de inhoud van berichten.",
"notLoggedIn": "Log in om de privéchat van de crew te gebruiken.", "notLoggedIn": "Log in om de privéchat van de crew te gebruiken.",

View file

@ -278,6 +278,9 @@ const drift = {
"legendWeatherOnly": "Tylko pogoda" "legendWeatherOnly": "Tylko pogoda"
}, },
"cordnUi": { "cordnUi": {
"shareInvite": 'Zaproś',
"shareInviteText": 'Dołącz do {{crew}} — prywatny czat ekipy',
"inviteCopied": 'Link zaproszenia skopiowany',
"title": "Prywatny czat", "title": "Prywatny czat",
"subtitle": "Szyfrowany end-to-end (MLS) między członkami crew. Koordynator przekazuje wyłącznie szyfrogram i metadane — nigdy nie widzi treści wiadomości.", "subtitle": "Szyfrowany end-to-end (MLS) między członkami crew. Koordynator przekazuje wyłącznie szyfrogram i metadane — nigdy nie widzi treści wiadomości.",
"notLoggedIn": "Zaloguj się, aby korzystać z prywatnego czatu crew.", "notLoggedIn": "Zaloguj się, aby korzystać z prywatnego czatu crew.",

View file

@ -280,6 +280,9 @@ const drift = {
legendWeatherOnly: "Apenas clima", legendWeatherOnly: "Apenas clima",
}, },
cordnUi: { cordnUi: {
shareInvite: 'Convidar',
shareInviteText: 'Entre em {{crew}} — chat privado do crew',
inviteCopied: 'Link de convite copiado',
title: "Chat privado", title: "Chat privado",
subtitle: "Criptografado de ponta a ponta (MLS) entre os membros da crew. O coordenador roteia apenas texto cifrado e metadados — nunca vê o conteúdo das mensagens.", subtitle: "Criptografado de ponta a ponta (MLS) entre os membros da crew. O coordenador roteia apenas texto cifrado e metadados — nunca vê o conteúdo das mensagens.",
notLoggedIn: "Faça login para usar o chat privado da crew.", notLoggedIn: "Faça login para usar o chat privado da crew.",

View file

@ -278,6 +278,9 @@ const drift = {
"legendWeatherOnly": "Doar vremea" "legendWeatherOnly": "Doar vremea"
}, },
"cordnUi": { "cordnUi": {
"shareInvite": 'Invită',
"shareInviteText": 'Alătură-te la {{crew}} — chat privat al echipei',
"inviteCopied": 'Link de invitație copiat',
"title": "Chat privat", "title": "Chat privat",
"subtitle": "Criptat end-to-end (MLS) între membrii crew-ului. Coordonatorul transportă doar text cifrat și metadate — nu vede niciodată conținutul mesajelor.", "subtitle": "Criptat end-to-end (MLS) între membrii crew-ului. Coordonatorul transportă doar text cifrat și metadate — nu vede niciodată conținutul mesajelor.",
"notLoggedIn": "Autentifică-te pentru a folosi chatul privat al crew-ului.", "notLoggedIn": "Autentifică-te pentru a folosi chatul privat al crew-ului.",

View file

@ -282,6 +282,9 @@ const drift = {
legendWeatherOnly: "Только погода", legendWeatherOnly: "Только погода",
}, },
cordnUi: { cordnUi: {
shareInvite: 'Пригласить',
shareInviteText: 'Присоединяйся к {{crew}} — приватный чат команды',
inviteCopied: 'Ссылка-приглашение скопирована',
title: "Приватный чат", title: "Приватный чат",
subtitle: "Сквозное шифрование (MLS) между участниками Crew. Координатор маршрутизирует только шифротекст и метаданные — он никогда не видит содержимое сообщений.", subtitle: "Сквозное шифрование (MLS) между участниками Crew. Координатор маршрутизирует только шифротекст и метаданные — он никогда не видит содержимое сообщений.",
notLoggedIn: "Войдите, чтобы использовать приватный чат Crew.", notLoggedIn: "Войдите, чтобы использовать приватный чат Crew.",

View file

@ -282,6 +282,9 @@ const drift = {
legendWeatherOnly: "Endast väder", legendWeatherOnly: "Endast väder",
}, },
cordnUi: { cordnUi: {
shareInvite: 'Bjud in',
shareInviteText: 'Gå med i {{crew}} — privat crewchatt',
inviteCopied: 'Inbjudningslänk kopierad',
title: "Privat chatt", title: "Privat chatt",
subtitle: "End-to-end-krypterad (MLS) mellan crewmedlemmar. Koordinatorn dirigerar bara chiffertext och metadata — den ser aldrig meddelandeinnehåll.", subtitle: "End-to-end-krypterad (MLS) mellan crewmedlemmar. Koordinatorn dirigerar bara chiffertext och metadata — den ser aldrig meddelandeinnehåll.",
notLoggedIn: "Logga in för att använda crewets privata chatt.", notLoggedIn: "Logga in för att använda crewets privata chatt.",

View file

@ -274,6 +274,9 @@ const driftStrings = {
legendWeatherOnly: 'เฉพาะสภาพอากาศ', legendWeatherOnly: 'เฉพาะสภาพอากาศ',
}, },
cordnUi: { cordnUi: {
shareInvite: 'เชิญ',
shareInviteText: 'เข้าร่วม {{crew}} — แชทส่วนตัวของ crew',
inviteCopied: 'คัดลอกลิงก์เชิญแล้ว',
title: 'แชทส่วนตัว', title: 'แชทส่วนตัว',
subtitle: 'เข้ารหัสต้นทาง-ปลายทาง (MLS) ระหว่างสมาชิก crew coordinator เพียงส่งต่อข้อความเข้ารหัสและ metadata — ไม่เคยเห็นเนื้อหาข้อความ', subtitle: 'เข้ารหัสต้นทาง-ปลายทาง (MLS) ระหว่างสมาชิก crew coordinator เพียงส่งต่อข้อความเข้ารหัสและ metadata — ไม่เคยเห็นเนื้อหาข้อความ',
notLoggedIn: 'เข้าสู่ระบบเพื่อใช้แชทส่วนตัวของ crew', notLoggedIn: 'เข้าสู่ระบบเพื่อใช้แชทส่วนตัวของ crew',

View file

@ -274,6 +274,9 @@ const driftStrings = {
legendWeatherOnly: 'Yalnızca hava', legendWeatherOnly: 'Yalnızca hava',
}, },
cordnUi: { cordnUi: {
shareInvite: 'Davet et',
shareInviteText: '{{crew}} grubuna katıl — özel ekip sohbeti',
inviteCopied: 'Davet bağlantısı kopyalandı',
title: 'Özel sohbet', title: 'Özel sohbet',
subtitle: 'Crew üyeleri arasında uçtan uca şifreli (MLS). Koordinatör yalnızca şifreli metni ve üst veriyi yönlendirir — mesaj içeriğini asla görmez.', subtitle: 'Crew üyeleri arasında uçtan uca şifreli (MLS). Koordinatör yalnızca şifreli metni ve üst veriyi yönlendirir — mesaj içeriğini asla görmez.',
notLoggedIn: "Crew'in özel sohbetini kullanmak için giriş yap.", notLoggedIn: "Crew'in özel sohbetini kullanmak için giriş yap.",

View file

@ -282,6 +282,9 @@ const drift = {
legendWeatherOnly: "Лише погода", legendWeatherOnly: "Лише погода",
}, },
cordnUi: { cordnUi: {
shareInvite: 'Запросити',
shareInviteText: 'Долучайся до {{crew}} — приватний чат команди',
inviteCopied: 'Посилання-запрошення скопійовано',
title: "Приватний чат", title: "Приватний чат",
subtitle: "Наскрізне шифрування (MLS) між учасниками команди. Координатор маршрутизує лише шифротекст і метадані — він ніколи не бачить вміст повідомлень.", subtitle: "Наскрізне шифрування (MLS) між учасниками команди. Координатор маршрутизує лише шифротекст і метадані — він ніколи не бачить вміст повідомлень.",
notLoggedIn: "Увійдіть, щоб користуватися приватним чатом команди.", notLoggedIn: "Увійдіть, щоб користуватися приватним чатом команди.",

View file

@ -274,6 +274,9 @@ const driftStrings = {
legendWeatherOnly: 'Chỉ thời tiết', legendWeatherOnly: 'Chỉ thời tiết',
}, },
cordnUi: { cordnUi: {
shareInvite: 'Mời',
shareInviteText: 'Tham gia {{crew}} — trò chuyện riêng của crew',
inviteCopied: 'Đã sao chép liên kết mời',
title: 'Chat riêng tư', title: 'Chat riêng tư',
subtitle: 'Mã hóa đầu-cuối (MLS) giữa các thành viên crew. Điều phối viên chỉ định tuyến bản mã và metadata — không bao giờ thấy nội dung tin nhắn.', subtitle: 'Mã hóa đầu-cuối (MLS) giữa các thành viên crew. Điều phối viên chỉ định tuyến bản mã và metadata — không bao giờ thấy nội dung tin nhắn.',
notLoggedIn: 'Đăng nhập để dùng chat riêng tư của crew.', notLoggedIn: 'Đăng nhập để dùng chat riêng tư của crew.',

View file

@ -290,6 +290,9 @@ const drift: Dict = {
legendWeatherOnly: '仅天气', legendWeatherOnly: '仅天气',
}, },
cordnUi: { cordnUi: {
shareInvite: '邀请',
shareInviteText: '加入 {{crew}} — 团队私密聊天',
inviteCopied: '邀请链接已复制',
title: '私密聊天', title: '私密聊天',
subtitle: 'Crew 成员之间的端到端加密(MLS)。协调器只路由密文和元数据——它永远看不到消息内容。', subtitle: 'Crew 成员之间的端到端加密(MLS)。协调器只路由密文和元数据——它永远看不到消息内容。',
notLoggedIn: '登录以使用 Crew 的私密聊天。', notLoggedIn: '登录以使用 Crew 的私密聊天。',

View file

@ -290,6 +290,9 @@ const drift: Dict = {
legendWeatherOnly: '僅天氣', legendWeatherOnly: '僅天氣',
}, },
cordnUi: { cordnUi: {
shareInvite: '邀請',
shareInviteText: '加入 {{crew}} — 團隊私密聊天',
inviteCopied: '邀請連結已複製',
title: '私密聊天', title: '私密聊天',
subtitle: 'Crew 成員之間的端到端加密(MLS)。協調器只路由密文和中繼資料——它永遠看不到訊息內容。', subtitle: 'Crew 成員之間的端到端加密(MLS)。協調器只路由密文和中繼資料——它永遠看不到訊息內容。',
notLoggedIn: '登入以使用 Crew 的私密聊天。', notLoggedIn: '登入以使用 Crew 的私密聊天。',