Messenger/packages/web/src/features/messaging/ui/MediaViewer.tsx
Заид Омар Медхат | Zaid Omar Medhat 7ab6b72866 Redesign web client: modern Telegram-shaped chat UI
Full presentation-layer rebuild of packages/web (no core/API/model changes).

- Design system: indigo light+dark token sets, 25 inline SVG icons, CSS
  foundation (spacing/radius/type/motion scales, per-theme elevation, reset,
  scrollbars).
- Shell: two-pane rail (brand, icon theme switch, live search, rich
  conversation rows, account footer, group/contacts compose) + auth card.
- Chat pane: sticky header with presence/typing, grouped bubbles with date
  separators, icon read-ticks, hover toolbar, inline edit, reaction pills,
  auto-growing composer with Enter-to-send, near-bottom-aware autoscroll.
- Media renderers: waveform voice player (WebAudio decode + pseudo-waveform
  fallback), circular video notes (rendered bare), image lightbox with
  zoom/pan, framed video->lightbox player, file cards.
- Restore + modernize notification toast styles.

Emoji kept only as reaction content; all chrome is icons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFRyKxkKEjfAgpXygzoNiD
2026-07-11 00:30:49 +05:00

173 lines
4.7 KiB
TypeScript

import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { CloseIcon, DownloadIcon } from '../../../shared/ui';
export interface ViewerItem {
type: 'image' | 'video';
url: string;
name: string;
}
interface ViewerContextValue {
open: (item: ViewerItem) => void;
}
const ViewerContext = createContext<ViewerContextValue | null>(null);
export const useMediaViewer = (): ViewerContextValue => {
const context = useContext(ViewerContext);
if (context === null) {
throw new Error('useMediaViewer must be used within a MediaViewerProvider');
}
return context;
};
interface Point {
x: number;
y: number;
}
const MIN_SCALE = 1;
const MAX_SCALE = 4;
const Overlay = ({ item, onClose }: { item: ViewerItem; onClose: () => void }): ReactElement => {
const [scale, setScale] = useState(1);
const [offset, setOffset] = useState<Point>({ x: 0, y: 0 });
const dragging = useRef<{ startX: number; startY: number; baseX: number; baseY: number } | null>(
null,
);
const reset = useCallback((): void => {
setScale(1);
setOffset({ x: 0, y: 0 });
}, []);
useEffect(() => {
const onKey = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('keydown', onKey);
};
}, [onClose]);
const onWheel = (event: React.WheelEvent): void => {
if (item.type !== 'image') {
return;
}
const nextScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale - event.deltaY * 0.0015));
setScale(nextScale);
if (nextScale === 1) {
setOffset({ x: 0, y: 0 });
}
};
const onPointerDown = (event: React.PointerEvent): void => {
if (scale <= 1) {
return;
}
event.currentTarget.setPointerCapture(event.pointerId);
dragging.current = {
startX: event.clientX,
startY: event.clientY,
baseX: offset.x,
baseY: offset.y,
};
};
const onPointerMove = (event: React.PointerEvent): void => {
const state = dragging.current;
if (state === null) {
return;
}
setOffset({
x: state.baseX + (event.clientX - state.startX),
y: state.baseY + (event.clientY - state.startY),
});
};
const onPointerUp = (): void => {
dragging.current = null;
};
return (
<div className="viewer" role="dialog" aria-modal="true" onClick={onClose}>
<div className="viewer-toolbar" onClick={(event) => {
event.stopPropagation();
}}>
<span className="viewer-name">{item.name}</span>
<div className="viewer-actions">
<a
className="icon-btn viewer-btn"
href={item.url}
download={item.name}
aria-label="Download"
onClick={(event) => {
event.stopPropagation();
}}
>
<DownloadIcon />
</a>
<button type="button" className="icon-btn viewer-btn" aria-label="Close" onClick={onClose}>
<CloseIcon />
</button>
</div>
</div>
<div className="viewer-stage" onClick={(event) => {
event.stopPropagation();
}}>
{item.type === 'image' ? (
<img
src={item.url}
alt={item.name}
className="viewer-img"
draggable={false}
style={{
transform: `translate(${String(offset.x)}px, ${String(offset.y)}px) scale(${String(scale)})`,
cursor: scale > 1 ? 'grab' : 'zoom-in',
}}
onWheel={onWheel}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onDoubleClick={reset}
onClick={() => {
if (scale === 1) {
setScale(2.2);
} else {
reset();
}
}}
/>
) : (
<video src={item.url} className="viewer-video" controls autoPlay />
)}
</div>
</div>
);
};
export const MediaViewerProvider = ({ children }: { children: ReactNode }): ReactElement => {
const [item, setItem] = useState<ViewerItem | null>(null);
const open = useCallback((next: ViewerItem): void => {
setItem(next);
}, []);
const close = useCallback((): void => {
setItem(null);
}, []);
const value = useMemo<ViewerContextValue>(() => ({ open }), [open]);
return (
<ViewerContext.Provider value={value}>
{children}
{item !== null ? createPortal(<Overlay item={item} onClose={close} />, document.body) : null}
</ViewerContext.Provider>
);
};