feat: reply + search + forward + archive/mute + error boundary

Messages:
- Reply-to: hover action, composer chip with cancel, quote bubble inside
  the replying message with tap-to-jump + amber highlight ring
- Search: header search button toggles in-conversation search bar with
  prev/next + match counter, auto-jump to active match
- Forward: multi-select conversation picker. Attachments are now carried
  over: download + decrypt source, re-encrypt under each target conv-key,
  re-upload with fresh per-attachment keys, insert new attachment rows

Conversations:
- Archive + mute per member. New migration 20260420000001 adds `archived`
  + `muted_until` on conversation_members. Shared helpers:
  setConversationArchived / setConversationMutedUntil / isConversationMuted
- ChatsPage: archive toggle in header with unread badge for archived
  bucket, split active/archived lists, muted indicator (BellOff icon,
  dimmed unread badge)
- ConversationRowMenu via createPortal (escapes sidebar overflow clip),
  forwardRef-based MenuItem so submenu positioning refs survive React 18
- ConversationsContext: suppresses notification sound + OS notif when
  target conversation is muted
- Refresh on `profiles UPDATE` realtime so peer avatar / displayName
  changes flow to conversation.members without manual refresh

Resilience:
- ErrorBoundary (Discord-style): centred spinner + escalating copy, no
  manual reload button. Backoff retry schedule [2s, 4s, 8s, 15s, 30s].
  Uses a keyed Fragment (not a div wrapper) so flex h-full chains survive
- App wrapped root + per-route RouteBoundary, conversation-level boundary
- AuthContext: flip `ready` immediately on cached session read; validate
  getUser in background so a stalled/offline Supabase doesn't freeze the
  app on the loading spinner

Crypto:
- Swap libsodium-wrappers -> libsodium-wrappers-sumo (compact build was
  missing crypto_pwhash so Argon2id vault KDF threw, falling back to
  plaintext localStorage on every launch)
- Shim d.ts for sumo types (sumo is API superset, no official types ship)
- vite optimizeDeps includes sumo with the "require" condition
- secureFileStore: exists(dir) check before mkdir; surface genuine
  permission errors instead of silent catch

