mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Clean up
This commit is contained in:
@@ -25,16 +25,16 @@ interface ChatInputWrapperProps {
|
||||
}
|
||||
|
||||
export function ChatInputWrapper(
|
||||
{
|
||||
onSendMessage,
|
||||
onSaveEdit,
|
||||
replyTo,
|
||||
replyToVisible,
|
||||
{
|
||||
onSendMessage,
|
||||
onSaveEdit,
|
||||
replyTo,
|
||||
replyToVisible,
|
||||
onClearReply,
|
||||
onCloseReply,
|
||||
editingMessage,
|
||||
editVisible = false,
|
||||
onClearEdit,
|
||||
onCloseReply,
|
||||
editingMessage,
|
||||
editVisible = false,
|
||||
onClearEdit,
|
||||
onCloseEdit,
|
||||
onProvideFileAdder,
|
||||
messagePanelRef,
|
||||
@@ -77,7 +77,7 @@ export function ChatInputWrapper(
|
||||
if (chatInputWrapperRef.current && messagePanelRef?.current) {
|
||||
const inputRect = chatInputWrapperRef.current.getBoundingClientRect();
|
||||
const panelRect = messagePanelRef.current.getBoundingClientRect();
|
||||
|
||||
|
||||
// Position menu 10px from message panel edge and 10px above the chat input
|
||||
// The animation will start 30px below this position
|
||||
setEmojiMenuPosition({
|
||||
@@ -207,9 +207,9 @@ export function ChatInputWrapper(
|
||||
className="emoji-btn" />
|
||||
</div>
|
||||
<RichTextArea
|
||||
className="message-input"
|
||||
id="message-input"
|
||||
placeholder="Напишите сообщение..."
|
||||
className="message-input"
|
||||
id="message-input"
|
||||
placeholder="Напишите сообщение..."
|
||||
autoComplete="off"
|
||||
text={message}
|
||||
rows={1}
|
||||
@@ -228,7 +228,7 @@ export function ChatInputWrapper(
|
||||
<div>Общий размер вложений превышает 4 ГБ.</div>
|
||||
<mdui-button slot="action" onClick={() => setErrorOpen(false)}>Закрыть</mdui-button>
|
||||
</MaterialDialog>
|
||||
|
||||
|
||||
<EmojiMenu
|
||||
isOpen={emojiMenuOpen}
|
||||
onClose={() => setEmojiMenuOpen(false)}
|
||||
|
||||
@@ -20,9 +20,9 @@ interface ChatMessagesProps {
|
||||
|
||||
export function ChatMessages({ messages = [], children, isDm = false, onReplySelect, onEditSelect, onDelete, onRetryMessage, dmRecipientPublicKey }: ChatMessagesProps) {
|
||||
const { user } = useAppState();
|
||||
|
||||
|
||||
// Use prop messages (panels provide their own messages)
|
||||
|
||||
|
||||
// Context menu state
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
||||
isOpen: false,
|
||||
@@ -89,13 +89,13 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
|
||||
async function handleReactionClick(messageId: number, emoji: string) {
|
||||
if (!user.authToken) return;
|
||||
|
||||
|
||||
try {
|
||||
if (isDm) {
|
||||
// For DM messages, we need to find the dm_envelope_id from the message
|
||||
const message = messages.find(m => m.id === messageId);
|
||||
const dmEnvelopeId = message?.runtimeData?.dmEnvelope?.id;
|
||||
|
||||
|
||||
if (dmEnvelopeId) {
|
||||
await request<AddDmReactionRequest["data"]>({
|
||||
type: "addDmReaction",
|
||||
@@ -131,8 +131,8 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
<Message
|
||||
key={message.id}
|
||||
message={message}
|
||||
isAuthor={isDm ?
|
||||
(message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
|
||||
isAuthor={isDm ?
|
||||
(message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
|
||||
(message.username === user.currentUser?.username)
|
||||
}
|
||||
onContextMenu={handleContextMenu}
|
||||
@@ -142,7 +142,7 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
))}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<MaterialDialog
|
||||
headline="Удалить сообщение?"
|
||||
@@ -151,13 +151,13 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
<mdui-button slot="action" variant="tonal" onClick={() => setDeleteDialogOpen(false)}>Отменить</mdui-button>
|
||||
<mdui-button slot="action" variant="filled" onClick={confirmDelete}>Удалить</mdui-button>
|
||||
</MaterialDialog>
|
||||
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu.message && (
|
||||
<MessageContextMenu
|
||||
message={contextMenu.message}
|
||||
isAuthor={isDm ?
|
||||
(contextMenu.message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
|
||||
isAuthor={isDm ?
|
||||
(contextMenu.message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
|
||||
(contextMenu.message.username === user.currentUser?.username)
|
||||
}
|
||||
onEdit={handleEdit}
|
||||
|
||||
@@ -38,13 +38,13 @@ export function EmojiMenu(props: EmojiMenuProps) {
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
|
||||
// Find which category is currently visible
|
||||
for (const [categoryName, element] of categoryRefs.current) {
|
||||
if (element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const containerRect = scrollRef.current.getBoundingClientRect();
|
||||
|
||||
|
||||
// Check if category header is in view
|
||||
if (rect.top <= containerRect.top + 50 && rect.bottom > containerRect.top + 50) {
|
||||
if (activeCategory !== categoryName) {
|
||||
@@ -60,9 +60,9 @@ export function EmojiMenu(props: EmojiMenuProps) {
|
||||
function scrollToCategory(categoryName: string) {
|
||||
const element = categoryRefs.current.get(categoryName);
|
||||
if (element && scrollRef.current) {
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ export function EmojiMenu(props: EmojiMenuProps) {
|
||||
if (tabElement && tabsRef.current) {
|
||||
const tabsRect = tabsRef.current.getBoundingClientRect();
|
||||
const tabRect = tabElement.getBoundingClientRect();
|
||||
|
||||
|
||||
// Check if tab is outside the visible area
|
||||
if (tabRect.left < tabsRect.left || tabRect.right > tabsRect.right) {
|
||||
tabElement.scrollIntoView({
|
||||
@@ -116,7 +116,7 @@ export function EmojiMenu(props: EmojiMenuProps) {
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`emoji-menu ${isOpen ? "open" : ""} ${mode}`}
|
||||
style={mode === "standalone" && position ? {
|
||||
@@ -146,17 +146,17 @@ export function EmojiMenu(props: EmojiMenuProps) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="emoji-grid"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{EMOJI_CATEGORIES.map((category) => {
|
||||
const emojis = category.name === "recent" ? recentEmojis : category.emojis;
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
key={category.name}
|
||||
ref={(el) => {
|
||||
if (el) categoryRefs.current.set(category.name, el);
|
||||
|
||||
@@ -84,7 +84,7 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr
|
||||
// Update existing reactions and add new ones
|
||||
setVisibleReactions(prev => {
|
||||
const updated = [...prev];
|
||||
|
||||
|
||||
// Update existing reactions
|
||||
uniqueReactions.forEach(reaction => {
|
||||
const existingIndex = updated.findIndex(r => r.emoji === reaction.emoji);
|
||||
@@ -97,7 +97,7 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return updated;
|
||||
});
|
||||
}, [reactions]);
|
||||
@@ -112,7 +112,7 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr
|
||||
{visibleReactions.map((reaction, index) => {
|
||||
const hasUserReacted = reaction.users.some(u => u.id === user.currentUser?.id);
|
||||
const isAnimating = animatingReactions.has(reaction.emoji);
|
||||
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${messageId || 'unknown'}-${reaction.emoji}-${reaction.count}-${index}`}
|
||||
@@ -140,9 +140,9 @@ interface MessageProps {
|
||||
}
|
||||
|
||||
interface Rect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number
|
||||
}
|
||||
|
||||
@@ -200,12 +200,12 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
console.warn("Conditions not met")
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Check if already decrypted
|
||||
if (decryptedFiles.has(file.path)) {
|
||||
return decryptedFiles.get(file.path) || null;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// no-op decrypt indicator removed from UI
|
||||
// Fetch encrypted file
|
||||
@@ -213,32 +213,32 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
headers: getAuthHeaders(user.authToken!)
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to fetch file");
|
||||
|
||||
|
||||
const encryptedData = await response.arrayBuffer();
|
||||
|
||||
|
||||
// Get current user's keys
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
|
||||
// Derive shared secret with the recipient's public key
|
||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(dmRecipientPublicKey));
|
||||
|
||||
|
||||
// Derive wrapping key using the salt from the DM envelope
|
||||
const wkRaw = await deriveWrappingKey(shared, ub64(dmEnvelope.salt), new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
|
||||
// Unwrap the message key
|
||||
const mk = await aesGcmDecrypt(wk, ub64(dmEnvelope.iv2), ub64(dmEnvelope.wrappedMk));
|
||||
|
||||
|
||||
// Decrypt the file using the message key
|
||||
const iv = new Uint8Array(encryptedData, 0, 12);
|
||||
const ciphertext = new Uint8Array(encryptedData, 12);
|
||||
const decrypted = await aesGcmDecrypt(await importAesGcmKey(mk), iv, ciphertext);
|
||||
|
||||
|
||||
// Create blob URL for download
|
||||
const blob = new Blob([decrypted.buffer as ArrayBuffer]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
|
||||
updateDecryptedFiles(draft => {
|
||||
draft.set(file.path, url);
|
||||
});
|
||||
@@ -390,7 +390,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
|
||||
async function handleProfileClick() {
|
||||
if (!user.authToken || !message.username) return;
|
||||
|
||||
|
||||
try {
|
||||
const userProfile = await fetchUserProfile(user.authToken, message.username);
|
||||
if (userProfile) {
|
||||
@@ -421,10 +421,10 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
const emojiRegex = /^[\p{Emoji}]+$/u;
|
||||
return messageText.length > 0 && emojiRegex.test(messageText) && messageText.length <= 4; // Most emojis are 1-4 characters
|
||||
}, [messageText]);
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
<div
|
||||
className={`message ${isAuthor ? "sent" : "received"} ${isEmojiMessage ? "emoji-message" : ""} ${isSingleEmojiMessage ? "single-emoji" : ""}`}
|
||||
data-id={message.id}
|
||||
onContextMenu={handleContextMenu}
|
||||
@@ -444,7 +444,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
|
||||
<div className="message-inner">
|
||||
{!isAuthor && !isDm && !isSingleEmojiMessage && (
|
||||
<div
|
||||
<div
|
||||
className="message-username"
|
||||
onClick={handleProfileClick}>
|
||||
{message.username}
|
||||
@@ -474,11 +474,11 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
<div className="attachment" key={idx}>
|
||||
{isImage ? (
|
||||
<div className="image-wrapper">
|
||||
<img
|
||||
<img
|
||||
ref={(el) => {
|
||||
if (el) imageRefs.current.set(file.path, el);
|
||||
}}
|
||||
src={imageSrc}
|
||||
src={imageSrc}
|
||||
alt={file.name || "image"}
|
||||
onClick={(e) => handleImageClick(file, e.currentTarget)}
|
||||
onLoad={() => updateLoadedImages(draft => { draft.add(file.path); })}
|
||||
@@ -491,8 +491,8 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
href="#"
|
||||
<a
|
||||
href="#"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
await downloadFile(file);
|
||||
@@ -512,7 +512,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
</mdui-list>
|
||||
)}
|
||||
|
||||
<Reactions
|
||||
<Reactions
|
||||
reactions={message.reactions}
|
||||
onReactionClick={(emoji) => onReactionClick?.(message.id, emoji)}
|
||||
messageId={message.id}
|
||||
@@ -521,11 +521,11 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
<div className="message-time">
|
||||
{formatTime(message.timestamp)}
|
||||
{message.is_edited ? " (edited)" : undefined}
|
||||
|
||||
|
||||
{isAuthor && message.is_read && (
|
||||
<span className="material-symbols outlined"></span>
|
||||
)}
|
||||
|
||||
|
||||
{isAuthor && message.runtimeData?.sendingState && (
|
||||
<span className="message-status-indicator">
|
||||
{message.runtimeData.sendingState.status === 'sending' && (
|
||||
@@ -545,7 +545,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
|
||||
{/* Fullscreen Image Viewer with shared-element like transition */}
|
||||
{fullscreenImage && createPortal(
|
||||
<div
|
||||
<div
|
||||
className={`fullscreen-image-overlay ${isAnimatingOpen ? "open" : "closing"}`}
|
||||
onClick={closeFullscreen}>
|
||||
<img
|
||||
|
||||
@@ -21,12 +21,12 @@ export interface ContextMenuState {
|
||||
position: Size2D;
|
||||
}
|
||||
|
||||
export function MessageContextMenu({
|
||||
message,
|
||||
isAuthor,
|
||||
onEdit,
|
||||
onReply,
|
||||
onDelete,
|
||||
export function MessageContextMenu({
|
||||
message,
|
||||
isAuthor,
|
||||
onEdit,
|
||||
onReply,
|
||||
onDelete,
|
||||
onRetry,
|
||||
onReactionClick,
|
||||
position,
|
||||
@@ -42,7 +42,7 @@ export function MessageContextMenu({
|
||||
const [initialDimensions, setInitialDimensions] = useState<{ width: number; height: number } | null>(null);
|
||||
const [expandUpward, setExpandUpward] = useState(false);
|
||||
const [contextMenuHeight, setContextMenuHeight] = useState<number | null>(null);
|
||||
|
||||
|
||||
// Refs for measuring actual dimensions
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const reactionBarRef = useRef<HTMLDivElement>(null);
|
||||
@@ -92,13 +92,13 @@ export function MessageContextMenu({
|
||||
y = viewportHeight - sharedRect.height;
|
||||
animation = 'entering-up';
|
||||
}
|
||||
|
||||
|
||||
setCalculatedPosition({ x, y });
|
||||
setAnimationClass(animation);
|
||||
setReactionBarPosition(reactionPosition);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
}
|
||||
}, [isOpen, position, isAuthor]);
|
||||
@@ -146,7 +146,7 @@ export function MessageContextMenu({
|
||||
// Set appropriate closing animation based on opening animation
|
||||
const closingAnimation = animationClass.replace('entering', 'closing');
|
||||
setAnimationClass(closingAnimation);
|
||||
|
||||
|
||||
// Wait for animation to complete before calling onOpenChange
|
||||
setTimeout(() => {
|
||||
onOpenChange(false);
|
||||
@@ -229,7 +229,7 @@ export function MessageContextMenu({
|
||||
// Measure the actual dimensions of the reaction bar content
|
||||
const reactionBarRect = reactionBarRef.current.getBoundingClientRect();
|
||||
const wrapperRect = wrapperRef.current.getBoundingClientRect();
|
||||
|
||||
|
||||
setInitialDimensions({ width: reactionBarRect.width, height: reactionBarRect.height });
|
||||
setContextMenuHeight(wrapperRect.height);
|
||||
|
||||
@@ -257,7 +257,7 @@ export function MessageContextMenu({
|
||||
}
|
||||
|
||||
return isOpen && (
|
||||
<div
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={`context-menu-wrapper ${animationClass}`}
|
||||
style={{
|
||||
@@ -267,7 +267,7 @@ export function MessageContextMenu({
|
||||
zIndex: 1000
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
|
||||
{/* Reaction Bar */}
|
||||
<div
|
||||
ref={reactionBarRef}
|
||||
@@ -303,8 +303,8 @@ export function MessageContextMenu({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={emojiMenuRef}
|
||||
<div
|
||||
ref={emojiMenuRef}
|
||||
className="emoji-menu-wrapper">
|
||||
<EmojiMenu
|
||||
isOpen={true}
|
||||
@@ -317,12 +317,12 @@ export function MessageContextMenu({
|
||||
</div>
|
||||
|
||||
{/* Context Menu */}
|
||||
<div
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className={`context-menu ${isEmojiMenuExpanded ? "faded" : ""}`}>
|
||||
{actions.map((action, i) => (
|
||||
action.show && (
|
||||
<div
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={action.onClick}
|
||||
key={i}
|
||||
|
||||
@@ -33,7 +33,7 @@ function ChatHeaderText({ panel }: { panel: MessagePanel | null }) {
|
||||
if (panel instanceof DMPanel) {
|
||||
const recipientId = panel.getRecipientId()!;
|
||||
const isTyping = chat.dmTypingUsers.get(recipientId);
|
||||
|
||||
|
||||
content = isTyping ? <TypingIndicator typingUsers={[]} /> : <OnlineStatus userId={recipientId} />;
|
||||
} else if (panel instanceof PublicChatPanel && otherTypingUsers.length > 0) {
|
||||
content = <TypingIndicator typingUsers={otherTypingUsers} />;
|
||||
@@ -90,12 +90,12 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
useEffect(() => {
|
||||
if (panel) {
|
||||
setPanelState(panel.getState());
|
||||
|
||||
|
||||
// Store the handler for cleanup
|
||||
panel.onStateChange = (newState: MessagePanelState) => {
|
||||
setPanelState(newState);
|
||||
};
|
||||
|
||||
|
||||
// Set up WebSocket message handler for this panel
|
||||
if (panel.handleWebSocketMessage) {
|
||||
setGlobalMessageHandler((message: WebSocketMessage<any>) => panel.handleWebSocketMessage(message));
|
||||
@@ -104,7 +104,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
setPanelState(null);
|
||||
setGlobalMessageHandler(null);
|
||||
}
|
||||
|
||||
|
||||
return () => {
|
||||
if (panel) {
|
||||
if (panel.onStateChange) {
|
||||
@@ -122,11 +122,11 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
useEffect(() => {
|
||||
if (chat.isSwitching) {
|
||||
setSwitchOut(true);
|
||||
|
||||
|
||||
// Use animation event listeners instead of hardcoded delays
|
||||
function handleAnimationEnd(event: Event) {
|
||||
const animationEvent = event as AnimationEvent;
|
||||
|
||||
|
||||
if (animationEvent.animationName === 'fadeOutUp') {
|
||||
// Apply pending panel exactly at the boundary between animations
|
||||
applyPendingPanel();
|
||||
@@ -138,10 +138,10 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
chat.setIsSwitching(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Add event listener to document to catch all animation events
|
||||
document.addEventListener('animationend', handleAnimationEnd);
|
||||
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
document.removeEventListener('animationend', handleAnimationEnd);
|
||||
@@ -152,9 +152,9 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
// Load messages when panel changes and animation is not running
|
||||
useEffect(() => {
|
||||
if (!chat.activePanel || chat.isSwitching || switchOut || switchIn) return;
|
||||
|
||||
|
||||
const panelState = chat.activePanel.getState();
|
||||
|
||||
|
||||
if (panelState.messages.length === 0 && !panelState.isLoading) {
|
||||
chat.activePanel.loadMessages();
|
||||
}
|
||||
@@ -180,7 +180,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
const id = requestAnimationFrame(() => {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
});
|
||||
|
||||
|
||||
return () => cancelAnimationFrame(id);
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
const dmPanel = panel as DMPanel;
|
||||
const userId = dmPanel.getDMUserId();
|
||||
const username = dmPanel.getDMUsername();
|
||||
|
||||
|
||||
if (userId && username) {
|
||||
initiateCall(userId, username);
|
||||
}
|
||||
@@ -202,7 +202,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
|
||||
async function handleProfileClick() {
|
||||
if (!panel) return;
|
||||
|
||||
|
||||
try {
|
||||
const profileData = await panel.getProfile();
|
||||
if (profileData) {
|
||||
@@ -215,9 +215,9 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
|
||||
return (
|
||||
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
|
||||
<div
|
||||
<div
|
||||
ref={messagePanelRef}
|
||||
className="chat-main"
|
||||
className="chat-main"
|
||||
id="chat-inner"
|
||||
onDragEnter={panel ? (e) => {
|
||||
if (!e.dataTransfer) return;
|
||||
@@ -252,9 +252,9 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
dragCounterRef.current = 0;
|
||||
} : undefined}>
|
||||
<div className="chat-header">
|
||||
<img
|
||||
src={panelState?.profilePicture || defaultAvatar}
|
||||
alt="Avatar"
|
||||
<img
|
||||
src={panelState?.profilePicture || defaultAvatar}
|
||||
alt="Avatar"
|
||||
className="chat-header-avatar"
|
||||
onClick={handleProfileClick}
|
||||
style={{ cursor: panel ? "pointer" : "default" }}
|
||||
@@ -272,10 +272,10 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
|
||||
{panelState?.isLoading ? (
|
||||
<div className="chat-messages" id="chat-messages">
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
color: "var(--mdui-color-on-surface-variant)"
|
||||
}}>
|
||||
@@ -283,9 +283,9 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
</div>
|
||||
</div>
|
||||
) : panelState && panel ? (
|
||||
<ChatMessages
|
||||
messages={panelState.messages}
|
||||
isDm={panel.isDm()}
|
||||
<ChatMessages
|
||||
messages={panelState.messages}
|
||||
isDm={panel.isDm()}
|
||||
dmRecipientPublicKey={(panel as DMPanel).dmData?.publicKey}
|
||||
onReplySelect={(message) => {
|
||||
if (editMessage || editVisible) {
|
||||
@@ -310,10 +310,10 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
</ChatMessages>
|
||||
) : (
|
||||
<div className="chat-messages" id="chat-messages">
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
color: "var(--mdui-color-on-surface-variant)"
|
||||
}}>
|
||||
@@ -324,10 +324,10 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
|
||||
{panel && (
|
||||
<>
|
||||
<AnimatedOpacity
|
||||
visible={isDragging}
|
||||
className="file-overlay"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
<AnimatedOpacity
|
||||
visible={isDragging}
|
||||
className="file-overlay"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => e.preventDefault()}>
|
||||
<div className="file-overlay-wrapper">
|
||||
<div className="file-overlay-inner">
|
||||
@@ -336,13 +336,13 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedOpacity>
|
||||
|
||||
|
||||
<ChatInputWrapper
|
||||
|
||||
|
||||
<ChatInputWrapper
|
||||
onSendMessage={(text, files) => {
|
||||
panel.handleSendMessage(text, replyTo?.id, files);
|
||||
setReplyTo(null);
|
||||
}}
|
||||
}}
|
||||
onSaveEdit={(content) => {
|
||||
if (editMessage) {
|
||||
panel.handleEditMessage(editMessage.id, content);
|
||||
@@ -397,7 +397,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Profile Dialog */}
|
||||
<ProfileDialog />
|
||||
</div>
|
||||
|
||||
@@ -9,19 +9,14 @@ import { useAppState } from "@/pages/chat/state";
|
||||
|
||||
interface OnlineStatusProps {
|
||||
userId: number;
|
||||
className?: string;
|
||||
showLastSeen?: boolean;
|
||||
}
|
||||
|
||||
export function OnlineStatus({ userId, className = "", showLastSeen = false }: OnlineStatusProps) {
|
||||
const { chat } = useAppState();
|
||||
const status = chat.onlineStatuses.get(userId);
|
||||
export function OnlineStatus({ userId, showLastSeen = false }: OnlineStatusProps) {
|
||||
const { chat, user } = useAppState();
|
||||
const status = userId === user.currentUser?.id ? { online: true, lastSeen: new Date().toISOString() } : chat.onlineStatuses.get(userId);
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formatLastSeen = (lastSeen: string): string => {
|
||||
function formatLastSeen(lastSeen: string): string {
|
||||
const date = new Date(lastSeen);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
@@ -40,15 +35,15 @@ export function OnlineStatus({ userId, className = "", showLastSeen = false }: O
|
||||
} else {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`online-status ${className}`}>
|
||||
<div className={`status-dot ${status.online ? "online" : "offline"}`}></div>
|
||||
<div className="online-status">
|
||||
<div className={`status-dot ${status?.online ? "online" : "offline"}`}></div>
|
||||
<span className="status-text">
|
||||
{status.online ? "В сети" : "Не в сети"}
|
||||
{!status ? "Загрузка..." : status?.online ? "В сети" : "Не в сети"}
|
||||
</span>
|
||||
{showLastSeen && !status.online && (
|
||||
{showLastSeen && status && !status.online && (
|
||||
<span className="last-seen">
|
||||
{formatLastSeen(status.lastSeen)}
|
||||
</span>
|
||||
|
||||
@@ -3,6 +3,6 @@ import { MessagePanelRenderer } from "./MessagePanelRenderer";
|
||||
|
||||
export function RightPanel() {
|
||||
const { chat } = useAppState();
|
||||
|
||||
|
||||
return <MessagePanelRenderer panel={chat.activePanel} />
|
||||
}
|
||||
@@ -8,11 +8,11 @@ import { id } from "@/utils/utils";
|
||||
export function CallWindow() {
|
||||
const { chat, toggleCallMinimize, user } = useAppState();
|
||||
const { call } = chat;
|
||||
const {
|
||||
acceptCall,
|
||||
rejectCall,
|
||||
remoteAudioRef,
|
||||
endCall,
|
||||
const {
|
||||
acceptCall,
|
||||
rejectCall,
|
||||
remoteAudioRef,
|
||||
endCall,
|
||||
toggleMute,
|
||||
toggleVideo,
|
||||
toggleScreenShare,
|
||||
@@ -42,7 +42,7 @@ export function CallWindow() {
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
|
||||
|
||||
if (call.status === "active" && call.startTime) {
|
||||
interval = setInterval(() => {
|
||||
setCallDuration(Math.floor((Date.now() - call.startTime!) / 1000));
|
||||
@@ -185,7 +185,7 @@ export function CallWindow() {
|
||||
autoPlay
|
||||
playsInline
|
||||
controls />
|
||||
|
||||
|
||||
{shouldRender && (
|
||||
<div
|
||||
className={`call-window ${(call.isActive ? call.isMinimized : wasMinimized) ? "minimized" : "maximized"} ${isDragging ? "dragging" : ""} ${getGradientClass()} ${isVisible ? "visible" : "hidden"}`}
|
||||
@@ -209,13 +209,13 @@ export function CallWindow() {
|
||||
>
|
||||
<div className="call-header">
|
||||
<div className="window-controls">
|
||||
<mdui-button-icon
|
||||
onClick={toggleCallMinimize}
|
||||
icon={call.isMinimized ? "open_in_full" : "close_fullscreen"}
|
||||
className="window-control-btn"
|
||||
<mdui-button-icon
|
||||
onClick={toggleCallMinimize}
|
||||
icon={call.isMinimized ? "open_in_full" : "close_fullscreen"}
|
||||
className="window-control-btn"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="call-header-info">
|
||||
<h3 className="username">{remoteUsername}</h3>
|
||||
<p className="status">{getStatusText()}</p>
|
||||
@@ -235,7 +235,7 @@ export function CallWindow() {
|
||||
{/* Main screen share area - takes most space when active */}
|
||||
<div className="screen-share-area">
|
||||
{/* Local screen share */}
|
||||
<div
|
||||
<div
|
||||
className="video-tile screen-share-tile local-screen-share"
|
||||
style={{ display: call.isSharingScreen ? "flex" : "none" }}>
|
||||
<video
|
||||
@@ -246,9 +246,9 @@ export function CallWindow() {
|
||||
muted />
|
||||
<div className="tile-label">Your Screen</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Remote screen share */}
|
||||
<div
|
||||
<div
|
||||
className="video-tile screen-share-tile remote-screen-share"
|
||||
style={{ display: call.isRemoteScreenSharing ? "flex" : "none" }}>
|
||||
<video
|
||||
|
||||
@@ -46,7 +46,7 @@ export function MinimizedCallBar() {
|
||||
<span className="status">{getStatusText()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="call-actions" onClick={(e) => e.stopPropagation()}>
|
||||
{call.status === "calling" && !call.isInitiator ? (
|
||||
<mdui-button-icon onClick={endCall} icon="call_end" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MessagePanel } from "./MessagePanel";
|
||||
import {
|
||||
fetchDMHistory,
|
||||
decryptDm,
|
||||
import {
|
||||
fetchDMHistory,
|
||||
decryptDm,
|
||||
sendDMViaWebSocket,
|
||||
sendDmWithFiles,
|
||||
editDmEnvelope,
|
||||
@@ -43,7 +43,7 @@ export class DMPanel extends MessagePanel {
|
||||
async activate(): Promise<void> {
|
||||
// Don't load messages immediately during activation to prevent animation freeze
|
||||
// Messages will be loaded after the animation completes
|
||||
|
||||
|
||||
// Subscribe to recipient's online status
|
||||
if (this.dmData?.userId) {
|
||||
onlineStatusManager.subscribe(this.dmData.userId);
|
||||
@@ -65,9 +65,9 @@ export class DMPanel extends MessagePanel {
|
||||
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
|
||||
const plaintext = await decryptDm(env, this.dmData!.publicKey);
|
||||
const username = formatDMUsername(
|
||||
env.senderId,
|
||||
env.recipientId,
|
||||
this.currentUser.currentUser?.id!,
|
||||
env.senderId,
|
||||
env.recipientId,
|
||||
this.currentUser.currentUser?.id!,
|
||||
this.dmData!.username
|
||||
);
|
||||
|
||||
@@ -146,10 +146,10 @@ export class DMPanel extends MessagePanel {
|
||||
if (!this.currentUser.authToken || !this.dmData || !content.trim()) return;
|
||||
|
||||
try {
|
||||
const payload: DmEncryptedJSON = {
|
||||
type: "text",
|
||||
data: {
|
||||
content: content.trim(),
|
||||
const payload: DmEncryptedJSON = {
|
||||
type: "text",
|
||||
data: {
|
||||
content: content.trim(),
|
||||
reply_to_id: replyToId ?? undefined
|
||||
}
|
||||
}
|
||||
@@ -193,12 +193,12 @@ export class DMPanel extends MessagePanel {
|
||||
async handleWebSocketMessage(response: DMWebSocketMessage): Promise<void> {
|
||||
if (response.type === "dmNew" && this.dmData) {
|
||||
const envelope = response.data;
|
||||
|
||||
|
||||
// If this is for the active DM conversation
|
||||
if (envelope.senderId === this.dmData.userId || envelope.recipientId === this.dmData.userId) {
|
||||
try {
|
||||
const dmMsg = await this.parseTextPayload(envelope, this.getMessages());
|
||||
|
||||
|
||||
// Check if this is a confirmation of a message we sent
|
||||
const isOurMessage = envelope.senderId !== this.dmData.userId;
|
||||
if (isOurMessage) {
|
||||
@@ -211,7 +211,7 @@ export class DMPanel extends MessagePanel {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
this.addMessage(dmMsg);
|
||||
|
||||
// Update last read if it's from the other user
|
||||
@@ -228,17 +228,17 @@ export class DMPanel extends MessagePanel {
|
||||
try {
|
||||
// Decrypt new content in-place
|
||||
const plaintext = await decryptDm(
|
||||
{
|
||||
id,
|
||||
senderId: 0,
|
||||
recipientId: 0,
|
||||
iv,
|
||||
ciphertext,
|
||||
salt,
|
||||
iv2,
|
||||
wrappedMk,
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id,
|
||||
senderId: 0,
|
||||
recipientId: 0,
|
||||
iv,
|
||||
ciphertext,
|
||||
salt,
|
||||
iv2,
|
||||
wrappedMk,
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
this.dmData.publicKey
|
||||
);
|
||||
let content = plaintext;
|
||||
@@ -272,7 +272,7 @@ export class DMPanel extends MessagePanel {
|
||||
if (this.dmData?.userId) {
|
||||
onlineStatusManager.unsubscribe(this.dmData.userId);
|
||||
}
|
||||
|
||||
|
||||
this.dmData = null;
|
||||
this.messagesLoaded = false;
|
||||
this.clearMessages();
|
||||
@@ -324,10 +324,10 @@ export class DMPanel extends MessagePanel {
|
||||
|
||||
async handleDeleteMessage(messageId: number): Promise<void> {
|
||||
if (!this.currentUser.authToken || !this.dmData) return;
|
||||
|
||||
|
||||
// Remove message immediately from UI
|
||||
this.deleteMessageImmediately(messageId);
|
||||
|
||||
|
||||
// Fire and forget server deletion; UI already updated
|
||||
await deleteDmEnvelope(messageId, this.dmData.userId, this.currentUser.authToken);
|
||||
}
|
||||
@@ -348,14 +348,14 @@ export class DMPanel extends MessagePanel {
|
||||
console.error("Failed to edit DM:", e);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async getProfile(): Promise<ProfileDialogData | null> {
|
||||
if (!this.dmData || !this.currentUser.authToken) return null;
|
||||
|
||||
|
||||
try {
|
||||
const userProfile = await fetchUserProfile(this.currentUser.authToken, this.dmData.username);
|
||||
if (!userProfile) return null;
|
||||
|
||||
|
||||
return {
|
||||
userId: userProfile.id,
|
||||
username: userProfile.username,
|
||||
@@ -373,10 +373,10 @@ export class DMPanel extends MessagePanel {
|
||||
|
||||
updateMessageReactions(dmEnvelopeId: number, reactions: any[]): void {
|
||||
const messages = this.getMessages();
|
||||
const messageIndex = messages.findIndex(msg =>
|
||||
const messageIndex = messages.findIndex(msg =>
|
||||
msg.runtimeData?.dmEnvelope?.id === dmEnvelopeId
|
||||
);
|
||||
|
||||
|
||||
if (messageIndex !== -1) {
|
||||
const updatedMessage = { ...messages[messageIndex] };
|
||||
updatedMessage.reactions = reactions;
|
||||
|
||||
@@ -89,7 +89,7 @@ export abstract class MessagePanel {
|
||||
|
||||
protected updateMessageReactions(messageId: number, reactions: any[]): void {
|
||||
this.updateState({
|
||||
messages: this.state.messages.map(msg =>
|
||||
messages: this.state.messages.map(msg =>
|
||||
msg.id === messageId ? { ...msg, reactions } : msg
|
||||
)
|
||||
});
|
||||
@@ -125,7 +125,7 @@ export abstract class MessagePanel {
|
||||
}
|
||||
|
||||
// ========== PUBLIC API ==========
|
||||
|
||||
|
||||
// Event handlers
|
||||
handleSendMessage(content: string, replyToId?: number, files: File[] = []): void {
|
||||
this.sendMessageWithImmediateDisplay(content, replyToId, files);
|
||||
@@ -136,10 +136,10 @@ export abstract class MessagePanel {
|
||||
if (!message?.runtimeData?.sendingState?.retryData) return;
|
||||
|
||||
const { content, replyToId, files } = message.runtimeData.sendingState.retryData;
|
||||
|
||||
|
||||
// Create new temp ID for retry
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
|
||||
// Update status back to sending and create new temp message
|
||||
const retryMessage: Message = {
|
||||
...message,
|
||||
@@ -184,7 +184,7 @@ export abstract class MessagePanel {
|
||||
// Clear the timeout since we're handling the failure immediately
|
||||
clearTimeout(timeoutId);
|
||||
this.pendingMessages.delete(tempId);
|
||||
|
||||
|
||||
// Update message to failed state directly
|
||||
this.updateState({
|
||||
messages: this.state.messages.map(msg => {
|
||||
@@ -211,7 +211,7 @@ export abstract class MessagePanel {
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
this.pendingMessages.delete(tempId);
|
||||
|
||||
|
||||
// Replace temporary message with confirmed one
|
||||
this.updateState({
|
||||
messages: this.state.messages.map(msg => {
|
||||
@@ -249,7 +249,7 @@ export abstract class MessagePanel {
|
||||
}
|
||||
|
||||
// ========== PRIVATE METHODS ==========
|
||||
|
||||
|
||||
// Create and display message immediately with sending state
|
||||
private async sendMessageWithImmediateDisplay(content: string, replyToId?: number, files: File[] = []): Promise<void> {
|
||||
if (!content.trim() && files.length === 0) return;
|
||||
@@ -326,7 +326,7 @@ export abstract class MessagePanel {
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
this.pendingMessages.delete(tempId);
|
||||
|
||||
|
||||
// Update message to failed state
|
||||
this.updateState({
|
||||
messages: this.state.messages.map(msg => {
|
||||
|
||||
@@ -70,7 +70,7 @@ export class PublicChatPanel extends MessagePanel {
|
||||
if (files.length === 0) {
|
||||
const response = await request({
|
||||
data: {
|
||||
content: content.trim(),
|
||||
content: content.trim(),
|
||||
reply_to_id: replyToId ?? null
|
||||
},
|
||||
credentials: {
|
||||
@@ -86,7 +86,7 @@ export class PublicChatPanel extends MessagePanel {
|
||||
const form = new FormData();
|
||||
form.append("payload", JSON.stringify({
|
||||
content: content.trim(),
|
||||
reply_to_id: replyToId ?? null
|
||||
reply_to_id: replyToId ?? null
|
||||
} satisfies SendMessageRequest["data"]));
|
||||
for (const f of files) form.append("files", f, f.name);
|
||||
const res = await fetch(`${API_BASE_URL}/send_message`, {
|
||||
@@ -119,7 +119,7 @@ export class PublicChatPanel extends MessagePanel {
|
||||
case 'newMessage':
|
||||
if (response.data) {
|
||||
const newMsg = response.data;
|
||||
|
||||
|
||||
// Check if this is a confirmation of a message we sent
|
||||
const isOurMessage = newMsg.username === this.currentUser.currentUser?.username;
|
||||
if (isOurMessage) {
|
||||
@@ -132,7 +132,7 @@ export class PublicChatPanel extends MessagePanel {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
this.addMessage(newMsg);
|
||||
}
|
||||
break;
|
||||
@@ -185,18 +185,18 @@ export class PublicChatPanel extends MessagePanel {
|
||||
async handleDeleteMessage(id: number): Promise<void> {
|
||||
// Remove message immediately from UI
|
||||
this.deleteMessageImmediately(id);
|
||||
|
||||
|
||||
// Fire and forget server deletion; UI already updated
|
||||
await request({
|
||||
type: "deleteMessage",
|
||||
data: { message_id: id },
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.currentUser.authToken!
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.currentUser.authToken!
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async getProfile(): Promise<ProfileDialogData | null> {
|
||||
return {
|
||||
username: "Общий чат",
|
||||
|
||||
Reference in New Issue
Block a user