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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user