feat: Cordn inbox, outbox, room controls and animated splash

This commit is contained in:
SkateSpots 2026-10-29 19:18:48 +07:00
parent d07f8a696d
commit a2ab5750ff
43 changed files with 1090 additions and 228 deletions

1
package-lock.json generated
View file

@ -151,6 +151,7 @@
"jsdom": "^26.1.0",
"postcss": "^8.4.47",
"rollup-plugin-visualizer": "^7.1.1",
"sharp": "0.35.5",
"tailwindcss": "^3.4.11",
"tsx": "^4.22.4",
"typescript": "5.8.3",

View file

@ -21,11 +21,11 @@
"check:i18n": "node scripts/check-i18n.mjs",
"test": "npm i --silent && tsc --noEmit && tsc -p cvm-server/tsconfig.json && tsc -p ai-proxy/tsconfig.json && tsc -p push-service/tsconfig.json && eslint && node scripts/check-i18n.mjs && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
"deploy": "npm i --silent && npm run build && npx -y nostr-deploy-cli deploy --skip-setup",
"android:init": "npm run android:build:apk && npx cap add android && npx cap sync android && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs",
"android:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-deep-links.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs",
"android:init": "npm run android:build:apk && npx cap add android && npx cap sync android && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.mjs",
"android:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-deep-links.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.mjs",
"android:open": "npx cap open android",
"android:version": "node scripts/sync-android-version.mjs",
"android:build": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-deep-links.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs && cd android && ./gradlew assembleRelease",
"android:build": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-deep-links.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.mjs && cd android && ./gradlew assembleRelease",
"android:build:apk": "CAPACITOR_BUILD=1 vite build && sed -i 's|<script type=\"module\" src=\"/shakespeare_tailwind[^\"]*\\.js\"></script>||g' dist/index.html && sed -i 's|<style type=\"text/tailwindcss\">|<style>|g' dist/index.html && cp dist/index.html dist/404.html",
"newsletter:weekly": "tsx scripts/newsletter-weekly.ts",
"translation:matrix": "node scripts/translation-matrix.mjs"
@ -173,6 +173,7 @@
"jsdom": "^26.1.0",
"postcss": "^8.4.47",
"rollup-plugin-visualizer": "^7.1.1",
"sharp": "0.35.5",
"tailwindcss": "^3.4.11",
"tsx": "^4.22.4",
"typescript": "5.8.3",

View file

