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