Compare commits

...

6 Commits

Author SHA1 Message Date
byGalax b65a3994f3 chore(desktop): release v0.17.4 2026-05-13 22:25:11 +02:00
byGalax aa609389fa feat(chat): render Media/Files + Group Info drawers inline, not overlaid
Both right-hand panels used absolute inset-y-0 right-0 and floated on
top of the conversation, hiding the messages directly underneath the
panel and looking unlike Discord's actual layout. Restructure:

* MediaFilesDrawer: drop absolute/z-index/shadow chrome, become a
  static flex column (w-[380px] shrink-0) with a left border. Internal
  layout unchanged.
* GroupInfoPanel: same treatment (w-[320px] shrink-0). Dropped the
  backdrop-blur and slide-up animation that only made sense as a modal.
* ConversationPage: wrap the chat content (voice rail, in-call panel,
  messages list, drag-overlay, input form) in a new
  `flex min-w-0 flex-1 flex-col` chat-column, and make that column a
  sibling of the drawers inside a new `flex flex-1 flex-row` row. The
  conversation header + search bar stay full-width above the row.

Result: opening a drawer narrows the chat column instead of covering
it, matching Discord's behaviour. The chat-column wrapper also carries
the `relative` anchor previously held by the outer wrapper so the
drag-and-drop overlay positions correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:21:05 +02:00
byGalax cb3cbd8827 feat(chat): show caller's name on call event pills in groups
In group conversations the "Outgoing/Incoming/Missed call" system pill
gave no clue WHO triggered the event — fine in a 1:1 where the only two
players are obvious, useless in a group with three+ members. Discord
puts the caller's name in the pill; mirror that.

CallEventRow now takes a senderDisplayName prop (plumbed through from
MessageBubble) and switches non-own labels to the name-aware variants:

* ended  + !mine + name → "{name} hat einen Anruf gestartet"
* missed + !mine + name → "Verpasster Anruf von {name}"
* declined + !mine + name → "Anruf von {name} abgelehnt"

Own events (mine) stay generic ("Outgoing call" / "No answer") since
the user already knows they were the initiator. Fallback path without
a name keeps the previous generic labels so nothing regresses if the
sender is unresolvable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:20:21 +02:00
byGalax d1f38ce313 chore(desktop): release v0.17.3 2026-05-12 22:59:44 +02:00
byGalax 0d65a134fd feat(settings): click own avatar in profile preview to view fullscreen
Lightbox was previously a file-private component inside AttachmentImage
(used for enlarging chat image attachments). Extracted to a standalone
components/Lightbox.tsx so other surfaces can reuse the same dialog
without duplicating Esc/backdrop/body-overflow plumbing.

In SettingsPage's profile live-preview, the round avatar overlapping the
banner is now wrapped in a transparent button that opens the Lightbox
with the full-resolution avatar URL on click. Cursor switches to
zoom-in. Disabled when the user only has the initial-letter placeholder
(nothing meaningful to enlarge). Native button chrome (border, padding,
button-face background) is reset to keep the avatar circle's appearance
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:58:22 +02:00
byGalax 12c66d676a fix(chat): use useLayoutEffect for scroll restore + auto-bottom to avoid mount flicker
The scroll-position memory introduced in 0.17.2 still produced a visible
"chat appears at the top then jumps" frame when switching back into a
conversation. Cause: both scroll-affecting effects (auto-bottom on new
messages, restore on chat re-entry) used useEffect, which fires AFTER
the browser paints the freshly-committed DOM. So users saw scrollTop=0
for one frame before the effect ran and corrected it.

Switching both to useLayoutEffect moves the scroll write into the same
commit phase as the message-list DOM update, so the very first paint
already shows the correct position — single paint, no flicker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:58:07 +02:00
8 changed files with 178 additions and 80 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chat-app/desktop",
"version": "0.17.2",
"version": "0.17.4",
"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.
@@ -93,7 +93,7 @@ export function GroupInfoPanel({ open, onClose, conversation }: Props) {
<aside
role="dialog"
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">
<div>
+51
View File
@@ -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;
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">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent/10 text-accent">
<ImageIcon className="h-5 w-5" />
+33 -1
View File
@@ -294,7 +294,14 @@ export function MessageBubble({
}
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 (
@@ -866,10 +873,15 @@ function CallEventRow({
parsed,
mine,
time,
senderDisplayName,
}: {
parsed: { status: string; mediaKind: string; durationSec: number };
mine: boolean;
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 status = parsed.status;
@@ -880,15 +892,35 @@ function CallEventRow({
? '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';
// 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 =
status === 'ended'
? mine
? t('app:chats.call_outgoing', { defaultValue: 'Outgoing 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'
? mine
? t('app:chats.call_no_answer', { defaultValue: 'No answer' })
: hasName
? 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;
+44 -22
View File
@@ -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';
@@ -406,7 +406,14 @@ 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;
@@ -423,7 +430,13 @@ export function ConversationPage() {
const restoredForRef = useRef<string | null>(null);
const isRestoringRef = useRef(false);
useEffect(() => {
// 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 || !id) return;
if (restoredForRef.current === id) return;
@@ -630,25 +643,13 @@ export function ConversationPage() {
/>
)}
{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)}
/>
{/* Layout row: chat column on the left grows to fill remaining width;
right-hand drawers (Media/Files, Group Info) render as inline
siblings so opening one narrows the chat instead of floating on
top of it (Discord parity). The chat-column wrapper holds the
`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
so anyone can pop in without an invite-ring; hidden in 1:1s unless
someone is already waiting. Hides automatically once we're in. */}
@@ -1006,6 +1007,27 @@ export function ConversationPage() {
</button>
</div>
</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
open={forwardTarget !== null}
+29
View File
@@ -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;
@@ -929,12 +933,34 @@ function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
<div
className="relative z-10 flex items-end gap-3 px-4 pb-3"
style={{ marginTop: '-2rem' }}
>
<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>
);
}