feat: voice messages, offline queue, delivery ticks, volume slider, admin + scaling

- Voice messages: MediaRecorder → encrypted attachment, custom waveform
  player via OfflineAudioContext, 60s limit + live mic-level meter
- Offline message queue: localStorage outbox, exponential backoff retries,
  optimistic pending bubble with retry/discard
- Delivery indicator: message_deliveries table + RLS (reciprocal receipts),
  ✓ / ✓✓ / ✓✓-blue tick states, group-aware (all members must ack)
- Per-participant volume slider in calls via right-click tile menu,
  persisted to localStorage, applied to attached audio elements
- Group call scaling: grid up to 12 tiles with pagination,
  active-speaker auto-promotion in fullscreen
- Push notifications scaffolding: service worker, VAPID subscription
  registration, notify-push edge function skeleton
- Backup recovery code: 24-char base32 code (~120 bits entropy) as
  alternative decrypt path, restore UI with mode toggle
- Admin panel: conversations list, audit log (admin_audit_log table +
  admin_log_action RPC), audit entry on user flag toggle
- Search v2: sender filter, attachment-only toggle, date range
- Reactions pop animation (scale 0.4→1.15→1 on count change)
- Message list windowing (150 default, expand via IntersectionObserver)
- Stub cleanup: removed dead ScreenshareStub from CallParticipantTile

Fixes:
- Focus-triggered flicker: dropped window.focus listeners in three spots,
  throttled visibilitychange/online wake-refreshes to 30s, keep existing
  data visible during background re-syncs (no more spinner on every click)
- Voice attachment audio element collapsed to 0px on peer side — now
  forces 280px min-width on bubble

Migrations (push required):
  20260421000001_message_deliveries.sql
  20260421000002_admin_audit_log.sql

Server TODO:
  VAPID keys + notify-push edge function deploy
