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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user