Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d1f38ce313 | |||
| 0d65a134fd | |||
| 12c66d676a | |||
| 0dde1dd1a3 | |||
| 81d3587a91 | |||
| 8be1105333 | |||
| f9e1d2f073 |
@@ -37,23 +37,26 @@ const __dirnameSafe = path.dirname(__filenameSafe);
|
||||
const DEV_URL = 'http://localhost:1420';
|
||||
const WINDOW_STATE_FILE = 'window-state.json';
|
||||
|
||||
// Pin userData FIRST — before any other Electron call that might cache a
|
||||
// productName-derived path. The 0.17.0 release saw users get logged out
|
||||
// after upgrading from 0.16.x: the most likely culprit was an internal
|
||||
// path resolution kicking off the moment `setName('Netralax')` ran, so
|
||||
// 0.17.1 swaps the order so the explicit override wins regardless of
|
||||
// what setName triggers internally. The literal 'ChatApp' here is the
|
||||
// pre-rename product folder — installed users' SQLite, secrets, sounds,
|
||||
// IndexedDB all live there and we never want to leave them stranded by
|
||||
// a future rebrand.
|
||||
app.setPath('userData', path.join(app.getPath('appData'), 'ChatApp'));
|
||||
|
||||
// App branding. productName in package.json drives the packaged exe name
|
||||
// (Netralax.exe) and electron-builder installer title. setName + the
|
||||
// AppUserModelId below cover the live process: window title fallback,
|
||||
// Windows taskbar grouping, notification source attribution.
|
||||
// AppUserModelId cover the live process: window title fallback, Windows
|
||||
// taskbar grouping, notification source attribution.
|
||||
app.setName('Netralax');
|
||||
if (process.platform === 'win32') {
|
||||
app.setAppUserModelId('cloud.netralax.desktop');
|
||||
}
|
||||
|
||||
// Pin userData to %APPDATA%\ChatApp regardless of productName so existing
|
||||
// installs keep their profile, sounds, secrets, SQLite. Electron's default
|
||||
// is %APPDATA%\<productName>, which after the Netralax rename would point
|
||||
// at an empty fresh dir — same painful migration as the Tauri → Electron
|
||||
// cut. Anchored to `appData` (the platform-AppData root) so productName
|
||||
// changes can't drag it.
|
||||
app.setPath('userData', path.join(app.getPath('appData'), 'ChatApp'));
|
||||
|
||||
// Run dev side-by-side with the installed packaged build by isolating the
|
||||
// renderer profile / secret-store / SQLite / IndexedDB / localStorage in
|
||||
// a separate userData dir. Without this both share `%APPDATA%\ChatApp`,
|
||||
@@ -64,6 +67,21 @@ if (!app.isPackaged) {
|
||||
app.setPath('userData', app.getPath('userData') + '-Dev');
|
||||
}
|
||||
|
||||
// Startup diagnostics — the 0.17.0 logout regression was hard to debug
|
||||
// because we had no record of the actual resolved paths. With this log
|
||||
// any future user can paste their main-process output and we can tell
|
||||
// at a glance whether userData ended up where we intended.
|
||||
console.log(
|
||||
'[main] resolved paths',
|
||||
JSON.stringify({
|
||||
appName: app.getName(),
|
||||
appData: app.getPath('appData'),
|
||||
userData: app.getPath('userData'),
|
||||
isPackaged: app.isPackaged,
|
||||
platform: process.platform,
|
||||
}),
|
||||
);
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
function resolvePreloadPath(): string {
|
||||
|
||||
@@ -40,20 +40,68 @@ function filePathFor(userId: string, encrypted: boolean): string {
|
||||
}
|
||||
|
||||
async function loadState(filePath: string, encrypted: boolean): Promise<Map<string, string>> {
|
||||
// Read step. Distinguish "no file yet" (genuinely new user — empty Map
|
||||
// is correct) from "file exists but unreadable" (corruption / DPAPI
|
||||
// breakage — we MUST NOT let the next write overwrite those bytes,
|
||||
// because the original ciphertext is the only path back to the user's
|
||||
// device keys if a future build can fix the read path).
|
||||
let rawBuf: Buffer | null = null;
|
||||
let rawStr: string | null = null;
|
||||
try {
|
||||
if (encrypted) {
|
||||
const buf = await fs.readFile(filePath);
|
||||
const json = safeStorage.decryptString(buf);
|
||||
rawBuf = await fs.readFile(filePath);
|
||||
} else {
|
||||
rawStr = await fs.readFile(filePath, 'utf8');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const code = (err as NodeJS.ErrnoException | null)?.code;
|
||||
if (code === 'ENOENT') return new Map();
|
||||
console.warn('[secure-store] read failed (non-ENOENT)', filePath, err);
|
||||
// For non-ENOENT read failures (EACCES, EBUSY, …) don't quarantine —
|
||||
// the file might be transiently locked. Empty map + future writes
|
||||
// will attempt to overwrite, matching the pre-0.17.1 behaviour for
|
||||
// these rarer cases.
|
||||
return new Map();
|
||||
}
|
||||
|
||||
// Parse / decrypt step.
|
||||
try {
|
||||
if (encrypted && rawBuf) {
|
||||
const json = safeStorage.decryptString(rawBuf);
|
||||
const parsed = JSON.parse(json) as { version?: number; entries?: Record<string, string> };
|
||||
return new Map(Object.entries(parsed.entries ?? {}));
|
||||
} else {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as { version?: number; entries?: Record<string, string> };
|
||||
}
|
||||
if (rawStr) {
|
||||
const parsed = JSON.parse(rawStr) as { version?: number; entries?: Record<string, string> };
|
||||
return new Map(Object.entries(parsed.entries ?? {}));
|
||||
}
|
||||
} catch {
|
||||
// Missing file or malformed contents — start fresh. The next write
|
||||
// will overwrite with a fresh blob.
|
||||
return new Map();
|
||||
} catch (err: unknown) {
|
||||
// CRITICAL: file existed but we couldn't decrypt or parse it. In the
|
||||
// pre-0.17.1 build we silently started fresh — the next set() then
|
||||
// scheduledSave() over-wrote the original ciphertext, destroying the
|
||||
// user's device keys forever. Now we rename the original to
|
||||
// `<file>.broken-<iso-ts>` BEFORE returning the empty map so the next
|
||||
// write goes to a new file and the original bytes survive for
|
||||
// forensics or a future decrypt-recovery path.
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const brokenPath = `${filePath}.broken-${ts}`;
|
||||
try {
|
||||
await fs.rename(filePath, brokenPath);
|
||||
console.error(
|
||||
`[secure-store] DECRYPT/PARSE FAILED for ${filePath} — preserved original at ${brokenPath}. Original error:`,
|
||||
err,
|
||||
);
|
||||
} catch (renameErr: unknown) {
|
||||
// Even rename failed — fall back to the old behaviour (silent empty
|
||||
// map) but log loudly so it's visible in the main-process output.
|
||||
console.error(
|
||||
'[secure-store] rename of broken file failed; original may be overwritten on next save',
|
||||
renameErr,
|
||||
'original decrypt error:',
|
||||
err,
|
||||
);
|
||||
}
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chat-app/desktop",
|
||||
"version": "0.17.0",
|
||||
"version": "0.17.3",
|
||||
"private": true,
|
||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||
"type": "module",
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { AlertIcon, SpinnerIcon, XIcon } from './icons';
|
||||
import { AlertIcon, SpinnerIcon } from './icons';
|
||||
import { Lightbox } from './Lightbox';
|
||||
|
||||
interface Props {
|
||||
handle: AttachmentHandle;
|
||||
@@ -136,42 +137,5 @@ export function AttachmentImage({ handle }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
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="attachment full"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="max-h-[92vh] max-w-[92vw] rounded-xl object-contain shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Lightbox extracted to ./Lightbox.tsx so the settings avatar preview and
|
||||
// any future surface can reuse the same dialog without duplication.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,33 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { fetchChangelog, type ChangelogEntry } from '../lib/changelog';
|
||||
import { SparklesIcon, SpinnerIcon } from '../components/icons';
|
||||
|
||||
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() {
|
||||
const [entries, setEntries] = useState<ChangelogEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
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(() => {
|
||||
let cancelled = false;
|
||||
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">
|
||||
<SparklesIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex-1">
|
||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||
Was ist neu
|
||||
</h1>
|
||||
@@ -42,6 +60,11 @@ export function ChangelogPage() {
|
||||
Alle Änderungen in dieser App, neueste zuerst.
|
||||
</p>
|
||||
</div>
|
||||
<VersionBadge
|
||||
status={versionStatus}
|
||||
installed={installedVersion}
|
||||
latest={latestVersion}
|
||||
/>
|
||||
</header>
|
||||
|
||||
{entries === null && !error && (
|
||||
@@ -118,3 +141,75 @@ function formatDate(iso: string): string {
|
||||
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 { 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 { useParams } from 'react-router-dom';
|
||||
|
||||
@@ -49,6 +49,15 @@ import { useTypingChannel } from '../lib/useTypingChannel';
|
||||
|
||||
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() {
|
||||
const { t } = useTranslation(['app', 'errors']);
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -397,17 +406,60 @@ export function ConversationPage() {
|
||||
if (id && messages.length > 0) markRead(id);
|
||||
}, [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;
|
||||
if (!el || !stickToBottom) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, [messages.length, stickToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
setStickToBottom(true);
|
||||
// Restore saved scroll position once the conversation's messages have
|
||||
// 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;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [id]);
|
||||
if (!el || !id) return;
|
||||
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 el = scrollRef.current;
|
||||
@@ -416,7 +468,14 @@ export function ConversationPage() {
|
||||
const nextStick = distanceFromBottom < STICK_THRESHOLD;
|
||||
setStickToBottom(nextStick);
|
||||
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 el = scrollRef.current;
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
uploadBannerBlob,
|
||||
} from '../lib/bannerUpload';
|
||||
import { ImageCropDialog } from '../components/ImageCropDialog';
|
||||
import { Lightbox } from '../components/Lightbox';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import {
|
||||
getPttSettings,
|
||||
@@ -799,6 +800,9 @@ function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
|
||||
// ratios.
|
||||
const [cropFile, setCropFile] = useState<File | 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 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"
|
||||
style={{ marginTop: '-2rem' }}
|
||||
>
|
||||
<Avatar
|
||||
url={avatarUrl}
|
||||
displayName={displayName}
|
||||
className="h-16 w-16 rounded-full text-2xl ring-4 ring-surface-3"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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="truncate text-sm font-semibold text-fg">
|
||||
{displayName ?? '—'}
|
||||
@@ -1061,6 +1087,9 @@ function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
|
||||
}}
|
||||
onClose={closeCropDialog}
|
||||
/>
|
||||
{avatarPreviewOpen && avatarUrl && (
|
||||
<Lightbox url={avatarUrl} onClose={() => setAvatarPreviewOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user