Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a560f24c45 | |||
| 87c2e45bb4 | |||
| b65a3994f3 | |||
| aa609389fa | |||
| cb3cbd8827 | |||
| d1f38ce313 | |||
| 0d65a134fd | |||
| 12c66d676a | |||
| 0dde1dd1a3 | |||
| 81d3587a91 |
@@ -28,6 +28,13 @@ import {
|
|||||||
type UpdateCheckResult,
|
type UpdateCheckResult,
|
||||||
type UpdateProgress,
|
type UpdateProgress,
|
||||||
} from './ipc-types';
|
} from './ipc-types';
|
||||||
|
// Static import of the desktop package.json so the bundler inlines the
|
||||||
|
// version string at build time. The previous `process.env.npm_package_-
|
||||||
|
// version` approach worked in dev (pnpm sets it as a script env var) but
|
||||||
|
// fell back to '0.0.0' in packaged builds — every installed user got
|
||||||
|
// flagged as "Update verfügbar" against their own actually-current
|
||||||
|
// version. resolveJsonModule + esModuleInterop are on in tsconfig.node.
|
||||||
|
import pkg from '../package.json';
|
||||||
|
|
||||||
type Unsubscribe = () => void;
|
type Unsubscribe = () => void;
|
||||||
|
|
||||||
@@ -40,7 +47,7 @@ function on<T>(channel: string, cb: (payload: T) => void): Unsubscribe {
|
|||||||
const api = {
|
const api = {
|
||||||
platform: ELECTRON_RUNTIME_MARKER,
|
platform: ELECTRON_RUNTIME_MARKER,
|
||||||
osPlatform: process.platform as NodeJS.Platform,
|
osPlatform: process.platform as NodeJS.Platform,
|
||||||
appVersion: process.env.npm_package_version ?? '0.0.0',
|
appVersion: pkg.version,
|
||||||
|
|
||||||
// Screen sources ---------------------------------------------------------
|
// Screen sources ---------------------------------------------------------
|
||||||
getScreenSources: (): Promise<ScreenSource[]> => ipcRenderer.invoke(CHANNELS.SCREEN_GET_SOURCES),
|
getScreenSources: (): Promise<ScreenSource[]> => ipcRenderer.invoke(CHANNELS.SCREEN_GET_SOURCES),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@chat-app/desktop",
|
"name": "@chat-app/desktop",
|
||||||
"version": "0.17.1",
|
"version": "0.17.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { AlertIcon, SpinnerIcon, XIcon } from './icons';
|
import { AlertIcon, SpinnerIcon } from './icons';
|
||||||
|
import { Lightbox } from './Lightbox';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
handle: AttachmentHandle;
|
handle: AttachmentHandle;
|
||||||
@@ -136,42 +137,5 @@ export function AttachmentImage({ handle }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Lightbox({ url, onClose }: { url: string; onClose: () => void }) {
|
// Lightbox extracted to ./Lightbox.tsx so the settings avatar preview and
|
||||||
useEffect(() => {
|
// any future surface can reuse the same dialog without duplication.
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Escape') onClose();
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', onKey);
|
|
||||||
const prevOverflow = document.body.style.overflow;
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', onKey);
|
|
||||||
document.body.style.overflow = prevOverflow;
|
|
||||||
};
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label="Bildansicht"
|
|
||||||
onClick={onClose}
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/90 p-6 backdrop-blur-sm animate-fade-in"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label="Schließen"
|
|
||||||
className="absolute right-4 top-4 flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-white/10 bg-ink-900/80 text-neutral-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
|
||||||
>
|
|
||||||
<XIcon className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
<img
|
|
||||||
src={url}
|
|
||||||
alt="attachment full"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="max-h-[92vh] max-w-[92vw] rounded-xl object-contain shadow-2xl"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export function GroupInfoPanel({ open, onClose, conversation }: Props) {
|
|||||||
<aside
|
<aside
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label={t('app:group.info_title')}
|
aria-label={t('app:group.info_title')}
|
||||||
className="absolute inset-y-0 right-0 z-20 flex w-[320px] flex-col border-l border-white/5 bg-ink-900/95 shadow-xl backdrop-blur-xl animate-slide-up"
|
className="flex w-[320px] shrink-0 flex-col border-l border-white/5 bg-ink-900/95"
|
||||||
>
|
>
|
||||||
<header className="flex items-center justify-between border-b border-white/5 px-5 py-4">
|
<header className="flex items-center justify-between border-b border-white/5 px-5 py-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { XIcon } from './icons';
|
||||||
|
|
||||||
|
// Fullscreen image viewer. Backdrop click + Esc close. Originally lived
|
||||||
|
// inside AttachmentImage.tsx as a file-private component; extracted here
|
||||||
|
// so other surfaces (settings avatar preview, future profile popover,
|
||||||
|
// etc.) can reuse the exact same dialog without duplicating the chrome.
|
||||||
|
//
|
||||||
|
// The image itself stops click propagation so a click on the picture
|
||||||
|
// keeps the lightbox open — only the backdrop or the explicit close
|
||||||
|
// button dismisses.
|
||||||
|
export function Lightbox({ url, onClose }: { url: string; onClose: () => void }) {
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
const prevOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
document.body.style.overflow = prevOverflow;
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Bildansicht"
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/90 p-6 backdrop-blur-sm animate-fade-in"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Schließen"
|
||||||
|
className="absolute right-4 top-4 flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-white/10 bg-ink-900/80 text-neutral-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
||||||
|
>
|
||||||
|
<XIcon className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="max-h-[92vh] max-w-[92vw] rounded-xl object-contain shadow-2xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ export function MediaFilesDrawer({ open, index, senderNameFor, onJumpToMessage,
|
|||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="absolute inset-y-0 right-0 z-40 flex w-full max-w-[380px] flex-col border-l border-line bg-surface-2 shadow-2xl dark:bg-[#2b2d31]">
|
<aside className="flex w-[380px] shrink-0 flex-col border-l border-line bg-surface-2 dark:bg-[#2b2d31]">
|
||||||
<header className="flex min-h-[65px] items-center gap-3 border-b border-line px-4">
|
<header className="flex min-h-[65px] items-center gap-3 border-b border-line px-4">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent/10 text-accent">
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent/10 text-accent">
|
||||||
<ImageIcon className="h-5 w-5" />
|
<ImageIcon className="h-5 w-5" />
|
||||||
|
|||||||
@@ -294,7 +294,14 @@ export function MessageBubble({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.kind === 'call_event') {
|
if (parsed.kind === 'call_event') {
|
||||||
return <CallEventRow parsed={parsed} mine={mine} time={time} />;
|
return (
|
||||||
|
<CallEventRow
|
||||||
|
parsed={parsed}
|
||||||
|
mine={mine}
|
||||||
|
time={time}
|
||||||
|
senderDisplayName={senderDisplayName ?? null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -866,10 +873,15 @@ function CallEventRow({
|
|||||||
parsed,
|
parsed,
|
||||||
mine,
|
mine,
|
||||||
time,
|
time,
|
||||||
|
senderDisplayName,
|
||||||
}: {
|
}: {
|
||||||
parsed: { status: string; mediaKind: string; durationSec: number };
|
parsed: { status: string; mediaKind: string; durationSec: number };
|
||||||
mine: boolean;
|
mine: boolean;
|
||||||
time: string;
|
time: string;
|
||||||
|
/** Discord-parity: in group chats the system pill should say WHO
|
||||||
|
* started/missed the call. Null means we don't know (fall back to the
|
||||||
|
* legacy generic labels). */
|
||||||
|
senderDisplayName: string | null;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
const status = parsed.status;
|
const status = parsed.status;
|
||||||
@@ -880,16 +892,36 @@ function CallEventRow({
|
|||||||
? 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200'
|
? 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200'
|
||||||
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200';
|
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200';
|
||||||
|
|
||||||
|
// For non-own events we prefer the name-aware label so group chats
|
||||||
|
// make it clear who triggered the call event. Own events stay generic
|
||||||
|
// ("Outgoing call" / "No answer") since the user already knows they
|
||||||
|
// were the initiator.
|
||||||
|
const hasName = !mine && !!senderDisplayName;
|
||||||
const label =
|
const label =
|
||||||
status === 'ended'
|
status === 'ended'
|
||||||
? mine
|
? mine
|
||||||
? t('app:chats.call_outgoing', { defaultValue: 'Outgoing call' })
|
? t('app:chats.call_outgoing', { defaultValue: 'Outgoing call' })
|
||||||
: t('app:chats.call_incoming', { defaultValue: 'Incoming call' })
|
: hasName
|
||||||
|
? t('app:chats.call_started_by', {
|
||||||
|
name: senderDisplayName,
|
||||||
|
defaultValue: '{{name}} hat einen Anruf gestartet',
|
||||||
|
})
|
||||||
|
: t('app:chats.call_incoming', { defaultValue: 'Incoming call' })
|
||||||
: status === 'missed'
|
: status === 'missed'
|
||||||
? mine
|
? mine
|
||||||
? t('app:chats.call_no_answer', { defaultValue: 'No answer' })
|
? t('app:chats.call_no_answer', { defaultValue: 'No answer' })
|
||||||
: t('app:chats.call_missed', { defaultValue: 'Missed call' })
|
: hasName
|
||||||
: t('app:chats.call_declined', { defaultValue: 'Call declined' });
|
? t('app:chats.call_missed_by', {
|
||||||
|
name: senderDisplayName,
|
||||||
|
defaultValue: 'Verpasster Anruf von {{name}}',
|
||||||
|
})
|
||||||
|
: t('app:chats.call_missed', { defaultValue: 'Missed call' })
|
||||||
|
: hasName
|
||||||
|
? t('app:chats.call_declined_by', {
|
||||||
|
name: senderDisplayName,
|
||||||
|
defaultValue: 'Anruf von {{name}} abgelehnt',
|
||||||
|
})
|
||||||
|
: t('app:chats.call_declined', { defaultValue: 'Call declined' });
|
||||||
|
|
||||||
const duration = parsed.durationSec > 0 ? formatDuration(parsed.durationSec) : null;
|
const duration = parsed.durationSec > 0 ? formatDuration(parsed.durationSec) : null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,33 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { fetchChangelog, type ChangelogEntry } from '../lib/changelog';
|
import { fetchChangelog, type ChangelogEntry } from '../lib/changelog';
|
||||||
import { SparklesIcon, SpinnerIcon } from '../components/icons';
|
import { SparklesIcon, SpinnerIcon } from '../components/icons';
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
// Installed app version comes from the preload bridge (process.env.npm_-
|
||||||
|
// package_version at preload build time). Falls back to '0.0.0' outside
|
||||||
|
// Electron so the page still renders in a browser preview.
|
||||||
|
const installedVersion = window.electronAPI?.appVersion ?? '0.0.0';
|
||||||
|
|
||||||
export function ChangelogPage() {
|
export function ChangelogPage() {
|
||||||
const [entries, setEntries] = useState<ChangelogEntry[] | null>(null);
|
const [entries, setEntries] = useState<ChangelogEntry[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [visible, setVisible] = useState(PAGE_SIZE);
|
const [visible, setVisible] = useState(PAGE_SIZE);
|
||||||
|
|
||||||
|
// Compare the installed version against the top changelog entry. The
|
||||||
|
// server-side changelog is sorted newest-first by the release script, so
|
||||||
|
// entries[0] is always the published latest.
|
||||||
|
const latestVersion = entries?.[0]?.version ?? null;
|
||||||
|
const versionStatus = useMemo<'loading' | 'current' | 'outdated' | 'ahead'>(() => {
|
||||||
|
if (entries === null) return 'loading';
|
||||||
|
if (!latestVersion) return 'current';
|
||||||
|
const cmp = compareSemver(installedVersion, latestVersion);
|
||||||
|
if (cmp === 0) return 'current';
|
||||||
|
if (cmp < 0) return 'outdated';
|
||||||
|
return 'ahead';
|
||||||
|
}, [entries, latestVersion]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -34,7 +52,7 @@ export function ChangelogPage() {
|
|||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-accent/15 text-accent">
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-accent/15 text-accent">
|
||||||
<SparklesIcon className="h-5 w-5" />
|
<SparklesIcon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||||
Was ist neu
|
Was ist neu
|
||||||
</h1>
|
</h1>
|
||||||
@@ -42,6 +60,11 @@ export function ChangelogPage() {
|
|||||||
Alle Änderungen in dieser App, neueste zuerst.
|
Alle Änderungen in dieser App, neueste zuerst.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<VersionBadge
|
||||||
|
status={versionStatus}
|
||||||
|
installed={installedVersion}
|
||||||
|
latest={latestVersion}
|
||||||
|
/>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{entries === null && !error && (
|
{entries === null && !error && (
|
||||||
@@ -118,3 +141,75 @@ function formatDate(iso: string): string {
|
|||||||
return iso;
|
return iso;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compact status chip in the header that tells the user whether their
|
||||||
|
// installed build matches the latest published version. Three visual
|
||||||
|
// tones: emerald (current), amber (outdated → update available), neutral
|
||||||
|
// (loading / unknown). The "ahead" case (dev build > released) shares the
|
||||||
|
// neutral tone since users running it always know what they're doing.
|
||||||
|
function VersionBadge({
|
||||||
|
status,
|
||||||
|
installed,
|
||||||
|
latest,
|
||||||
|
}: {
|
||||||
|
status: 'loading' | 'current' | 'outdated' | 'ahead';
|
||||||
|
installed: string;
|
||||||
|
latest: string | null;
|
||||||
|
}) {
|
||||||
|
if (status === 'loading') {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface-2 px-2.5 py-1 text-[11px] font-medium tabular-nums text-fg-muted">
|
||||||
|
v{installed}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === 'current') {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title="Du läufst auf der neuesten Version."
|
||||||
|
className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-emerald-500/40 bg-emerald-500/10 px-2.5 py-1 text-[11px] font-semibold tabular-nums text-emerald-700 dark:text-emerald-300"
|
||||||
|
>
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||||
|
v{installed} · aktuell
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === 'outdated' && latest) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title={`Update verfügbar — neueste Version: v${latest}.`}
|
||||||
|
className="inline-flex shrink-0 flex-col items-end gap-0.5 rounded-md border border-amber-400/50 bg-amber-400/10 px-2.5 py-1 text-[11px] font-semibold tabular-nums text-amber-800 dark:text-amber-200"
|
||||||
|
>
|
||||||
|
<span>v{installed} · Update verfügbar</span>
|
||||||
|
<span className="text-[10px] font-normal opacity-80">neueste: v{latest}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title="Du läufst auf einer neueren Version als veröffentlicht (z.B. Dev-Build)."
|
||||||
|
className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface-2 px-2.5 py-1 text-[11px] font-medium tabular-nums text-fg-muted"
|
||||||
|
>
|
||||||
|
v{installed}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lightweight semver comparator: parses major.minor.patch as ints and
|
||||||
|
// compares numerically. Returns negative if a < b, zero if equal, positive
|
||||||
|
// if a > b. Handles malformed inputs by treating non-numeric segments as
|
||||||
|
// 0 so a typo doesn't flag a perfectly current install as outdated.
|
||||||
|
function compareSemver(a: string, b: string): number {
|
||||||
|
const parse = (s: string): [number, number, number] => {
|
||||||
|
const parts = s.split('.').map((p) => {
|
||||||
|
const n = parseInt(p, 10);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
});
|
||||||
|
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
|
||||||
|
};
|
||||||
|
const [aMaj, aMin, aPat] = parse(a);
|
||||||
|
const [bMaj, bMin, bPat] = parse(b);
|
||||||
|
if (aMaj !== bMaj) return aMaj - bMaj;
|
||||||
|
if (aMin !== bMin) return aMin - bMin;
|
||||||
|
return aPat - bPat;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { parseMessagePayload } from '@chat-app/shared/chat';
|
import { parseMessagePayload } from '@chat-app/shared/chat';
|
||||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
@@ -49,6 +49,15 @@ import { useTypingChannel } from '../lib/useTypingChannel';
|
|||||||
|
|
||||||
const STICK_THRESHOLD = 80;
|
const STICK_THRESHOLD = 80;
|
||||||
|
|
||||||
|
// Per-conversation scroll memory. Module-scoped so it survives re-mounts
|
||||||
|
// of ConversationPage when the route param (`id`) changes — switching
|
||||||
|
// chats unmounts/remounts the page in our router setup. Session-only
|
||||||
|
// (lost on reload, like Discord). The `stickToBottom` flag is preserved
|
||||||
|
// alongside the pixel offset so a chat the user left at the bottom keeps
|
||||||
|
// auto-following new messages when they return; a chat scrolled up
|
||||||
|
// returns to the exact spot the user was reading.
|
||||||
|
const scrollPositions = new Map<string, { scrollTop: number; stickToBottom: boolean }>();
|
||||||
|
|
||||||
export function ConversationPage() {
|
export function ConversationPage() {
|
||||||
const { t } = useTranslation(['app', 'errors']);
|
const { t } = useTranslation(['app', 'errors']);
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -397,17 +406,60 @@ export function ConversationPage() {
|
|||||||
if (id && messages.length > 0) markRead(id);
|
if (id && messages.length > 0) markRead(id);
|
||||||
}, [id, messages.length, markRead]);
|
}, [id, messages.length, markRead]);
|
||||||
|
|
||||||
useEffect(() => {
|
// useLayoutEffect: run synchronously after DOM commit, before the
|
||||||
|
// browser paints. Using useEffect here let one frame of "scrollTop = 0
|
||||||
|
// (top of list)" paint between message-list mount and the auto-scroll,
|
||||||
|
// which is exactly the "flickers to a different position, then jumps"
|
||||||
|
// glitch users saw when re-entering a chat. Layout-effect fires while
|
||||||
|
// the message list is in the DOM but before paint, so the first frame
|
||||||
|
// already shows the correct scroll position.
|
||||||
|
useLayoutEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (!el || !stickToBottom) return;
|
if (!el || !stickToBottom) return;
|
||||||
el.scrollTop = el.scrollHeight;
|
el.scrollTop = el.scrollHeight;
|
||||||
}, [messages.length, stickToBottom]);
|
}, [messages.length, stickToBottom]);
|
||||||
|
|
||||||
useEffect(() => {
|
// Restore saved scroll position once the conversation's messages have
|
||||||
setStickToBottom(true);
|
// actually rendered. The earlier version fired on `[id]` alone and ran
|
||||||
|
// before the message list populated — scrollHeight was still tiny, so
|
||||||
|
// `el.scrollTop = saved.scrollTop` got clamped to 0 by the browser
|
||||||
|
// and the user landed at the top instead of the saved position. By
|
||||||
|
// waiting for `messages.length > 0` we know the rendered scrollHeight
|
||||||
|
// is meaningful. `restoredForRef` ensures the restore runs at most
|
||||||
|
// once per chat switch (subsequent message arrivals don't re-trigger).
|
||||||
|
const restoredForRef = useRef<string | null>(null);
|
||||||
|
const isRestoringRef = useRef(false);
|
||||||
|
|
||||||
|
// useLayoutEffect, same reason as above: writing scrollTop here happens
|
||||||
|
// before the first paint of the freshly-mounted chat, so the user
|
||||||
|
// doesn't see a frame at scrollTop=0 before the jump to the saved
|
||||||
|
// position. Combined with the messages.length gate this means the
|
||||||
|
// re-entry shows the message list AT the saved scroll location in one
|
||||||
|
// single paint — no "loaded then jumped" effect.
|
||||||
|
useLayoutEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (el) el.scrollTop = el.scrollHeight;
|
if (!el || !id) return;
|
||||||
}, [id]);
|
if (restoredForRef.current === id) return;
|
||||||
|
// Wait for the conversation's messages to populate; for a chat that
|
||||||
|
// truly has zero messages the bottom and the top are the same anyway.
|
||||||
|
if (messages.length === 0) return;
|
||||||
|
restoredForRef.current = id;
|
||||||
|
const saved = scrollPositions.get(id);
|
||||||
|
// Suppress handleScroll's persistence during the programmatic scroll
|
||||||
|
// below — otherwise the browser's clamp/normalisation could write a
|
||||||
|
// different scrollTop back into the Map and lose the saved position.
|
||||||
|
isRestoringRef.current = true;
|
||||||
|
if (saved && !saved.stickToBottom) {
|
||||||
|
el.scrollTop = saved.scrollTop;
|
||||||
|
setStickToBottom(false);
|
||||||
|
} else {
|
||||||
|
setStickToBottom(true);
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
isRestoringRef.current = false;
|
||||||
|
});
|
||||||
|
}, [id, messages.length]);
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
@@ -416,7 +468,14 @@ export function ConversationPage() {
|
|||||||
const nextStick = distanceFromBottom < STICK_THRESHOLD;
|
const nextStick = distanceFromBottom < STICK_THRESHOLD;
|
||||||
setStickToBottom(nextStick);
|
setStickToBottom(nextStick);
|
||||||
if (nextStick) setNewMessagesWhileAway(0);
|
if (nextStick) setNewMessagesWhileAway(0);
|
||||||
}, []);
|
// Persist position per chat so re-entering this conversation lands
|
||||||
|
// where the user left off (see scrollPositions module-level Map).
|
||||||
|
// Skipped during the in-flight restore so we don't immediately
|
||||||
|
// overwrite the saved position with a clamped value.
|
||||||
|
if (id && !isRestoringRef.current) {
|
||||||
|
scrollPositions.set(id, { scrollTop: el.scrollTop, stickToBottom: nextStick });
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
const jumpToBottom = useCallback(() => {
|
const jumpToBottom = useCallback(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
@@ -584,31 +643,19 @@ export function ConversationPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isGroup && conversation && (
|
{/* Layout row: chat column on the left grows to fill remaining width;
|
||||||
<GroupInfoPanel
|
right-hand drawers (Media/Files, Group Info) render as inline
|
||||||
open={infoPanelOpen}
|
siblings so opening one narrows the chat instead of floating on
|
||||||
onClose={() => setInfoPanelOpen(false)}
|
top of it (Discord parity). The chat-column wrapper holds the
|
||||||
conversation={conversation}
|
`relative` anchor for the drag-and-drop overlay further below. */}
|
||||||
/>
|
<div className="flex min-h-0 flex-1 flex-row">
|
||||||
)}
|
<div className="relative flex min-w-0 flex-1 flex-col">
|
||||||
|
{/* Discord-style persistent voice-channel rail. Always visible in groups
|
||||||
<MediaFilesDrawer
|
so anyone can pop in without an invite-ring; hidden in 1:1s unless
|
||||||
open={mediaDrawerOpen}
|
someone is already waiting. Hides automatically once we're in. */}
|
||||||
index={attachmentIndex}
|
{conversation && !incomingHere && <VoiceChannelRail conversation={conversation} />}
|
||||||
senderNameFor={senderNameFor}
|
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
|
||||||
onJumpToMessage={(messageId) => {
|
{conversation && <InCallPanel conversation={conversation} />}
|
||||||
setMediaDrawerOpen(false);
|
|
||||||
jumpToMessage(messageId);
|
|
||||||
}}
|
|
||||||
onClose={() => setMediaDrawerOpen(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Discord-style persistent voice-channel rail. Always visible in groups
|
|
||||||
so anyone can pop in without an invite-ring; hidden in 1:1s unless
|
|
||||||
someone is already waiting. Hides automatically once we're in. */}
|
|
||||||
{conversation && !incomingHere && <VoiceChannelRail conversation={conversation} />}
|
|
||||||
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
|
|
||||||
{conversation && <InCallPanel conversation={conversation} />}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
@@ -960,6 +1007,27 @@ export function ConversationPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isGroup && conversation && (
|
||||||
|
<GroupInfoPanel
|
||||||
|
open={infoPanelOpen}
|
||||||
|
onClose={() => setInfoPanelOpen(false)}
|
||||||
|
conversation={conversation}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MediaFilesDrawer
|
||||||
|
open={mediaDrawerOpen}
|
||||||
|
index={attachmentIndex}
|
||||||
|
senderNameFor={senderNameFor}
|
||||||
|
onJumpToMessage={(messageId) => {
|
||||||
|
setMediaDrawerOpen(false);
|
||||||
|
jumpToMessage(messageId);
|
||||||
|
}}
|
||||||
|
onClose={() => setMediaDrawerOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ForwardDialog
|
<ForwardDialog
|
||||||
open={forwardTarget !== null}
|
open={forwardTarget !== null}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
uploadBannerBlob,
|
uploadBannerBlob,
|
||||||
} from '../lib/bannerUpload';
|
} from '../lib/bannerUpload';
|
||||||
import { ImageCropDialog } from '../components/ImageCropDialog';
|
import { ImageCropDialog } from '../components/ImageCropDialog';
|
||||||
|
import { Lightbox } from '../components/Lightbox';
|
||||||
import { devLocalSecretStore } from '../lib/secretStore';
|
import { devLocalSecretStore } from '../lib/secretStore';
|
||||||
import {
|
import {
|
||||||
getPttSettings,
|
getPttSettings,
|
||||||
@@ -799,6 +800,9 @@ function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
|
|||||||
// ratios.
|
// ratios.
|
||||||
const [cropFile, setCropFile] = useState<File | null>(null);
|
const [cropFile, setCropFile] = useState<File | null>(null);
|
||||||
const [cropKind, setCropKind] = useState<'avatar' | 'banner' | null>(null);
|
const [cropKind, setCropKind] = useState<'avatar' | 'banner' | null>(null);
|
||||||
|
// Lightbox toggle for the avatar live-preview. Clicking the in-page
|
||||||
|
// avatar opens a fullscreen view; clicking outside / Esc dismisses.
|
||||||
|
const [avatarPreviewOpen, setAvatarPreviewOpen] = useState(false);
|
||||||
|
|
||||||
const userId = profile?.userId;
|
const userId = profile?.userId;
|
||||||
const avatarUrl = profile?.avatarUrl ?? null;
|
const avatarUrl = profile?.avatarUrl ?? null;
|
||||||
@@ -930,11 +934,33 @@ function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
|
|||||||
className="relative z-10 flex items-end gap-3 px-4 pb-3"
|
className="relative z-10 flex items-end gap-3 px-4 pb-3"
|
||||||
style={{ marginTop: '-2rem' }}
|
style={{ marginTop: '-2rem' }}
|
||||||
>
|
>
|
||||||
<Avatar
|
<button
|
||||||
url={avatarUrl}
|
type="button"
|
||||||
displayName={displayName}
|
onClick={() => {
|
||||||
className="h-16 w-16 rounded-full text-2xl ring-4 ring-surface-3"
|
if (avatarUrl) setAvatarPreviewOpen(true);
|
||||||
/>
|
}}
|
||||||
|
// Disabled when there's no uploaded avatar — clicking the
|
||||||
|
// generated-initial placeholder would open an empty lightbox.
|
||||||
|
disabled={!avatarUrl}
|
||||||
|
aria-label={
|
||||||
|
avatarUrl
|
||||||
|
? t('app:settings.avatar_preview', { defaultValue: 'Profilbild vergrößern' })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
// appearance-none + reset border/bg/padding so the native
|
||||||
|
// button chrome (outset border, button-face background, 1px
|
||||||
|
// padding) doesn't draw a box around the avatar circle.
|
||||||
|
className={
|
||||||
|
'appearance-none border-0 bg-transparent p-0 rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||||
|
(avatarUrl ? 'cursor-zoom-in' : 'cursor-default')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
url={avatarUrl}
|
||||||
|
displayName={displayName}
|
||||||
|
className="h-16 w-16 rounded-full text-2xl ring-4 ring-surface-3"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
<div className="min-w-0 flex-1 pb-1">
|
<div className="min-w-0 flex-1 pb-1">
|
||||||
<div className="truncate text-sm font-semibold text-fg">
|
<div className="truncate text-sm font-semibold text-fg">
|
||||||
{displayName ?? '—'}
|
{displayName ?? '—'}
|
||||||
@@ -1061,6 +1087,9 @@ function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
|
|||||||
}}
|
}}
|
||||||
onClose={closeCropDialog}
|
onClose={closeCropDialog}
|
||||||
/>
|
/>
|
||||||
|
{avatarPreviewOpen && avatarUrl && (
|
||||||
|
<Lightbox url={avatarUrl} onClose={() => setAvatarPreviewOpen(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user