Messenger/packages/web/src/features/messaging/ui/ChatView.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

507 lines
18 KiB
TypeScript

import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { KeyboardEvent, ReactElement, SyntheticEvent } from 'react';
import type { Conversation, Message, PublicUser } from '@altricade/core';
import {
PaperclipIcon,
MicIcon,
VideoIcon,
StopIcon,
SendIcon,
CloseIcon,
CheckIcon,
DoubleCheckIcon,
EditIcon,
TrashIcon,
UsersIcon,
} from '../../../shared/ui';
import { useConversationMessages } from '../model';
import { useRecorder } from '../recorder';
import { MediaMessage, isRoundVideo } from './MediaMessage';
import { MediaViewerProvider } from './MediaViewer';
const QUICK_REACTIONS = ['👍', '❤️', '😂', '🎉'];
const GROUP_WINDOW_MS = 5 * 60 * 1000;
const formatElapsed = (ms: number): string => {
const total = Math.floor(ms / 1000);
const minutes = Math.floor(total / 60);
const seconds = total % 60;
return `${String(minutes)}:${seconds < 10 ? '0' : ''}${String(seconds)}`;
};
const clock = (iso: string): string =>
new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const dayKey = (iso: string): string => new Date(iso).toDateString();
const dayLabel = (iso: string): string => {
const then = new Date(iso);
const now = new Date();
if (then.toDateString() === now.toDateString()) {
return 'Today';
}
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
if (then.toDateString() === yesterday.toDateString()) {
return 'Yesterday';
}
return then.toLocaleDateString([], { day: 'numeric', month: 'long' });
};
// Two messages belong to the same visual group when they share a sender and
// day and land within the grouping window (order-agnostic).
const inSameGroup = (base: Message, other: Message | undefined): boolean => {
if (other === undefined) {
return false;
}
return (
other.senderId === base.senderId &&
dayKey(other.createdAt) === dayKey(base.createdAt) &&
Math.abs(new Date(base.createdAt).getTime() - new Date(other.createdAt).getTime()) <
GROUP_WINDOW_MS
);
};
interface Props {
conversation: Conversation;
me: PublicUser;
onlineMap: Record<string, boolean>;
}
const headerTitle = (conversation: Conversation): string => {
if (conversation.type === 'group') {
return conversation.title ?? 'Group';
}
if (conversation.peer === null) {
return 'Direct';
}
return conversation.peer.displayName;
};
const ChatHeader = ({
conversation,
online,
typing,
}: {
conversation: Conversation;
online: boolean;
typing: boolean;
}): ReactElement => {
const peer = conversation.peer;
const isGroup = conversation.type === 'group';
const subtitle = typing
? 'typing…'
: isGroup
? 'Group'
: online
? 'online'
: 'offline';
return (
<header className="chat-header">
{peer !== null && peer.avatarUrl !== null ? (
<img src={peer.avatarUrl} alt="" className="avatar chat-header-avatar" />
) : (
<span className="avatar chat-header-avatar avatar-placeholder">
{isGroup ? <UsersIcon size={20} /> : headerTitle(conversation).charAt(0)}
</span>
)}
<div className="chat-header-text">
<span className="chat-header-title">{headerTitle(conversation)}</span>
<span className={typing ? 'chat-header-sub typing-sub' : 'chat-header-sub'}>
{!isGroup && online && !typing ? <span className="online-inline" /> : null}
{subtitle}
</span>
</div>
</header>
);
};
export const ChatView = ({ conversation, me, onlineMap }: Props): ReactElement => {
const {
messages,
loading,
typingUserIds,
peerReadSeq,
send,
sendMedia,
edit,
remove,
toggleReaction,
notifyTyping,
} = useConversationMessages(conversation, me);
const [text, setText] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState('');
const recorder = useRecorder((file) => {
void sendMedia(file, '');
});
const previewRef = useRef<HTMLVideoElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const atBottomRef = useRef(true);
const isGroup = conversation.type === 'group';
const peerOnline = conversation.peer !== null && onlineMap[conversation.peer.id] === true;
// Mirror the live camera stream into the in-composer preview while recording.
useEffect(() => {
const element = previewRef.current;
if (element !== null) {
element.srcObject = recorder.previewStream;
}
}, [recorder.previewStream]);
// Jump to the newest message when opening a conversation.
useEffect(() => {
atBottomRef.current = true;
}, [conversation.id]);
// Keep the scrollback pinned to the newest message — but only when the reader
// is already near the bottom, so scrolling up through history isn't yanked.
useLayoutEffect(() => {
const node = scrollRef.current;
if (node !== null && atBottomRef.current) {
node.scrollTop = node.scrollHeight;
}
}, [messages.length, typingUserIds.length]);
const onScroll = (): void => {
const node = scrollRef.current;
if (node !== null) {
atBottomRef.current = node.scrollHeight - node.scrollTop - node.clientHeight < 80;
}
};
const submit = async (event: SyntheticEvent): Promise<void> => {
event.preventDefault();
const trimmed = text.trim();
if (trimmed === '') {
return;
}
setText('');
await send(trimmed);
};
const onComposerKey = (event: KeyboardEvent<HTMLTextAreaElement>): void => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void submit(event);
}
};
const onAttach = (event: SyntheticEvent<HTMLInputElement>): void => {
const input = event.currentTarget;
const file = input.files?.[0];
if (file !== undefined) {
void sendMedia(file, '');
}
input.value = '';
};
const startEdit = (message: Message): void => {
setEditingId(message.id);
setDraft(message.content);
};
const commitEdit = async (messageId: string): Promise<void> => {
const next = draft.trim();
setEditingId(null);
if (next !== '') {
await edit(messageId, next);
}
};
const tick = (message: Message): ReactElement | null => {
if (conversation.type !== 'direct' || message.senderId !== me.id || message.deletedAt !== null) {
return null;
}
const read = peerReadSeq >= message.seq;
return (
<span className={read ? 'tick tick-read' : 'tick'}>
{read ? <DoubleCheckIcon size={15} /> : <CheckIcon size={15} />}
</span>
);
};
return (
<MediaViewerProvider>
<section className="chat-pane">
<ChatHeader
conversation={conversation}
online={peerOnline}
typing={typingUserIds.length > 0}
/>
<div className="message-scroll" ref={scrollRef} onScroll={onScroll}>
{loading ? <p className="chat-loading muted">Loading</p> : null}
<div className="message-list">
{messages.map((message, index) => {
const prev = index > 0 ? messages[index - 1] : undefined;
const next = index < messages.length - 1 ? messages[index + 1] : undefined;
const mine = message.senderId === me.id;
const newDay = prev === undefined || dayKey(prev.createdAt) !== dayKey(message.createdAt);
const groupStart = newDay || !inSameGroup(message, prev);
const groupEnd = !inSameGroup(message, next);
const showAvatar = !mine && isGroup;
const deleted = message.deletedAt !== null;
const editing = editingId === message.id;
const media = message.media;
// Round video notes render without bubble chrome (like Telegram).
const bareMedia =
media !== null && !deleted && media.kind === 'video' && isRoundVideo(media.name);
const hasMedia = media !== null && !deleted && !bareMedia;
const lineClass = [
'msg-line',
mine ? 'mine' : 'theirs',
groupStart ? 'group-start' : '',
groupEnd ? 'group-end' : '',
]
.filter((token) => token !== '')
.join(' ');
const bubbleClass = [
'bubble',
mine ? 'bubble-mine' : 'bubble-theirs',
groupStart ? 'is-start' : '',
groupEnd ? 'is-end' : '',
bareMedia ? 'bubble-bare' : '',
hasMedia ? 'has-media' : '',
]
.filter((token) => token !== '')
.join(' ');
return (
<Fragment key={message.id}>
{newDay ? (
<div className="day-sep">
<span>{dayLabel(message.createdAt)}</span>
</div>
) : null}
<div className={lineClass}>
{showAvatar ? (
groupEnd ? (
message.sender.avatarUrl !== null ? (
<img src={message.sender.avatarUrl} alt="" className="avatar msg-avatar" />
) : (
<span className="avatar msg-avatar avatar-placeholder">
{message.sender.displayName.charAt(0)}
</span>
)
) : (
<span className="msg-avatar-spacer" />
)
) : null}
<div className="bubble-wrap">
{showAvatar && groupStart && !deleted ? (
<span className="msg-sender">{message.sender.displayName}</span>
) : null}
<div className={bubbleClass}>
{editing ? (
<div className="bubble-edit">
<textarea
className="edit-input"
value={draft}
autoFocus
onChange={(event) => {
setDraft(event.target.value);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void commitEdit(message.id);
}
if (event.key === 'Escape') {
setEditingId(null);
}
}}
/>
<div className="edit-actions">
<button
type="button"
className="icon-btn edit-cancel"
aria-label="Cancel"
onClick={() => {
setEditingId(null);
}}
>
<CloseIcon size={17} />
</button>
<button
type="button"
className="icon-btn accent edit-save"
aria-label="Save"
onClick={() => {
void commitEdit(message.id);
}}
>
<CheckIcon size={17} />
</button>
</div>
</div>
) : deleted ? (
<span className="bubble-deleted">Message deleted</span>
) : (
<>
{message.media !== null ? (
<MediaMessage
conversationId={conversation.id}
message={message}
mine={mine}
/>
) : null}
{message.content.length > 0 ? (
<span className="bubble-text">{message.content}</span>
) : null}
<span className="bubble-meta">
{message.editedAt !== null ? (
<span className="bubble-edited">edited</span>
) : null}
<span className="bubble-time">{clock(message.createdAt)}</span>
{tick(message)}
</span>
</>
)}
{!deleted && !editing ? (
<div className="msg-tools">
{QUICK_REACTIONS.map((emoji) => (
<button
key={emoji}
type="button"
className="tool-react"
aria-label={`React ${emoji}`}
onClick={() => {
void toggleReaction(message, emoji);
}}
>
{emoji}
</button>
))}
{mine ? (
<>
<span className="tool-divider" />
<button
type="button"
className="tool-btn"
aria-label="Edit"
onClick={() => {
startEdit(message);
}}
>
<EditIcon size={16} />
</button>
<button
type="button"
className="tool-btn tool-danger"
aria-label="Delete"
onClick={() => {
void remove(message.id);
}}
>
<TrashIcon size={16} />
</button>
</>
) : null}
</div>
) : null}
</div>
{message.reactions.length > 0 ? (
<div className="reactions">
{message.reactions.map((reaction) => (
<button
key={reaction.emoji}
type="button"
className={reaction.mine ? 'reaction is-mine' : 'reaction'}
onClick={() => {
void toggleReaction(message, reaction.emoji);
}}
>
<span className="reaction-emoji">{reaction.emoji}</span>
<span className="reaction-count">{reaction.count}</span>
</button>
))}
</div>
) : null}
</div>
</div>
</Fragment>
);
})}
</div>
</div>
{recorder.recording !== null ? (
<div className="composer recording-bar">
{recorder.recording === 'video' ? (
<video ref={previewRef} className="record-preview" autoPlay muted playsInline />
) : null}
<span className="record-dot" aria-hidden="true" />
<span className="record-label">
{recorder.recording === 'video' ? 'Recording video' : 'Recording voice'} ·{' '}
{formatElapsed(recorder.elapsedMs)}
</span>
<button type="button" className="icon-btn" title="Cancel" onClick={recorder.cancel}>
<CloseIcon />
</button>
<button
type="button"
className="icon-btn accent"
title="Stop and send"
onClick={recorder.finish}
>
<StopIcon />
</button>
</div>
) : (
<form className="composer" onSubmit={(event) => void submit(event)}>
<label className="icon-btn" title="Attach file">
<PaperclipIcon />
<input type="file" hidden onChange={onAttach} />
</label>
<textarea
className="composer-input"
rows={1}
value={text}
placeholder="Message…"
onChange={(event) => {
setText(event.target.value);
notifyTyping();
}}
onKeyDown={onComposerKey}
/>
<button
type="button"
className="icon-btn"
title="Record voice message"
onClick={() => {
recorder.start('voice');
}}
>
<MicIcon />
</button>
<button
type="button"
className="icon-btn"
title="Record video message"
onClick={() => {
recorder.start('video');
}}
>
<VideoIcon />
</button>
<button
type="submit"
className="icon-btn accent composer-send"
title="Send"
disabled={text.trim() === ''}
>
<SendIcon />
</button>
</form>
)}
</section>
</MediaViewerProvider>
);
};