This commit is contained in:
2026-04-21 01:14:16 +02:00
parent da85f0ba54
commit a04ecf7a19
40 changed files with 4286 additions and 430 deletions
+121 -1
View File
@@ -1,4 +1,6 @@
import {
type AdminAuditEntry,
type AdminConversationRow,
type AdminProfileFlag,
type AdminProfileRow,
type AdminSetting,
@@ -6,8 +8,11 @@ import {
deleteInvite,
type InviteRecord,
listAdminSettings,
listAllConversations,
listAllProfiles,
listAuditLog,
listInvites,
logAdminAction,
setInviteDisabled,
setUserFlag,
updateAdminSetting,
@@ -31,20 +36,26 @@ export function AdminPage() {
const [settings, setSettings] = useState<AdminSetting[]>([]);
const [invites, setInvites] = useState<InviteRecord[]>([]);
const [users, setUsers] = useState<AdminProfileRow[]>([]);
const [conversations, setConversations] = useState<AdminConversationRow[]>([]);
const [audit, setAudit] = useState<AdminAuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
setLoading(true);
const [s, i, u] = await Promise.all([
const [s, i, u, c, a] = await Promise.all([
listAdminSettings(supabase),
listInvites(supabase),
listAllProfiles(supabase),
listAllConversations(supabase),
listAuditLog(supabase, 50),
]);
setSettings(s);
setInvites(i);
setUsers(u);
setConversations(c);
setAudit(a);
setError(null);
} catch (err: unknown) {
const code = extractErrorCode(err);
@@ -92,6 +103,8 @@ export function AdminPage() {
<SettingsSection settings={settings} onRefresh={refresh} />
<InvitesSection invites={invites} onRefresh={refresh} />
<UsersSection users={users} onRefresh={refresh} />
<ConversationsSection conversations={conversations} />
<AuditSection entries={audit} users={users} />
</>
)}
</div>
@@ -295,6 +308,13 @@ function UsersSection({ users, onRefresh }: { users: AdminProfileRow[]; onRefres
async function toggle(userId: string, flag: AdminProfileFlag, value: boolean) {
try {
await setUserFlag(supabase, userId, flag, value);
// Best-effort audit; don't fail the UI if the log write fails.
void logAdminAction(
supabase,
'user.set_flag',
{ type: 'user', id: userId },
{ flag, value },
).catch(() => undefined);
await onRefresh();
} catch (err: unknown) {
console.error(err);
@@ -345,6 +365,106 @@ function UsersSection({ users, onRefresh }: { users: AdminProfileRow[]; onRefres
);
}
// --- Conversations ---------------------------------------------------------
function ConversationsSection({ conversations }: { conversations: AdminConversationRow[] }) {
if (conversations.length === 0) {
return (
<Section title="Unterhaltungen">
<p className="text-sm text-fg-muted">Keine Unterhaltungen.</p>
</Section>
);
}
return (
<Section title={'Unterhaltungen (' + conversations.length + ')'}>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="text-[10px] uppercase tracking-[0.1em] text-fg-muted">
<tr>
<th className="py-2 pr-3">Name/ID</th>
<th className="py-2 pr-3">Typ</th>
<th className="py-2 pr-3">Members</th>
<th className="py-2 pr-3">Letzte Nachricht</th>
<th className="py-2 pr-3">Erstellt</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{conversations.map((c) => (
<tr key={c.id}>
<td className="py-2 pr-3 font-mono text-[11px] text-fg">
{c.name ?? c.id.slice(0, 12)}
</td>
<td className="py-2 pr-3 text-xs text-fg-muted">{c.type}</td>
<td className="py-2 pr-3 text-xs text-fg">{c.memberCount}</td>
<td className="py-2 pr-3 text-xs text-fg-muted">
{c.lastMessageAt
? new Date(c.lastMessageAt).toLocaleString()
: '—'}
</td>
<td className="py-2 pr-3 text-xs text-fg-muted">
{new Date(c.createdAt).toLocaleDateString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
);
}
// --- Audit log -------------------------------------------------------------
function AuditSection({
entries,
users,
}: {
entries: AdminAuditEntry[];
users: AdminProfileRow[];
}) {
const nameByUserId = new Map(users.map((u) => [u.userId, u.displayName]));
if (entries.length === 0) {
return (
<Section title="Audit-Log">
<p className="text-sm text-fg-muted">Keine Einträge.</p>
</Section>
);
}
return (
<Section title={'Audit-Log (letzte ' + entries.length + ')'}>
<ul className="divide-y divide-line text-sm">
{entries.map((e) => (
<li key={e.id} className="flex items-start gap-3 py-2">
<span className="w-32 shrink-0 font-mono text-[10px] text-fg-muted">
{new Date(e.createdAt).toLocaleString()}
</span>
<div className="min-w-0 flex-1">
<p className="text-xs">
<span className="font-semibold text-fg">
{e.actorId ? nameByUserId.get(e.actorId) ?? e.actorId.slice(0, 8) : '—'}
</span>{' '}
<span className="text-accent">{e.action}</span>
{e.targetId && (
<span className="text-fg-muted">
{' '}
on {e.targetType}:{e.targetId.slice(0, 8)}
</span>
)}
</p>
{Object.keys(e.metadata).length > 0 && (
<p className="mt-0.5 font-mono text-[10px] text-fg-muted">
{JSON.stringify(e.metadata)}
</p>
)}
</div>
</li>
))}
</ul>
</Section>
);
}
// --- Bits ------------------------------------------------------------------
function Toggle({
+352 -63
View File
@@ -22,12 +22,16 @@ import { InCallPanel } from '../components/InCallPanel';
import { IncomingCallPanel } from '../components/IncomingCallPanel';
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
import { TypingIndicator } from '../components/TypingIndicator';
import { VoiceRecorder } from '../components/VoiceRecorder';
import type { DecryptedMessage } from '@chat-app/shared/chat';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useConversationsContext } from '../context/ConversationsContext';
import type { OutboxItem } from '../lib/messageOutbox';
import { useConversationMessages } from '../lib/useConversationMessages';
import { useMessageReactions } from '../lib/useMessageReactions';
import { useGroupReceipts } from '../lib/useGroupReceipts';
import { markDelivered, useMessageDeliveries } from '../lib/useMessageDeliveries';
import { markRead as markMessagesReadRemote, useMessageReads } from '../lib/useMessageReads';
import { usePeerPresence } from '../lib/usePeerPresence';
import { useTypingChannel } from '../lib/useTypingChannel';
@@ -47,11 +51,12 @@ export function ConversationPage() {
const peerId = conversation?.peer?.userId;
const peerPresence = usePeerPresence(peerId);
const { messages, loading, error, send } = useConversationMessages({
conversationId: id,
userId: session?.user.id,
deviceId: device?.id,
});
const { messages, loading, error, send, pending, retryPending, cancelPending } =
useConversationMessages({
conversationId: id,
userId: session?.user.id,
deviceId: device?.id,
});
const messageIds = useMemo(() => messages.map((m) => m.id), [messages]);
const { byMessage: reactionsByMessage, toggle: toggleReaction } = useMessageReactions(
messageIds,
@@ -66,6 +71,36 @@ export function ConversationPage() {
[messages, myId],
);
const { peerReadSet } = useMessageReads(ownMessageIds, peerId);
const { peerDeliveredSet } = useMessageDeliveries(ownMessageIds, peerId);
// Group receipts: only meaningful when conversation is a group. We feed it
// ownMessageIds since we only render delivery state on the sender side.
const isGroup = conversation?.type === 'group';
const { deliveredByMessage: groupDelivered, readByMessage: groupRead } =
useGroupReceipts(ownMessageIds, myId, !!isGroup);
const groupRecipientCount = useMemo(() => {
if (!isGroup || !conversation) return 0;
return conversation.members.filter((m) => m.userId !== myId).length;
}, [isGroup, conversation, myId]);
// Mark every peer-authored message as delivered on our side. Idempotent,
// so rerunning for already-acknowledged ids is a no-op server-side.
const deliveredTrackedRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!myId || messages.length === 0) return;
const toMark: string[] = [];
for (const m of messages) {
if (m.senderId === myId) continue;
if (deliveredTrackedRef.current.has(m.id)) continue;
deliveredTrackedRef.current.add(m.id);
toMark.push(m.id);
}
if (toMark.length > 0) {
void markDelivered(toMark).catch((err: unknown) => {
console.warn('markDelivered failed', err);
});
}
}, [messages, myId]);
const lastSeenMessageId = useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
@@ -100,8 +135,14 @@ export function ConversationPage() {
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchIdx, setSearchIdx] = useState(0);
const [searchSenderId, setSearchSenderId] = useState<string>('');
const [searchAttachmentsOnly, setSearchAttachmentsOnly] = useState(false);
const [searchDateFrom, setSearchDateFrom] = useState<string>('');
const [searchDateTo, setSearchDateTo] = useState<string>('');
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const [displayCount, setDisplayCount] = useState<number>(150);
const scrollRef = useRef<HTMLDivElement>(null);
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const composerRef = useRef<HTMLTextAreaElement>(null);
@@ -111,8 +152,28 @@ export function ConversationPage() {
setForwardTarget(null);
setSearchOpen(false);
setSearchQuery('');
setDisplayCount(150);
}, [id]);
// Expand window when the "load older" sentinel scrolls into view. Doubles
// effective window on each trigger so scrolling up quickly converges to
// rendering everything.
useEffect(() => {
const el = loadMoreSentinelRef.current;
if (!el) return;
if (displayCount >= messages.length) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
setDisplayCount((n) => Math.min(messages.length, n * 2));
}
},
{ root: scrollRef.current, rootMargin: '200px 0px' },
);
observer.observe(el);
return () => observer.disconnect();
}, [displayCount, messages.length]);
const messageById = useMemo(() => {
const m = new Map<string, DecryptedMessage>();
for (const msg of messages) m.set(msg.id, msg);
@@ -176,17 +237,39 @@ export function ConversationPage() {
setForwardTarget(m);
}, []);
// Search matches: messages whose decrypted text includes the query.
// Search matches: messages matching query + filters. Empty query is allowed
// when filters are active, so users can e.g. show "all attachments from
// alice in the last week" without a text query.
const searchActive = useMemo(
() =>
searchQuery.trim().length > 0 ||
searchSenderId !== '' ||
searchAttachmentsOnly ||
searchDateFrom !== '' ||
searchDateTo !== '',
[searchQuery, searchSenderId, searchAttachmentsOnly, searchDateFrom, searchDateTo],
);
const searchMatches = useMemo(() => {
if (!searchActive) return [] as DecryptedMessage[];
const q = searchQuery.trim().toLowerCase();
if (!q) return [] as DecryptedMessage[];
const fromTs = searchDateFrom ? new Date(searchDateFrom).getTime() : null;
// Date inputs cover whole days — bump 'to' to end-of-day.
const toTs = searchDateTo
? new Date(searchDateTo).getTime() + 24 * 3600 * 1000 - 1
: null;
return messages.filter((m) => {
if (searchSenderId && m.senderId !== searchSenderId) return false;
const created = new Date(m.createdAt).getTime();
if (fromTs !== null && created < fromTs) return false;
if (toTs !== null && created > toTs) return false;
if (!m.plaintext) return false;
const parsed = parseMessagePayload(m.plaintext);
if (parsed.kind !== 'text') return false;
return parsed.text.toLowerCase().includes(q);
if (searchAttachmentsOnly && parsed.attachments.length === 0) return false;
if (q && !parsed.text.toLowerCase().includes(q)) return false;
return true;
});
}, [messages, searchQuery]);
}, [messages, searchActive, searchQuery, searchSenderId, searchAttachmentsOnly, searchDateFrom, searchDateTo]);
// Reset/clamp the active match index when the match set changes.
useEffect(() => {
@@ -278,7 +361,6 @@ export function ConversationPage() {
setAttachments((prev) => [...prev, ...next].slice(0, 4));
}
const isGroup = conversation?.type === 'group';
const { state: callState } = useCall();
// Hide the chat header while this conversation hosts an active call — the
// call topbar inside the dock already shows the channel name + duration,
@@ -308,6 +390,15 @@ export function ConversationPage() {
onQueryChange={setSearchQuery}
matches={searchMatches.length}
activeIdx={searchIdx}
senderId={searchSenderId}
onSenderChange={setSearchSenderId}
attachmentsOnly={searchAttachmentsOnly}
onAttachmentsOnlyChange={setSearchAttachmentsOnly}
dateFrom={searchDateFrom}
onDateFromChange={setSearchDateFrom}
dateTo={searchDateTo}
onDateToChange={setSearchDateTo}
members={conversation?.members ?? []}
onPrev={() =>
setSearchIdx((cur) =>
searchMatches.length === 0 ? 0 : (cur - 1 + searchMatches.length) % searchMatches.length,
@@ -319,6 +410,10 @@ export function ConversationPage() {
onClose={() => {
setSearchOpen(false);
setSearchQuery('');
setSearchSenderId('');
setSearchAttachmentsOnly(false);
setSearchDateFrom('');
setSearchDateTo('');
}}
/>
)}
@@ -345,7 +440,18 @@ export function ConversationPage() {
<p className="text-center text-sm text-fg-muted"></p>
) : (
<ul className="space-y-0.5">
{messages.map((m, idx) => {
{displayCount < messages.length && (
<li>
<div
ref={loadMoreSentinelRef}
className="flex items-center justify-center py-2 text-xs text-fg-muted"
>
Lade ältere Nachrichten
</div>
</li>
)}
{messages.slice(Math.max(0, messages.length - displayCount)).map((m, sliceIdx) => {
const idx = Math.max(0, messages.length - displayCount) + sliceIdx;
// A "run" is consecutive bubbles from the same sender with
// nothing between them. Call-event separators break the run —
// a bubble whose immediate next neighbour is a call_event must
@@ -384,6 +490,19 @@ export function ConversationPage() {
reactions={reactionsByMessage.get(m.id) ?? []}
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)}
showSeen={m.id === lastSeenMessageId}
{...(m.senderId === myId
? {
deliveryState: computeDeliveryState({
messageId: m.id,
isGroup: !!isGroup,
recipientCount: groupRecipientCount,
peerReadSet,
peerDeliveredSet,
groupRead,
groupDelivered,
}),
}
: {})}
quoted={buildQuoted(m.replyToId)}
onJumpToMessage={jumpToMessage}
onReply={handleReply}
@@ -393,6 +512,15 @@ export function ConversationPage() {
</li>
);
})}
{pending.map((p) => (
<li key={p.id}>
<PendingBubble
item={p}
onRetry={() => retryPending(p.id)}
onCancel={() => cancelPending(p.id)}
/>
</li>
))}
</ul>
)}
</div>
@@ -473,6 +601,18 @@ export function ConversationPage() {
>
<PlusIcon className="h-4 w-4" />
</button>
<VoiceRecorder
disabled={sending}
onComplete={async (file) => {
try {
await send('', [file], replyTo?.id ?? null);
setReplyTo(null);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'send failed';
setSendError(msg);
}
}}
/>
<textarea
ref={composerRef}
value={text}
@@ -516,65 +656,134 @@ interface SearchBarProps {
onQueryChange: (q: string) => void;
matches: number;
activeIdx: number;
senderId: string;
onSenderChange: (id: string) => void;
attachmentsOnly: boolean;
onAttachmentsOnlyChange: (v: boolean) => void;
dateFrom: string;
onDateFromChange: (v: string) => void;
dateTo: string;
onDateToChange: (v: string) => void;
members: { userId: string; profile: { displayName?: string | null } | null }[];
onPrev: () => void;
onNext: () => void;
onClose: () => void;
}
function SearchBar({ query, onQueryChange, matches, activeIdx, onPrev, onNext, onClose }: SearchBarProps) {
function SearchBar({
query,
onQueryChange,
matches,
activeIdx,
senderId,
onSenderChange,
attachmentsOnly,
onAttachmentsOnlyChange,
dateFrom,
onDateFromChange,
dateTo,
onDateToChange,
members,
onPrev,
onNext,
onClose,
}: SearchBarProps) {
const { t } = useTranslation(['app']);
return (
<div className="flex items-center gap-2 border-b border-line bg-surface-2 px-4 py-2">
<SearchIcon className="h-4 w-4 shrink-0 text-fg-muted" />
<input
type="search"
autoFocus
value={query}
onChange={(e) => onQueryChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Enter') {
e.preventDefault();
if (e.shiftKey) onPrev();
else onNext();
}
}}
placeholder={t('app:chats.search_in_conv', { defaultValue: 'In Unterhaltung suchen…' })}
className="min-w-0 flex-1 bg-transparent text-sm text-fg placeholder-fg-muted outline-none"
/>
<span className="shrink-0 text-xs tabular-nums text-fg-muted">
{matches === 0
? query.trim().length > 0
? t('app:chats.search_none', { defaultValue: 'Keine Treffer' })
: ''
: activeIdx + 1 + ' / ' + matches}
</span>
<button
type="button"
onClick={onPrev}
disabled={matches === 0}
aria-label="Previous"
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronUpIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={onNext}
disabled={matches === 0}
aria-label="Next"
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronDownIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg"
>
<XIcon className="h-4 w-4" />
</button>
<div className="flex flex-col gap-2 border-b border-line bg-surface-2 px-4 py-2">
<div className="flex items-center gap-2">
<SearchIcon className="h-4 w-4 shrink-0 text-fg-muted" />
<input
type="search"
autoFocus
value={query}
onChange={(e) => onQueryChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Enter') {
e.preventDefault();
if (e.shiftKey) onPrev();
else onNext();
}
}}
placeholder={t('app:chats.search_in_conv', { defaultValue: 'In Unterhaltung suchen…' })}
className="min-w-0 flex-1 bg-transparent text-sm text-fg placeholder-fg-muted outline-none"
/>
<span className="shrink-0 text-xs tabular-nums text-fg-muted">
{matches === 0
? query.trim().length > 0
? t('app:chats.search_none', { defaultValue: 'Keine Treffer' })
: ''
: activeIdx + 1 + ' / ' + matches}
</span>
<button
type="button"
onClick={onPrev}
disabled={matches === 0}
aria-label="Previous"
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronUpIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={onNext}
disabled={matches === 0}
aria-label="Next"
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronDownIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg"
>
<XIcon className="h-4 w-4" />
</button>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-fg-muted">
<select
value={senderId}
onChange={(e) => onSenderChange(e.target.value)}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent"
>
<option value="">Alle Sender</option>
{members.map((m) => (
<option key={m.userId} value={m.userId}>
{m.profile?.displayName ?? m.userId.slice(0, 6)}
</option>
))}
</select>
<label className="flex cursor-pointer items-center gap-1 rounded-md border border-line bg-surface-3 px-2 py-1 text-fg">
<input
type="checkbox"
checked={attachmentsOnly}
onChange={(e) => onAttachmentsOnlyChange(e.target.checked)}
className="accent-accent"
/>
<span>Nur Anhänge</span>
</label>
<label className="flex items-center gap-1">
<span>Von</span>
<input
type="date"
value={dateFrom}
onChange={(e) => onDateFromChange(e.target.value)}
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent"
/>
</label>
<label className="flex items-center gap-1">
<span>Bis</span>
<input
type="date"
value={dateTo}
onChange={(e) => onDateToChange(e.target.value)}
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent"
/>
</label>
</div>
</div>
);
}
@@ -583,6 +792,86 @@ function SearchBar({ query, onQueryChange, matches, activeIdx, onPrev, onNext, o
// regular bubble is found or the array boundary is reached. Used to decide
// run-grouping for avatar placement so call separators don't bleed into
// sender continuity.
function computeDeliveryState(args: {
messageId: string;
isGroup: boolean;
recipientCount: number;
peerReadSet: Set<string>;
peerDeliveredSet: Set<string>;
groupRead: Map<string, Set<string>>;
groupDelivered: Map<string, Set<string>>;
}): 'sent' | 'delivered' | 'read' {
// DM: single peer ack flips state.
if (!args.isGroup) {
if (args.peerReadSet.has(args.messageId)) return 'read';
if (args.peerDeliveredSet.has(args.messageId)) return 'delivered';
return 'sent';
}
// Group: state advances only when ALL recipients have acknowledged. With
// 0 recipients (admin-only group), we keep 'sent' so we don't show
// misleading completed ticks.
if (args.recipientCount === 0) return 'sent';
const reads = args.groupRead.get(args.messageId);
if (reads && reads.size >= args.recipientCount) return 'read';
const delivered = args.groupDelivered.get(args.messageId);
if (delivered && delivered.size >= args.recipientCount) return 'delivered';
return 'sent';
}
function PendingBubble({
item,
onRetry,
onCancel,
}: {
item: OutboxItem;
onRetry: () => void;
onCancel: () => void;
}) {
const failed = item.attempts >= 8;
return (
<div className="flex justify-end py-0.5">
<div
className={
'max-w-[72%] rounded-[18px] px-3.5 py-2 text-sm ' +
(failed
? 'border border-rose-500/40 bg-rose-500/10 text-rose-700 dark:text-rose-100'
: 'border border-dashed border-accent/50 bg-accent/10 text-fg opacity-80')
}
>
<p className="whitespace-pre-wrap break-words">{item.text}</p>
<div className="mt-1 flex items-center justify-end gap-2 text-[10px] uppercase tracking-wider text-fg-muted">
{failed ? (
<>
<span>{item.lastError ?? 'Senden fehlgeschlagen'}</span>
<button
type="button"
onClick={onRetry}
className="cursor-pointer rounded px-1.5 py-0.5 font-semibold text-accent hover:underline"
>
Erneut
</button>
<button
type="button"
onClick={onCancel}
className="cursor-pointer rounded px-1.5 py-0.5 font-semibold text-rose-600 hover:underline dark:text-rose-300"
>
Verwerfen
</button>
</>
) : (
<>
<SpinnerIcon className="h-3 w-3 animate-spin" />
<span>
{item.attempts === 0 ? 'Senden…' : 'Wiederhole (' + item.attempts + ')'}
</span>
</>
)}
</div>
</div>
</div>
);
}
function Banner({ children }: { children: React.ReactNode }) {
return (
<div