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
+89 -9
View File
@@ -56,6 +56,8 @@ interface Props {
onReply?: (m: DecryptedMessage) => void;
/** Hover action: parent opens forward dialog for current message. */
onForward?: (m: DecryptedMessage) => void;
/** Click on the message's avatar surfaces the author's profile card. */
onAvatarClick?: (userId: string, ev: React.MouseEvent) => void;
/** Highlighted state — set briefly after a jump. */
highlighted?: boolean;
}
@@ -76,6 +78,7 @@ export function MessageBubble({
onJumpToMessage,
onReply,
onForward,
onAvatarClick,
highlighted = false,
}: Props) {
const { t } = useTranslation(['app']);
@@ -97,6 +100,47 @@ export function MessageBubble({
const withinEditWindow = age < EDIT_WINDOW_MS;
const bodyText = initialText;
const attachments = initialAttachments;
// /tempmsg ephemeral window. expireMs embedded in plaintext JSON; sender
// fires the soft-delete when the clock runs out. Receivers just watch
// the deletedAt flip via realtime.
const expireMs = parsed.kind === 'text' ? parsed.expireMs : undefined;
const [tickNow, setTickNow] = useState<number>(() => Date.now());
useEffect(() => {
if (expireMs === undefined) return;
const id = window.setInterval(() => setTickNow(Date.now()), 1000);
return () => window.clearInterval(id);
}, [expireMs]);
const msLeft =
expireMs !== undefined
? Math.max(0, expireMs - (tickNow - createdAt.getTime()))
: null;
useEffect(() => {
if (!mine) return;
if (expireMs === undefined) return;
if (message.deletedAt) return;
const remaining = Math.max(0, expireMs - age);
const timer = window.setTimeout(() => {
void softDeleteMessage(supabase, message.id).catch((err: unknown) => {
console.warn('ephemeral auto-delete failed', err);
});
}, remaining);
return () => window.clearTimeout(timer);
}, [mine, expireMs, age, message.id, message.deletedAt]);
// Receiver-side auto-hide when the expiry window elapses even if the
// sender's delete hasn't propagated yet (network hiccup, offline-sender).
const [localExpired, setLocalExpired] = useState<boolean>(
msLeft !== null && msLeft <= 0,
);
useEffect(() => {
if (expireMs === undefined) return;
if (localExpired) return;
const remaining = Math.max(0, expireMs - age);
const t = window.setTimeout(() => setLocalExpired(true), remaining);
return () => window.clearTimeout(t);
}, [expireMs, age, localExpired]);
const canEdit =
parsed.kind === 'text' &&
mine &&
@@ -181,13 +225,16 @@ export function MessageBubble({
[onToggleReaction],
);
if (message.deletedAt) {
if (message.deletedAt || localExpired) {
return (
<div className={'flex items-end gap-2 ' + (mine ? 'flex-row-reverse' : '')}>
<AvatarSlot
show={isLastOfRun}
url={senderAvatarUrl ?? null}
displayName={senderDisplayName ?? null}
{...(onAvatarClick
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
: {})}
/>
<div className="max-w-[calc(70%-2.5rem)] rounded-2xl border border-line bg-surface-2 px-3.5 py-1.5 text-xs italic text-fg-muted">
{t('app:chats.deleted')}
@@ -274,23 +321,30 @@ export function MessageBubble({
type="button"
onClick={() => onJumpToMessage?.(quoted.id)}
className={
'mb-1.5 flex w-full cursor-pointer items-stretch gap-2 rounded-md px-2 py-1.5 text-left text-xs transition hover:opacity-90 ' +
'mb-1.5 flex w-full cursor-pointer items-stretch gap-2 rounded-md pl-2 pr-2.5 py-1.5 text-left text-xs transition hover:brightness-110 hover:shadow-sm ' +
(mine
? 'bg-white/15 text-accent-fg/90'
: 'bg-surface-2 text-fg-muted')
? 'bg-white/10 text-accent-fg/90'
: 'bg-surface-2/80 text-fg-muted ring-1 ring-inset ring-line')
}
>
<span
aria-hidden="true"
className={
'w-0.5 shrink-0 rounded-full ' + (mine ? 'bg-white/50' : 'bg-accent')
'-ml-1 w-1 shrink-0 rounded-full ' +
(mine ? 'bg-white/70' : 'bg-accent')
}
/>
<span className="min-w-0 flex-1">
<span className={'block truncate font-semibold ' + (mine ? '' : 'text-fg')}>
{quoted.senderName}
<span className="min-w-0 flex-1 pl-1">
<span
className={
'flex items-center gap-1 truncate text-[11px] font-semibold ' +
(mine ? 'text-accent-fg' : 'text-accent')
}
>
<ReplyIcon className="h-3 w-3 shrink-0" />
<span className="truncate">{quoted.senderName}</span>
</span>
<span className="block truncate italic opacity-90">
<span className="mt-0.5 block truncate opacity-80">
{quoted.deleted
? t('app:chats.deleted')
: quoted.isAttachment && !quoted.snippet
@@ -337,6 +391,17 @@ export function MessageBubble({
{message.editedAt && !message.deletedAt && (
<span className="italic">· {t('app:chats.edited')}</span>
)}
{msLeft !== null && msLeft > 0 && (
<span
className={
'inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider ' +
(mine ? 'bg-white/20' : 'bg-rose-500/15 text-rose-600 dark:text-rose-300')
}
title="Selbstzerstörung"
>
{Math.ceil(msLeft / 1000)}s
</span>
)}
</div>
</div>
)}
@@ -457,14 +522,29 @@ function AvatarSlot({
show,
url,
displayName,
onClick,
}: {
show: boolean;
url: string | null;
displayName: string | null;
onClick?: (ev: React.MouseEvent) => void;
}) {
if (!show) {
return <div aria-hidden="true" className="h-8 w-8 shrink-0" />;
}
if (onClick) {
return (
<button
type="button"
data-user-popover-trigger
onClick={onClick}
className="shrink-0 cursor-pointer rounded-full transition hover:ring-2 hover:ring-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
aria-label={displayName ?? 'Profil'}
>
<Avatar url={url} displayName={displayName ?? ''} className="h-8 w-8 text-xs" />
</button>
);
}
return (
<Avatar
url={url}