@ -0,0 +1,62 @@
/**
* Generate Android launch-screen bitmaps from the tracked brand source.
*
* `resources/splash.png` is the orange SkateSpots wheel. Capacitor's generated
* `android/` tree ships a stock blue placeholder, so every release must rebuild
* these drawables. Keeps the native first frame on-brand before the animated
* web splash takes over.
*/
import { mkdir } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import sharp from 'sharp';
const ROOT = resolve(import.meta.dirname, '..');
const SOURCE = resolve(ROOT, 'resources/splash.png');
const RES = resolve(ROOT, 'android/app/src/main/res');
const TARGETS = [
['drawable/splash.png', 480, 320],
['drawable-land-mdpi/splash.png', 480, 320],
['drawable-land-hdpi/splash.png', 800, 480],
['drawable-land-xhdpi/splash.png', 1280, 720],
['drawable-land-xxhdpi/splash.png', 1600, 960],
['drawable-land-xxxhdpi/splash.png', 1920, 1280],
['drawable-port-mdpi/splash.png', 320, 480],
['drawable-port-hdpi/splash.png', 480, 800],
['drawable-port-xhdpi/splash.png', 720, 1280],
['drawable-port-xxhdpi/splash.png', 960, 1600],
['drawable-port-xxxhdpi/splash.png', 1280, 1920],
];
const glow = (width, height) => Buffer.from(`
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="g" cx="50%" cy="50%" r="38%">
<stop offset="0" stop-color="#ff6a00" stop-opacity="0.30"/>
<stop offset="0.55" stop-color="#ff0040" stop-opacity="0.08"/>
<stop offset="1" stop-color="#050505" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="100%" height="100%" fill="url(#g)"/>
</svg>`);
for (const [relative, width, height] of TARGETS) {
const logoSize = Math.max(64, Math.round(Math.min(width, height) * 0.3));
const logo = await sharp(SOURCE)
.resize(logoSize, logoSize, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png()
.toBuffer();
const out = resolve(RES, relative);
await mkdir(dirname(out), { recursive: true });
await sharp({
create: { width, height, channels: 4, background: '#050505' },
})
.composite([
{ input: glow(width, height), blend: 'over' },
{ input: logo, gravity: 'center' },
])
.png()
.toFile(out);
console.log(`splash ${relative} ${width}x${height}`);
}

View file

@ -843,7 +843,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
alt="SkateSpots"
className="w-9 h-9 rounded-lg flex-shrink-0 shadow-lg shadow-red-500/30"
/>
<span className="min-w-0 truncate font-display text-xl neon-red tracking-wide" style={{
<span className="min-w-0 truncate font-display text-lg neon-red tracking-wide" style={{
textShadow: '0 0 8px #ff0040'
}}>SKATESPOTS</span>
</Link>

View file

@ -29,6 +29,19 @@ const MAX_MS = 2600; // hard cap — never block on a slow/offline network
const FADE_MS = 480;
const READY_HOLD_MS = 340; // savour the 100% "ready" beat before revealing
const SPARKS = [
{ sx: '-8px', sy: '18px', ex: '-54px', ey: '44px', delay: '0ms' },
{ sx: '4px', sy: '22px', ex: '38px', ey: '58px', delay: '90ms' },
{ sx: '-16px', sy: '10px', ex: '-72px', ey: '18px', delay: '180ms' },
{ sx: '12px', sy: '14px', ex: '66px', ey: '30px', delay: '260ms' },
{ sx: '-4px', sy: '24px', ex: '-28px', ey: '76px', delay: '340ms' },
{ sx: '8px', sy: '24px', ex: '22px', ey: '78px', delay: '430ms' },
{ sx: '-12px', sy: '2px', ex: '-48px', ey: '-16px', delay: '520ms' },
{ sx: '14px', sy: '4px', ex: '52px', ey: '-12px', delay: '610ms' },
{ sx: '-2px', sy: '28px', ex: '-10px', ey: '88px', delay: '700ms' },
{ sx: '2px', sy: '28px', ex: '12px', ey: '90px', delay: '790ms' },
];
const STATUS_KEYS = [
'bootUi.statusWaking',
'bootUi.statusTuning',
@ -171,34 +184,73 @@ export function SplashScreen() {
className="pointer-events-none absolute left-1/2 top-1/2 h-[520px] w-[520px] -translate-x-1/2 -translate-y-1/2 rounded-full blur-[110px]"
style={{
background:
'radial-gradient(circle, rgba(255,0,64,0.30) 0%, rgba(204,255,0,0.07) 46%, transparent 70%)',
'radial-gradient(circle, rgba(255,106,0,0.32) 0%, rgba(255,0,64,0.08) 46%, transparent 70%)',
...anim('splashGlow 3.4s ease-in-out infinite'),
}}
/>
<div className="relative z-10 flex flex-col items-center px-8">
{/* Logo in a glowing conic halo ring */}
{/* The orange wheel mark rolls in, keeps a slow roll, and throws sparks */}
<div className="relative" style={anim('splashRise 620ms cubic-bezier(0.22,1,0.36,1) both')}>
{/* Rotating conic ring */}
{/* Rotating conic ring — orange to match the wheel, with a lime kick */}
<div
className="absolute -inset-3 rounded-[26px]"
className="absolute -inset-3 rounded-full"
style={{
background:
'conic-gradient(from 0deg, transparent 0deg, #ff0040 90deg, #ccff00 180deg, transparent 300deg)',
opacity: ready ? 0.9 : 0.55,
filter: 'blur(6px)',
'conic-gradient(from 0deg, transparent 0deg, #ff6a00 90deg, #ccff00 190deg, transparent 310deg)',
opacity: ready ? 0.9 : 0.58,
filter: 'blur(7px)',
transition: 'opacity 400ms ease',
...anim('splashSpin 3.2s linear infinite'),
}}
aria-hidden="true"
/>
{/* Mask ring interior so only a thin halo shows */}
<div className="absolute -inset-1 rounded-[22px] bg-[#050505]" aria-hidden="true" />
<div style={anim('splashPulse 2.2s ease-in-out infinite')} className="relative">
<img
src="/images/icon-1024.png"
alt="SkateSpots"
className="relative h-24 w-24 rounded-[20px] shadow-[0_0_50px_rgba(255,0,64,0.55)]"
<div className="absolute -inset-1 rounded-full bg-[#050505]" aria-hidden="true" />
{/* Sparks are decorative and disabled for reduced-motion users. */}
{!prefersReducedMotion && (
<div className="pointer-events-none absolute inset-0" aria-hidden="true">
{SPARKS.map((spark, index) => (
<span
key={index}
className="splash-spark"
style={{
'--sx': spark.sx,
'--sy': spark.sy,
'--ex': spark.ex,
'--ey': spark.ey,
animationDelay: spark.delay,
} as React.CSSProperties}
/>
))}
</div>
)}
{/* Bounce wrapper + rolling wheel. Nested transforms keep the spin
from fighting the entry animation. */}
<div
style={anim('splashLogoRollIn 720ms cubic-bezier(0.22,1,0.36,1) both')}
className="relative"
>
<div
style={anim('splashHover 1.9s ease-in-out infinite')}
className="relative"
>
<div style={anim('splashWheelRoll 5.4s linear infinite')} className="relative">
<img
src="/images/icon-1024.png"
alt="SkateSpots"
draggable={false}
className="relative h-28 w-28 select-none drop-shadow-[0_0_34px_rgba(255,106,0,0.65)]"
/>
</div>
</div>
{/* Contact shadow / dust line under the wheel */}
<div
className="absolute -bottom-2 left-1/2 h-2 w-20 -translate-x-1/2 rounded-full bg-[#ff6a00]/30 blur-md"
style={anim('splashDust 1.9s ease-in-out infinite')}
aria-hidden="true"
/>
</div>
</div>

View file

@ -16,6 +16,7 @@ import {
RefreshCw,
Reply,
ScanLine,
Search,
Send,
Share2,
ShieldMinus,
@ -24,6 +25,8 @@ import {
Trash2,
UserPlus,
Users,
Volume2,
VolumeX,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@ -59,13 +62,15 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useQueryClient } from '@tanstack/react-query';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useCordnNative } from '@/hooks/useCordnNative';
import { useDmMute } from '@/hooks/useDmMute';
import { useCordnRoomRead } from '@/hooks/useCordnRoomRead';
import { enqueueCordnOutbox, markCordnOutboxAttempt, readCordnOutbox, removeCordnOutbox } from '@/lib/cordnOutbox';
import { shortNpub } from '@/lib/coordinator';
import { isNativePlatform } from '@/lib/platform';
import { formatDistanceToNow } from '@/lib/dateFnsI18n';
import { cn } from '@/lib/utils';
import type { CordnRoom } from '@/lib/cordn/room';
import { SKATE_CREW_KIND, type SkateCrew } from '@/lib/types';
import { readCordnSeen, writeCordnSeen } from '@/lib/cordn/unread';
import { clearPendingJoin, readPendingJoin, writePendingJoin } from '@/lib/cordn/pendingJoin';
type RoomScope = 'crew' | 'community';
@ -98,6 +103,8 @@ interface PendingMessage {
text: string;
imageUrl?: string;
state: 'sending' | 'failed';
outboxId?: string;
attempts?: number;
}
/**
@ -176,6 +183,9 @@ export function CordnNativePanel({
const sendNative = native.send;
const checkInviteNative = native.checkInvite;
const [draft, setDraft] = useState('');
const [searchOpen, setSearchOpen] = useState(false);
const [search, setSearch] = useState('');
const [online, setOnline] = useState(() => typeof navigator === 'undefined' || navigator.onLine);
const [replyTo, setReplyTo] = useState<{ id: string; content: string } | null>(null);
const [reactingTo, setReactingTo] = useState<string | null>(null);
const readSentRef = useRef<Set<string>>(new Set());
@ -194,6 +204,10 @@ export function CordnNativePanel({
const [deviceLinkPending, setDeviceLinkPending] = useState(false);
const [scannerOpen, setScannerOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const { isMuted, toggle: toggleMute } = useDmMute();
const { reads, markActive, markSeen } = useCordnRoomRead(user?.pubkey);
const muteKey = native.groupId ? `cordn:${native.groupId}` : undefined;
const muted = isMuted(muteKey);
const fileRef = useRef<HTMLInputElement>(null);
const checkingRef = useRef(false);
const loadedRequestsRef = useRef(false);
@ -229,6 +243,74 @@ export function CordnNativePanel({
setAwaiting(readPendingJoin(user.pubkey, native.groupId));
}, [user?.pubkey, native.groupId]);
const latestInboundAt = useMemo(
() =>
native.messages.reduce(
(max, message) =>
message.direction === 'inbound' && message.sender !== user?.pubkey
? Math.max(max, message.createdAt)
: max,
0,
),
[native.messages, user?.pubkey],
);
useEffect(() => {
if (native.groupId && latestInboundAt > 0) markActive(native.groupId, latestInboundAt);
}, [native.groupId, latestInboundAt, markActive]);
useEffect(() => {
if (active && native.groupId && latestInboundAt > 0) markSeen(native.groupId, latestInboundAt);
}, [active, native.groupId, latestInboundAt, markSeen]);
useEffect(() => {
const onOnline = () => setOnline(true);
const onOffline = () => setOnline(false);
window.addEventListener('online', onOnline);
window.addEventListener('offline', onOffline);
return () => {
window.removeEventListener('online', onOnline);
window.removeEventListener('offline', onOffline);
};
}, []);
// A different room must not inherit the previous room's draft/pending bubble.
useEffect(() => {
setPending(null);
setReplyTo(null);
setReactingTo(null);
setSearch('');
setSearchOpen(false);
}, [native.groupId]);
// Restore the oldest failed send after a reload/room switch. It is retried
// automatically a few times when the app is online, then stays queued for a
// deliberate tap.
useEffect(() => {
if (!user?.pubkey || !native.groupId) return;
const [next] = readCordnOutbox(user.pubkey, native.groupId);
if (!next) return;
setPending((current) =>
current ?? {
text: next.text,
imageUrl: next.imageUrl,
state: 'failed',
outboxId: next.id,
attempts: next.attempts,
},
);
}, [user?.pubkey, native.groupId]);
const displayMessages = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return native.messages;
return native.messages.filter(
(message) =>
message.content.toLowerCase().includes(query) ||
(message.imageUrl ?? '').toLowerCase().includes(query),
);
}, [native.messages, search]);
useEffect(() => {
if (native.group && awaiting) {
if (user?.pubkey) clearPendingJoin(user.pubkey, native.groupId);
@ -264,36 +346,37 @@ export function CordnNativePanel({
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]);
// "New since last opened" marker. The panel records the latest inbound
// timestamp; a hidden tab compares it with the local seen cursor. The first
// observation seeds the cursor so old history never reads as unread.
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);
const state = reads[groupGid];
if (!state) {
markSeen(groupGid, latestInboundAt);
onUnreadChange?.(false);
} else {
onUnreadChange?.(latest > seen);
return;
}
}, [active, groupGid, native.ready, user?.pubkey, messageCursor.latest, onUnreadChange]);
markActive(groupGid, latestInboundAt);
onUnreadChange?.(latestInboundAt > state.seenAt);
}, [
active,
groupGid,
native.ready,
user?.pubkey,
reads,
latestInboundAt,
markActive,
markSeen,
onUnreadChange,
]);
// Opening the private tab pulls anything missed while it was hidden. Silent
// signers also poll in the hook; this makes tab activation immediate.
@ -335,18 +418,53 @@ export function CordnNativePanel({
}, [t]);
const deliver = useCallback(
async (text: string, imageUrl?: string) => {
async (text: string, imageUrl?: string, outboxId?: string): Promise<boolean> => {
setNotice(null);
setPending({ text, imageUrl, state: 'sending' });
const currentAttempt = outboxId
? readCordnOutbox(user?.pubkey, native.groupId).find((item) => item.id === outboxId)?.attempts
: undefined;
setPending({
text,
imageUrl,
state: 'sending',
outboxId,
attempts: currentAttempt,
});
const sent = await sendNative(text, imageUrl);
if (sent) {
markSent(sent.id);
setPending(null);
} else {
setPending((current) => (current ? { ...current, state: 'failed' } : current));
if (outboxId) removeCordnOutbox(user?.pubkey, native.groupId, outboxId);
const [next] = readCordnOutbox(user?.pubkey, native.groupId);
setPending(next ? {
text: next.text,
imageUrl: next.imageUrl,
state: 'failed',
outboxId: next.id,
attempts: next.attempts,
} : null);
return true;
}
let nextOutboxId = outboxId;
let attempts = currentAttempt ?? 0;
if (nextOutboxId) {
markCordnOutboxAttempt(user?.pubkey, native.groupId, nextOutboxId);
attempts += 1;
} else {
const item = enqueueCordnOutbox(user?.pubkey, native.groupId, text, imageUrl);
nextOutboxId = item?.id;
attempts = item?.attempts ?? 1;
}
setPending({
text,
imageUrl,
state: 'failed',
outboxId: nextOutboxId,
attempts,
});
return false;
},
[sendNative, markSent],
[sendNative, markSent, user?.pubkey, native.groupId],
);
const handleSend = useCallback(async () => {
@ -354,21 +472,41 @@ export function CordnNativePanel({
if (!body || native.busy) return;
setDraft('');
const content = withReply(body);
const sent = await native.send(content);
if (sent) {
markSent(sent.id);
setReplyTo(null);
setPending(null);
} else {
setPending({ text: content, state: 'failed' });
}
}, [draft, native, withReply, markSent]);
const ok = await deliver(content);
if (ok) setReplyTo(null);
}, [draft, native.busy, withReply, deliver]);
const retrySend = useCallback(async () => {
if (!pending || pending.state !== 'failed') return;
await deliver(pending.text, pending.imageUrl);
await deliver(pending.text, pending.imageUrl, pending.outboxId);
}, [pending, deliver]);
const discardPending = useCallback(() => {
if (pending?.outboxId) removeCordnOutbox(user?.pubkey, native.groupId, pending.outboxId);
const [next] = readCordnOutbox(user?.pubkey, native.groupId);
setPending(next ? {
text: next.text,
imageUrl: next.imageUrl,
state: 'failed',
outboxId: next.id,
attempts: next.attempts,
} : null);
}, [pending, user?.pubkey, native.groupId]);
// A failed send is retried a few times when the app is online, then left for
// the explicit Retry button. This avoids both data loss and infinite retry.
useEffect(() => {
if (
!pending ||
pending.state !== 'failed' ||
!online ||
!native.ready ||
(pending.attempts ?? 0) >= 3
) return;
const timer = window.setTimeout(() => void retrySend(), 12_000);
return () => window.clearTimeout(timer);
}, [pending, online, native.ready, retrySend]);
const onPickImage = useCallback(
async (file: File | undefined) => {
if (!file) return;
@ -586,6 +724,36 @@ export function CordnNativePanel({
<Lock className="h-4 w-4 text-teal-400" /> {copy.title}
</CardTitle>
<div className="flex flex-wrap items-center justify-end gap-2">
{native.group && (
<>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-gray-400 hover:text-teal-300"
onClick={() => {
setSearchOpen((open) => !open);
if (searchOpen) setSearch('');
}}
aria-label={t('common.search')}
title={t('common.search')}
>
<Search className="h-3.5 w-3.5" />
</Button>
{muteKey && (
<Button
size="icon"
variant="ghost"
className={cn('h-7 w-7 hover:text-amber-300', muted ? 'text-amber-400' : 'text-gray-400')}
onClick={() => toggleMute(muteKey)}
aria-pressed={muted}
aria-label={muted ? t('walletUi.trust.unmute') : t('walletUi.trust.mute')}
title={muted ? t('walletUi.trust.unmute') : t('walletUi.trust.mute')}
>
{muted ? <Volume2 className="h-3.5 w-3.5" /> : <VolumeX className="h-3.5 w-3.5" />}
</Button>
)}
</>
)}
{native.group && (
<Badge variant="secondary" className="text-[10px]">
{t('cordnUi.memberCount', { count: native.group.members.length })}
@ -646,6 +814,18 @@ export function CordnNativePanel({
</div>
</div>
<p className="pt-1 text-xs text-gray-500">{copy.subtitle}</p>
{!isCommunity && (
<details className="group mt-1 rounded-md border border-white/10 bg-black/20 px-2.5 py-1.5">
<summary className="flex cursor-pointer list-none items-center gap-1 text-[11px] text-gray-400">
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-180" />
{t('cordnUi.compareTitle')}
</summary>
<div className="mt-1.5 space-y-1 text-[11px] leading-relaxed text-gray-500">
<p>{t('cordnUi.compareChat')}</p>
<p>{t('cordnUi.comparePrivate')}</p>
</div>
</details>
)}
</CardHeader>
<CardContent className="space-y-3">
@ -793,6 +973,7 @@ export function CordnNativePanel({
groupRef={native.groupRef}
coordinatorPubkey={native.coordinator.serverPubkey}
keyPackageReady={native.keyPackageReady}
lastCheckedAt={native.lastCheckedAt}
copied={copied}
onCopyRef={() => void copyRef()}
/>
@ -821,7 +1002,6 @@ export function CordnNativePanel({
onToggleAdmin={() => void updateAdminRole(member.pubkey, roleOf(member.pubkey) !== 'admin')}
canRemove={canManage && member.pubkey !== user.pubkey && member.pubkey !== crew.pubkey}
onRemove={() => {
if (!window.confirm(t('cordnUi.removeMemberConfirm', { name: shortNpub(member.pubkey) }))) return;
void native.removeMember(member.leafIndex).then((ok) => {
if (!ok) setNotice(t('cordnUi.removeMemberFailed'));
});
@ -848,16 +1028,41 @@ export function CordnNativePanel({
</div>
</details>
{searchOpen && (
<div className="flex items-center gap-2">
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t('common.search')}
aria-label={t('common.search')}
className="h-8 border-white/10 bg-black/40 text-xs"
autoFocus
/>
{search && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-gray-400"
onClick={() => setSearch('')}
aria-label={t('common.clear')}
title={t('common.clear')}
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
)}
<div
ref={scrollRef}
className="max-h-80 space-y-2 overflow-y-auto rounded-md border border-white/10 bg-black/30 p-3"
>
{native.messages.length === 0 && !pending ? (
{displayMessages.length === 0 && !pending ? (
<p className="py-6 text-center text-xs text-gray-500">
{t('cordnUi.placeholder')}
{search.trim() ? t('common.noResults') : t('cordnUi.placeholder')}
</p>
) : (
native.messages.map((m) => (
displayMessages.map((m) => (
<div
key={m.id}
className={cn('flex flex-col gap-0.5', m.direction === 'outbound' && 'items-end')}
@ -993,6 +1198,9 @@ export function CordnNativePanel({
{pending.state === 'sending'
? t('cordnUi.sending')
: t('cordnUi.sendFailed')}
{pending.state === 'failed' && pending.outboxId && (
<span className="ml-1 text-gray-500">· {t('cordnUi.queued')}</span>
)}
</span>
</div>
<div className="max-w-[85%] space-y-1 overflow-hidden rounded-2xl bg-teal-500/20 px-3 py-1.5 text-sm text-teal-100 opacity-80">
@ -1021,7 +1229,7 @@ export function CordnNativePanel({
size="sm"
variant="ghost"
className="h-7 gap-1 px-2 text-xs text-gray-400"
onClick={() => setPending(null)}
onClick={discardPending}
>
<X className="h-3 w-3" /> {t('cordnUi.dismiss')}
</Button>
@ -1116,6 +1324,7 @@ export function CordnNativePanel({
groupRef={native.groupRef}
coordinatorPubkey={native.coordinator.serverPubkey}
keyPackageReady={native.keyPackageReady || !!native.group}
lastCheckedAt={native.lastCheckedAt}
copied={copied}
onCopyRef={() => void copyRef()}
/>
@ -1263,12 +1472,14 @@ function AdvancedPanel({
groupRef,
coordinatorPubkey,
keyPackageReady,
lastCheckedAt,
copied,
onCopyRef,
}: {
groupRef: string | null;
coordinatorPubkey: string;
keyPackageReady: boolean;
lastCheckedAt: number | null;
copied: boolean;
onCopyRef: () => void;
}) {
@ -1305,6 +1516,14 @@ function AdvancedPanel({
{keyPackageReady ? t('cordnUi.keyPackageReady') : t('cordnUi.keyPackageMissing')}
</span>
</div>
<div className="flex items-center justify-between gap-2 text-[11px] text-gray-500">
<span>{t('cordnUi.lastChecked')}</span>
<span className="text-gray-400">
{lastCheckedAt
? formatDistanceToNow(new Date(lastCheckedAt), { addSuffix: true })
: '—'}
</span>
</div>
{groupRef && (
<>
<div className="flex items-center gap-2 text-[11px] text-gray-500">
@ -1454,16 +1673,31 @@ function MemberRow({
</Button>
)}
{canRemove && onRemove && (
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-gray-500 hover:bg-red-500/10 hover:text-red-300"
onClick={onRemove}
aria-label={t('common.remove')}
title={t('common.remove')}
>
<Trash2 className="h-3 w-3" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-gray-500 hover:bg-red-500/10 hover:text-red-300"
aria-label={t('common.remove')}
title={t('common.remove')}
>
<Trash2 className="h-3 w-3" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('common.remove')}</AlertDialogTitle>
<AlertDialogDescription>
{t('cordnUi.removeMemberConfirm', { name: shortNpub(pubkey) })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={onRemove}>{t('common.remove')}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>

View file

@ -103,6 +103,8 @@ export interface UseCordnNativeResult {
groupRef: string | null;
/** True when this device is set up: it holds the group or a published key package. */
keyPackageReady: boolean;
/** Last completed coordinator/session check, or null before the first one. */
lastCheckedAt: number | null;
/** Re-open the session after a connection error (the Retry button). */
retry: () => void;
}
@ -151,6 +153,7 @@ export function useCordnNative({
const [error, setError] = useState<string | null>(null);
const [groupRef, setGroupRef] = useState<string | null>(null);
const [keyPackageReady, setKeyPackageReady] = useState(false);
const [lastCheckedAt, setLastCheckedAt] = useState<number | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const interactive = isInteractiveSigner(user, logins);
@ -209,9 +212,13 @@ export function useCordnNative({
opened.listKeyPackages().some((kp) => kp.publishedAt !== undefined && !kp.consumed),
);
setReady(true);
setLastCheckedAt(Date.now());
})
.catch((err) => {
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
if (!cancelled) {
setError(err instanceof Error ? err.message : String(err));
setLastCheckedAt(Date.now());
}
});
return () => {
cancelled = true;
@ -233,8 +240,10 @@ export function useCordnNative({
setMessages(next);
setGroup(session.listGroups().find((g) => g.gid === group.gid) ?? group);
setError(null);
setLastCheckedAt(Date.now());
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setLastCheckedAt(Date.now());
}
}, [group]);
@ -257,6 +266,7 @@ export function useCordnNative({
return undefined;
} finally {
setBusy(false);
setLastCheckedAt(Date.now());
}
}, []);
@ -549,6 +559,7 @@ export function useCordnNative({
passiveSigner,
groupRef,
keyPackageReady,
lastCheckedAt,
retry,
};
}

View file

@ -0,0 +1,47 @@
import { useCallback, useEffect, useState } from 'react';
import {
cordnRoomReadEventName,
markCordnRoomActive,
markCordnRoomSeen,
readCordnRoomReads,
type CordnRoomReadMap,
} from '@/lib/cordnRoomRead';
/**
* Reactive view of {@link readCordnRoomReads}. Safe to mount on list pages:
* it only reads localStorage and listens for same-tab/cross-tab updates.
*/
export function useCordnRoomRead(pubkey: string | undefined) {
const [reads, setReads] = useState<CordnRoomReadMap>(() => readCordnRoomReads(pubkey));
useEffect(() => {
setReads(readCordnRoomReads(pubkey));
const onChange = (event: Event) => {
const detail = (event as CustomEvent<{ pubkey?: string; reads?: CordnRoomReadMap }>).detail;
if (!detail || detail.pubkey !== pubkey || !detail.reads) return;
setReads(detail.reads);
};
window.addEventListener(cordnRoomReadEventName(), onChange);
return () => window.removeEventListener(cordnRoomReadEventName(), onChange);
}, [pubkey]);
const isUnread = useCallback(
(gid: string | undefined) => {
if (!gid) return false;
const state = reads[gid];
return !!state && state.latestAt > state.seenAt;
},
[reads],
);
const markActive = useCallback(
(gid: string, latestAt: number) => markCordnRoomActive(pubkey, gid, latestAt),
[pubkey],
);
const markSeen = useCallback(
(gid: string, latestAt: number) => markCordnRoomSeen(pubkey, gid, latestAt),
[pubkey],
);
return { reads, isUnread, markActive, markSeen };
}

View file

@ -918,10 +918,10 @@ const batch1 = {
"noGroupMember": "Encara no ets al xat privat d'aquesta colla.",
"create": "Crea el xat privat",
"requestJoin": "Demana d'unir-t'hi",
"requestAgain": "Request again",
"requestHandled": "Request handled",
"copyCordnLink": "Copy Cordn link",
"cordnWebCaveat": "Cordn web may stall after sign-in; the in-app join is more reliable.",
"requestAgain": "Torna a sol·licitar",
"requestHandled": "Sol·licitud gestionada",
"copyCordnLink": "Copia l’enllaç de Cordn",
"cordnWebCaveat": "Cordn web es pot encallar després d’iniciar sessió; unir-te des de l’app és més fiable.",
"checkInvite": "Comprova si hi ha invitació",
"requested": "Sol·licitud enviada: un administrador de la colla l'aprovarà.",
"noInvite": "Encara no hi ha cap invitació. Demana a un administrador que aprovi la teva sol·licitud.",
@ -964,7 +964,11 @@ const batch1 = {
onboardingMember3: "Ja hi ets: els missatges van xifrats.",
encryptedNote: "Només ho poden llegir els membres de la colla; el coordinador només retransmet missatges xifrats.",
advanced: "Avançat",
compareTitle: "Xat vs xat privat",
compareChat: "El xat és un DM de grup NIP-17 estàndard. Funciona amb altres clients Nostr i no té coordinador.",
comparePrivate: "El xat privat utilitza MLS: control de membres més fort i secret de reenviament. El coordinador veu les metadades del grup i el text xifrat, mai el text dels missatges.",
coordinator: "Coordinador",
lastChecked: "Darrera comprovació",
keyPackage: "Paquet de claus",
keyPackageReady: "A punt en aquest dispositiu",
keyPackageMissing: "No configurat en aquest dispositiu",
@ -988,6 +992,7 @@ const batch1 = {
qrTitle: "Escaneja per unir-te al xat privat",
qrHint: "Els membres de la colla ho escanegen amb la càmera del mòbil. L’enllaç obre la pestanya Xat privat.",
sendFailed: "No s’ha pogut enviar: toca Reintenta.",
queued: "En cua en aquest dispositiu",
sending: "S’està enviant…",
sent: "Enviat",
retry: "Reintenta",
@ -1023,7 +1028,7 @@ const batch1 = {
"noInvite": "Encara no hi ha invitació. El bot normalment aprova en un minut; després toca «Comprova si hi ha invitació».",
"pendingRequests": "{{count}} sol·licituds d'entrada en espera",
"pendingRequestsDesc": "El bot normalment aprova automàticament. Si s'ha encallat, obre la sala de sota i toca Comprova les sol·licituds.",
"botMissing": "The community bot isn’t in this room right now, so an admin will review join requests.",
"botMissing": "El bot de la comunitat no és en aquesta sala ara mateix, així que un admin revisarà les sol·licituds d’unió.",
"secondDeviceHint": "Ja ets membre en un altre dispositiu? Fes servir «Enllaça aquest dispositiu» per a un traspàs directe, o torna a demanar d’entrar amb aquest dispositiu.",
"create": "Crea la sala de la comunitat",
"setupDeviceBody": "Això dóna a aquest dispositiu una clau per a la sala de la comunitat. Un administrador o el bot l'aprova una vegada.",

View file

@ -395,6 +395,7 @@ const batch2 = {
newMessage: "Missatge nou",
groups: "Grups",
refresh: "Actualitza",
privateRooms: "Sales privades",
crews: "Crews",
crewFounder: "Fundador",
crewMember: "Membre",

View file

@ -82,6 +82,7 @@ const drift = {
},
"dms": {
"refresh": "Obnovit",
"privateRooms": "Soukromé místnosti",
"crews": "Crew",
"crewFounder": "Zakladatel",
"crewMember": "Člen",
@ -307,10 +308,10 @@ const drift = {
"noGroupMember": "V soukromém chatu této crew zatím nejsi.",
"create": "Vytvořit soukromý chat",
"requestJoin": "Požádat o připojení",
"requestAgain": "Request again",
"requestHandled": "Request handled",
"copyCordnLink": "Copy Cordn link",
"cordnWebCaveat": "Cordn web may stall after sign-in; the in-app join is more reliable.",
"requestAgain": "Požádat znovu",
"requestHandled": "Žádost vyřízena",
"copyCordnLink": "Kopírovat odkaz Cordn",
"cordnWebCaveat": "Cordn web může po přihlášení zamrznout; připojení v aplikaci je spolehlivější.",
"checkInvite": "Zkontrolovat pozvánku",
"requested": "Žádost odeslána — administrátor crew ji schválí.",
"noInvite": "Zatím nečeká žádná pozvánka. Požádej administrátora o schválení žádosti.",
@ -350,7 +351,11 @@ const drift = {
onboardingMember3: "Jsi uvnitř — zprávy jsou šifrované.",
encryptedNote: "Číst to mohou jen členové crew; koordinátor pouze přenáší šifrované zprávy.",
advanced: "Pokročilé",
compareTitle: "Chat vs. soukromý chat",
compareChat: "Chat je standardní skupinová DM podle NIP-17. Funguje s jinými klienty Nostr a nemá žádného koordinátora.",
comparePrivate: "Soukromý chat používá MLS: silnější kontrolu členství a dopředné utajení. Koordinátor vidí metadata skupiny a šifrovaný text, nikdy obsah zpráv.",
coordinator: "Koordinátor",
lastChecked: "Naposledy zkontrolováno",
keyPackage: "Balíček klíčů",
keyPackageReady: "Připraveno na tomto zařízení",
keyPackageMissing: "Není nastaveno na tomto zařízení",
@ -374,6 +379,7 @@ const drift = {
qrTitle: "Naskenuj a připoj se k soukromému chatu",
qrHint: "Členové crew to naskenují kamerou telefonu. Odkaz otevře kartu Soukromý chat.",
sendFailed: "Nepodařilo se odeslat — klepni na Zkusit znovu.",
queued: "Ve frontě na tomto zařízení",
sending: "Odesílání…",
sent: "Odesláno",
retry: "Zkusit znovu",
@ -409,7 +415,7 @@ const drift = {
"noInvite": "Zatím žádná pozvánka. Bot obvykle schválí do minuty — potom klepni na „Zkontrolovat pozvánku“.",
"pendingRequests": "{{count}} čekajících žádostí o připojení",
"pendingRequestsDesc": "Bot obvykle schvaluje automaticky. Pokud se zasekl, otevři níže místnost a klepni na Zkontrolovat žádosti.",
"botMissing": "The community bot isn’t in this room right now, so an admin will review join requests.",
"botMissing": "Komunitní bot teď v této místnosti není, takže žádosti o připojení zkontroluje administrátor.",
"secondDeviceHint": "Už jsi členem na jiném zařízení? Použij „Propojit toto zařízení“ pro přímé předání, nebo požádej o připojení znovu s tímto zařízením.",
"create": "Vytvořit komunitní místnost",
"setupDeviceBody": "Toto zařízení tím získá klíč do komunitní místnosti. Schválí ho jednou administrátor nebo bot.",

View file

@ -84,6 +84,7 @@ const drift = {
},
dms: {
refresh: "Aktualisieren",
privateRooms: "Private Räume",
crews: "Crews",
crewFounder: "Gründer",
crewMember: "Mitglied",
@ -309,10 +310,10 @@ const drift = {
noGroupMember: "Du bist noch nicht im privaten Chat dieser Crew.",
create: "Privaten Chat erstellen",
requestJoin: "Um Beitritt bitten",
requestAgain: "Request again",
requestHandled: "Request handled",
copyCordnLink: "Copy Cordn link",
cordnWebCaveat: "Cordn web may stall after sign-in; the in-app join is more reliable.",
requestAgain: "Erneut anfragen",
requestHandled: "Anfrage bearbeitet",
copyCordnLink: "Cordn-Link kopieren",
cordnWebCaveat: "Cordn Web kann nach der Anmeldung hängen bleiben; der Beitritt in der App ist zuverlässiger.",
checkInvite: "Nach Einladung suchen",
requested: "Anfrage gesendet – ein Crew-Admin wird sie genehmigen.",
noInvite: "Noch keine Einladung. Bitte einen Admin, deine Anfrage zu genehmigen.",
@ -352,7 +353,11 @@ const drift = {
onboardingMember3: "Du bist dabei – Nachrichten sind verschlüsselt.",
encryptedNote: "Nur Crew-Mitglieder können das lesen; der Koordinator leitet nur verschlüsselte Nachrichten weiter.",
advanced: "Erweitert",
compareTitle: "Chat vs. privater Chat",
compareChat: "Der Chat ist eine standardmäßige NIP-17-Gruppen-DM. Er funktioniert mit anderen Nostr-Clients und hat keinen Koordinator.",
comparePrivate: "Der private Chat nutzt MLS: stärkere Mitgliederverwaltung und Forward Secrecy. Der Koordinator sieht Gruppenmetadaten und Chiffretext, niemals den Nachrichtentext.",
coordinator: "Koordinator",
lastChecked: "Zuletzt geprüft",
keyPackage: "Schlüsselpaket",
keyPackageReady: "Auf diesem Gerät bereit",
keyPackageMissing: "Auf diesem Gerät nicht eingerichtet",
@ -376,6 +381,7 @@ const drift = {
qrTitle: "Scannen und dem privaten Chat beitreten",
qrHint: "Crew-Mitglieder scannen das mit der Handykamera. Der Link öffnet den Tab „Privater Chat“.",
sendFailed: "Senden fehlgeschlagen – tippe auf Wiederholen.",
queued: "Auf diesem Gerät in der Warteschlange",
sending: "Senden…",
sent: "Gesendet",
retry: "Wiederholen",
@ -411,7 +417,7 @@ const drift = {
noInvite: "Noch keine Einladung. Der Bot genehmigt normalerweise innerhalb einer Minute – tippe danach auf „Nach Einladung suchen“.",
pendingRequests: "{{count}} offene Beitrittsanfragen",
pendingRequestsDesc: "Der Bot genehmigt normalerweise automatisch. Wenn es hängt, öffne unten den Raum und tippe auf Anfragen prüfen.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
botMissing: "Der Community-Bot ist gerade nicht in diesem Raum, daher prüft ein Admin die Beitrittsanfragen.",
secondDeviceHint: "Schon Mitglied auf einem anderen Gerät? Nutze „Dieses Gerät verknüpfen“ für eine direkte Übergabe oder bitte mit diesem Gerät erneut um Beitritt.",
create: "Community-Raum erstellen",
setupDeviceBody: "Damit erhält dieses Gerät einen Schlüssel für den Community-Raum. Ein Admin oder der Bot bestätigt ihn einmal.",

View file

@ -86,6 +86,7 @@ const drift = {
},
dms: {
refresh: "Ανανέωση",
privateRooms: "Ιδιωτικά δωμάτια",
crews: "Crews",
crewFounder: "Ιδρυτής",
crewMember: "Μέλος",
@ -311,10 +312,10 @@ const drift = {
noGroupMember: "Δεν είσαι ακόμη στην ιδιωτική συνομιλία αυτού του crew.",
create: "Δημιουργία ιδιωτικής συνομιλίας",
requestJoin: "Ζήτα συμμετοχή",
requestAgain: "Request again",
requestHandled: "Request handled",
copyCordnLink: "Copy Cordn link",
cordnWebCaveat: "Cordn web may stall after sign-in; the in-app join is more reliable.",
requestAgain: "Νέο αίτημα",
requestHandled: "Το αίτημα διεκπεραιώθηκε",
copyCordnLink: "Αντιγραφή συνδέσμου Cordn",
cordnWebCaveat: "Το Cordn web μπορεί να κολλήσει μετά τη σύνδεση· η συμμετοχή από την εφαρμογή είναι πιο αξιόπιστη.",
checkInvite: "Έλεγχος πρόσκλησης",
requested: "Το αίτημα στάλθηκε — ένας διαχειριστής του crew θα το εγκρίνει.",
noInvite: "Δεν περιμένει ακόμη πρόσκληση. Ζήτα από έναν διαχειριστή να εγκρίνει το αίτημά σου.",
@ -354,7 +355,11 @@ const drift = {
onboardingMember3: "Είστε μέσα — τα μηνύματα είναι κρυπτογραφημένα.",
encryptedNote: "Μόνο τα μέλη του crew μπορούν να το διαβάσουν· ο συντονιστής απλώς προωθεί κρυπτογραφημένα μηνύματα.",
advanced: "Για προχωρημένους",
compareTitle: "Συνομιλία vs. ιδιωτική συνομιλία",
compareChat: "Η συνομιλία είναι ένα τυπικό group DM του NIP-17. Λειτουργεί με άλλους πελάτες Nostr και δεν έχει συντονιστή.",
comparePrivate: "Η ιδιωτική συνομιλία χρησιμοποιεί MLS: ισχυρότερο έλεγχο μελών και εμπιστευτικότητα προς τα εμπρός. Ο συντονιστής βλέπει μεταδεδομένα ομάδας και κρυπτοκείμενο, ποτέ το κείμενο των μηνυμάτων.",
coordinator: "Συντονιστής",
lastChecked: "Τελευταίος έλεγχος",
keyPackage: "Πακέτο κλειδιών",
keyPackageReady: "Έτοιμο σε αυτή τη συσκευή",
keyPackageMissing: "Δεν έχει ρυθμιστεί σε αυτή τη συσκευή",
@ -378,6 +383,7 @@ const drift = {
qrTitle: "Σαρώστε για να μπείτε στην ιδιωτική συνομιλία",
qrHint: "Τα μέλη του crew το σαρώνουν με την κάμερα του κινητού. Ο σύνδεσμος ανοίγει την καρτέλα Ιδιωτική συνομιλία.",
sendFailed: "Δεν στάλθηκε — πατήστε Επανάληψη.",
queued: "Σε ουρά σε αυτή τη συσκευή",
sending: "Αποστολή…",
sent: "Στάλθηκε",
retry: "Επανάληψη",
@ -413,7 +419,7 @@ const drift = {
noInvite: "Δεν υπάρχει ακόμη πρόσκληση. Το bot εγκρίνει συνήθως μέσα σε ένα λεπτό — μετά πάτησε «Έλεγχος πρόσκλησης».",
pendingRequests: "{{count}} αιτήματα συμμετοχής σε αναμονή",
pendingRequestsDesc: "Το bot εγκρίνει συνήθως αυτόματα. Αν έχει κολλήσει, άνοιξε το δωμάτιο παρακάτω και πάτησε Έλεγχος αιτημάτων.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
botMissing: "Το bot της κοινότητας δεν είναι αυτή τη στιγμή σε αυτό το δωμάτιο, οπότε ένας διαχειριστής θα εξετάσει τα αιτήματα συμμετοχής.",
secondDeviceHint: "Είσαι ήδη μέλος σε άλλη συσκευή; Χρησιμοποίησε «Σύνδεση αυτής της συσκευής» για άμεση μεταβίβαση ή ζήτησε ξανά να συμμετάσχεις με αυτή τη συσκευή.",
create: "Δημιουργία δωματίου κοινότητας",
setupDeviceBody: "Αυτό δίνει σε αυτή τη συσκευή ένα κλειδί για το δωμάτιο της κοινότητας. Ένας διαχειριστής ή το bot το εγκρίνει μία φορά.",

View file

@ -4090,6 +4090,7 @@ socialUi: {
newMessage: 'New Message',
groups: 'Groups',
refresh: 'Refresh',
privateRooms: 'Private rooms',
crews: 'Crews',
crewFounder: 'Founder',
crewMember: 'Member',

View file

@ -4090,6 +4090,7 @@ socialUi: {
newMessage: 'Nuevo mensaje',
groups: 'Grupos',
refresh: 'Actualizar',
privateRooms: 'Salas privadas',
crews: 'Peñas',
crewFounder: 'Fundador',
crewMember: 'Miembro',

View file

@ -918,10 +918,10 @@ const batch1 = {
noGroupMember: "Oraindik ez zaude talde honen txat pribatuan.",
create: "Sortu txat pribatua",
requestJoin: "Eskatu sartzea",
requestAgain: "Request again",
requestHandled: "Request handled",
copyCordnLink: "Copy Cordn link",
cordnWebCaveat: "Cordn web may stall after sign-in; the in-app join is more reliable.",
requestAgain: "Eskatu berriro",
requestHandled: "Eskaera kudeatuta",
copyCordnLink: "Kopiatu Cordn esteka",
cordnWebCaveat: "Cordn web blokeatu egin daiteke saioa hasi ondoren; app barruan bat egitea fidagarriagoa da.",
checkInvite: "Egiaztatu gonbidapena",
requested: "Eskaera bidali da — taldeko administratzaile batek onartuko du.",
noInvite: "Oraindik ez dago gonbidapenik zain. Eskatu administratzaile bati zure eskaera onartzeko.",
@ -964,7 +964,11 @@ const batch1 = {
onboardingMember3: "Barruan zaude — mezuak zifratuta daude.",
encryptedNote: "Taldeko kideek soilik irakur dezakete hau; koordinatzaileak mezu zifratuak soilik birbidaltzen ditu.",
advanced: "Aurreratua",
compareTitle: "Txata vs txat pribatua",
compareChat: "Txata NIP-17 talde-DM estandar bat da. Beste Nostr bezeroekin funtzionatzen du eta ez du koordinatzailerik.",
comparePrivate: "Txat pribatuak MLS erabiltzen du: kideen kontrola sendoagoa eta aurreranzko sekretua. Koordinatzaileak taldearen metadatuak eta testu zifratua ikusten ditu, inoiz ez mezuen testua.",
coordinator: "Koordinatzailea",
lastChecked: "Azken egiaztapena",
keyPackage: "Gako-paketea",
keyPackageReady: "Prest gailu honetan",
keyPackageMissing: "Ez dago konfiguratuta gailu honetan",
@ -988,6 +992,7 @@ const batch1 = {
qrTitle: "Eskaneatu txat pribatura sartzeko",
qrHint: "Taldeko kideek telefonoaren kamerarekin eskaneatzen dute. Estekak Txat pribatua fitxa irekitzen du.",
sendFailed: "Ezin izan da bidali — sakatu Berriro saiatu.",
queued: "Gailu honetan ilaran",
sending: "Bidaltzen…",
sent: "Bidalita",
retry: "Berriro saiatu",
@ -1023,7 +1028,7 @@ const batch1 = {
noInvite: "Oraindik ez dago gonbidapenik. Botak normalean minutu batean onartzen du — gero sakatu «Egiaztatu gonbidapena».",
pendingRequests: "{{count}} sartzeko eskaera zain",
pendingRequestsDesc: "Botak normalean automatikoki onartzen du. Blokeatuta badago, ireki beheko gela eta sakatu Egiaztatu eskaerak.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
botMissing: "Komunitatearen bot-a ez dago gela honetan oraintxe, beraz administratzaile batek berrikusiko ditu bat egiteko eskaerak.",
secondDeviceHint: "Beste gailu batean kide zara jada? Erabili «Estekatu gailu hau» zuzeneko transferentzia egiteko, edo eskatu berriro gailu honekin bat egiteko.",
create: "Sortu komunitate gela",
setupDeviceBody: "Honek gailu honi komunitate gelarako gako bat ematen dio. Administratzaile batek edo botak behin onartzen du.",

View file

@ -395,6 +395,7 @@ const batch2 = {
newMessage: "Mezu berria",
groups: "Taldeak",
refresh: "Freskatu",
privateRooms: "Gela pribatuak",
crews: "Koadrilak",
crewFounder: "Sortzailea",
crewMember: "Kidea",

View file

@ -84,6 +84,7 @@ const drift = {
},
dms: {
refresh: "Actualiser",
privateRooms: "Salons privés",
crews: "Crews",
crewFounder: "Fondateur",
crewMember: "Membre",
@ -309,10 +310,10 @@ const drift = {
noGroupMember: "Vous n'êtes pas encore dans le chat privé de cette Crew.",
create: "Créer le chat privé",
requestJoin: "Demander à rejoindre",
requestAgain: "Request again",
requestHandled: "Request handled",
copyCordnLink: "Copy Cordn link",
cordnWebCaveat: "Cordn web may stall after sign-in; the in-app join is more reliable.",
requestAgain: "Redemander",
requestHandled: "Demande traitée",
copyCordnLink: "Copier le lien Cordn",
cordnWebCaveat: "Cordn web peut se bloquer après la connexion ; rejoindre depuis l’app est plus fiable.",
checkInvite: "Vérifier l'invitation",
requested: "Demande envoyée — un admin de la Crew l'approuvera.",
noInvite: "Aucune invitation en attente. Demandez à un admin d'approuver votre demande.",
@ -352,7 +353,11 @@ const drift = {
onboardingMember3: "Vous y êtes — les messages sont chiffrés.",
encryptedNote: "Seuls les membres de la Crew peuvent le lire ; le coordinateur ne relaie que des messages chiffrés.",
advanced: "Avancé",
compareTitle: "Chat vs chat privé",
compareChat: "Le chat est un DM de groupe NIP-17 standard. Il fonctionne avec d'autres clients Nostr et n'a pas de coordinateur.",
comparePrivate: "Le chat privé utilise MLS : un contrôle des membres plus fort et une confidentialité persistante. Le coordinateur voit les métadonnées du groupe et le texte chiffré, jamais le contenu des messages.",
coordinator: "Coordinateur",
lastChecked: "Dernière vérification",
keyPackage: "Paquet de clés",
keyPackageReady: "Prêt sur cet appareil",
keyPackageMissing: "Non configuré sur cet appareil",
@ -376,6 +381,7 @@ const drift = {
qrTitle: "Scannez pour rejoindre le chat privé",
qrHint: "Les membres de la Crew le scannent avec l’appareil photo de leur téléphone. Le lien ouvre l’onglet Chat privé.",
sendFailed: "Échec de l’envoi — touchez Réessayer.",
queued: "En file sur cet appareil",
sending: "Envoi…",
sent: "Envoyé",
retry: "Réessayer",
@ -411,7 +417,7 @@ const drift = {
noInvite: "Pas encore d’invitation. Le bot approuve généralement en moins d’une minute — appuie ensuite sur « Vérifier l’invitation ».",
pendingRequests: "{{count}} demande(s) d’adhésion en attente",
pendingRequestsDesc: "Le bot approuve généralement automatiquement. S’il est bloqué, ouvre le salon ci-dessous et appuie sur Vérifier les demandes.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
botMissing: "Le bot de la communauté n’est pas dans ce salon pour le moment ; un admin examinera les demandes d’adhésion.",
secondDeviceHint: "Déjà membre sur un autre appareil ? Utilise « Lier cet appareil » pour un transfert direct, ou redemande à rejoindre avec cet appareil.",
create: "Créer le salon de la communauté",
setupDeviceBody: "Cela donne à cet appareil une clé pour le salon de la communauté. Un admin ou le bot l’approuve une fois.",

View file

@ -918,10 +918,10 @@ const batch1 = {
noGroupMember: "Aínda non estás no chat privado desta crew.",
create: "Crear o chat privado",
requestJoin: "Pedir acceso",
requestAgain: "Request again",
requestHandled: "Request handled",
copyCordnLink: "Copy Cordn link",
cordnWebCaveat: "Cordn web may stall after sign-in; the in-app join is more reliable.",
requestAgain: "Solicitar de novo",
requestHandled: "Solicitude xestionada",
copyCordnLink: "Copiar a ligazón de Cordn",
cordnWebCaveat: "Cordn web pode bloquearse despois de iniciar sesión; unirse desde a app é máis fiable.",
checkInvite: "Comprobar se hai invitación",
requested: "Solicitude enviada: un administrador da crew aprobaraa.",
noInvite: "Aínda non hai ningunha invitación. Pídele a un administrador que aprobe a túa solicitude.",
@ -964,7 +964,11 @@ const batch1 = {
onboardingMember3: "Xa estás dentro: as mensaxes van cifradas.",
encryptedNote: "Só os membros da crew poden ler isto; o coordinador só retransmite mensaxes cifradas.",
advanced: "Avanzado",
compareTitle: "Chat vs chat privado",
compareChat: "O chat é un DM de grupo NIP-17 estándar. Funciona con outros clientes Nostr e non ten coordinador.",
comparePrivate: "O chat privado usa MLS: control de membros máis forte e segredo cara adiante. O coordinador ve os metadatos do grupo e o texto cifrado, nunca o texto das mensaxes.",
coordinator: "Coordinador",
lastChecked: "Última comprobación",
keyPackage: "Paquete de chaves",
keyPackageReady: "Listo neste dispositivo",
keyPackageMissing: "Sen configurar neste dispositivo",
@ -988,6 +992,7 @@ const batch1 = {
qrTitle: "Escanea para entrar no chat privado",
qrHint: "Os membros da crew escanéano coa cámara do móbil. A ligazón abre a pestana Chat privado.",
sendFailed: "Non se puido enviar: toca Reintentar.",
queued: "En cola neste dispositivo",
sending: "Enviando…",
sent: "Enviado",
retry: "Reintentar",
@ -1023,7 +1028,7 @@ const batch1 = {
noInvite: "Aínda non hai invitación. O bot adoita aprobar nun minuto; despois toca «Comprobar se hai invitación».",
pendingRequests: "{{count}} solicitudes de entrada en espera",
pendingRequestsDesc: "O bot adoita aprobar automaticamente. Se se atascou, abre a sala de abaixo e toca Comprobar solicitudes.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
botMissing: "O bot da comunidade non está nesta sala agora mesmo, así que un administrador revisará as solicitudes de unión.",
secondDeviceHint: "Xa es membro noutro dispositivo? Usa «Vincular este dispositivo» para un traspaso directo, ou pide entrar de novo con este dispositivo.",
create: "Crear a sala da comunidade",
setupDeviceBody: "Isto dálle a este dispositivo unha chave para a sala da comunidade. Un administrador ou o bot apruxaa unha vez.",

View file

@ -395,6 +395,7 @@ const batch2 = {
newMessage: "Nova mensaxe",
groups: "Grupos",
refresh: "Actualizar",
privateRooms: "Salas privadas",
crews: "Crews",
crewFounder: "Fundador",
crewMember: "Membro",

View file

@ -918,10 +918,10 @@ const batch1 = {
"noGroupMember": "आप अभी इस क्रू की निजी चैट में नहीं हैं।",
"create": "निजी चैट बनाएँ",
"requestJoin": "शामिल होने का अनुरोध करें",
"requestAgain": "Request again",
"requestHandled": "Request handled",
"copyCordnLink": "Copy Cordn link",
"cordnWebCaveat": "Cordn web may stall after sign-in; the in-app join is more reliable.",
"requestAgain": "फिर से अनुरोध करें",
"requestHandled": "अनुरोध संभाला गया",
"copyCordnLink": "Cordn लिंक कॉपी करें",
"cordnWebCaveat": "Cordn वेब साइन-इन के बाद अटक सकता है; ऐप में शामिल होना ज़्यादा भरोसेमंद है।",
"checkInvite": "निमंत्रण जाँचें",
"requested": "अनुरोध भेजा गया — क्रू एडमिन इसे स्वीकृत करेगा।",
"noInvite": "अभी कोई निमंत्रण प्रतीक्षा में नहीं। एडमिन से अपना अनुरोध स्वीकृत करने को कहें।",
@ -964,7 +964,11 @@ const batch1 = {
onboardingMember3: "आप अंदर हैं — संदेश एन्क्रिप्टेड हैं।",
encryptedNote: "इसे केवल क्रू सदस्य ही पढ़ सकते हैं; कोऑर्डिनेटर केवल एन्क्रिप्टेड संदेश आगे भेजता है।",
advanced: "उन्नत",
compareTitle: "चैट बनाम निजी चैट",
compareChat: "चैट एक मानक NIP-17 समूह DM है। यह अन्य Nostr क्लाइंट के साथ काम करता है और इसमें कोई समन्वयक नहीं होता।",
comparePrivate: "निजी चैट MLS का उपयोग करता है: सदस्यों पर मज़बूत नियंत्रण और फ़ॉरवर्ड गोपनीयता। समन्वयक समूह मेटाडेटा और सिफ़रटेक्स्ट देखता है, संदेश का पाठ कभी नहीं।",
coordinator: "कोऑर्डिनेटर",
lastChecked: "अंतिम जाँच",
keyPackage: "कुंजी पैकेज",
keyPackageReady: "इस डिवाइस पर तैयार",
keyPackageMissing: "इस डिवाइस पर सेट नहीं है",
@ -988,6 +992,7 @@ const batch1 = {
qrTitle: "निजी चैट में शामिल होने के लिए स्कैन करें",
qrHint: "क्रू सदस्य इसे फ़ोन कैमरे से स्कैन करते हैं। लिंक निजी चैट टैब खोलता है।",
sendFailed: "भेजा नहीं जा सका — रीट्राई टैप करें।",
queued: "इस डिवाइस पर कतार में",
sending: "भेजा जा रहा है…",
sent: "भेज दिया",
retry: "रीट्राई",
@ -1023,7 +1028,7 @@ const batch1 = {
"noInvite": "अभी कोई निमंत्रण नहीं। बॉट आमतौर पर एक मिनट में मंज़ूरी दे देता है — उसके बाद “निमंत्रण जाँचें” टैप करें।",
"pendingRequests": "{{count}} शामिल होने के अनुरोध इंतज़ार में",
"pendingRequestsDesc": "बॉट आमतौर पर अपने आप मंज़ूरी देता है। अगर अटका हुआ है, तो नीचे रूम खोलें और “अनुरोध जाँचें” टैप करें।",
"botMissing": "The community bot isn’t in this room right now, so an admin will review join requests.",
"botMissing": "कम्युनिटी बॉट अभी इस रूम में नहीं है, इसलिए एक एडमिन शामिल होने के अनुरोधों की समीक्षा करेगा।",
"secondDeviceHint": "क्या आप पहले से किसी दूसरे डिवाइस पर सदस्य हैं? सीधे हैंडऑफ़ के लिए “इस डिवाइस को लिंक करें” का उपयोग करें, या इस डिवाइस से दोबारा जुड़ने का अनुरोध करें।",
"create": "कम्युनिटी रूम बनाएँ",
"setupDeviceBody": "इससे इस डिवाइस को कम्युनिटी रूम की एक कुंजी मिलती है। कोई एडमिन या बॉट इसे एक बार मंज़ूर करता है।",

View file

@ -395,6 +395,7 @@ const batch2 = {
"newMessage": "नया संदेश",
"groups": "ग्रुप",
"refresh": "रीफ़्रेश",
"privateRooms": "निजी कमरे",
"crews": "क्रू",
"crewFounder": "संस्थापक",
"crewMember": "सदस्य",

View file

@ -78,6 +78,7 @@ const driftStrings = {
},
dms: {
refresh: 'Muat ulang',
privateRooms: 'Ruang privat',
crews: 'Crew',
crewFounder: 'Pendiri',
crewMember: 'Anggota',
@ -303,10 +304,10 @@ const driftStrings = {
noGroupMember: 'Kamu belum ada di chat privat crew ini.',
create: 'Buat chat privat',
requestJoin: 'Minta gabung',
requestAgain: "Request again",
requestHandled: "Request handled",
copyCordnLink: "Copy Cordn link",
cordnWebCaveat: "Cordn web may stall after sign-in; the in-app join is more reliable.",
requestAgain: "Minta lagi",
requestHandled: "Permintaan ditangani",
copyCordnLink: "Salin tautan Cordn",
cordnWebCaveat: "Cordn web bisa macet setelah masuk; bergabung lewat aplikasi lebih andal.",
checkInvite: 'Periksa undangan',
requested: 'Permintaan terkirim — admin crew akan menyetujuinya.',
noInvite: 'Belum ada undangan menunggu. Minta admin menyetujui permintaanmu.',
@ -346,7 +347,11 @@ const driftStrings = {
onboardingMember3: "Kamu sudah masuk — pesan terenkripsi.",
encryptedNote: "Hanya anggota crew yang bisa membacanya; koordinator hanya meneruskan pesan terenkripsi.",
advanced: "Lanjutan",
compareTitle: "Chat vs chat privat",
compareChat: "Chat adalah DM grup NIP-17 standar. Bekerja dengan klien Nostr lain dan tidak memiliki koordinator.",
comparePrivate: "Chat privat menggunakan MLS: kontrol keanggotaan lebih kuat dan kerahasiaan ke depan. Koordinator melihat metadata grup dan ciphertext, bukan teks pesan.",
coordinator: "Koordinator",
lastChecked: "Terakhir diperiksa",
keyPackage: "Paket kunci",
keyPackageReady: "Siap di perangkat ini",
keyPackageMissing: "Belum disiapkan di perangkat ini",
@ -370,6 +375,7 @@ const driftStrings = {
qrTitle: "Pindai untuk bergabung ke chat privat",
qrHint: "Anggota crew memindai ini dengan kamera ponsel. Tautan membuka tab Chat privat.",
sendFailed: "Gagal mengirim — ketuk Coba lagi.",
queued: "Dalam antrean di perangkat ini",
sending: "Mengirim…",
sent: "Terkirim",
retry: "Coba lagi",
@ -405,7 +411,7 @@ const driftStrings = {
noInvite: "Belum ada undangan. Bot biasanya menyetujui dalam satu menit — ketuk “Periksa undangan” setelahnya.",
pendingRequests: "{{count}} permintaan bergabung menunggu",
pendingRequestsDesc: "Bot biasanya menyetujui secara otomatis. Jika macet, buka ruang di bawah dan ketuk Periksa permintaan.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
botMissing: "Bot komunitas sedang tidak ada di ruangan ini, jadi admin akan meninjau permintaan bergabung.",
secondDeviceHint: "Sudah menjadi anggota di perangkat lain? Gunakan “Tautkan perangkat ini” untuk serah terima langsung, atau minta bergabung lagi dengan perangkat ini.",
create: "Buat ruang komunitas",
setupDeviceBody: "Ini memberi perangkat ini kunci untuk ruang komunitas. Admin atau bot menyetujuinya satu kali.",

View file

@ -84,6 +84,7 @@ const drift = {
},
dms: {
refresh: "Aggiorna",
privateRooms: "Stanze private",
crews: "Crew",
crewFounder: "Fondatore",
crewMember: "Membro",
@ -309,10 +310,10 @@ const drift = {
noGroupMember: "Non sei ancora nella chat privata di questa crew.",
create: "Crea la chat privata",
requestJoin: "Chiedi di unirti",
requestAgain: "Request again",
requestHandled: "Request handled",
copyCordnLink: "Copy Cordn link",
cordnWebCaveat: "Cordn web may stall after sign-in; the in-app join is more reliable.",
requestAgain: "Richiedi di nuovo",
requestHandled: "Richiesta gestita",
copyCordnLink: "Copia link Cordn",
cordnWebCaveat: "Cordn web può bloccarsi dopo l’accesso; l’adesione dall’app è più affidabile.",
checkInvite: "Controlla l'invito",
requested: "Richiesta inviata — un admin della crew la approverà.",
noInvite: "Nessun invito in attesa. Chiedi a un admin di approvare la tua richiesta.",
@ -352,7 +353,11 @@ const drift = {
onboardingMember3: "Sei dentro — i messaggi sono cifrati.",
encryptedNote: "Solo i membri della crew possono leggerlo; il coordinatore inoltra solo messaggi cifrati.",
advanced: "Avanzate",
compareTitle: "Chat vs chat privata",
compareChat: "La chat è un DM di gruppo NIP-17 standard. Funziona con altri client Nostr e non ha un coordinatore.",
comparePrivate: "La chat privata usa MLS: controllo dei membri più forte e segretezza in avanti. Il coordinatore vede i metadati del gruppo e il testo cifrato, mai il testo dei messaggi.",
coordinator: "Coordinatore",
lastChecked: "Ultimo controllo",
keyPackage: "Pacchetto di chiavi",
keyPackageReady: "Pronto su questo dispositivo",
keyPackageMissing: "Non configurato su questo dispositivo",
@ -376,6 +381,7 @@ const drift = {
qrTitle: "Scansiona per unirti alla chat privata",
qrHint: "I membri della crew lo scansionano con la fotocamera del telefono. Il link apre la scheda Chat privata.",
sendFailed: "Invio non riuscito — tocca Riprova.",
queued: "In coda su questo dispositivo",
sending: "Invio…",
sent: "Inviato",
retry: "Riprova",
@ -411,7 +417,7 @@ const drift = {
noInvite: "Nessun invito ancora. Il bot di solito approva entro un minuto — tocca «Controlla l’invito» dopo.",
pendingRequests: "{{count}} richieste di adesione in attesa",
pendingRequestsDesc: "Il bot di solito approva automaticamente. Se è bloccato, apri la stanza qui sotto e tocca Controlla le richieste.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
botMissing: "Il bot della community non è in questa stanza al momento, quindi un admin esaminerà le richieste di accesso.",
secondDeviceHint: "Sei già membro su un altro dispositivo? Usa «Collega questo dispositivo» per un passaggio diretto, oppure chiedi di nuovo di entrare con questo dispositivo.",
create: "Crea la stanza della community",
setupDeviceBody: "Questo assegna a questo dispositivo una chiave per la stanza della community. Un admin o il bot la approva una volta.",

View file

@ -94,6 +94,7 @@ const drift: Dict = {
},
dms: {
refresh: '更新',
privateRooms: 'プライベートルーム',
crews: 'クルー',
crewFounder: '創設者',
crewMember: 'メンバー',
@ -319,10 +320,10 @@ const drift: Dict = {
noGroupMember: 'あなたはまだこのクルーのプライベートチャットに参加していません。',
create: 'プライベートチャットを作成',
requestJoin: '参加をリクエスト',
requestAgain: "Request again",
requestHandled: "Request handled",
copyCordnLink: "Copy Cordn link",
cordnWebCaveat: "Cordn web may stall after sign-in; the in-app join is more reliable.",
requestAgain: "もう一度リクエスト",
requestHandled: "リクエストを処理しました",
copyCordnLink: "Cordnリンクをコピー",
cordnWebCaveat: "Cordn webはサインイン後に止まることがあります。アプリ内からの参加がより確実です。",
checkInvite: '招待を確認',
requested: 'リクエストを送信しました — クルー管理者が承認します。',
noInvite: 'まだ招待はありません。管理者にリクエストの承認を依頼してください。',
@ -362,7 +363,11 @@ const drift: Dict = {
onboardingMember3: "参加しました — メッセージは暗号化されています。",
encryptedNote: "読めるのはクルーメンバーだけです。コーディネーターは暗号化されたメッセージを中継するだけです。",
advanced: "詳細設定",
compareTitle: "チャットとプライベートチャット",
compareChat: "チャットは標準的な NIP-17 のグループDMです。他の Nostr クライアントとも動作し、コーディネーターは存在しません。",
comparePrivate: "プライベートチャットは MLS を使用し、メンバー管理がより強固で、前方秘匿性を備えています。コーディネーターはグループのメタデータと暗号文を目にしますが、メッセージ本文を見ることはありません。",
coordinator: "コーディネーター",
lastChecked: "最終確認",
keyPackage: "キーパッケージ",
keyPackageReady: "このデバイスで準備完了",
keyPackageMissing: "このデバイスでは未設定",
@ -386,6 +391,7 @@ const drift: Dict = {
qrTitle: "スキャンしてプライベートチャットに参加",
qrHint: "クルーメンバーがスマートフォンのカメラでスキャンします。リンクからプライベートチャットタブが開きます。",
sendFailed: "送信できませんでした — 「再試行」をタップしてください。",
queued: "このデバイスで待機中",
sending: "送信中…",
sent: "送信済み",
retry: "再試行",
@ -421,7 +427,7 @@ const drift: Dict = {
noInvite: "まだ招待がありません。ボットは通常1分以内に承認します。その後で「招待を確認」をタップしてください。",
pendingRequests: "参加リクエスト {{count}} 件が待機中",
pendingRequestsDesc: "ボットは通常自動で承認します。止まっている場合は、下のルームを開いて「リクエストを確認」をタップしてください。",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
botMissing: "コミュニティボットが現在このルームにいないため、管理者が参加リクエストを確認します。",
secondDeviceHint: "別のデバイスで既にメンバーですか?直接引き継ぐには「このデバイスをリンク」を使うか、このデバイスで改めて参加をリクエストしてください。",
create: "コミュニティルームを作成",
setupDeviceBody: "このデバイスにコミュニティルームの鍵を付与します。管理者またはボットが一度だけ承認します。",

View file

@ -94,6 +94,7 @@ const drift: Dict = {
},
dms: {
refresh: '새로고침',
privateRooms: '비공개 방',
crews: '크루',
crewFounder: '창립자',
crewMember: '멤버',
@ -319,10 +320,10 @@ const drift: Dict = {
noGroupMember: '아직 이 크루의 비공개 채팅에 참여하지 않았습니다.',
create: '비공개 채팅 만들기',
requestJoin: '참여 요청',
requestAgain: "Request again",
requestHandled: "Request handled",
copyCordnLink: "Copy Cordn link",
cordnWebCaveat: "Cordn web may stall after sign-in; the in-app join is more reliable.",
requestAgain: "다시 요청",
requestHandled: "요청 처리됨",
copyCordnLink: "Cordn 링크 복사",
cordnWebCaveat: "Cordn 웹은 로그인 후 멈출 수 있습니다. 앱에서 참여하는 편이 더 안정적입니다.",
checkInvite: '초대 확인',
requested: '요청을 보냈습니다 — 크루 관리자가 승인합니다.',
noInvite: '아직 대기 중인 초대가 없습니다. 관리자에게 요청 승인을 부탁하세요.',
@ -362,7 +363,11 @@ const drift: Dict = {
onboardingMember3: "참여했습니다 — 메시지는 암호화됩니다.",
encryptedNote: "크루 멤버만 읽을 수 있으며, 코디네이터는 암호화된 메시지만 중계합니다.",
advanced: "고급",
compareTitle: "채팅 vs 비공개 채팅",
compareChat: "채팅은 표준 NIP-17 그룹 DM입니다. 다른 Nostr 클라이언트와 호환되며 코디네이터가 없습니다.",
comparePrivate: "비공개 채팅은 MLS를 사용하여 멤버 관리가 더 강력하고 전방 비밀성이 보장됩니다. 코디네이터는 그룹 메타데이터와 암호문만 볼 수 있으며 메시지 본문은 절대 보지 못합니다.",
coordinator: "코디네이터",
lastChecked: "마지막 확인",
keyPackage: "키 패키지",
keyPackageReady: "이 기기에서 준비됨",
keyPackageMissing: "이 기기에서 설정되지 않음",
@ -386,6 +391,7 @@ const drift: Dict = {
qrTitle: "스캔하여 비공개 채팅 참여",
qrHint: "크루 멤버가 휴대폰 카메라로 스캔합니다. 링크를 열면 비공개 채팅 탭으로 이동합니다.",
sendFailed: "보내지 못했습니다 — 다시 시도를 누르세요.",
queued: "이 기기에서 대기 중",
sending: "보내는 중…",
sent: "보냄",
retry: "다시 시도",
@ -421,7 +427,7 @@ const drift: Dict = {
noInvite: "아직 초대가 없습니다. 봇은 보통 1분 이내에 승인합니다. 그 후 “초대 확인”을 탭하세요.",
pendingRequests: "참여 요청 {{count}}개 대기 중",
pendingRequestsDesc: "봇은 보통 자동으로 승인합니다. 멈춰 있으면 아래 방을 열고 요청 확인을 탭하세요.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
botMissing: "커뮤니티 봇이 현재 이 방에 없어서 관리자가 참여 요청을 검토합니다.",
secondDeviceHint: "다른 기기에서 이미 멤버인가요? 직접 인계하려면 “이 기기 연결”을 사용하거나, 이 기기로 다시 참여를 요청하세요.",
create: "커뮤니티 방 만들기",
setupDeviceBody: "이 기기에 커뮤니티 방 열쇠를 부여합니다. 관리자나 봇이 한 번 승인합니다.",

View file

@ -66,7 +66,11 @@ const cordnUi = {
encryptedNote:
'Only crew members can read this; the coordinator only relays encrypted messages.',
advanced: 'Advanced',
compareTitle: 'Chat vs Private chat',
compareChat: 'Chat is a standard NIP-17 group DM. It works with other Nostr clients and has no coordinator.',
comparePrivate: 'Private chat uses MLS: stronger membership control and forward secrecy. The coordinator sees group metadata and ciphertext, never message text.',
coordinator: 'Coordinator',
lastChecked: 'Last checked',
keyPackage: 'Key package',
keyPackageReady: 'Ready on this device',
keyPackageMissing: 'Not set up on this device',
@ -91,6 +95,7 @@ const cordnUi = {
qrHint:
'Crew members scan this with their phone camera. The link opens the Private chat tab.',
sendFailed: 'Couldn’t send — tap Retry.',
queued: 'Queued on this device',
sending: 'Sending…',
sent: 'Sent',
retry: 'Retry',
@ -174,7 +179,11 @@ const cordnUi = {
encryptedNote:
'Solo los miembros de la peña pueden leerlo; el coordinador solo retransmite mensajes cifrados.',
advanced: 'Avanzado',
compareTitle: 'Chat vs chat privado',
compareChat: 'El chat es un DM grupal NIP-17 estándar. Funciona con otros clientes Nostr y no usa coordinador.',
comparePrivate: 'El chat privado usa MLS: más control de miembros y secreto futuro. El coordinador ve metadatos del grupo y texto cifrado, nunca el contenido.',
coordinator: 'Coordinador',
lastChecked: 'Última comprobación',
keyPackage: 'Paquete de claves',
keyPackageReady: 'Listo en este dispositivo',
keyPackageMissing: 'Sin configurar en este dispositivo',
@ -199,6 +208,7 @@ const cordnUi = {
qrHint:
'Los miembros de la peña pueden escanearlo con la cámara del móvil. El enlace abre la pestaña del chat privado.',
sendFailed: 'No se pudo enviar: pulsa Reintentar.',
queued: 'En cola en este dispositivo',
sending: 'Enviando…',
sent: 'Enviado',
retry: 'Reintentar',

View file

@ -82,6 +82,7 @@ const drift = {
},
"dms": {
"refresh": "Vernieuwen",
"privateRooms": "Privéruimtes",
"crews": "Crews",
"crewFounder": "Oprichter",
"crewMember": "Lid",
@ -307,10 +308,10 @@ const drift = {
"noGroupMember": "Je zit nog niet in de privéchat van deze crew.",
"create": "De privéchat aanmaken",
"requestJoin": "Vragen om deel te nemen",
"requestAgain": "Request again",
"requestHandled": "Request handled",
"copyCordnLink": "Copy Cordn link",
"cordnWebCaveat": "Cordn web may stall after sign-in; the in-app join is more reliable.",
"requestAgain": "Opnieuw aanvragen",
"requestHandled": "Aanvraag afgehandeld",
"copyCordnLink": "Cordn-link kopiëren",
"cordnWebCaveat": "Cordn web kan na inloggen vastlopen; deelnemen in de app is betrouwbaarder.",
"checkInvite": "Op uitnodiging controleren",
"requested": "Verzoek verzonden — een crew-admin keurt het goed.",
"noInvite": "Nog geen uitnodiging. Vraag een admin je verzoek goed te keuren.",
@ -350,7 +351,11 @@ const drift = {
onboardingMember3: "Je doet mee — berichten zijn versleuteld.",
encryptedNote: "Alleen crewleden kunnen dit lezen; de coördinator geeft alleen versleutelde berichten door.",
advanced: "Geavanceerd",
compareTitle: "Chat vs. privéchat",
compareChat: "Chat is een standaard NIP-17-groeps-DM. Het werkt met andere Nostr-clients en heeft geen coördinator.",
comparePrivate: "Privéchat gebruikt MLS: sterkere ledenbeheer en forward secrecy. De coördinator ziet groepsmetadata en versleutelde tekst, nooit de berichttekst.",
coordinator: "Coördinator",
lastChecked: "Laatst gecontroleerd",
keyPackage: "Sleutelpakket",
keyPackageReady: "Klaar op dit apparaat",
keyPackageMissing: "Niet ingesteld op dit apparaat",
@ -374,6 +379,7 @@ const drift = {
qrTitle: "Scan om deel te nemen aan de privéchat",
qrHint: "Crewleden scannen dit met de camera van hun telefoon. De link opent het tabblad Privéchat.",
sendFailed: "Verzenden mislukt — tik op Opnieuw.",
queued: "In wachtrij op dit apparaat",
sending: "Verzenden…",
sent: "Verzonden",
retry: "Opnieuw",
@ -409,7 +415,7 @@ const drift = {
"noInvite": "Nog geen uitnodiging. De bot keurt meestal binnen een minuut goed — tik daarna op ‘Op uitnodiging controleren’.",
"pendingRequests": "{{count}} openstaande verzoeken om deel te nemen",
"pendingRequestsDesc": "De bot keurt meestal automatisch goed. Als het vastloopt, open dan hieronder de ruimte en tik op Verzoeken controleren.",
"botMissing": "The community bot isn’t in this room right now, so an admin will review join requests.",
"botMissing": "De communitybot is nu niet in deze kamer, dus een beheerder bekijkt de deelnameverzoeken.",
"secondDeviceHint": "Al lid op een ander apparaat? Gebruik ‘Dit apparaat koppelen’ voor een directe overdracht, of vraag opnieuw om lid te worden met dit apparaat.",
"create": "Communityruimte aanmaken",
"setupDeviceBody": "Dit geeft dit apparaat een sleutel voor de communityruimte. Een beheerder of de bot keurt die één keer goed.",

View file

@ -82,6 +82,7 @@ const drift = {
},
"dms": {
"refresh": "Odśwież",
"privateRooms": "Pokoje prywatne",
"crews": "Crewy",
"crewFounder": "Założyciel",
"crewMember": "Członek",
@ -307,10 +308,10 @@ const drift = {
"noGroupMember": "Nie należysz jeszcze do prywatnego czatu tego crew.",
"create": "Utwórz prywatny czat",
"requestJoin": "Poproś o dołączenie",
"requestAgain": "Request again",
"requestHandled": "Request handled",
"copyCordnLink": "Copy Cordn link",
"cordnWebCaveat": "Cordn web may stall after sign-in; the in-app join is more reliable.",
"requestAgain": "Poproś ponownie",
"requestHandled": "Prośba obsłużona",
"copyCordnLink": "Kopiuj link Cordn",
"cordnWebCaveat": "Cordn web może się zawiesić po zalogowaniu; dołączanie w aplikacji jest pewniejsze.",
"checkInvite": "Sprawdź zaproszenie",
"requested": "Prośba wysłana — administrator crew ją zatwierdzi.",
"noInvite": "Brak oczekującego zaproszenia. Poproś administratora o zatwierdzenie prośby.",
@ -350,7 +351,11 @@ const drift = {
onboardingMember3: "Jesteś w środku — wiadomości są szyfrowane.",
encryptedNote: "Mogą to czytać tylko członkowie crew; koordynator przekazuje wyłącznie zaszyfrowane wiadomości.",
advanced: "Zaawansowane",
compareTitle: "Czat a czat prywatny",
compareChat: "Czat to standardowa grupowa wiadomość DM w NIP-17. Działa z innymi klientami Nostr i nie ma koordynatora.",
comparePrivate: "Czat prywatny korzysta z MLS: silniejsza kontrola członkostwa i tajność forward. Koordynator widzi metadane grupy i szyfrogram, nigdy treść wiadomości.",
coordinator: "Koordynator",
lastChecked: "Ostatnio sprawdzono",
keyPackage: "Pakiet kluczy",
keyPackageReady: "Gotowe na tym urządzeniu",
keyPackageMissing: "Nieskonfigurowane na tym urządzeniu",
@ -374,6 +379,7 @@ const drift = {
qrTitle: "Zeskanuj, aby dołączyć do prywatnego czatu",
qrHint: "Członkowie crew skanują to aparatem telefonu. Link otwiera kartę Prywatny czat.",
sendFailed: "Nie udało się wysłać — dotknij Ponów.",
queued: "W kolejce na tym urządzeniu",
sending: "Wysyłanie…",
sent: "Wysłano",
retry: "Ponów",
@ -409,7 +415,7 @@ const drift = {
"noInvite": "Brak zaproszenia. Bot zwykle zatwierdza w ciągu minuty — potem dotknij „Sprawdź zaproszenie”.",
"pendingRequests": "{{count}} oczekujących próśb o dołączenie",
"pendingRequestsDesc": "Bot zwykle zatwierdza automatycznie. Jeśli utknął, otwórz pokój poniżej i dotknij Sprawdź prośby.",
"botMissing": "The community bot isn’t in this room right now, so an admin will review join requests.",
"botMissing": "Bota społeczności nie ma teraz w tym pokoju, więc prośby o dołączenie sprawdzi administrator.",
"secondDeviceHint": "Masz już konto na innym urządzeniu? Użyj „Połącz to urządzenie”, aby przenieść je bezpośrednio, albo poproś ponownie o dołączenie z tego urządzenia.",
"create": "Utwórz pokój społeczności",
"setupDeviceBody": "To daje temu urządzeniu klucz do pokoju społeczności. Administrator lub bot zatwierdza go jednorazowo.",

View file

@ -84,6 +84,7 @@ const drift = {
},
dms: {
refresh: "Atualizar",
privateRooms: "Salas privadas",
crews: "Crews",
crewFounder: "Fundador",
crewMember: "Membro",
@ -309,10 +310,10 @@ const drift = {
noGroupMember: "Você ainda não está no chat privado desta crew.",
create: "Criar o chat privado",
requestJoin: "Pedir para entrar",
requestAgain: "Request again",
requestHandled: "Request handled",
copyCordnLink: "Copy Cordn link",
cordnWebCaveat: "Cordn web may stall after sign-in; the in-app join is more reliable.",
requestAgain: "Pedir novamente",
requestHandled: "Pedido tratado",
copyCordnLink: "Copiar link do Cordn",
cordnWebCaveat: "O Cordn web pode travar após o login; entrar pelo app é mais confiável.",
checkInvite: "Verificar convite",
requested: "Solicitação enviada — um admin da crew vai aprovar.",
noInvite: "Nenhum convite aguardando. Peça a um admin para aprovar sua solicitação.",
@ -352,7 +353,11 @@ const drift = {
onboardingMember3: "Você entrou — as mensagens são criptografadas.",
encryptedNote: "Apenas membros da crew podem ler isto; o coordenador só repassa mensagens criptografadas.",
advanced: "Avançado",
compareTitle: "Chat vs chat privado",
compareChat: "O chat é um DM de grupo NIP-17 padrão. Funciona com outros clientes Nostr e não tem coordenador.",
comparePrivate: "O chat privado usa MLS: controle de membros mais forte e sigilo encaminhado. O coordenador vê metadados do grupo e texto cifrado, nunca o texto das mensagens.",
coordinator: "Coordenador",
lastChecked: "Última verificação",
keyPackage: "Pacote de chaves",
keyPackageReady: "Pronto neste dispositivo",
keyPackageMissing: "Não configurado neste dispositivo",
@ -376,6 +381,7 @@ const drift = {
qrTitle: "Escaneie para entrar no chat privado",
qrHint: "Os membros da crew escaneiam com a câmera do celular. O link abre a aba Chat privado.",
sendFailed: "Não foi possível enviar — toque em Tentar novamente.",
queued: "Na fila neste dispositivo",
sending: "Enviando…",
sent: "Enviado",
retry: "Tentar novamente",
@ -411,7 +417,7 @@ const drift = {
noInvite: "Nenhum convite ainda. O bot costuma aprovar em até um minuto — depois toque em “Verificar convite”.",
pendingRequests: "{{count}} pedido(s) de entrada aguardando",
pendingRequestsDesc: "O bot costuma aprovar automaticamente. Se travou, abra a sala abaixo e toque em Verificar pedidos.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
botMissing: "O bot da comunidade não está nesta sala agora, então um admin analisará os pedidos de entrada.",
secondDeviceHint: "Já é membro em outro dispositivo? Use “Vincular este dispositivo” para uma transferência direta, ou peça para entrar de novo com este dispositivo.",
create: "Criar a sala da comunidade",
setupDeviceBody: "Isso dá a este dispositivo uma chave para a sala da comunidade. Um admin ou o bot aprova uma vez.",

View file

@ -82,6 +82,7 @@ const drift = {
},
"dms": {
"refresh": "Reîmprospătează",
"privateRooms": "Camere private",
"crews": "Crews",
"crewFounder": "Fondator",
"crewMember": "Membru",
@ -307,10 +308,10 @@ const drift = {
"noGroupMember": "Nu ești încă în chatul privat al acestui crew.",
"create": "Creează chatul privat",
"requestJoin": "Cere să te alături",
"requestAgain": "Request again",
"requestHandled": "Request handled",
"copyCordnLink": "Copy Cordn link",
"cordnWebCaveat": "Cordn web may stall after sign-in; the in-app join is more reliable.",
"requestAgain": "Solicită din nou",
"requestHandled": "Cerere soluționată",
"copyCordnLink": "Copiază linkul Cordn",
"cordnWebCaveat": "Cordn web se poate bloca după autentificare; alăturarea din aplicație este mai sigură.",
"checkInvite": "Verifică invitația",
"requested": "Cerere trimisă — un admin de crew o va aproba.",
"noInvite": "Nicio invitație în așteptare încă. Cere unui admin să aprobe cererea ta.",
@ -350,7 +351,11 @@ const drift = {
onboardingMember3: "Ai intrat — mesajele sunt criptate.",
encryptedNote: "Doar membrii crew-ului pot citi asta; coordonatorul doar retransmite mesaje criptate.",
advanced: "Avansat",
compareTitle: "Chat vs chat privat",
compareChat: "Chatul este un DM de grup NIP-17 standard. Funcționează cu alți clienți Nostr și nu are coordonator.",
comparePrivate: "Chatul privat folosește MLS: control mai strict al membrilor și secretizare forward. Coordonatorul vede metadatele grupului și textul cifrat, niciodată textul mesajelor.",
coordinator: "Coordonator",
lastChecked: "Ultima verificare",
keyPackage: "Pachet de chei",
keyPackageReady: "Pregătit pe acest dispozitiv",
keyPackageMissing: "Neconfigurat pe acest dispozitiv",
@ -374,6 +379,7 @@ const drift = {
qrTitle: "Scanează pentru a intra în chatul privat",
qrHint: "Membrii crew-ului scanează cu camera telefonului. Linkul deschide fila Chat privat.",
sendFailed: "Trimiterea a eșuat — apasă Reîncearcă.",
queued: "În coadă pe acest dispozitiv",
sending: "Se trimite…",
sent: "Trimis",
retry: "Reîncearcă",
@ -409,7 +415,7 @@ const drift = {
"noInvite": "Încă nicio invitație. Botul aprobă de obicei într-un minut — apoi apasă „Verifică invitația”.",
"pendingRequests": "{{count}} cereri de alăturare în așteptare",
"pendingRequestsDesc": "Botul aprobă de obicei automat. Dacă s-a blocat, deschide camera de mai jos și apasă Verifică cererile.",
"botMissing": "The community bot isn’t in this room right now, so an admin will review join requests.",
"botMissing": "Botul comunității nu este în această cameră acum, așa că un administrator va examina cererile de alăturare.",
"secondDeviceHint": "Ești deja membru pe alt dispozitiv? Folosește „Conectează acest dispozitiv” pentru un transfer direct sau cere din nou să te alături cu acest dispozitiv.",
"create": "Creează camera comunității",
"setupDeviceBody": "Aceasta oferă acestui dispozitiv o cheie pentru camera comunității. Un administrator sau botul o aprobă o dată.",

View file

@ -86,6 +86,7 @@ const drift = {
},
dms: {
refresh: "Обновить",
privateRooms: "Приватные комнаты",
crews: "Crew",
crewFounder: "Основатель",
crewMember: "Участник",
@ -311,10 +312,10 @@ const drift = {
noGroupMember: "Вы ещё не в приватном чате этого Crew.",
create: "Создать приватный чат",
requestJoin: "Попросить присоединиться",
requestAgain: "Request again",
requestHandled: "Request handled",
copyCordnLink: "Copy Cordn link",
cordnWebCaveat: "Cordn web may stall after sign-in; the in-app join is more reliable.",
requestAgain: "Запросить снова",
requestHandled: "Заявка обработана",
copyCordnLink: "Копировать ссылку Cordn",
cordnWebCaveat: "Cordn web может зависнуть после входа; присоединение в приложении надёжнее.",
checkInvite: "Проверить приглашение",
requested: "Запрос отправлен — администратор Crew одобрит его.",
noInvite: "Приглашение пока не ждёт. Попросите администратора одобрить ваш запрос.",
@ -354,7 +355,11 @@ const drift = {
onboardingMember3: "Вы внутри — сообщения зашифрованы.",
encryptedNote: "Читать могут только участники Crew; координатор лишь передаёт зашифрованные сообщения.",
advanced: "Дополнительно",
compareTitle: "Чат и приватный чат",
compareChat: "Чат — это стандартный групповой DM по NIP-17. Он работает с другими клиентами Nostr и не требует координатора.",
comparePrivate: "Приватный чат использует MLS: более строгий контроль участников и прямая секретность. Координатор видит метаданные группы и шифротекст, но никогда — текст сообщений.",
coordinator: "Координатор",
lastChecked: "Последняя проверка",
keyPackage: "Пакет ключей",
keyPackageReady: "Готово на этом устройстве",
keyPackageMissing: "Не настроено на этом устройстве",
@ -378,6 +383,7 @@ const drift = {
qrTitle: "Отсканируйте, чтобы войти в приватный чат",
qrHint: "Участники Crew сканируют код камерой телефона. Ссылка открывает вкладку «Приватный чат».",
sendFailed: "Не удалось отправить — нажмите «Повторить».",
queued: "В очереди на этом устройстве",
sending: "Отправка…",
sent: "Отправлено",
retry: "Повторить",
@ -413,7 +419,7 @@ const drift = {
noInvite: "Приглашения пока нет. Бот обычно одобряет в течение минуты — после этого нажми «Проверить приглашение».",
pendingRequests: "{{count}} запросов на вступление в ожидании",
pendingRequestsDesc: "Бот обычно одобряет автоматически. Если застряло, открой комнату ниже и нажми «Проверить запросы».",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
botMissing: "Бота сообщества сейчас нет в этой комнате, поэтому заявки на вступление рассмотрит администратор.",
secondDeviceHint: "Уже участник на другом устройстве? Используй «Привязать это устройство» для прямой передачи или попроси снова присоединиться с этого устройства.",
create: "Создать комнату сообщества",
setupDeviceBody: "Это даёт устройству ключ от комнаты сообщества. Администратор или бот подтверждает его один раз.",

View file

@ -86,6 +86,7 @@ const drift = {
},
dms: {
refresh: "Uppdatera",
privateRooms: "Privata rum",
crews: "Crews",
crewFounder: "Grundare",
crewMember: "Medlem",
@ -311,10 +312,10 @@ const drift = {
noGroupMember: "Du är inte med i det här crewets privata chatt än.",
create: "Skapa den privata chatten",
requestJoin: "Be om att gå med",
requestAgain: "Request again",
requestHandled: "Request handled",
copyCordnLink: "Copy Cordn link",
cordnWebCaveat: "Cordn web may stall after sign-in; the in-app join is more reliable.",
requestAgain: "Begär igen",
requestHandled: "Förfrågan hanterad",
copyCordnLink: "Kopiera Cordn-länk",
cordnWebCaveat: "Cordn web kan hänga sig efter inloggning; att gå med i appen är mer tillförlitligt.",
checkInvite: "Kontrollera inbjudan",
requested: "Begäran skickad — en crewadmin godkänner den.",
noInvite: "Ingen inbjudan väntar än. Be en admin godkänna din begäran.",
@ -354,7 +355,11 @@ const drift = {
onboardingMember3: "Du är med — meddelandena är krypterade.",
encryptedNote: "Bara crewmedlemmar kan läsa det här; koordinatorn vidarebefordrar bara krypterade meddelanden.",
advanced: "Avancerat",
compareTitle: "Chatt vs privat chatt",
compareChat: "Chatt är en vanlig NIP-17-grupp-DM. Den fungerar med andra Nostr-klienter och har ingen koordinator.",
comparePrivate: "Privat chatt använder MLS: starkare medlemskontroll och forward secrecy. Koordinatorn ser gruppmetadata och chiffertext, aldrig meddelandetexten.",
coordinator: "Koordinator",
lastChecked: "Senast kontrollerad",
keyPackage: "Nyckelpaket",
keyPackageReady: "Redo på den här enheten",
keyPackageMissing: "Inte inställt på den här enheten",
@ -378,6 +383,7 @@ const drift = {
qrTitle: "Skanna för att gå med i den privata chatten",
qrHint: "Crewmedlemmar skannar det här med telefonkameran. Länken öppnar fliken Privat chatt.",
sendFailed: "Kunde inte skicka — tryck på Försök igen.",
queued: "I kö på den här enheten",
sending: "Skickar…",
sent: "Skickat",
retry: "Försök igen",
@ -413,7 +419,7 @@ const drift = {
noInvite: "Ingen inbjudan än. Boten godkänner oftast inom en minut — tryck på ”Kontrollera inbjudan” efter det.",
pendingRequests: "{{count}} ansökningar om att gå med väntar",
pendingRequestsDesc: "Boten godkänner oftast automatiskt. Om det har fastnat, öppna rummet nedan och tryck på Kontrollera begäranden.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
botMissing: "Communityboten är inte i det här rummet just nu, så en admin granskar ansökningarna om att gå med.",
secondDeviceHint: "Redan medlem på en annan enhet? Använd ”Länka den här enheten” för en direkt överlämning, eller be att få gå med igen med den här enheten.",
create: "Skapa communityrummet",
setupDeviceBody: "Detta ger enheten en nyckel till communityrummet. En admin eller boten godkänner den en gång.",

View file

@ -78,6 +78,7 @@ const driftStrings = {
},
dms: {
refresh: 'รีเฟรช',
privateRooms: 'ห้องส่วนตัว',
crews: 'Crews',
crewFounder: 'ผู้ก่อตั้ง',
crewMember: 'สมาชิก',
@ -303,10 +304,10 @@ const driftStrings = {
noGroupMember: 'คุณยังไม่ได้อยู่ในแชทส่วนตัวของ crew นี้',
create: 'สร้างแชทส่วนตัว',
requestJoin: 'ขอเข้าร่วม',
requestAgain: "Request again",
requestHandled: "Request handled",
copyCordnLink: "Copy Cordn link",
cordnWebCaveat: "Cordn web may stall after sign-in; the in-app join is more reliable.",
requestAgain: "ขออีกครั้ง",
requestHandled: "จัดการคำขอแล้ว",
copyCordnLink: "คัดลอกลิงก์ Cordn",
cordnWebCaveat: "Cordn web อาจค้างหลังเข้าสู่ระบบ การเข้าร่วมในแอปเชื่อถือได้มากกว่า",
checkInvite: 'ตรวจสอบคำเชิญ',
requested: 'ส่งคำขอแล้ว — แอดมิน crew จะอนุมัติ',
noInvite: 'ยังไม่มีคำเชิญที่รออยู่ ขอให้แอดมินอนุมัติคำขอของคุณ',
@ -346,7 +347,11 @@ const driftStrings = {
onboardingMember3: "คุณเข้าร่วมแล้ว — ข้อความถูกเข้ารหัส",
encryptedNote: "เฉพาะสมาชิก crew เท่านั้นที่อ่านได้ coordinator เพียงส่งต่อข้อความที่เข้ารหัส",
advanced: "ขั้นสูง",
compareTitle: "แชทกับแชทส่วนตัว",
compareChat: "แชทคือ DM กลุ่มมาตรฐานแบบ NIP-17 ใช้ได้กับไคลเอนต์ Nostr อื่นและไม่มีผู้ประสานงาน",
comparePrivate: "แชทส่วนตัวใช้ MLS: ควบคุมสมาชิกได้เข้มงวดกว่าและมีความลับไปข้างหน้า ผู้ประสานงานเห็นข้อมูลเมตาของกลุ่มและข้อความที่เข้ารหัส แต่ไม่เห็นเนื้อหาข้อความ",
coordinator: "coordinator",
lastChecked: "ตรวจสอบล่าสุด",
keyPackage: "แพ็กเกจคีย์",
keyPackageReady: "พร้อมบนอุปกรณ์นี้",
keyPackageMissing: "ยังไม่ได้ตั้งค่าบนอุปกรณ์นี้",
@ -370,6 +375,7 @@ const driftStrings = {
qrTitle: "สแกนเพื่อเข้าร่วมแชทส่วนตัว",
qrHint: "สมาชิก crew สแกนด้วยกล้องโทรศัพท์ ลิงก์จะเปิดแท็บแชทส่วนตัว",
sendFailed: "ส่งไม่สำเร็จ — แตะลองใหม่",
queued: "อยู่ในคิวบนอุปกรณ์นี้",
sending: "กำลังส่ง…",
sent: "ส่งแล้ว",
retry: "ลองใหม่",
@ -405,7 +411,7 @@ const driftStrings = {
noInvite: "ยังไม่มีคำเชิญ บอทมักอนุมัติภายในหนึ่งนาที — หลังจากนั้นแตะ “ตรวจสอบคำเชิญ”",
pendingRequests: "มีคำขอเข้าร่วม {{count}} รายการรออยู่",
pendingRequestsDesc: "บอทมักอนุมัติโดยอัตโนมัติ หากค้างอยู่ ให้เปิดห้องด้านล่างแล้วแตะ ตรวจสอบคำขอ",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
botMissing: "ขณะนี้บอทของชุมชนไม่ได้อยู่ในห้องนี้ แอดมินจะตรวจสอบคำขอเข้าร่วม",
secondDeviceHint: "เป็นสมาชิกบนอุปกรณ์อื่นอยู่แล้วหรือ? ใช้ “เชื่อมโยงอุปกรณ์นี้” เพื่อโอนย้ายโดยตรง หรือขอเข้าร่วมอีกครั้งด้วยอุปกรณ์นี้",
create: "สร้างห้องชุมชน",
setupDeviceBody: "สิ่งนี้มอบกุญแจสำหรับห้องชุมชนให้อุปกรณ์นี้ ผู้ดูแลหรือบอทจะอนุมัติหนึ่งครั้ง",

View file

@ -78,6 +78,7 @@ const driftStrings = {
},
dms: {
refresh: 'Yenile',
privateRooms: 'Özel odalar',
crews: "Crew'ler",
crewFounder: 'Kurucu',
crewMember: 'Üye',
@ -303,10 +304,10 @@ const driftStrings = {
noGroupMember: "Henüz bu crew'in özel sohbetinde değilsin.",
create: 'Özel sohbeti oluştur',
requestJoin: 'Katılmayı iste',
requestAgain: "Request again",
requestHandled: "Request handled",
copyCordnLink: "Copy Cordn link",
cordnWebCaveat: "Cordn web may stall after sign-in; the in-app join is more reliable.",
requestAgain: "Tekrar iste",
requestHandled: "İstek işlendi",
copyCordnLink: "Cordn bağlantısını kopyala",
cordnWebCaveat: "Cordn web oturum açtıktan sonra takılabilir; uygulama içinden katılmak daha güvenilir.",
checkInvite: 'Davetiye kontrol et',
requested: 'İstek gönderildi — bir crew yöneticisi onaylayacak.',
noInvite: 'Henüz bekleyen davetiye yok. Bir yöneticiden isteğini onaylamasını iste.',
@ -346,7 +347,11 @@ const driftStrings = {
onboardingMember3: "İçerdesin — mesajlar şifreli.",
encryptedNote: "Bunu yalnızca crew üyeleri okuyabilir; koordinatör yalnızca şifreli mesajları iletir.",
advanced: "Gelişmiş",
compareTitle: "Sohbet ve özel sohbet",
compareChat: "Sohbet, standart bir NIP-17 grup DM'sidir. Diğer Nostr istemcileriyle çalışır ve koordinatörü yoktur.",
comparePrivate: "Özel sohbet MLS kullanır: daha güçlü üyelik kontrolü ve ileriye dönük gizlilik. Koordinatör grup meta verilerini ve şifreli metni görür, mesaj metnini asla görmez.",
coordinator: "Koordinatör",
lastChecked: "Son kontrol",
keyPackage: "Anahtar paketi",
keyPackageReady: "Bu cihazda hazır",
keyPackageMissing: "Bu cihazda ayarlanmadı",
@ -370,6 +375,7 @@ const driftStrings = {
qrTitle: "Özel sohbete katılmak için tara",
qrHint: "Crew üyeleri bunu telefon kamerasıyla tarar. Bağlantı Özel sohbet sekmesini açar.",
sendFailed: "Gönderilemedi — Yeniden dene’ye dokun.",
queued: "Bu cihazda sırada",
sending: "Gönderiliyor…",
sent: "Gönderildi",
retry: "Yeniden dene",
@ -405,7 +411,7 @@ const driftStrings = {
noInvite: "Henüz davetiye yok. Bot genellikle bir dakika içinde onaylar — ardından “Davetiye kontrol et”e dokun.",
pendingRequests: "{{count}} katılma isteği bekliyor",
pendingRequestsDesc: "Bot genellikle otomatik onaylar. Takıldıysa aşağıdaki odayı aç ve İstekleri kontrol et’e dokun.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
botMissing: "Topluluk botu şu anda bu odada değil, bu yüzden katılma isteklerini bir yönetici inceleyecek.",
secondDeviceHint: "Başka bir cihazda zaten üye misiniz? Doğrudan devir için “Bu cihazı bağla”yı kullanın veya bu cihazla yeniden katılma isteği gönderin.",
create: "Topluluk odasını oluştur",
setupDeviceBody: "Bu, bu cihaza topluluk odası için bir anahtar verir. Bir yönetici veya bot bunu bir kez onaylar.",

View file

@ -86,6 +86,7 @@ const drift = {
},
dms: {
refresh: "Оновити",
privateRooms: "Приватні кімнати",
crews: "Команди",
crewFounder: "Засновник",
crewMember: "Учасник",
@ -311,10 +312,10 @@ const drift = {
noGroupMember: "Ви ще не в приватному чаті цієї команди.",
create: "Створити приватний чат",
requestJoin: "Попросити приєднатися",
requestAgain: "Request again",
requestHandled: "Request handled",
copyCordnLink: "Copy Cordn link",
cordnWebCaveat: "Cordn web may stall after sign-in; the in-app join is more reliable.",
requestAgain: "Запросити знову",
requestHandled: "Заявку оброблено",
copyCordnLink: "Копіювати посилання Cordn",
cordnWebCaveat: "Cordn web може зависнути після входу; приєднання в застосунку надійніше.",
checkInvite: "Перевірити запрошення",
requested: "Запит надіслано — адміністратор команди його схвалить.",
noInvite: "Запрошення ще не чекає. Попросіть адміністратора схвалити ваш запит.",
@ -354,7 +355,11 @@ const drift = {
onboardingMember3: "Ви всередині — повідомлення зашифровані.",
encryptedNote: "Читати можуть лише учасники команди; координатор лише передає зашифровані повідомлення.",
advanced: "Додатково",
compareTitle: "Чат і приватний чат",
compareChat: "Чат — це стандартний груповий DM за NIP-17. Він працює з іншими клієнтами Nostr і не потребує координатора.",
comparePrivate: "Приватний чат використовує MLS: суворіший контроль учасників і пряма секретність. Координатор бачить метадані групи та шифротекст, але ніколи — текст повідомлень.",
coordinator: "Координатор",
lastChecked: "Остання перевірка",
keyPackage: "Пакет ключів",
keyPackageReady: "Готово на цьому пристрої",
keyPackageMissing: "Не налаштовано на цьому пристрої",
@ -378,6 +383,7 @@ const drift = {
qrTitle: "Відскануйте, щоб увійти до приватного чату",
qrHint: "Учасники команди сканують код камерою телефона. Посилання відкриває вкладку «Приватний чат».",
sendFailed: "Не вдалося надіслати — натисніть «Повторити».",
queued: "У черзі на цьому пристрої",
sending: "Надсилання…",
sent: "Надіслано",
retry: "Повторити",
@ -413,7 +419,7 @@ const drift = {
noInvite: "Запрошення ще немає. Бот зазвичай схвалює протягом хвилини — після цього натисни «Перевірити запрошення».",
pendingRequests: "{{count}} запитів на приєднання в очікуванні",
pendingRequestsDesc: "Бот зазвичай схвалює автоматично. Якщо застрягло, відкрий кімнату нижче й натисни «Перевірити запити».",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
botMissing: "Бота спільноти зараз немає в цій кімнаті, тож заявки на вступ розгляне адміністратор.",
secondDeviceHint: "Уже є учасником на іншому пристрої? Використай «Прив’язати цей пристрій» для прямого перенесення або попроси знову долучитися з цього пристрою.",
create: "Створити кімнату спільноти",
setupDeviceBody: "Це дає цьому пристрою ключ до кімнати спільноти. Адміністратор або бот підтверджує його один раз.",

View file

@ -78,6 +78,7 @@ const driftStrings = {
},
dms: {
refresh: 'Làm mới',
privateRooms: 'Phòng riêng',
crews: 'Crews',
crewFounder: 'Người sáng lập',
crewMember: 'Thành viên',
@ -303,10 +304,10 @@ const driftStrings = {
noGroupMember: 'Bạn chưa ở trong chat riêng tư của crew này.',
create: 'Tạo chat riêng tư',
requestJoin: 'Xin tham gia',
requestAgain: "Request again",
requestHandled: "Request handled",
copyCordnLink: "Copy Cordn link",
cordnWebCaveat: "Cordn web may stall after sign-in; the in-app join is more reliable.",
requestAgain: "Yêu cầu lại",
requestHandled: "Đã xử lý yêu cầu",
copyCordnLink: "Sao chép liên kết Cordn",
cordnWebCaveat: "Cordn web có thể bị treo sau khi đăng nhập; tham gia trong ứng dụng đáng tin cậy hơn.",
checkInvite: 'Kiểm tra lời mời',
requested: 'Đã gửi yêu cầu — quản trị viên crew sẽ duyệt.',
noInvite: 'Chưa có lời mời nào. Hãy nhờ quản trị viên duyệt yêu cầu của bạn.',
@ -346,7 +347,11 @@ const driftStrings = {
onboardingMember3: "Bạn đã vào — tin nhắn được mã hóa.",
encryptedNote: "Chỉ thành viên crew mới đọc được; điều phối viên chỉ chuyển tiếp tin nhắn đã mã hóa.",
advanced: "Nâng cao",
compareTitle: "Trò chuyện và trò chuyện riêng tư",
compareChat: "Trò chuyện là một DM nhóm NIP-17 tiêu chuẩn. Nó hoạt động với các ứng dụng Nostr khác và không có người điều phối.",
comparePrivate: "Trò chuyện riêng tư dùng MLS: kiểm soát thành viên chặt chẽ hơn và bảo mật chuyển tiếp. Người điều phối thấy siêu dữ liệu nhóm và bản mã, không bao giờ thấy nội dung tin nhắn.",
coordinator: "Điều phối viên",
lastChecked: "Kiểm tra lần cuối",
keyPackage: "Gói khóa",
keyPackageReady: "Sẵn sàng trên thiết bị này",
keyPackageMissing: "Chưa thiết lập trên thiết bị này",
@ -370,6 +375,7 @@ const driftStrings = {
qrTitle: "Quét để tham gia chat riêng tư",
qrHint: "Thành viên crew quét mã bằng camera điện thoại. Liên kết mở tab Chat riêng tư.",
sendFailed: "Không gửi được — nhấn Thử lại.",
queued: "Đang xếp hàng trên thiết bị này",
sending: "Đang gửi…",
sent: "Đã gửi",
retry: "Thử lại",
@ -405,7 +411,7 @@ const driftStrings = {
noInvite: "Chưa có lời mời. Bot thường phê duyệt trong vòng một phút — sau đó nhấn “Kiểm tra lời mời”.",
pendingRequests: "{{count}} yêu cầu tham gia đang chờ",
pendingRequestsDesc: "Bot thường phê duyệt tự động. Nếu bị kẹt, mở phòng bên dưới và nhấn Kiểm tra yêu cầu.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
botMissing: "Bot cộng đồng hiện không có trong phòng này, nên quản trị viên sẽ xem xét các yêu cầu tham gia.",
secondDeviceHint: "Đã là thành viên trên thiết bị khác? Dùng “Liên kết thiết bị này” để chuyển giao trực tiếp, hoặc yêu cầu tham gia lại bằng thiết bị này.",
create: "Tạo phòng cộng đồng",
setupDeviceBody: "Việc này cấp cho thiết bị này một khóa cho phòng cộng đồng. Quản trị viên hoặc bot phê duyệt một lần.",

View file

@ -94,6 +94,7 @@ const drift: Dict = {
},
dms: {
refresh: '刷新',
privateRooms: '私密房间',
crews: 'Crew',
crewFounder: '创始人',
crewMember: '成员',
@ -319,10 +320,10 @@ const drift: Dict = {
noGroupMember: '你还没有加入此 Crew 的私密聊天。',
create: '创建私密聊天',
requestJoin: '请求加入',
requestAgain: "Request again",
requestHandled: "Request handled",
copyCordnLink: "Copy Cordn link",
cordnWebCaveat: "Cordn web may stall after sign-in; the in-app join is more reliable.",
requestAgain: "再次请求",
requestHandled: "请求已处理",
copyCordnLink: "复制 Cordn 链接",
cordnWebCaveat: "Cordn 网页版登录后可能会卡住;在应用内加入更可靠。",
checkInvite: '检查邀请',
requested: '请求已发送——Crew 管理员会批准。',
noInvite: '还没有等待中的邀请。请管理员批准你的请求。',
@ -362,7 +363,11 @@ const drift: Dict = {
onboardingMember3: "你已加入——消息已加密。",
encryptedNote: "只有 Crew 成员可以阅读;协调器只转发加密消息。",
advanced: "高级",
compareTitle: "聊天与私密聊天",
compareChat: "聊天是标准的 NIP-17 群组私信。它可与其他 Nostr 客户端互通,且没有协调者。",
comparePrivate: "私密聊天使用 MLS:更严格的成员控制和前向保密。协调者只能看到群组元数据和密文,永远看不到消息正文。",
coordinator: "协调器",
lastChecked: "上次检查",
keyPackage: "密钥包",
keyPackageReady: "已在此设备上就绪",
keyPackageMissing: "尚未在此设备上设置",
@ -386,6 +391,7 @@ const drift: Dict = {
qrTitle: "扫描加入私密聊天",
qrHint: "Crew 成员用手机摄像头扫描。链接会打开“私密聊天”标签页。",
sendFailed: "发送失败——点击重试。",
queued: "已在此设备上排队",
sending: "发送中…",
sent: "已发送",
retry: "重试",
@ -421,7 +427,7 @@ const drift: Dict = {
noInvite: "还没有邀请。机器人通常在一分钟内批准——之后请点按“检查邀请”。",
pendingRequests: "{{count}} 个加入请求正在等待",
pendingRequestsDesc: "机器人通常会自动批准。如果卡住了,请打开下面的房间并点按“检查请求”。",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
botMissing: "社区机器人目前不在这个房间,因此管理员会审核加入请求。",
secondDeviceHint: "已在其他设备上加入?使用“关联此设备”直接交接,或用此设备再次申请加入。",
create: "创建社区房间",
setupDeviceBody: "这会为此设备提供社区房间的密钥,管理员或机器人会批准一次。",

View file

@ -94,6 +94,7 @@ const drift: Dict = {
},
dms: {
refresh: '重新整理',
privateRooms: '私密房間',
crews: 'Crew',
crewFounder: '創始人',
crewMember: '成員',
@ -319,10 +320,10 @@ const drift: Dict = {
noGroupMember: '你還沒有加入此 Crew 的私密聊天。',
create: '建立私密聊天',
requestJoin: '要求加入',
requestAgain: "Request again",
requestHandled: "Request handled",
copyCordnLink: "Copy Cordn link",
cordnWebCaveat: "Cordn web may stall after sign-in; the in-app join is more reliable.",
requestAgain: "再次請求",
requestHandled: "請求已處理",
copyCordnLink: "複製 Cordn 連結",
cordnWebCaveat: "Cordn 網頁版登入後可能會卡住;在應用程式內加入更可靠。",
checkInvite: '檢查邀請',
requested: '要求已送出——Crew 管理員會批准。',
noInvite: '還沒有等待中的邀請。請管理員批准你的要求。',
@ -362,7 +363,11 @@ const drift: Dict = {
onboardingMember3: "你已加入——訊息已加密。",
encryptedNote: "只有 Crew 成員可以閱讀;協調器只轉送加密訊息。",
advanced: "進階",
compareTitle: "聊天與私密聊天",
compareChat: "聊天是標準的 NIP-17 群組私訊。可與其他 Nostr 用戶端互通,且沒有協調者。",
comparePrivate: "私密聊天使用 MLS:更嚴格的成員控制與前向保密。協調者只能看到群組中繼資料與密文,永遠看不到訊息內容。",
coordinator: "協調器",
lastChecked: "上次檢查",
keyPackage: "金鑰包",
keyPackageReady: "已在此裝置上就緒",
keyPackageMissing: "尚未在此裝置上設定",
@ -386,6 +391,7 @@ const drift: Dict = {
qrTitle: "掃描加入私密聊天",
qrHint: "Crew 成員用手機相機掃描。連結會開啟「私密聊天」分頁。",
sendFailed: "傳送失敗——點重試。",
queued: "已在此裝置上排隊",
sending: "傳送中…",
sent: "已傳送",
retry: "重試",
@ -421,7 +427,7 @@ const drift: Dict = {
noInvite: "還沒有邀請。機器人通常在一分鐘內核准——之後請點按「檢查邀請」。",
pendingRequests: "{{count}} 個加入要求正在等待",
pendingRequestsDesc: "機器人通常會自動核准。如果卡住了,請開啟下面的房間並點按「檢查要求」。",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
botMissing: "社群機器人目前不在這個房間,因此管理員會審核加入請求。",
secondDeviceHint: "已在其他裝置上加入?使用「連結此裝置」直接交接,或用此裝置再次申請加入。",
create: "建立社群房間",
setupDeviceBody: "這會為此裝置提供社群房間的金鑰,管理員或機器人會核准一次。",

View file

@ -340,6 +340,47 @@
@keyframes splashSpin {
to { transform: rotate(360deg); }
}
@keyframes splashLogoRollIn {
0% { opacity: 0; transform: translateX(-58px) rotate(-210deg) scale(0.82); }
62% { opacity: 1; transform: translateX(5px) rotate(14deg) scale(1.03); }
100% { opacity: 1; transform: translateX(0) rotate(0deg) scale(1); }
}
@keyframes splashWheelRoll {
to { transform: rotate(360deg); }
}
@keyframes splashHover {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@keyframes splashDust {
0%, 100% { opacity: 0.28; transform: translateX(-50%) scaleX(1); }
50% { opacity: 0.5; transform: translateX(-50%) scaleX(1.25); }
}
@keyframes splashSpark {
0% { opacity: 0; transform: translate(-50%, -50%) translate(var(--sx), var(--sy)) scale(0.45); }
12% { opacity: 1; }
100% { opacity: 0; transform: translate(-50%, -50%) translate(var(--ex), var(--ey)) scale(0); }
}
.splash-spark {
position: absolute;
left: 50%;
top: 50%;
width: 5px;
height: 5px;
border-radius: 9999px;
background: #ffb347;
box-shadow: 0 0 10px 3px rgba(255, 106, 0, 0.85);
animation: splashSpark 1.15s ease-out infinite;
}
.splash-spark:nth-child(3n) {
width: 4px;
height: 4px;
background: #ccff00;
box-shadow: 0 0 9px 3px rgba(204, 255, 0, 0.55);
}
.splash-spark:nth-child(3n + 1) {
background: #ffd166;
}
@keyframes splashRise {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }

117
src/lib/cordnOutbox.ts Normal file
View file

@ -0,0 +1,117 @@
/**
* Device-local outbox for Cordn (MLS) messages that failed to send.
*
* The encrypted room state lives in IndexedDB, but a failed MLS send otherwise
* disappears when the panel unmounts. This keeps the plaintext draft locally
* (same trust boundary as the composer) until it is sent or explicitly
* discarded. It is deliberately NOT published or synced anywhere.
*/
const KEY_PREFIX = 'skatespots:cordn-outbox:';
const MAX_ITEMS = 50;
export interface CordnOutboxItem {
id: string;
gid: string;
text: string;
imageUrl?: string;
createdAt: number;
attempts: number;
}
function key(pubkey: string, gid: string): string {
return `${KEY_PREFIX}${pubkey}:${gid}`;
}
function makeId(): string {
try {
return crypto.randomUUID();
} catch {
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
}
function write(pubkey: string, gid: string, items: CordnOutboxItem[]): void {
try {
localStorage.setItem(key(pubkey, gid), JSON.stringify(items.slice(-MAX_ITEMS)));
} catch {
// private mode / quota — the in-memory pending bubble still works
}
}
export function readCordnOutbox(pubkey: string | undefined, gid: string | undefined): CordnOutboxItem[] {
if (!pubkey || !gid) return [];
try {
const raw = localStorage.getItem(key(pubkey, gid));
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed
.filter((item): item is CordnOutboxItem => {
if (!item || typeof item !== 'object') return false;
const value = item as Partial<CordnOutboxItem>;
return (
typeof value.id === 'string' &&
typeof value.gid === 'string' &&
typeof value.text === 'string' &&
Number.isFinite(value.createdAt) &&
Number.isFinite(value.attempts)
);
})
.sort((a, b) => a.createdAt - b.createdAt);
} catch {
return [];
}
}
export function enqueueCordnOutbox(
pubkey: string | undefined,
gid: string | undefined,
text: string,
imageUrl?: string,
): CordnOutboxItem | null {
if (!pubkey || !gid || (!text.trim() && !imageUrl)) return null;
const items = readCordnOutbox(pubkey, gid);
const item: CordnOutboxItem = {
id: makeId(),
gid,
text,
imageUrl,
createdAt: Date.now(),
attempts: 1,
};
items.push(item);
write(pubkey, gid, items);
return item;
}
export function markCordnOutboxAttempt(
pubkey: string | undefined,
gid: string | undefined,
id: string,
): void {
if (!pubkey || !gid) return;
const items = readCordnOutbox(pubkey, gid);
const next = items.map((item) =>
item.id === id ? { ...item, attempts: item.attempts + 1 } : item,
);
write(pubkey, gid, next);
}
export function removeCordnOutbox(
pubkey: string | undefined,
gid: string | undefined,
id: string,
): void {
if (!pubkey || !gid) return;
write(pubkey, gid, readCordnOutbox(pubkey, gid).filter((item) => item.id !== id));
}
export function clearCordnOutbox(pubkey: string | undefined, gid: string | undefined): void {
if (!pubkey || !gid) return;
try {
localStorage.removeItem(key(pubkey, gid));
} catch {
// ignore
}
}

93
src/lib/cordnRoomRead.ts Normal file
View file

@ -0,0 +1,93 @@
/**
* Device-local read state for Cordn rooms.
*
* This is intentionally lightweight: the panel that actually holds the MLS
* group records the newest inbound message and, while the room is visible,
* marks it seen. The Messages hub can then show an honest unread dot without
* decrypting anything in the background. A room that has never been opened
* has no entry (and therefore no invented badge).
*/
const KEY_PREFIX = 'skatespots:cordn-room-read:';
const EVENT = 'skatespots:cordn-room-read-change';
export interface CordnRoomRead {
/** Newest inbound message timestamp (seconds). */
latestAt: number;
/** Newest timestamp the viewer has acknowledged. */
seenAt: number;
}
export type CordnRoomReadMap = Record<string, CordnRoomRead>;
function storageKey(pubkey: string): string {
return `${KEY_PREFIX}${pubkey}`;
}
function readRaw(pubkey: string | undefined): CordnRoomReadMap {
if (!pubkey) return {};
try {
const raw = localStorage.getItem(storageKey(pubkey));
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object') return {};
const result: CordnRoomReadMap = {};
for (const [gid, value] of Object.entries(parsed as Record<string, unknown>)) {
if (!value || typeof value !== 'object') continue;
const latestAt = Number((value as { latestAt?: unknown }).latestAt);
const seenAt = Number((value as { seenAt?: unknown }).seenAt);
if (!Number.isFinite(latestAt) || !Number.isFinite(seenAt)) continue;
result[gid] = { latestAt: Math.max(0, latestAt), seenAt: Math.max(0, seenAt) };
}
return result;
} catch {
return {};
}
}
function writeRaw(pubkey: string, reads: CordnRoomReadMap): void {
try {
localStorage.setItem(storageKey(pubkey), JSON.stringify(reads));
window.dispatchEvent(new CustomEvent(EVENT, { detail: { pubkey, reads } }));
} catch {
// private mode / quota — badges simply won't update
}
}
/** Read every known room state for one account. */
export function readCordnRoomReads(pubkey: string | undefined): CordnRoomReadMap {
return readRaw(pubkey);
}
/** Record a new inbound message without marking it read. */
export function markCordnRoomActive(
pubkey: string | undefined,
gid: string,
latestAt: number,
): void {
if (!pubkey || !gid || latestAt <= 0) return;
const reads = readRaw(pubkey);
const previous = reads[gid] ?? { latestAt: 0, seenAt: 0 };
if (latestAt <= previous.latestAt) return;
reads[gid] = { ...previous, latestAt };
writeRaw(pubkey, reads);
}
/** Mark the room seen up to its newest known inbound message. */
export function markCordnRoomSeen(
pubkey: string | undefined,
gid: string,
latestAt: number,
): void {
if (!pubkey || !gid || latestAt <= 0) return;
const reads = readRaw(pubkey);
const previous = reads[gid] ?? { latestAt: 0, seenAt: 0 };
const nextLatest = Math.max(previous.latestAt, latestAt);
if (nextLatest <= previous.seenAt) return;
reads[gid] = { latestAt: nextLatest, seenAt: nextLatest };
writeRaw(pubkey, reads);
}
export function cordnRoomReadEventName(): string {
return EVENT;
}

View file

@ -16,6 +16,8 @@ import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
import { useConversations, useGroupConversations, type Conversation } from '@/hooks/useDirectMessages';
import { useUserCrews } from '@/hooks/useCrews';
import { useCordnGroupGids } from '@/hooks/useCordnNative';
import { useCordnRoomRead } from '@/hooks/useCordnRoomRead';
import { COMMUNITY_ROOM } from '@/lib/cordn/community';
import { SKATE_CREW_KIND } from '@/lib/types';
import { useDMRead } from '@/hooks/useDMRead';
import { useDmUnreadSummary } from '@/hooks/useDmUnreadSummary';
@ -33,6 +35,7 @@ import {
X,
Users,
RefreshCw,
MessagesSquare,
} from 'lucide-react';
import { cn } from '@/lib/utils';
@ -221,6 +224,55 @@ function ConversationSkeleton() {
);
}
function CordnRoomRow({
to,
name,
subtitle,
hasGroup,
unread,
icon: Icon,
}: {
to: string;
name: string;
subtitle: string;
hasGroup: boolean;
unread: boolean;
icon: typeof Lock;
}) {
const { t } = useTranslation();
return (
<Link to={to}>
<Card className="hover:bg-gray-900/50 transition-colors cursor-pointer border-gray-800 hover:border-teal-500/50 active:scale-[0.99]">
<CardContent className="p-3">
<div className="flex items-center gap-3">
<div className="relative flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full bg-teal-500/15 text-teal-400">
<Icon className="h-5 w-5" />
{unread && (
<span
className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full border-2 border-black bg-[#ccff00]"
aria-label={t('socialUi.dms.unreadMessages')}
/>
)}
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate font-bold text-white">{name}</h3>
<p className="mt-0.5 truncate text-xs text-gray-500">{subtitle}</p>
</div>
<span
className={cn(
'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide',
hasGroup ? 'bg-teal-500/15 text-teal-300' : 'bg-white/10 text-gray-400',
)}
>
{hasGroup ? t('socialUi.dms.crewOpen') : t('socialUi.dms.crewSetUp')}
</span>
</div>
</CardContent>
</Card>
</Link>
);
}
export default function DirectMessagesPage() {
const { t } = useTranslation();
const { user } = useCurrentUser();
@ -232,6 +284,7 @@ export default function DirectMessagesPage() {
const { getLastRead } = useDMRead();
const { markSeen: markDmEnvelopesSeen } = useDmUnreadSummary(!!user);
const { isMuted } = useDmMute();
const { isUnread: isCordnUnread } = useCordnRoomRead(user?.pubkey);
// The viewer's crews — surfaced here as entry points to their private (MLS)
// chat. `cordnGids` is a local IndexedDB read (no signer prompt) used to mark
// which crews already have a chat on this device.
@ -377,46 +430,40 @@ export default function DirectMessagesPage() {
}
/>
{/* Crews — entry points into each crew's private (MLS) chat */}
{myCrews.length > 0 && (
<div className="mb-5">
<h2 className="mb-2 flex items-center gap-1.5 text-xs font-bold uppercase tracking-widest text-gray-500">
<Lock className="h-3.5 w-3.5" /> {t('socialUi.dms.crews')}
</h2>
<div className="space-y-3">
{myCrews.map(({ crew, role }) => {
const hasGroup = cordnGids?.has(`${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}`) ?? false;
return (
<Link key={crew.pubkey} to={`/crew/${crew.pubkey}/${crew.identifier}?tab=mls`}>
<Card className="hover:bg-gray-900/50 transition-colors cursor-pointer border-gray-800 hover:border-teal-500/50 active:scale-[0.99]">
<CardContent className="p-3">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full bg-teal-500/15 text-teal-400">
<Lock className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate font-bold text-white">{crew.name}</h3>
<p className="mt-0.5 truncate text-xs text-gray-500">
{role === 'founder' ? t('socialUi.dms.crewFounder') : t('socialUi.dms.crewMember')}
</p>
</div>
<span
className={cn(
'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide',
hasGroup ? 'bg-teal-500/15 text-teal-300' : 'bg-white/10 text-gray-400',
)}
>
{hasGroup ? t('socialUi.dms.crewOpen') : t('socialUi.dms.crewSetUp')}
</span>
</div>
</CardContent>
</Card>
</Link>
);
})}
</div>
{/* Cordn private rooms — community + each crew's MLS chat */}
<div className="mb-5">
<h2 className="mb-2 flex items-center gap-1.5 text-xs font-bold uppercase tracking-widest text-gray-500">
<Lock className="h-3.5 w-3.5" /> {t('socialUi.dms.privateRooms')}
</h2>
<div className="space-y-3">
<CordnRoomRow
to="/community"
name={t('communityUi.title')}
subtitle={t('communityUi.subtitle')}
hasGroup={cordnGids?.has(COMMUNITY_ROOM.gid ?? 'skatespots:community') ?? false}
unread={
isCordnUnread(COMMUNITY_ROOM.gid) &&
!isMuted(`cordn:${COMMUNITY_ROOM.gid}`)
}
icon={MessagesSquare}
/>
{myCrews.map(({ crew, role }) => {
const gid = `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}`;
const hasGroup = cordnGids?.has(gid) ?? false;
return (
<CordnRoomRow
key={crew.pubkey}
to={`/crew/${crew.pubkey}/${crew.identifier}?tab=mls`}
name={crew.name}
subtitle={role === 'founder' ? t('socialUi.dms.crewFounder') : t('socialUi.dms.crewMember')}
hasGroup={hasGroup}
unread={isCordnUnread(gid) && !isMuted(`cordn:${gid}`)}
icon={Lock}
/>
);
})}
</div>
)}
</div>
{/* Group conversations (NIP-17 group DMs) */}
{groups && groups.length > 0 && (