Tauri:
- fs scope adds `$APPLOCALDATA` direct entry (not just /**) so the app
  data directory itself can be mkdir'd on first launch

Chat layout:
- Skip call_event messages when computing avatar run boundaries so a
  regular bubble followed by a call event from the same sender still
  shows its avatar
This commit is contained in:
2026-04-20 15:42:49 +02:00
parent 1fab2edc57
commit de431386ea
28 changed files with 1574 additions and 150 deletions
+1 -1
View File
@@ -65,7 +65,7 @@ export function AdminPage() {
}, [refresh]);
return (
<div className="h-full bg-surface-3 text-fg">
<div className="min-h-full bg-surface-3 text-fg">
<div className="mx-auto flex max-w-4xl flex-col gap-6 px-6 py-8">
<header>
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
+126 -26
View File
@@ -1,12 +1,19 @@
import { acceptDm, type ConversationSummary } from '@chat-app/shared/chat';
import {
acceptDm,
type ConversationSummary,
isConversationMuted,
} from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { NavLink, Outlet, useParams } from 'react-router-dom';
import { ConversationRowMenu } from '../components/ConversationRowMenu';
import { CreateGroupDialog } from '../components/CreateGroupDialog';
import {
AddUserIcon,
ArchiveIcon,
BellOffIcon,
ChatBubbleIcon,
SearchIcon,
SpinnerIcon,
@@ -21,6 +28,7 @@ export function ChatsPage() {
const { id: activeId } = useParams<{ id: string }>();
const [createOpen, setCreateOpen] = useState(false);
const [query, setQuery] = useState('');
const [showArchived, setShowArchived] = useState(false);
const sorted = useMemo(() => {
return [...conversations].sort((a, b) => {
@@ -30,7 +38,7 @@ export function ChatsPage() {
});
}, [conversations]);
const filtered = useMemo(() => {
const queryFiltered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return sorted;
return sorted.filter((c) => {
@@ -40,10 +48,24 @@ export function ChatsPage() {
});
}, [sorted, query]);
// Split into active vs archived — the user toggles which bucket shows in the
// main list. Archived conversations with unread messages still surface so
// the user can't accidentally silence an ongoing conversation permanently.
const activeItems = useMemo(
() => queryFiltered.filter((c) => !c.archived),
[queryFiltered],
);
const archivedItems = useMemo(
() => queryFiltered.filter((c) => c.archived),
[queryFiltered],
);
const archivedUnread = archivedItems.reduce((s, c) => s + (unread[c.id] ?? 0), 0);
return (
<div className="flex h-full">
<ConversationList
items={filtered}
items={showArchived ? archivedItems : activeItems}
loading={loading}
error={error}
activeId={activeId}
@@ -60,6 +82,9 @@ export function ChatsPage() {
}
}}
onNewGroup={() => setCreateOpen(true)}
showArchived={showArchived}
onToggleArchived={() => setShowArchived((v) => !v)}
archivedUnread={archivedUnread}
/>
<div className="flex-1 border-l border-line bg-surface-3">
<Outlet />
@@ -79,6 +104,9 @@ interface ConversationListProps {
onQueryChange: (q: string) => void;
onAccept: (id: string) => void;
onNewGroup: () => void;
showArchived: boolean;
onToggleArchived: () => void;
archivedUnread: number;
}
function ConversationList({
@@ -91,6 +119,9 @@ function ConversationList({
onQueryChange,
onAccept,
onNewGroup,
showArchived,
onToggleArchived,
archivedUnread,
}: ConversationListProps) {
const { t } = useTranslation(['app']);
@@ -101,17 +132,53 @@ function ConversationList({
>
<header className="flex items-center justify-between px-4 pb-3 pt-5">
<h2 className="font-display text-base font-semibold tracking-tight text-fg">
{t('app:nav.chats')}
{showArchived
? t('app:chats.archived_title', { defaultValue: 'Archiv' })
: t('app:nav.chats')}
</h2>
<button
type="button"
onClick={onNewGroup}
aria-label={t('app:chats.new_group')}
title={t('app:chats.new_group')}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<AddUserIcon className="h-4 w-4" />
</button>
<div className="flex items-center gap-1">
<button
type="button"
onClick={onToggleArchived}
aria-label={
showArchived
? t('app:chats.show_active', { defaultValue: 'Aktive anzeigen' })
: t('app:chats.show_archived', { defaultValue: 'Archiv anzeigen' })
}
title={
showArchived
? t('app:chats.show_active', { defaultValue: 'Aktive anzeigen' })
: t('app:chats.show_archived', { defaultValue: 'Archiv anzeigen' })
}
className={
'relative flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(showArchived
? 'bg-accent/15 text-accent'
: 'text-fg-muted hover:bg-surface-3 hover:text-fg')
}
>
<ArchiveIcon className="h-4 w-4" />
{!showArchived && archivedUnread > 0 && (
<span
aria-hidden="true"
className="absolute -right-0.5 -top-0.5 flex min-w-[16px] items-center justify-center rounded-full bg-accent px-1 text-[9px] font-bold leading-tight text-accent-fg"
>
{archivedUnread > 9 ? '9+' : archivedUnread}
</span>
)}
</button>
{!showArchived && (
<button
type="button"
onClick={onNewGroup}
aria-label={t('app:chats.new_group')}
title={t('app:chats.new_group')}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<AddUserIcon className="h-4 w-4" />
</button>
)}
</div>
</header>
<div className="px-3 pb-2">
@@ -140,10 +207,24 @@ function ConversationList({
) : items.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-line bg-surface-3 text-accent">
<ChatBubbleIcon className="h-5 w-5" />
{showArchived ? (
<ArchiveIcon className="h-5 w-5" />
) : (
<ChatBubbleIcon className="h-5 w-5" />
)}
</div>
<p className="text-sm text-fg-muted">{t('app:chats.empty_title')}</p>
<p className="text-xs text-fg-muted/80">{t('app:chats.empty_subtitle')}</p>
<p className="text-sm text-fg-muted">
{showArchived
? t('app:chats.archived_empty_title', { defaultValue: 'Nichts archiviert' })
: t('app:chats.empty_title')}
</p>
<p className="text-xs text-fg-muted/80">
{showArchived
? t('app:chats.archived_empty_subtitle', {
defaultValue: 'Archivierte Unterhaltungen erscheinen hier.',
})
: t('app:chats.empty_subtitle')}
</p>
</div>
) : (
<ul className="flex-1 overflow-y-auto px-2 pb-2">
@@ -213,11 +294,12 @@ function ConversationRow({
);
}
const muted = isConversationMuted(item.mutedUntil);
return (
<NavLink
to={'/chats/' + item.id}
className={
'my-1 flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
'group relative my-1 flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(active
? 'bg-accent/15 text-fg'
: 'text-fg hover:bg-surface-3/70')
@@ -225,24 +307,42 @@ function ConversationRow({
>
<ConvAvatar url={avatarUrl} title={title} isGroup={item.type === 'group'} />
<div className="min-w-0 flex-1">
<p
className={
'truncate text-sm ' +
(unreadCount > 0 ? 'font-bold' : 'font-semibold')
}
>
{title}
</p>
<div className="flex items-center gap-1.5">
<p
className={
'truncate text-sm ' +
(unreadCount > 0 && !muted ? 'font-bold' : 'font-semibold')
}
>
{title}
</p>
{muted && (
<BellOffIcon
aria-hidden="true"
className="h-3 w-3 shrink-0 text-fg-muted"
/>
)}
</div>
<p className="truncate text-xs text-fg-muted">{preview}</p>
</div>
{unreadCount > 0 && (
<span
aria-label={'Unread: ' + unreadCount}
className="inline-flex min-w-[20px] items-center justify-center rounded-full bg-accent px-1.5 text-[10px] font-bold leading-tight text-accent-fg"
className={
'inline-flex min-w-[20px] items-center justify-center rounded-full px-1.5 text-[10px] font-bold leading-tight ' +
(muted
? 'bg-fg-muted/30 text-fg-muted'
: 'bg-accent text-accent-fg')
}
>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
<ConversationRowMenu
conversationId={item.id}
archived={item.archived}
mutedUntil={item.mutedUntil}
/>
</NavLink>
);
}
+272 -28
View File
@@ -5,12 +5,24 @@ import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { ConversationHeader } from '../components/ConversationHeader';
import { ForwardDialog } from '../components/ForwardDialog';
import { GroupInfoPanel } from '../components/GroupInfoPanel';
import { AlertIcon, ArrowRightIcon, PlusIcon, SpinnerIcon, XIcon } from '../components/icons';
import {
AlertIcon,
ArrowRightIcon,
ChevronDownIcon,
ChevronUpIcon,
PlusIcon,
ReplyIcon,
SearchIcon,
SpinnerIcon,
XIcon,
} from '../components/icons';
import { InCallPanel } from '../components/InCallPanel';
import { IncomingCallPanel } from '../components/IncomingCallPanel';
import { MessageBubble } from '../components/MessageBubble';
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
import { TypingIndicator } from '../components/TypingIndicator';
import type { DecryptedMessage } from '@chat-app/shared/chat';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useConversationsContext } from '../context/ConversationsContext';
@@ -83,8 +95,114 @@ export function ConversationPage() {
const [stickToBottom, setStickToBottom] = useState(true);
const [attachments, setAttachments] = useState<File[]>([]);
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
const [replyTo, setReplyTo] = useState<DecryptedMessage | null>(null);
const [forwardTarget, setForwardTarget] = useState<DecryptedMessage | null>(null);
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchIdx, setSearchIdx] = useState(0);
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const composerRef = useRef<HTMLTextAreaElement>(null);
// Drop reply-to / clear search state when switching conversation.
useEffect(() => {
setReplyTo(null);
setForwardTarget(null);
setSearchOpen(false);
setSearchQuery('');
}, [id]);
const messageById = useMemo(() => {
const m = new Map<string, DecryptedMessage>();
for (const msg of messages) m.set(msg.id, msg);
return m;
}, [messages]);
const senderNameFor = useCallback(
(senderId: string): string => {
if (senderId === myId) return t('app:chats.you', { defaultValue: 'Du' });
const profile =
conversation?.members.find((mm) => mm.userId === senderId)?.profile ??
(senderId !== myId ? conversation?.peer ?? null : null);
return profile?.displayName ?? '?';
},
[conversation, myId, t],
);
const buildQuoted = useCallback(
(replyToId: string | null): QuotedRef | null => {
if (!replyToId) return null;
const target = messageById.get(replyToId);
if (!target) {
return {
id: replyToId,
senderName: '…',
snippet: t('app:chats.quote_unavailable', { defaultValue: 'Nachricht nicht verfügbar' }),
isAttachment: false,
deleted: true,
};
}
const parsed = parseMessagePayload(target.plaintext);
const text = parsed.kind === 'text' ? parsed.text : '';
const hasAttachment = parsed.kind === 'text' && parsed.attachments.length > 0;
return {
id: target.id,
senderName: senderNameFor(target.senderId),
snippet: text.length > 120 ? text.slice(0, 120) + '…' : text,
isAttachment: hasAttachment,
deleted: !!target.deletedAt,
};
},
[messageById, senderNameFor, t],
);
const jumpToMessage = useCallback((targetId: string) => {
const el = scrollRef.current?.querySelector<HTMLElement>(
'[data-message-id="' + CSS.escape(targetId) + '"]',
);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
setHighlightedId(targetId);
window.setTimeout(() => setHighlightedId((cur) => (cur === targetId ? null : cur)), 1600);
}, []);
const handleReply = useCallback((m: DecryptedMessage) => {
setReplyTo(m);
composerRef.current?.focus();
}, []);
const handleForward = useCallback((m: DecryptedMessage) => {
setForwardTarget(m);
}, []);
// Search matches: messages whose decrypted text includes the query.
const searchMatches = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return [] as DecryptedMessage[];
return messages.filter((m) => {
if (!m.plaintext) return false;
const parsed = parseMessagePayload(m.plaintext);
if (parsed.kind !== 'text') return false;
return parsed.text.toLowerCase().includes(q);
});
}, [messages, searchQuery]);
// Reset/clamp the active match index when the match set changes.
useEffect(() => {
if (searchMatches.length === 0) {
setSearchIdx(0);
return;
}
setSearchIdx((cur) => Math.min(cur, searchMatches.length - 1));
}, [searchMatches.length]);
// Auto-jump to current match.
useEffect(() => {
if (!searchOpen || searchMatches.length === 0) return;
const target = searchMatches[searchIdx];
if (target) jumpToMessage(target.id);
}, [searchOpen, searchMatches, searchIdx, jumpToMessage]);
useEffect(() => {
if (!id) return;
@@ -123,9 +241,10 @@ export function ConversationPage() {
setSending(true);
setSendError(null);
try {
await send(text, attachments);
await send(text, attachments, replyTo?.id ?? null);
setText('');
setAttachments([]);
setReplyTo(null);
if (fileInputRef.current) fileInputRef.current.value = '';
setStickToBottom(true);
notifyStopTyping();
@@ -178,10 +297,32 @@ export function ConversationPage() {
<ConversationHeader
conversation={conversation}
peerPresence={peerPresence}
onSearchClick={() => setSearchOpen((v) => !v)}
{...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})}
/>
)}
{!callHereActive && searchOpen && (
<SearchBar
query={searchQuery}
onQueryChange={setSearchQuery}
matches={searchMatches.length}
activeIdx={searchIdx}
onPrev={() =>
setSearchIdx((cur) =>
searchMatches.length === 0 ? 0 : (cur - 1 + searchMatches.length) % searchMatches.length,
)
}
onNext={() =>
setSearchIdx((cur) => (searchMatches.length === 0 ? 0 : (cur + 1) % searchMatches.length))
}
onClose={() => {
setSearchOpen(false);
setSearchQuery('');
}}
/>
)}
{isGroup && conversation && (
<GroupInfoPanel
open={infoPanelOpen}
@@ -205,19 +346,24 @@ export function ConversationPage() {
) : (
<ul className="space-y-0.5">
{messages.map((m, idx) => {
// Skip call_event messages when computing run boundaries — they
// render as centred separators, not chat bubbles, so they
// shouldn't count toward sender continuity. Without this,
// a real bubble followed by a call event from the same sender
// would be treated as "in the middle of a run" and lose its
// avatar.
const prev = findAdjacent(messages, idx, -1);
const next = findAdjacent(messages, idx, +1);
const grouped = !!prev && prev.senderId === m.senderId;
// 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
// anchor the avatar, even if another bubble from the same
// sender appears after the separator.
const prevRaw = messages[idx - 1];
const nextRaw = messages[idx + 1];
const prevIsCallEvent =
!!prevRaw && parseMessagePayload(prevRaw.plaintext).kind === 'call_event';
const nextIsCallEvent =
!!nextRaw && parseMessagePayload(nextRaw.plaintext).kind === 'call_event';
const grouped =
!!prevRaw && prevRaw.senderId === m.senderId && !prevIsCallEvent;
// Anchor avatar on the LAST message of a run so it aligns with
// the bubble's tail (bottom corner). Tail is bottom-left for
// mine, bottom-right for peer — see rounded-[…_4px_…] above.
const isLastOfRun = !next || next.senderId !== m.senderId;
const isLastOfRun =
!nextRaw || nextRaw.senderId !== m.senderId || nextIsCallEvent;
// DM fallback: if member lookup fails (e.g. transient sync), fall
// back to conversation.peer so the peer's avatar still resolves.
const memberProfile =
@@ -238,6 +384,11 @@ export function ConversationPage() {
reactions={reactionsByMessage.get(m.id) ?? []}
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)}
showSeen={m.id === lastSeenMessageId}
quoted={buildQuoted(m.replyToId)}
onJumpToMessage={jumpToMessage}
onReply={handleReply}
onForward={handleForward}
highlighted={highlightedId === m.id}
/>
</li>
);
@@ -258,6 +409,38 @@ export function ConversationPage() {
</div>
)}
{replyTo && (
<div className="mb-2 flex items-stretch gap-2 rounded-lg border border-line bg-surface-2 p-2 text-sm">
<span aria-hidden="true" className="w-1 shrink-0 rounded-full bg-accent" />
<ReplyIcon className="mt-0.5 h-4 w-4 shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-semibold text-fg">
{t('app:chats.replying_to', {
name: senderNameFor(replyTo.senderId),
defaultValue: 'Antwort an ' + senderNameFor(replyTo.senderId),
})}
</p>
<p className="truncate text-xs italic text-fg-muted">
{(() => {
if (!replyTo.plaintext) return '…';
const p = parseMessagePayload(replyTo.plaintext);
if (p.kind !== 'text') return '';
if (!p.text && p.attachments.length > 0) return '📎';
return p.text;
})()}
</p>
</div>
<button
type="button"
onClick={() => setReplyTo(null)}
aria-label={t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
className="shrink-0 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>
)}
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map((file, idx) => (
@@ -291,6 +474,7 @@ export function ConversationPage() {
<PlusIcon className="h-4 w-4" />
</button>
<textarea
ref={composerRef}
value={text}
onChange={(e) => {
setText(e.target.value);
@@ -316,6 +500,81 @@ export function ConversationPage() {
</button>
</div>
</form>
<ForwardDialog
open={forwardTarget !== null}
message={forwardTarget}
currentConversationId={id ?? null}
onClose={() => setForwardTarget(null)}
/>
</div>
);
}
interface SearchBarProps {
query: string;
onQueryChange: (q: string) => void;
matches: number;
activeIdx: number;
onPrev: () => void;
onNext: () => void;
onClose: () => void;
}
function SearchBar({ query, onQueryChange, matches, activeIdx, 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>
);
}
@@ -324,21 +583,6 @@ export function ConversationPage() {
// 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 findAdjacent<T extends { plaintext: string | null }>(
messages: T[],
idx: number,
step: 1 | -1,
): T | undefined {
let i = idx + step;
while (i >= 0 && i < messages.length) {
const m = messages[i];
if (!m) return undefined;
if (parseMessagePayload(m.plaintext).kind !== 'call_event') return m;
i += step;
}
return undefined;
}
function Banner({ children }: { children: React.ReactNode }) {
return (
<div
+2 -2
View File
@@ -107,8 +107,8 @@ export function FriendsPage() {
);
return (
<div className="h-full bg-surface-3 text-fg">
<div className="mx-auto flex h-full max-w-3xl flex-col gap-5 px-6 py-8">
<div className="min-h-full bg-surface-3 text-fg">
<div className="mx-auto flex min-h-full max-w-3xl flex-col gap-5 px-6 py-8">
<header className="flex items-center justify-between gap-4">
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
{t('app:friends.title')}
+1 -1
View File
@@ -76,7 +76,7 @@ export function SettingsPage() {
}
return (
<div className="h-full bg-surface-3 text-fg">
<div className="min-h-full bg-surface-3 text-fg">
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
<header className="mb-2">
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">