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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Avatar } from '../components/Avatar';
|
||||
import { BackupExportDialog } from '../components/BackupExportDialog';
|
||||
import { BackupRestoreDialog } from '../components/BackupRestoreDialog';
|
||||
import { RingtoneSettings } from '../components/RingtoneSettings';
|
||||
import { SoundboardSettings } from '../components/SoundboardSettings';
|
||||
import { LockIcon } from '../components/icons';
|
||||
@@ -347,10 +348,99 @@ function AudioQualityControls() {
|
||||
'Mono 48 kbps mit Noise-Suppression, Echo-Cancellation und Auto-Gain. Optimiert für Sprache im Raum.',
|
||||
})}
|
||||
</p>
|
||||
|
||||
<SettingRow
|
||||
label={t('app:settings.noise_suppression', { defaultValue: 'Noise Suppression' })}
|
||||
>
|
||||
<InlineToggle
|
||||
checked={cfg.noiseSuppression}
|
||||
onChange={(v) => updateAudioSettings({ noiseSuppression: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t('app:settings.noise_suppression_hint', {
|
||||
defaultValue:
|
||||
'Unterdrückt Hintergrundgeräusche (Tastatur, Lüfter, Café-Lärm). Ausschalten nur bei Musik/Instrumenten.',
|
||||
})}
|
||||
</p>
|
||||
|
||||
<SettingRow
|
||||
label={t('app:settings.video_blur', {
|
||||
defaultValue: 'Video-Hintergrund unscharf',
|
||||
})}
|
||||
>
|
||||
<InlineToggle
|
||||
checked={cfg.videoBackgroundBlur}
|
||||
onChange={(v) => updateAudioSettings({ videoBackgroundBlur: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t('app:settings.video_blur_hint', {
|
||||
defaultValue:
|
||||
'Blendet den Hintergrund hinter dir aus. Braucht etwas GPU-Leistung und lädt beim ersten Aktivieren ~1,5 MB Modell nach.',
|
||||
})}
|
||||
</p>
|
||||
|
||||
<SettingRow
|
||||
label={t('app:settings.voice_threshold', {
|
||||
defaultValue: 'Sprach-Erkennungs-Schwelle',
|
||||
})}
|
||||
>
|
||||
<div className="flex w-48 items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={0.005}
|
||||
max={0.1}
|
||||
step={0.005}
|
||||
value={cfg.voiceThreshold}
|
||||
onChange={(e) =>
|
||||
updateAudioSettings({ voiceThreshold: Number(e.target.value) })
|
||||
}
|
||||
className="flex-1 accent-accent"
|
||||
/>
|
||||
<span className="w-10 tabular-nums text-right text-[11px] text-fg-muted">
|
||||
{(cfg.voiceThreshold * 100).toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t('app:settings.voice_threshold_hint', {
|
||||
defaultValue:
|
||||
'Wann der grüne Sprech-Ring aufleuchtet. Niedriger = empfindlicher (leise Stimme erfassen), höher = tolerant gegen Raumlärm.',
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineToggle({
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={
|
||||
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(checked ? 'bg-accent' : 'bg-surface')
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
'absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition ' +
|
||||
(checked ? 'translate-x-5' : '')
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface AvatarControlsProps {
|
||||
patchProfile: (patch: Parameters<typeof updateOwnProfile>[1]) => Promise<void>;
|
||||
busy: boolean;
|
||||
@@ -464,6 +554,7 @@ function DeviceKeyBackupControls() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { profile, device } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [restoreOpen, setRestoreOpen] = useState(false);
|
||||
const [privateKey, setPrivateKey] = useState<Uint8Array | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const canRun = !!profile?.userId && !!device?.id;
|
||||
@@ -502,14 +593,24 @@ function DeviceKeyBackupControls() {
|
||||
'Backup deines Geräteschlüssels. Ohne Backup kein Zugriff auf alte Nachrichten wenn Gerät oder Browser-Storage verloren geht. Wiederherstellung passiert im Gerät-Einrichtungsbildschirm.',
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canRun}
|
||||
onClick={() => void handleOpen()}
|
||||
className="mt-3 inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:backup.export_open', { defaultValue: 'Backup erstellen' })}
|
||||
</button>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canRun}
|
||||
onClick={() => void handleOpen()}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:backup.export_open', { defaultValue: 'Backup erstellen' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!profile?.userId}
|
||||
onClick={() => setRestoreOpen(true)}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-semibold text-fg transition hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:backup.restore_open', { defaultValue: 'Backup wiederherstellen' })}
|
||||
</button>
|
||||
</div>
|
||||
{err && (
|
||||
<p className="mt-2 text-xs text-rose-600 dark:text-rose-300">{err}</p>
|
||||
)}
|
||||
@@ -523,6 +624,14 @@ function DeviceKeyBackupControls() {
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{profile && (
|
||||
<BackupRestoreDialog
|
||||
open={restoreOpen}
|
||||
userId={profile.userId}
|
||||
onClose={() => setRestoreOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user