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
507 lines
18 KiB
TypeScript
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>
|
|
);
|
|
};
|