feat: backup/restore, user profile popover, image compress, video blur, wake lock

- backup/restore dialog + user profile popover components
- image compression, video blur, wake lock utilities
- message cache + conversation messages hook refinements
- call context, active speakers, screen share dialog tweaks
- audio + screen share settings persistence
- refreshed app icons (smaller sizes) across all platforms
This commit is contained in:
2026-04-21 12:11:09 +02:00
parent 48ac9d2922
commit 1303c8e26f
71 changed files with 1077 additions and 114 deletions
+130 -8
View File
@@ -24,12 +24,15 @@ import { InCallPanel } from '../components/InCallPanel';
import { IncomingCallPanel } from '../components/IncomingCallPanel';
import { MentionAutocomplete } from '../components/MentionAutocomplete';
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
import { UserProfilePopover } from '../components/UserProfilePopover';
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 { compressImages } from '../lib/imageCompress';
import { searchCachedMessages } from '../lib/messageCache';
import type { OutboxItem } from '../lib/messageOutbox';
import { useConversationMessages } from '../lib/useConversationMessages';
import { useMessageReactions } from '../lib/useMessageReactions';
@@ -45,7 +48,7 @@ export function ConversationPage() {
const { t } = useTranslation(['app', 'errors']);
const { id } = useParams<{ id: string }>();
const { session, device } = useAuth();
const { conversations, setActiveConversation, markRead } = useConversationsContext();
const { conversations, setActiveConversation, markRead, unread } = useConversationsContext();
const conversation = useMemo(
() => conversations.find((c) => c.id === id) ?? null,
@@ -145,6 +148,14 @@ export function ConversationPage() {
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const [displayCount, setDisplayCount] = useState<number>(150);
const [isDraggingFile, setIsDraggingFile] = useState(false);
// Snapshot of the "first-unread-message" id captured once the very first
// render of this conversation lands. Stays fixed until the user switches
// away so the divider doesn't jump around while new messages arrive.
const firstUnreadRef = useRef<string | null>(null);
const firstUnreadComputedRef = useRef<boolean>(false);
const [profilePopover, setProfilePopover] = useState<
{ userId: string; x: number; y: number } | null
>(null);
const [mentionState, setMentionState] = useState<
{ query: string; start: number } | null
>(null);
@@ -161,8 +172,27 @@ export function ConversationPage() {
setSearchOpen(false);
setSearchQuery('');
setDisplayCount(150);
firstUnreadRef.current = null;
firstUnreadComputedRef.current = false;
}, [id]);
// On first message-list populate for this conversation, pin the divider
// above the oldest-unread message. We only compute once — subsequent
// inserts push the divider "further back" visually, which matches
// Discord's behaviour.
useEffect(() => {
if (firstUnreadComputedRef.current) return;
if (!id || messages.length === 0) return;
const count = unread[id] ?? 0;
firstUnreadComputedRef.current = true;
if (count === 0 || count > messages.length) {
firstUnreadRef.current = null;
return;
}
const boundary = messages[messages.length - count];
firstUnreadRef.current = boundary ? boundary.id : null;
}, [id, messages, unread]);
// Expand window when the "load older" sentinel scrolls into view. Doubles
// effective window on each trigger so scrolling up quickly converges to
// rendering everything.
@@ -257,6 +287,32 @@ export function ConversationPage() {
searchDateTo !== '',
[searchQuery, searchSenderId, searchAttachmentsOnly, searchDateFrom, searchDateTo],
);
// FTS5-backed supplementary results: covers cached messages that aren't in
// the currently-loaded window (`messages`). Runs only when there's a text
// query — filters alone stay in-memory because they depend on already-
// decrypted payload state.
const [ftsExtras, setFtsExtras] = useState<DecryptedMessage[]>([]);
useEffect(() => {
if (!id) {
setFtsExtras([]);
return;
}
const q = searchQuery.trim();
if (q.length < 2) {
setFtsExtras([]);
return;
}
let cancelled = false;
void searchCachedMessages(id, q, 200).then((rows) => {
if (cancelled) return;
setFtsExtras(rows);
});
return () => {
cancelled = true;
};
}, [id, searchQuery]);
const searchMatches = useMemo(() => {
if (!searchActive) return [] as DecryptedMessage[];
const q = searchQuery.trim().toLowerCase();
@@ -265,7 +321,26 @@ export function ConversationPage() {
const toTs = searchDateTo
? new Date(searchDateTo).getTime() + 24 * 3600 * 1000 - 1
: null;
return messages.filter((m) => {
// Union the live `messages` array with any FTS5-only rows not yet
// loaded into memory, keyed by id so we don't double-count.
const seen = new Set<string>();
const pool: DecryptedMessage[] = [];
for (const m of messages) {
if (!seen.has(m.id)) {
seen.add(m.id);
pool.push(m);
}
}
for (const m of ftsExtras) {
if (!seen.has(m.id)) {
seen.add(m.id);
pool.push(m);
}
}
pool.sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
return pool.filter((m) => {
if (searchSenderId && m.senderId !== searchSenderId) return false;
const created = new Date(m.createdAt).getTime();
if (fromTs !== null && created < fromTs) return false;
@@ -277,7 +352,16 @@ export function ConversationPage() {
if (q && !parsed.text.toLowerCase().includes(q)) return false;
return true;
});
}, [messages, searchActive, searchQuery, searchSenderId, searchAttachmentsOnly, searchDateFrom, searchDateTo]);
}, [
messages,
ftsExtras,
searchActive,
searchQuery,
searchSenderId,
searchAttachmentsOnly,
searchDateFrom,
searchDateTo,
]);
// Reset/clamp the active match index when the match set changes.
useEffect(() => {
@@ -353,9 +437,13 @@ export function ConversationPage() {
}
}
function ingestFiles(files: File[]) {
async function ingestFiles(files: File[]) {
// Pre-compression so heavy phone photos (typically 4-8MB) don't bust the
// 10MB limit and don't waste storage/bandwidth. Non-image + animated
// files are passed through unchanged.
const compressed = await compressImages(files);
const next: File[] = [];
for (const f of files) {
for (const f of compressed) {
if (f.size > 10 * 1024 * 1024) {
setSendError('Datei zu groß (max 10 MB)');
continue;
@@ -367,7 +455,7 @@ export function ConversationPage() {
function handleFilesChosen(list: FileList | null) {
if (!list) return;
ingestFiles(Array.from(list));
void ingestFiles(Array.from(list));
}
const { state: callState } = useCall();
@@ -405,7 +493,7 @@ export function ConversationPage() {
if (!e.dataTransfer?.files?.length) return;
e.preventDefault();
setIsDraggingFile(false);
ingestFiles(Array.from(e.dataTransfer.files));
void ingestFiles(Array.from(e.dataTransfer.files));
}}
>
{!callHereActive && (
@@ -512,6 +600,18 @@ export function ConversationPage() {
(m.senderId !== myId ? (conversation?.peer ?? null) : null);
return (
<li key={m.id}>
{firstUnreadRef.current === m.id && (
<div
aria-label="Neue Nachrichten"
className="my-2 flex items-center gap-3 px-2"
>
<span className="h-px flex-1 bg-rose-500/60" />
<span className="rounded-full bg-rose-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-rose-500">
Neue Nachrichten
</span>
<span className="h-px flex-1 bg-rose-500/60" />
</div>
)}
<MessageBubble
message={m}
mine={m.senderId === myId}
@@ -540,6 +640,14 @@ export function ConversationPage() {
onJumpToMessage={jumpToMessage}
onReply={handleReply}
onForward={handleForward}
onAvatarClick={(uid, ev) => {
ev.stopPropagation();
setProfilePopover({
userId: uid,
x: ev.clientX,
y: ev.clientY,
});
}}
highlighted={highlightedId === m.id}
/>
</li>
@@ -754,7 +862,7 @@ export function ConversationPage() {
}
if (pics.length > 0) {
e.preventDefault();
ingestFiles(pics);
void ingestFiles(pics);
}
}}
rows={1}
@@ -778,6 +886,20 @@ export function ConversationPage() {
currentConversationId={id ?? null}
onClose={() => setForwardTarget(null)}
/>
{profilePopover && (
<UserProfilePopover
userId={profilePopover.userId}
profile={
conversation?.members.find((m) => m.userId === profilePopover.userId)?.profile ??
conversation?.peer ??
null
}
x={profilePopover.x}
y={profilePopover.y}
onClose={() => setProfilePopover(null)}
/>
)}
</div>
);
}