mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-23 03:25:07 +03:00
Migrate to SCSS modules
This commit is contained in:
@@ -7,6 +7,7 @@ import Quote from "@/core/components/Quote";
|
||||
import { useImmer } from "use-immer";
|
||||
import { EmojiMenu } from "./EmojiMenu";
|
||||
import { MaterialButton, MaterialIcon, MaterialIconButton } from "@/utils/material";
|
||||
import styles from "@/pages/chat/css/ChatInput.module.scss";
|
||||
|
||||
interface ChatInputWrapperProps {
|
||||
onSendMessage: (message: string, files: File[]) => void;
|
||||
@@ -144,8 +145,8 @@ export function ChatInputWrapper(
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-input-wrapper" ref={chatInputWrapperRef}>
|
||||
<form className="input-group" id="message-form" onSubmit={handleSubmit}>
|
||||
<div className={styles.chatInputWrapper} ref={chatInputWrapperRef}>
|
||||
<form className={styles.inputGroup} id="message-form" onSubmit={handleSubmit}>
|
||||
<AnimatePresence onExitComplete={onCloseEdit}>
|
||||
{editVisible && editingMessage && (
|
||||
<motion.div
|
||||
@@ -155,13 +156,13 @@ export function ChatInputWrapper(
|
||||
transition={{ duration: 0.25 }}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<div className="reply-preview contextual-preview">
|
||||
<div className={styles.contextualPreview}>
|
||||
<MaterialIcon name="edit" />
|
||||
<Quote className="reply-content contextual-content" background="surfaceContainer">
|
||||
<span className="reply-username">{editingMessage!.username}</span>
|
||||
<span className="reply-text">{editingMessage!.content}</span>
|
||||
<Quote className={`${styles.quote} ${styles.contextualContent}`} background="surfaceContainer">
|
||||
<span className={styles.replyUsername}>{editingMessage!.username}</span>
|
||||
<span className={styles.replyText}>{editingMessage!.content}</span>
|
||||
</Quote>
|
||||
<MaterialIconButton icon="close" className="reply-cancel" onClick={onClearEdit}></MaterialIconButton>
|
||||
<MaterialIconButton icon="close" className={styles.replyCancel} onClick={onClearEdit}></MaterialIconButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -175,13 +176,13 @@ export function ChatInputWrapper(
|
||||
transition={{ duration: 0.25 }}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<div className="reply-preview contextual-preview">
|
||||
<div className={styles.contextualPreview}>
|
||||
<MaterialIcon name="reply" />
|
||||
<Quote className="reply-content contextual-content" background="surfaceContainer">
|
||||
<span className="reply-username">{replyTo!.username}</span>
|
||||
<span className="reply-text">{replyTo!.content}</span>
|
||||
<Quote className={`${styles.quote} ${styles.contextualContent}`} background="surfaceContainer">
|
||||
<span className={styles.replyUsername}>{replyTo!.username}</span>
|
||||
<span className={styles.replyText}>{replyTo!.content}</span>
|
||||
</Quote>
|
||||
<MaterialIconButton icon="close" className="reply-cancel" onClick={onClearReply}></MaterialIconButton>
|
||||
<MaterialIconButton icon="close" className={styles.replyCancel} onClick={onClearReply}></MaterialIconButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -195,9 +196,9 @@ export function ChatInputWrapper(
|
||||
transition={{ duration: 0.25 }}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<div className="attachments-preview contextual-preview">
|
||||
<div className={`${styles.attachmentsPreview} ${styles.contextualPreview}`}>
|
||||
<MaterialIcon name="attach_file" />
|
||||
<div className="attachments-chips">
|
||||
<div className={styles.attachmentsChips}>
|
||||
{selectedFiles.map((file, i) => (
|
||||
<mdui-chip
|
||||
key={i}
|
||||
@@ -217,22 +218,22 @@ export function ChatInputWrapper(
|
||||
</mdui-chip>
|
||||
))}
|
||||
</div>
|
||||
<MaterialIconButton icon="close" className="reply-cancel" onClick={() => setAttachmentsVisible(false)}></MaterialIconButton>
|
||||
<MaterialIconButton icon="close" className={styles.replyCancel} onClick={() => setAttachmentsVisible(false)}></MaterialIconButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div className="chat-input">
|
||||
<div className="left-buttons">
|
||||
<div className={styles.chatInput}>
|
||||
<div className={styles.leftButtons}>
|
||||
<MaterialIconButton
|
||||
icon="mood"
|
||||
onClick={handleEmojiButtonClick}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onMouseUp={e => e.stopPropagation()}
|
||||
className="emoji-btn" />
|
||||
className={styles.emojiBtn} />
|
||||
</div>
|
||||
<RichTextArea
|
||||
className="message-input"
|
||||
className={styles.messageInput}
|
||||
id="message-input"
|
||||
placeholder="Напишите сообщение..."
|
||||
autoComplete="off"
|
||||
@@ -240,9 +241,9 @@ export function ChatInputWrapper(
|
||||
rows={1}
|
||||
onTextChange={handleMessageChange}
|
||||
onEnter={handleSubmit} />
|
||||
<div className="buttons">
|
||||
<MaterialIconButton icon="attach_file" onClick={handleAttachClick} className="attach-btn"></MaterialIconButton>
|
||||
<button type="submit" className="send-btn">
|
||||
<div className={styles.buttons}>
|
||||
<MaterialIconButton icon="attach_file" onClick={handleAttachClick}></MaterialIconButton>
|
||||
<button type="submit" className={styles.sendBtn}>
|
||||
<span className="material-symbols filled">{editingMessage ? "check" : "send"}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,11 @@ import { Message } from "./Message";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import type { Message as MessageType } from "@/core/types";
|
||||
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { MaterialDialog } from "@/core/components/Dialog";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { AddReactionRequest, AddDmReactionRequest } from "@/core/types";
|
||||
import { MaterialButton } from "@/utils/material";
|
||||
import { confirm } from "mdui/functions/confirm";
|
||||
import styles from "@/pages/chat/css/right-panel.module.scss";
|
||||
|
||||
interface ChatMessagesProps {
|
||||
messages?: MessageType[];
|
||||
@@ -22,8 +22,6 @@ 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,
|
||||
@@ -31,18 +29,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
position: { x: 0, y: 0 }
|
||||
});
|
||||
|
||||
// Delete dialog
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [toBeDeleted, setToBeDeleted] = useState<{ id: number; isDm: boolean } | null>(null);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!deleteDialogOpen) {
|
||||
setToBeDeleted(null);
|
||||
}
|
||||
}, [deleteDialogOpen]);
|
||||
|
||||
|
||||
function handleContextMenu(e: React.MouseEvent, message: MessageType) {
|
||||
e.preventDefault();
|
||||
setContextMenu({
|
||||
@@ -67,19 +53,17 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
if (onReplySelect) onReplySelect(message);
|
||||
};
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!toBeDeleted || !user.authToken) return;
|
||||
try {
|
||||
onDelete?.(toBeDeleted.id);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete message:", error);
|
||||
}
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
|
||||
async function handleDelete(message: MessageType) {
|
||||
setToBeDeleted({ id: message.id, isDm });
|
||||
setDeleteDialogOpen(true);
|
||||
try {
|
||||
await confirm({
|
||||
headline: "Удалить сообщение?",
|
||||
confirmText: "Удалить",
|
||||
cancelText: "Отменить",
|
||||
onConfirm: () => onDelete?.(message.id)
|
||||
});
|
||||
} catch (error) {
|
||||
// User cancelled
|
||||
}
|
||||
}
|
||||
|
||||
function handleRetry(message: MessageType) {
|
||||
@@ -127,7 +111,7 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="chat-messages" id="chat-messages">
|
||||
<div className={styles.chatMessages} id="chat-messages">
|
||||
{messages.map((message: MessageType) => (
|
||||
<Message
|
||||
key={message.id}
|
||||
@@ -144,14 +128,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<MaterialDialog
|
||||
headline="Удалить сообщение?"
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}>
|
||||
<MaterialButton slot="action" variant="tonal" onClick={() => setDeleteDialogOpen(false)}>Отменить</MaterialButton>
|
||||
<MaterialButton slot="action" variant="filled" onClick={confirmDelete}>Удалить</MaterialButton>
|
||||
</MaterialDialog>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu.message && (
|
||||
<MessageContextMenu
|
||||
@@ -170,8 +146,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
onOpenChange={handleContextMenuOpenChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { EMOJI_CATEGORIES, getRecentEmojis, addRecentEmoji } from "./emojiData";
|
||||
import type { Size2D } from "@/core/types";
|
||||
import styles from "@/pages/chat/css/EmojiMenu.module.scss";
|
||||
|
||||
interface BaseEmojiMenuProps {
|
||||
isOpen: boolean;
|
||||
@@ -118,26 +119,29 @@ export function EmojiMenu(props: EmojiMenuProps) {
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`emoji-menu ${isOpen ? "open" : ""} ${mode}`}
|
||||
style={mode === "standalone" && position ? {
|
||||
position: "fixed",
|
||||
left: position.x,
|
||||
bottom: position.y,
|
||||
zIndex: 1000,
|
||||
pointerEvents: isOpen ? "auto" : "none"
|
||||
} : {
|
||||
pointerEvents: isOpen ? "auto" : "none"
|
||||
className={`${styles.emojiMenu} ${isOpen ? styles.open : ""} ${mode === "integrated" ? styles.integrated : ""}`}
|
||||
style={{
|
||||
pointerEvents: isOpen ? "auto" : "none",
|
||||
...(mode === "standalone" && position ? {
|
||||
position: "fixed",
|
||||
left: position.x,
|
||||
bottom: position.y,
|
||||
zIndex: 1000,
|
||||
} : {}),
|
||||
...(mode === "integrated" ? {
|
||||
position: "relative",
|
||||
} : {})
|
||||
}}
|
||||
>
|
||||
<div className="emoji-menu-header">
|
||||
<div ref={tabsRef} className="emoji-category-tabs">
|
||||
>
|
||||
<div className={styles.emojiMenuHeader}>
|
||||
<div ref={tabsRef} className={styles.emojiCategoryTabs}>
|
||||
{EMOJI_CATEGORIES.map((category) => (
|
||||
<button
|
||||
key={category.name}
|
||||
ref={(el) => {
|
||||
if (el) tabRefs.current.set(category.name, el);
|
||||
}}
|
||||
className={`emoji-category-tab ${activeCategory === category.name ? "active" : ""}`}
|
||||
className={`${styles.emojiCategoryTab} ${activeCategory === category.name ? styles.active : ""}`}
|
||||
onClick={() => scrollToCategory(category.name)}
|
||||
title={category.name}
|
||||
>
|
||||
@@ -149,7 +153,7 @@ export function EmojiMenu(props: EmojiMenuProps) {
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="emoji-grid"
|
||||
className={styles.emojiGrid}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{EMOJI_CATEGORIES.map((category) => {
|
||||
@@ -161,17 +165,17 @@ export function EmojiMenu(props: EmojiMenuProps) {
|
||||
ref={(el) => {
|
||||
if (el) categoryRefs.current.set(category.name, el);
|
||||
}}
|
||||
className="emoji-category-section"
|
||||
className={styles.emojiCategorySection}
|
||||
>
|
||||
<h3 className="emoji-category-title">
|
||||
<h3 className={styles.emojiCategoryTitle}>
|
||||
{category.name.charAt(0).toUpperCase() + category.name.slice(1)}
|
||||
</h3>
|
||||
{emojis.length > 0 ? (
|
||||
<div className="emoji-category-grid">
|
||||
<div className={styles.emojiCategoryGrid}>
|
||||
{emojis.map((emoji, index) => (
|
||||
<button
|
||||
key={`${category.name}-${index}`}
|
||||
className="emoji-item"
|
||||
className={styles.emojiItem}
|
||||
onClick={() => handleEmojiClick(emoji)}
|
||||
title={emoji}
|
||||
>
|
||||
@@ -180,7 +184,7 @@ export function EmojiMenu(props: EmojiMenuProps) {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="emoji-empty-state">
|
||||
<div className={styles.emojiEmptyState}>
|
||||
<span>No {category.name} emojis</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useImmer } from "use-immer";
|
||||
import { createPortal } from "react-dom";
|
||||
import { parseProfileLink } from "@/core/profileLinks";
|
||||
import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material";
|
||||
import styles from "@/pages/chat/css/Message.module.scss";
|
||||
|
||||
interface MessageReactionsProps {
|
||||
reactions?: Reaction[];
|
||||
@@ -111,7 +112,7 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="message-reactions">
|
||||
<div className={styles.messageReactions}>
|
||||
{visibleReactions.map((reaction, index) => {
|
||||
const hasUserReacted = reaction.users.some(u => u.id === user.currentUser?.id);
|
||||
const isAnimating = animatingReactions.has(reaction.emoji);
|
||||
@@ -119,12 +120,12 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr
|
||||
return (
|
||||
<button
|
||||
key={`${messageId || 'unknown'}-${reaction.emoji}-${reaction.count}-${index}`}
|
||||
className={`reaction-button ${hasUserReacted ? "reacted" : ""} ${isAnimating ? "removing" : ""}`}
|
||||
className={`${styles.reactionButton} ${hasUserReacted ? styles.reacted : ""} ${isAnimating ? styles.removing : ""}`}
|
||||
onClick={() => onReactionClick(reaction.emoji)}
|
||||
title={reaction.users.map(u => u.username).join(", ")}
|
||||
>
|
||||
<span className="reaction-emoji">{reaction.emoji}</span>
|
||||
<span className="reaction-count">{reaction.count}</span>
|
||||
<span className={styles.reactionEmoji}>{reaction.emoji}</span>
|
||||
<span className={styles.reactionCount}>{reaction.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -177,7 +178,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
|
||||
// Now process @mentions that aren't in existing links
|
||||
content = content.replace(/@([a-zA-Z0-9_.-]+)/g, (match, username) => {
|
||||
return `<a href="https://fromchat.ru/@${username}" class="mention-link">${match}</a>`;
|
||||
return `<a href="https://fromchat.ru/@${username}" class="${styles.mentionLink}">${match}</a>`;
|
||||
});
|
||||
|
||||
// Restore the original links
|
||||
@@ -188,7 +189,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
return {
|
||||
__html: DOMPurify.sanitize(parse(content, { async: false })).trim()
|
||||
};
|
||||
}, [message.content]);
|
||||
}, [message.content, styles.mentionLink]);
|
||||
|
||||
// Auto-decrypt images in DMs
|
||||
useEffect(() => {
|
||||
@@ -473,12 +474,12 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`message ${isAuthor ? "sent" : "received"} ${isEmojiMessage ? "emoji-message" : ""} ${isSingleEmojiMessage ? "single-emoji" : ""}`}
|
||||
className={`${styles.message} ${isAuthor ? styles.sent : styles.received} ${isEmojiMessage ? styles.emojiMessage : ""} ${isSingleEmojiMessage ? "" : ""}`}
|
||||
data-id={message.id}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{!isAuthor && !isDm && (
|
||||
<div className="message-profile-pic" onClick={handleProfileClick}>
|
||||
<div className={styles.messageProfilePic} onClick={handleProfileClick}>
|
||||
<img
|
||||
src={message.username?.startsWith("Deleted User #") ? defaultAvatar : (message.profile_picture || defaultAvatar)}
|
||||
alt={message.username}
|
||||
@@ -489,10 +490,10 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="message-inner">
|
||||
<div className={styles.messageInner}>
|
||||
{!isAuthor && !isDm && !isSingleEmojiMessage && (
|
||||
<div
|
||||
className="message-username"
|
||||
className={styles.messageUsername}
|
||||
onClick={handleProfileClick}>
|
||||
{message.username}
|
||||
<StatusBadge
|
||||
@@ -504,19 +505,19 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
)}
|
||||
|
||||
{message.reply_to && (
|
||||
<Quote className="reply-preview contextual-content" background={isAuthor ? "primaryContainer" : "surfaceContainer"}>
|
||||
<span className="reply-username">{message.reply_to.username}</span>
|
||||
<span className="reply-text">{message.reply_to.content}</span>
|
||||
<Quote className={`${styles.replyPreview} ${styles.contextualContent}`} background={isAuthor ? "primaryContainer" : "surfaceContainer"}>
|
||||
<span className={styles.replyUsername}>{message.reply_to.username}</span>
|
||||
<span className={styles.replyText}>{message.reply_to.content}</span>
|
||||
</Quote>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`message-content ${isEmojiMessage ? "emoji-content" : ""} ${isSingleEmojiMessage ? "single-emoji-content" : ""}`}
|
||||
className={`${styles.messageContent} ${isEmojiMessage ? styles.emojiContent : ""} ${isSingleEmojiMessage ? styles.singleEmojiContent : ""}`}
|
||||
dangerouslySetInnerHTML={formattedMessage}
|
||||
onClick={handleLinkClick} />
|
||||
|
||||
{message.files && message.files.length > 0 && (
|
||||
<MaterialList className="message-attachments">
|
||||
<MaterialList className={styles.messageAttachments}>
|
||||
{message.files.map((file, idx) => {
|
||||
const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || "");
|
||||
const isEncryptedDm = Boolean(isDm && file.encrypted);
|
||||
@@ -526,9 +527,9 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
const isSending = message.runtimeData?.sendingState?.status === 'sending';
|
||||
|
||||
return (
|
||||
<div className="attachment" key={idx}>
|
||||
<div className={styles.attachment} key={idx}>
|
||||
{isImage ? (
|
||||
<div className="image-wrapper">
|
||||
<div className={styles.imageWrapper}>
|
||||
<img
|
||||
ref={(el) => {
|
||||
if (el) imageRefs.current.set(file.path, el);
|
||||
@@ -537,10 +538,10 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
alt={file.name || "image"}
|
||||
onClick={(e) => handleImageClick(file, e.currentTarget)}
|
||||
onLoad={() => updateLoadedImages(draft => { draft.add(file.path); })}
|
||||
className={`attachement-image ${loadedImages.has(file.path) ? "" : "loading"}`}
|
||||
className={`${styles.attachementImage} ${loadedImages.has(file.path) ? "" : styles.loading}`}
|
||||
/>
|
||||
{(!loadedImages.has(file.path) || isSending) && (
|
||||
<div className="loading-overlay">
|
||||
<div className={styles.loadingOverlay}>
|
||||
<MaterialCircularProgress />
|
||||
</div>
|
||||
)}
|
||||
@@ -554,7 +555,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
}}
|
||||
>
|
||||
<MaterialListItem>
|
||||
<span className="with-icon-gap">
|
||||
<span className={styles.withIconGap}>
|
||||
{isDownloading ? <MaterialCircularProgress /> : null}
|
||||
{(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")}
|
||||
</span>
|
||||
@@ -573,7 +574,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
messageId={message.id}
|
||||
/>
|
||||
|
||||
<div className="message-time">
|
||||
<div className={styles.messageTime}>
|
||||
{formatTime(message.timestamp)}
|
||||
{message.is_edited ? " (edited)" : undefined}
|
||||
|
||||
@@ -582,15 +583,15 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
)}
|
||||
|
||||
{isAuthor && message.runtimeData?.sendingState && (
|
||||
<span className="message-status-indicator">
|
||||
<span className={styles.messageStatusIndicator}>
|
||||
{message.runtimeData.sendingState.status === 'sending' && (
|
||||
<MaterialCircularProgress style={{ width: '16px', height: '16px' }} />
|
||||
)}
|
||||
{message.runtimeData.sendingState.status === 'failed' && (
|
||||
<span className="material-symbols error-icon">error</span>
|
||||
<span className={`material-symbols ${styles.errorIcon}`}>error</span>
|
||||
)}
|
||||
{message.runtimeData.sendingState.status === 'sent' && (
|
||||
<span className="material-symbols success-icon">check</span>
|
||||
<span className={`material-symbols ${styles.successIcon}`}>check</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
@@ -601,12 +602,12 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
{/* Fullscreen Image Viewer with shared-element like transition */}
|
||||
{fullscreenImage && createPortal(
|
||||
<div
|
||||
className={`fullscreen-image-overlay ${isAnimatingOpen ? "open" : "closing"}`}
|
||||
className={`${styles.fullscreenImageOverlay} ${isAnimatingOpen ? "" : styles.closing}`}
|
||||
onClick={closeFullscreen}>
|
||||
<img
|
||||
src={fullscreenImage.src}
|
||||
alt={fullscreenImage.name}
|
||||
className={`fullscreen-animated-image ${isAnimatingOpen ? "to-end" : "to-start"}`}
|
||||
className={styles.fullscreenAnimatedImage}
|
||||
style={{
|
||||
left: `${isAnimatingOpen ? fullscreenImage.endRect.left : fullscreenImage.startRect.left}px`,
|
||||
top: `${isAnimatingOpen ? fullscreenImage.endRect.top : fullscreenImage.startRect.top}px`,
|
||||
@@ -615,10 +616,10 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
<div className="fullscreen-controls top-right" onClick={e => e.stopPropagation()}>
|
||||
<div className={`${styles.fullscreenControls} ${styles.topRight}`} onClick={e => e.stopPropagation()}>
|
||||
<MaterialIconButton icon="close" onClick={closeFullscreen} />
|
||||
{isDownloadingFullscreen ? (
|
||||
<div className="progress-wrapper">
|
||||
<div className={styles.progressWrapper}>
|
||||
<MaterialCircularProgress />
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from "react";
|
||||
import type { Message, Size2D } from "@/core/types";
|
||||
import { EmojiMenu } from "./EmojiMenu";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import styles from "@/pages/chat/css/MessageContextMenu.module.scss";
|
||||
|
||||
interface MessageContextMenuProps {
|
||||
message: Message;
|
||||
@@ -39,9 +40,8 @@ export function MessageContextMenu({
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [reactionBarPosition, setReactionBarPosition] = useState<Size2D>({ x: 0, y: 0 });
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<Size2D>(position);
|
||||
const [animationClass, setAnimationClass] = useState('entering');
|
||||
const [reactionBarAnimationClass, setReactionBarAnimationClass] = useState('entering');
|
||||
const [reactionBarSide, setReactionBarSide] = useState<'left' | 'right'>('left');
|
||||
const [animationClass, setAnimationClass] = useState<keyof typeof styles>(styles.entering);
|
||||
const [reactionBarAnimationClass, setReactionBarAnimationClass] = useState<keyof typeof styles>(styles.entering);
|
||||
const [isEmojiMenuExpanded, setIsEmojiMenuExpanded] = useState(false);
|
||||
const [initialDimensions, setInitialDimensions] = useState<{ width: number; height: number } | null>(null);
|
||||
const [expandUpward, setExpandUpward] = useState(false);
|
||||
@@ -74,8 +74,7 @@ export function MessageContextMenu({
|
||||
let menuY = position.y;
|
||||
let reactionX = position.x;
|
||||
let reactionY = position.y - reactionBarRect.height - 10; // Position above menu
|
||||
let animation = 'entering';
|
||||
let reactionSide: 'left' | 'right' = 'left';
|
||||
let animation: keyof typeof styles = styles.entering;
|
||||
let reactionPositionedRight = false;
|
||||
|
||||
// Check if reaction bar would overflow at the top
|
||||
@@ -83,19 +82,16 @@ export function MessageContextMenu({
|
||||
// Position reaction bar to the right side of the context menu instead
|
||||
reactionX = menuX + contextMenuRect.width + 10;
|
||||
reactionY = menuY; // Align with menu top
|
||||
reactionSide = 'right';
|
||||
reactionPositionedRight = true;
|
||||
animation = 'entering-right'; // Use right-side animation
|
||||
animation = styles.enteringRight; // Use right-side animation
|
||||
} else {
|
||||
// Try positioning above menu first
|
||||
reactionSide = 'left';
|
||||
|
||||
// Check if shared rect would overflow horizontally
|
||||
if (menuX + sharedRect.width > viewportWidth) {
|
||||
menuX = position.x - contextMenuRect.width;
|
||||
reactionX = menuX;
|
||||
animation = 'entering-left';
|
||||
reactionSide = 'right';
|
||||
animation = styles.enteringLeft;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,14 +107,13 @@ export function MessageContextMenu({
|
||||
if (reactionPositionedRight && reactionX + reactionBarRect.width > viewportWidth) {
|
||||
// Position to the left side instead
|
||||
reactionX = menuX - reactionBarRect.width - 10;
|
||||
reactionSide = 'left';
|
||||
}
|
||||
|
||||
// Check if shared rect would overflow bottom edge (only if reaction bar is above)
|
||||
if (!reactionPositionedRight && menuY + sharedRect.height > viewportHeight) {
|
||||
menuY = viewportHeight - sharedRect.height;
|
||||
reactionY = menuY - reactionBarRect.height - 10;
|
||||
animation = 'entering-up';
|
||||
animation = styles.enteringUp;
|
||||
}
|
||||
|
||||
// Ensure menu doesn't go off the right edge
|
||||
@@ -133,7 +128,6 @@ export function MessageContextMenu({
|
||||
setReactionBarPosition({ x: reactionX, y: reactionY });
|
||||
setAnimationClass(animation);
|
||||
setReactionBarAnimationClass(animation);
|
||||
setReactionBarSide(reactionSide);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -147,7 +141,9 @@ export function MessageContextMenu({
|
||||
if (isOpen && !isClosing) {
|
||||
// Check if the click is on a context menu element or reaction bar
|
||||
const target = event.target as Element;
|
||||
if (!target.closest('.context-menu') && !target.closest('.context-menu-reaction-bar')) {
|
||||
// Use refs instead of class selectors for CSS modules
|
||||
if ((!contextMenuRef.current || !contextMenuRef.current.contains(target)) &&
|
||||
(!reactionBarRef.current || !reactionBarRef.current.contains(target))) {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
@@ -181,17 +177,15 @@ export function MessageContextMenu({
|
||||
|
||||
function handleClose() {
|
||||
setIsClosing(true);
|
||||
// Set appropriate closing animation based on opening animation
|
||||
const closingAnimation = animationClass.replace('entering', 'closing');
|
||||
setAnimationClass(closingAnimation);
|
||||
setReactionBarAnimationClass(closingAnimation);
|
||||
setAnimationClass(styles.closing);
|
||||
setReactionBarAnimationClass(styles.closing);
|
||||
|
||||
// Wait for animation to complete before calling onOpenChange
|
||||
setTimeout(() => {
|
||||
onOpenChange(false);
|
||||
setIsClosing(false);
|
||||
setAnimationClass('entering'); // Reset for next opening
|
||||
setReactionBarAnimationClass('entering'); // Reset for next opening
|
||||
setAnimationClass(styles.entering); // Reset for next opening
|
||||
setReactionBarAnimationClass(styles.entering); // Reset for next opening
|
||||
// Reset emoji menu state after context menu animation completes
|
||||
setIsEmojiMenuExpanded(false);
|
||||
setInitialDimensions(null);
|
||||
@@ -307,7 +301,7 @@ export function MessageContextMenu({
|
||||
{/* Reaction Bar */}
|
||||
<div
|
||||
ref={reactionBarRef}
|
||||
className={`context-menu-reaction-bar ${reactionBarSide} ${reactionBarAnimationClass} ${isEmojiMenuExpanded ? "expanded" : ""} ${expandUpward ? "expand-upward" : ""}`}
|
||||
className={`${styles.contextMenuReactionBar} ${reactionBarAnimationClass} ${isEmojiMenuExpanded ? styles.expanded : ""} ${expandUpward ? styles.expandUpward : ""}`}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
...(isEmojiMenuExpanded && expandUpward
|
||||
@@ -326,11 +320,11 @@ export function MessageContextMenu({
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{!isEmojiMenuExpanded ? (
|
||||
<div className="reaction-bar-content">
|
||||
<div className={styles.reactionBarContent}>
|
||||
{QUICK_REACTIONS.map((emoji, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="reaction-emoji-button"
|
||||
className={styles.reactionEmojiButton}
|
||||
onClick={async () => await handleReactionClick(emoji)}
|
||||
title={emoji}
|
||||
>
|
||||
@@ -338,7 +332,7 @@ export function MessageContextMenu({
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="reaction-expand-button"
|
||||
className={styles.reactionExpandButton}
|
||||
onClick={handleExpandClick}
|
||||
title="More emojis"
|
||||
>
|
||||
@@ -348,7 +342,7 @@ export function MessageContextMenu({
|
||||
) : (
|
||||
<div
|
||||
ref={emojiMenuRef}
|
||||
className="emoji-menu-wrapper">
|
||||
className={styles.emojiMenuWrapper}>
|
||||
<EmojiMenu
|
||||
isOpen={true}
|
||||
onClose={handleClose}
|
||||
@@ -362,7 +356,7 @@ export function MessageContextMenu({
|
||||
{/* Context Menu */}
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className={`context-menu ${animationClass} ${isEmojiMenuExpanded ? "faded" : ""}`}
|
||||
className={`${styles.contextMenu} ${animationClass} ${isEmojiMenuExpanded ? styles.faded : ""}`}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: `${contextMenuPosition.y}px`,
|
||||
@@ -373,7 +367,7 @@ export function MessageContextMenu({
|
||||
{actions.map((action, i) => (
|
||||
action.show && (
|
||||
<div
|
||||
className="context-menu-item"
|
||||
className={styles.contextMenuItem}
|
||||
onClick={action.onClick}
|
||||
key={i}>
|
||||
<span className="material-symbols">{action.icon}</span>
|
||||
|
||||
@@ -15,6 +15,8 @@ import { OnlineStatus } from "./OnlineStatus";
|
||||
import { typingManager } from "@/core/typingManager";
|
||||
import { PublicChatPanel } from "./panels/PublicChatPanel";
|
||||
import { MaterialIcon, MaterialIconButton } from "@/utils/material";
|
||||
import styles from "@/pages/chat/css/layout.module.scss";
|
||||
import rightPanelStyles from "@/pages/chat/css/right-panel.module.scss";
|
||||
|
||||
interface MessagePanelRendererProps {
|
||||
panel: MessagePanel | null;
|
||||
@@ -196,7 +198,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
const panelKey = chat.activePanel?.getState().title || "empty";
|
||||
|
||||
return (
|
||||
<div className="chat-container">
|
||||
<div className={styles.chatContainer}>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={panelKey}
|
||||
@@ -204,12 +206,11 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="chat-wrapper"
|
||||
className={rightPanelStyles.chatWrapper}
|
||||
>
|
||||
<div
|
||||
ref={messagePanelRef}
|
||||
className="chat-main"
|
||||
id="chat-inner"
|
||||
className={rightPanelStyles.chatMain}
|
||||
onDragEnter={panel ? (e) => {
|
||||
if (!e.dataTransfer) return;
|
||||
e.preventDefault();
|
||||
@@ -242,16 +243,16 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
setIsDragging(false);
|
||||
dragCounterRef.current = 0;
|
||||
} : undefined}>
|
||||
<div className="chat-header">
|
||||
<div className={rightPanelStyles.chatHeader}>
|
||||
<img
|
||||
src={panelState?.profilePicture || defaultAvatar}
|
||||
alt="Avatar"
|
||||
className="chat-header-avatar"
|
||||
className={rightPanelStyles.chatHeaderAvatar}
|
||||
onClick={handleProfileClick}
|
||||
style={{ cursor: panel ? "pointer" : "default" }}
|
||||
/>
|
||||
<div className="chat-header-info">
|
||||
<div className="info-chat">
|
||||
<div className={rightPanelStyles.chatHeaderInfo}>
|
||||
<div className={rightPanelStyles.infoChat}>
|
||||
<h4 id="chat-name">{panelState?.title || "Выбор чата"}</h4>
|
||||
<ChatHeaderText panel={panel} />
|
||||
</div>
|
||||
@@ -262,7 +263,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
</div>
|
||||
|
||||
{panelState?.isLoading ? (
|
||||
<div className="chat-messages" id="chat-messages">
|
||||
<div className={rightPanelStyles.chatMessages} id="chat-messages">
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
@@ -300,7 +301,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
<div ref={messagesEndRef} />
|
||||
</ChatMessages>
|
||||
) : (
|
||||
<div className="chat-messages" id="chat-messages">
|
||||
<div className={rightPanelStyles.chatMessages} id="chat-messages">
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
@@ -314,30 +315,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
)}
|
||||
|
||||
{panel && (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{isDragging && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="file-overlay"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="file-overlay-wrapper">
|
||||
<div className="file-overlay-inner">
|
||||
<MaterialIcon name="upload_file" />
|
||||
<span>Отпустите файл(ы) для добавления</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
|
||||
<ChatInputWrapper
|
||||
<ChatInputWrapper
|
||||
onSendMessage={(text, files) => {
|
||||
panel.handleSendMessage(text, replyTo?.id, files);
|
||||
setReplyTo(null);
|
||||
@@ -393,9 +371,29 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{panel && (
|
||||
<AnimatePresence>
|
||||
{isDragging && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className={rightPanelStyles.fileOverlay}
|
||||
>
|
||||
<div className={rightPanelStyles.fileOverlayWrapper}>
|
||||
<div className={rightPanelStyles.fileOverlayInner}>
|
||||
<MaterialIcon name="upload_file" />
|
||||
<span>Отпустите файл(ы) для добавления</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import styles from "@/pages/chat/css/TypingIndicators.module.scss";
|
||||
|
||||
interface OnlineIndicatorProps {
|
||||
userId: number;
|
||||
@@ -22,8 +23,8 @@ export function OnlineIndicator({ userId, className = "" }: OnlineIndicatorProps
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`online-indicator ${className}`}>
|
||||
<div className="indicator-dot online"></div>
|
||||
<div className={`${styles.onlineIndicator} ${className}`}>
|
||||
<div className={`${styles.indicatorDot} ${styles.online}`}></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import styles from "@/pages/chat/css/TypingIndicators.module.scss";
|
||||
|
||||
interface OnlineStatusProps {
|
||||
userId: number;
|
||||
@@ -38,9 +39,9 @@ export function OnlineStatus({ userId, showLastSeen = false }: OnlineStatusProps
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="online-status">
|
||||
<div className={`status-dot ${status?.online ? "online" : "offline"}`}></div>
|
||||
<span className="status-text">
|
||||
<div className={styles.onlineStatus}>
|
||||
<div className={`${styles.statusDot} ${status?.online ? styles.online : styles.offline}`}></div>
|
||||
<span className={styles.statusText}>
|
||||
{!status ? "Загрузка..." : status?.online ? "В сети" : "Не в сети"}
|
||||
</span>
|
||||
{showLastSeen && status && !status.online && (
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import styles from "@/pages/chat/css/TypingIndicators.module.scss";
|
||||
|
||||
interface TypingIndicatorProps {
|
||||
typingUsers: string[]; // Array of usernames who are typing
|
||||
@@ -23,13 +24,13 @@ export function TypingIndicator({ typingUsers }: TypingIndicatorProps) {
|
||||
}, [typingUsers]);
|
||||
|
||||
return (
|
||||
<div className="typing-indicator">
|
||||
<div className="typing-dots">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<div className={styles.typingIndicator}>
|
||||
<div className={styles.typingDots}>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
<span className="typing-text">{typingText}</span>
|
||||
<span className={styles.typingText}>{typingText}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAppState, type CallStatus } from "@/pages/chat/state";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import useCall from "@/pages/chat/hooks/useCall";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
import { createPortal } from "react-dom";
|
||||
import { id } from "@/utils/utils";
|
||||
import { MaterialIconButton } from "@/utils/material";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import styles from "@/pages/chat/css/callWindow.module.scss";
|
||||
|
||||
export function CallWindow() {
|
||||
const { chat, toggleCallMinimize, user } = useAppState();
|
||||
@@ -26,20 +28,11 @@ export function CallWindow() {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
const [callDuration, setCallDuration] = useState(0);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [shouldRender, setShouldRender] = useState(false);
|
||||
const [wasMinimized, setWasMinimized] = useState(false);
|
||||
const [callData, setCallData] = useState<{
|
||||
remoteUsername: string | null;
|
||||
status: CallStatus;
|
||||
isInitiator: boolean;
|
||||
isMuted: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const status = callData?.status || call.status;
|
||||
const remoteUsername = callData?.remoteUsername || call.remoteUsername;
|
||||
const isInitiator = callData?.isInitiator || call.isInitiator;
|
||||
const isMuted = callData?.isMuted || call.isMuted;
|
||||
const status = call.status;
|
||||
const remoteUsername = call.remoteUsername;
|
||||
const isInitiator = call.isInitiator;
|
||||
const isMuted = call.isMuted;
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
@@ -57,55 +50,6 @@ export function CallWindow() {
|
||||
};
|
||||
}, [call.status, call.startTime]);
|
||||
|
||||
// Preserve call data during exit animation
|
||||
useEffect(() => {
|
||||
if (call.isActive) {
|
||||
setCallData({
|
||||
remoteUsername: call.remoteUsername,
|
||||
status: call.status,
|
||||
isInitiator: call.isInitiator,
|
||||
isMuted: call.isMuted
|
||||
});
|
||||
}
|
||||
}, [call.isActive, call.remoteUsername, call.status, call.isInitiator, call.isMuted]);
|
||||
|
||||
// Track minimized state for exit animation
|
||||
useEffect(() => {
|
||||
if (call.isActive) {
|
||||
setWasMinimized(call.isMinimized);
|
||||
}
|
||||
}, [call.isActive, call.isMinimized]);
|
||||
|
||||
// Handle visibility animation with entrance and exit delays
|
||||
useEffect(() => {
|
||||
if (call.isActive) {
|
||||
setShouldRender(true);
|
||||
// Small delay to ensure DOM is ready, then trigger animation
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setIsVisible(true);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
if (shouldRender) {
|
||||
// Call ended - start exit animation
|
||||
setIsVisible(false);
|
||||
// After animation completes, stop rendering
|
||||
const timer = setTimeout(() => {
|
||||
setShouldRender(false);
|
||||
setCallData(null);
|
||||
setWasMinimized(false);
|
||||
}, 400); // Match the CSS transition duration
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
// Call not active and not rendered - ensure clean state
|
||||
setShouldRender(false);
|
||||
setIsVisible(false);
|
||||
setCallData(null);
|
||||
setWasMinimized(false);
|
||||
}
|
||||
}
|
||||
}, [call.isActive, shouldRender, wasMinimized]);
|
||||
|
||||
// Handle dragging for PiP mode
|
||||
useEffect(() => {
|
||||
@@ -135,14 +79,6 @@ export function CallWindow() {
|
||||
};
|
||||
}, [isDragging, call.isMinimized, dragOffset]);
|
||||
|
||||
// Cleanup effect to reset state when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setIsVisible(false);
|
||||
setShouldRender(false);
|
||||
setCallData(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
@@ -166,64 +102,75 @@ export function CallWindow() {
|
||||
function getGradientClass() {
|
||||
switch (status) {
|
||||
case "calling":
|
||||
return "gradient-calling";
|
||||
return styles.gradientCalling;
|
||||
case "connecting":
|
||||
return "gradient-connecting";
|
||||
return styles.gradientConnecting;
|
||||
case "active":
|
||||
return "gradient-active";
|
||||
return styles.gradientActive;
|
||||
default:
|
||||
return "gradient-default";
|
||||
return styles.gradientDefault;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const isMinimized = call.isMinimized;
|
||||
|
||||
return (
|
||||
createPortal(
|
||||
<>
|
||||
<audio
|
||||
ref={remoteAudioRef}
|
||||
className="remote-audio"
|
||||
className={styles.remoteAudio}
|
||||
autoPlay
|
||||
playsInline
|
||||
controls />
|
||||
|
||||
{shouldRender && (
|
||||
<div
|
||||
className={`call-window ${(call.isActive ? call.isMinimized : wasMinimized) ? "minimized" : "maximized"} ${isDragging ? "dragging" : ""} ${getGradientClass()} ${isVisible ? "visible" : "hidden"}`}
|
||||
style={(call.isActive ? call.isMinimized : wasMinimized) ? {
|
||||
left: pipPosition.x,
|
||||
top: pipPosition.y
|
||||
} : undefined}
|
||||
onMouseDown={(e) => {
|
||||
if (call.isMinimized) {
|
||||
// Only start dragging if not clicking on a button
|
||||
// TODO change it to e.stopPropagation() on the buttons
|
||||
if (!e.target.closest("mdui-button-icon")) {
|
||||
setIsDragging(true);
|
||||
setDragOffset({
|
||||
x: e.clientX - pipPosition.x,
|
||||
y: e.clientY - pipPosition.y
|
||||
});
|
||||
}
|
||||
<AnimatePresence>
|
||||
{call.isActive && (
|
||||
<motion.div
|
||||
className={`${styles.callWindow} ${isMinimized ? styles.minimized : styles.maximized} ${isDragging ? styles.dragging : ""} ${getGradientClass()}`}
|
||||
style={isMinimized ? {
|
||||
left: pipPosition.x,
|
||||
top: pipPosition.y
|
||||
} : undefined}
|
||||
initial={false}
|
||||
exit={isMinimized ?
|
||||
{ opacity: 0, scale: 0.7 } :
|
||||
{ opacity: 0, y: -100 }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="call-header">
|
||||
<div className="window-controls">
|
||||
transition={{
|
||||
opacity: { duration: 0.4 },
|
||||
scale: { duration: 0.4 },
|
||||
y: { duration: 0.4 }
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (isMinimized) {
|
||||
if (!e.target.closest("mdui-button-icon")) {
|
||||
setIsDragging(true);
|
||||
setDragOffset({
|
||||
x: e.clientX - pipPosition.x,
|
||||
y: e.clientY - pipPosition.y
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={styles.callHeader}>
|
||||
<div className={styles.windowControls}>
|
||||
<MaterialIconButton
|
||||
onClick={toggleCallMinimize}
|
||||
icon={call.isMinimized ? "open_in_full" : "close_fullscreen"}
|
||||
className="window-control-btn"
|
||||
className={styles.windowControlBtn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="call-header-info">
|
||||
<h3 className="username">{remoteUsername}</h3>
|
||||
<p className="status">{getStatusText()}</p>
|
||||
<div className={styles.callHeaderInfo}>
|
||||
<h3 className={styles.username}>{remoteUsername}</h3>
|
||||
<p className={styles.status}>{getStatusText()}</p>
|
||||
{!call.isMinimized && call.encryptionEmojis.length > 0 && (
|
||||
<div className="encryption-emojis">
|
||||
<div className={styles.encryptionEmojis}>
|
||||
{call.encryptionEmojis.map((emoji, index) => (
|
||||
<span key={index} className="encryption-emoji">
|
||||
<span key={index} className={styles.encryptionEmoji}>
|
||||
{emoji}
|
||||
</span>
|
||||
))}
|
||||
@@ -232,75 +179,75 @@ export function CallWindow() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`call-content ${(call.isSharingScreen || call.isRemoteScreenSharing) ? "with-screen-share" : ""}`}>
|
||||
<div className={`${styles.callContent} ${(call.isSharingScreen || call.isRemoteScreenSharing) ? styles.withScreenShare : ""}`}>
|
||||
{/* Main screen share area - takes most space when active */}
|
||||
<div className="screen-share-area">
|
||||
<div className={styles.screenShareArea}>
|
||||
{/* Local screen share */}
|
||||
<div
|
||||
className="video-tile screen-share-tile local-screen-share"
|
||||
className={`${styles.videoTile} ${styles.screenShareTile} ${styles.localScreenShare}`}
|
||||
style={{ display: call.isSharingScreen ? "flex" : "none" }}>
|
||||
<video
|
||||
ref={localScreenShareRef}
|
||||
className="video-element screen-share-video"
|
||||
className={`${styles.videoElement} ${styles.screenShareVideo}`}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted />
|
||||
<div className="tile-label">Your Screen</div>
|
||||
<div className={styles.tileLabel}>Your Screen</div>
|
||||
</div>
|
||||
|
||||
{/* Remote screen share */}
|
||||
<div
|
||||
className="video-tile screen-share-tile remote-screen-share"
|
||||
className={`${styles.videoTile} ${styles.screenShareTile} ${styles.remoteScreenShare}`}
|
||||
style={{ display: call.isRemoteScreenSharing ? "flex" : "none" }}>
|
||||
<video
|
||||
ref={remoteScreenShareRef}
|
||||
className="video-element screen-share-video"
|
||||
className={`${styles.videoElement} ${styles.screenShareVideo}`}
|
||||
autoPlay
|
||||
playsInline />
|
||||
<div className="tile-label">{remoteUsername}'s Screen</div>
|
||||
<div className={styles.tileLabel}>{remoteUsername}'s Screen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video tiles sidebar - appears on right when screen share is active */}
|
||||
<div className="video-tiles-sidebar">
|
||||
<div className={styles.videoTilesSidebar}>
|
||||
{/* Local video tile */}
|
||||
<div className="video-tile local-video">
|
||||
<div className={`${styles.videoTile} ${styles.localVideo}`}>
|
||||
<video
|
||||
ref={localVideoRef}
|
||||
className="video-element"
|
||||
className={styles.videoElement}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
style={{ display: call.isVideoEnabled ? "block" : "none" }} />
|
||||
{!call.isVideoEnabled && (
|
||||
<div className="video-placeholder">
|
||||
<img src={defaultAvatar} alt="Avatar" className="placeholder-avatar" />
|
||||
<span className="placeholder-username">{user.currentUser?.username || "You"}</span>
|
||||
<div className={styles.videoPlaceholder}>
|
||||
<img src={defaultAvatar} alt="Avatar" className={styles.placeholderAvatar} />
|
||||
<span className={styles.placeholderUsername}>{user.currentUser?.username || "You"}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="tile-label">You</div>
|
||||
<div className={styles.tileLabel}>You</div>
|
||||
</div>
|
||||
|
||||
{/* Remote video tile */}
|
||||
<div className="video-tile remote-video">
|
||||
<div className={`${styles.videoTile} ${styles.remoteVideo}`}>
|
||||
<video
|
||||
ref={remoteVideoRef}
|
||||
className="video-element"
|
||||
className={styles.videoElement}
|
||||
autoPlay
|
||||
playsInline
|
||||
style={{ display: call.isRemoteVideoEnabled ? "block" : "none" }} />
|
||||
{!call.isRemoteVideoEnabled && (
|
||||
<div className="video-placeholder">
|
||||
<img src={defaultAvatar} alt="Avatar" className="placeholder-avatar" />
|
||||
<span className="placeholder-username">{remoteUsername}</span>
|
||||
<div className={styles.videoPlaceholder}>
|
||||
<img src={defaultAvatar} alt="Avatar" className={styles.placeholderAvatar} />
|
||||
<span className={styles.placeholderUsername}>{remoteUsername}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="tile-label">{remoteUsername}</div>
|
||||
<div className={styles.tileLabel}>{remoteUsername}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="call-controls">
|
||||
<div className={styles.callControls}>
|
||||
{status === "calling" && !isInitiator ? (
|
||||
<>
|
||||
<MaterialIconButton onClick={acceptCall} icon="call" />
|
||||
@@ -315,8 +262,9 @@ export function CallWindow() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>,
|
||||
id("root")
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user