mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-23 03:25:07 +03:00
Create new Material components, redesign the chat UI, add security settings
This commit is contained in:
@@ -6,6 +6,7 @@ import type { Message } from "@/core/types";
|
||||
import Quote from "@/core/components/Quote";
|
||||
import { useImmer } from "use-immer";
|
||||
import { EmojiMenu } from "./EmojiMenu";
|
||||
import { MaterialButton, MaterialIcon, MaterialIconButton } from "@/utils/material";
|
||||
|
||||
interface ChatInputWrapperProps {
|
||||
onSendMessage: (message: string, files: File[]) => void;
|
||||
@@ -155,12 +156,12 @@ export function ChatInputWrapper(
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<div className="reply-preview contextual-preview">
|
||||
<mdui-icon name="edit" />
|
||||
<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>
|
||||
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearEdit}></mdui-button-icon>
|
||||
<MaterialIconButton icon="close" className="reply-cancel" onClick={onClearEdit}></MaterialIconButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -175,12 +176,12 @@ export function ChatInputWrapper(
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<div className="reply-preview contextual-preview">
|
||||
<mdui-icon name="reply" />
|
||||
<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>
|
||||
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearReply}></mdui-button-icon>
|
||||
<MaterialIconButton icon="close" className="reply-cancel" onClick={onClearReply}></MaterialIconButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -195,7 +196,7 @@ export function ChatInputWrapper(
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<div className="attachments-preview contextual-preview">
|
||||
<mdui-icon name="attach_file" />
|
||||
<MaterialIcon name="attach_file" />
|
||||
<div className="attachments-chips">
|
||||
{selectedFiles.map((file, i) => (
|
||||
<mdui-chip
|
||||
@@ -211,19 +212,19 @@ export function ChatInputWrapper(
|
||||
}
|
||||
}}
|
||||
>
|
||||
<mdui-icon slot="icon" name="attach_file"></mdui-icon>
|
||||
<MaterialIcon slot="icon" name="attach_file"></MaterialIcon>
|
||||
<span className="name">{file.name}</span>
|
||||
</mdui-chip>
|
||||
))}
|
||||
</div>
|
||||
<mdui-button-icon icon="close" className="reply-cancel" onClick={() => setAttachmentsVisible(false)}></mdui-button-icon>
|
||||
<MaterialIconButton icon="close" className="reply-cancel" onClick={() => setAttachmentsVisible(false)}></MaterialIconButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div className="chat-input">
|
||||
<div className="left-buttons">
|
||||
<mdui-button-icon
|
||||
<MaterialIconButton
|
||||
icon="mood"
|
||||
onClick={handleEmojiButtonClick}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
@@ -240,7 +241,7 @@ export function ChatInputWrapper(
|
||||
onTextChange={handleMessageChange}
|
||||
onEnter={handleSubmit} />
|
||||
<div className="buttons">
|
||||
<mdui-button-icon icon="attach_file" onClick={handleAttachClick} className="attach-btn"></mdui-button-icon>
|
||||
<MaterialIconButton icon="attach_file" onClick={handleAttachClick} className="attach-btn"></MaterialIconButton>
|
||||
<button type="submit" className="send-btn">
|
||||
<span className="material-symbols filled">{editingMessage ? "check" : "send"}</span>
|
||||
</button>
|
||||
@@ -250,7 +251,7 @@ export function ChatInputWrapper(
|
||||
<MaterialDialog open={errorOpen} onOpenChange={setErrorOpen} close-on-overlay-click close-on-esc>
|
||||
<div slot="headline">Ошибка</div>
|
||||
<div>Общий размер вложений превышает 4 ГБ.</div>
|
||||
<mdui-button slot="action" onClick={() => setErrorOpen(false)}>Закрыть</mdui-button>
|
||||
<MaterialButton slot="action" onClick={() => setErrorOpen(false)}>Закрыть</MaterialButton>
|
||||
</MaterialDialog>
|
||||
|
||||
<EmojiMenu
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useEffect, useState, type ReactNode } from "react";
|
||||
import { MaterialDialog } from "@/core/components/Dialog";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { AddReactionRequest, AddDmReactionRequest } from "@/core/types";
|
||||
import { MaterialButton } from "@/utils/material";
|
||||
|
||||
interface ChatMessagesProps {
|
||||
messages?: MessageType[];
|
||||
@@ -147,8 +148,8 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
headline="Удалить сообщение?"
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}>
|
||||
<mdui-button slot="action" variant="tonal" onClick={() => setDeleteDialogOpen(false)}>Отменить</mdui-button>
|
||||
<mdui-button slot="action" variant="filled" onClick={confirmDelete}>Удалить</mdui-button>
|
||||
<MaterialButton slot="action" variant="tonal" onClick={() => setDeleteDialogOpen(false)}>Отменить</MaterialButton>
|
||||
<MaterialButton slot="action" variant="filled" onClick={confirmDelete}>Удалить</MaterialButton>
|
||||
</MaterialDialog>
|
||||
|
||||
{/* Context Menu */}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ub64 } from "@/utils/utils";
|
||||
import { useImmer } from "use-immer";
|
||||
import { createPortal } from "react-dom";
|
||||
import { parseProfileLink } from "@/core/profileLinks";
|
||||
import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material";
|
||||
|
||||
interface MessageReactionsProps {
|
||||
reactions?: Reaction[];
|
||||
@@ -415,10 +416,9 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
}
|
||||
|
||||
async function handleLinkClick(e: React.MouseEvent<HTMLDivElement>) {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
if (target.tagName === 'A') {
|
||||
const profileLink = parseProfileLink((target as HTMLAnchorElement).href);
|
||||
if (e.target.tagName === 'A') {
|
||||
const link = (e.target as unknown as HTMLAnchorElement).href;
|
||||
const profileLink = parseProfileLink(link);
|
||||
|
||||
if (profileLink) {
|
||||
e.preventDefault();
|
||||
@@ -443,7 +443,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
isOwnProfile: userProfile.id === user.currentUser?.id
|
||||
});
|
||||
} else {
|
||||
throw new Error("Invalid link: " + (target as HTMLAnchorElement).href);
|
||||
throw new Error(`Invalid link: ${link}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user profile from link:", error);
|
||||
@@ -483,8 +483,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
src={message.username?.startsWith("Deleted User #") ? defaultAvatar : (message.profile_picture || defaultAvatar)}
|
||||
alt={message.username}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = defaultAvatar;
|
||||
e.target.src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -517,7 +516,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
onClick={handleLinkClick} />
|
||||
|
||||
{message.files && message.files.length > 0 && (
|
||||
<mdui-list className="message-attachments">
|
||||
<MaterialList className="message-attachments">
|
||||
{message.files.map((file, idx) => {
|
||||
const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || "");
|
||||
const isEncryptedDm = Boolean(isDm && file.encrypted);
|
||||
@@ -542,7 +541,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
/>
|
||||
{(!loadedImages.has(file.path) || isSending) && (
|
||||
<div className="loading-overlay">
|
||||
<mdui-circular-progress />
|
||||
<MaterialCircularProgress />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -554,18 +553,18 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
await downloadFile(file);
|
||||
}}
|
||||
>
|
||||
<mdui-list-item>
|
||||
<MaterialListItem>
|
||||
<span className="with-icon-gap">
|
||||
{isDownloading ? <mdui-circular-progress /> : null}
|
||||
{isDownloading ? <MaterialCircularProgress /> : null}
|
||||
{(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")}
|
||||
</span>
|
||||
</mdui-list-item>
|
||||
</MaterialListItem>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</mdui-list>
|
||||
</MaterialList>
|
||||
)}
|
||||
|
||||
<Reactions
|
||||
@@ -585,7 +584,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
{isAuthor && message.runtimeData?.sendingState && (
|
||||
<span className="message-status-indicator">
|
||||
{message.runtimeData.sendingState.status === 'sending' && (
|
||||
<mdui-circular-progress style={{ width: '16px', height: '16px' }} />
|
||||
<MaterialCircularProgress style={{ width: '16px', height: '16px' }} />
|
||||
)}
|
||||
{message.runtimeData.sendingState.status === 'failed' && (
|
||||
<span className="material-symbols error-icon">error</span>
|
||||
@@ -617,13 +616,13 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
<div className="fullscreen-controls top-right" onClick={e => e.stopPropagation()}>
|
||||
<mdui-button-icon icon="close" onClick={closeFullscreen} />
|
||||
<MaterialIconButton icon="close" onClick={closeFullscreen} />
|
||||
{isDownloadingFullscreen ? (
|
||||
<div className="progress-wrapper">
|
||||
<mdui-circular-progress />
|
||||
<MaterialCircularProgress />
|
||||
</div>
|
||||
) : (
|
||||
<mdui-button-icon icon="download" onClick={downloadImage} />
|
||||
<MaterialIconButton icon="download" onClick={downloadImage} />
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { TypingIndicator } from "./TypingIndicator";
|
||||
import { OnlineStatus } from "./OnlineStatus";
|
||||
import { typingManager } from "@/core/typingManager";
|
||||
import { PublicChatPanel } from "./panels/PublicChatPanel";
|
||||
import { MaterialIcon, MaterialIconButton } from "@/utils/material";
|
||||
|
||||
interface MessagePanelRendererProps {
|
||||
panel: MessagePanel | null;
|
||||
@@ -199,198 +200,202 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={panelKey}
|
||||
ref={messagePanelRef}
|
||||
className="chat-main"
|
||||
id="chat-inner"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onDragEnter={panel ? (e) => {
|
||||
if (!e.dataTransfer) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current += 1;
|
||||
// Only show overlay when actual files are dragged
|
||||
const hasFiles = Array.from(e.dataTransfer.types || []).includes("Files");
|
||||
if (hasFiles) setIsDragging(true);
|
||||
} : undefined}
|
||||
onDragOver={panel ? (e) => {
|
||||
if (!e.dataTransfer) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
} : undefined}
|
||||
onDragLeave={panel ? (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
|
||||
if (dragCounterRef.current === 0) setIsDragging(false);
|
||||
} : undefined}
|
||||
onDrop={panel ? (e) => {
|
||||
if (!e.dataTransfer) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const files = Array.from(e.dataTransfer.files || []);
|
||||
if (files.length > 0 && addFilesRef.current) {
|
||||
addFilesRef.current(files);
|
||||
}
|
||||
setIsDragging(false);
|
||||
dragCounterRef.current = 0;
|
||||
} : undefined}>
|
||||
<div className="chat-header">
|
||||
<img
|
||||
src={panelState?.profilePicture || defaultAvatar}
|
||||
alt="Avatar"
|
||||
className="chat-header-avatar"
|
||||
onClick={handleProfileClick}
|
||||
style={{ cursor: panel ? "pointer" : "default" }}
|
||||
/>
|
||||
<div className="chat-header-info">
|
||||
<div className="info-chat">
|
||||
<h4 id="chat-name">{panelState?.title || "Выбор чата"}</h4>
|
||||
<ChatHeaderText panel={panel} />
|
||||
className="chat-wrapper"
|
||||
>
|
||||
<div
|
||||
ref={messagePanelRef}
|
||||
className="chat-main"
|
||||
id="chat-inner"
|
||||
onDragEnter={panel ? (e) => {
|
||||
if (!e.dataTransfer) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current += 1;
|
||||
// Only show overlay when actual files are dragged
|
||||
const hasFiles = Array.from(e.dataTransfer.types || []).includes("Files");
|
||||
if (hasFiles) setIsDragging(true);
|
||||
} : undefined}
|
||||
onDragOver={panel ? (e) => {
|
||||
if (!e.dataTransfer) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
} : undefined}
|
||||
onDragLeave={panel ? (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
|
||||
if (dragCounterRef.current === 0) setIsDragging(false);
|
||||
} : undefined}
|
||||
onDrop={panel ? (e) => {
|
||||
if (!e.dataTransfer) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const files = Array.from(e.dataTransfer.files || []);
|
||||
if (files.length > 0 && addFilesRef.current) {
|
||||
addFilesRef.current(files);
|
||||
}
|
||||
setIsDragging(false);
|
||||
dragCounterRef.current = 0;
|
||||
} : undefined}>
|
||||
<div className="chat-header">
|
||||
<img
|
||||
src={panelState?.profilePicture || defaultAvatar}
|
||||
alt="Avatar"
|
||||
className="chat-header-avatar"
|
||||
onClick={handleProfileClick}
|
||||
style={{ cursor: panel ? "pointer" : "default" }}
|
||||
/>
|
||||
<div className="chat-header-info">
|
||||
<div className="info-chat">
|
||||
<h4 id="chat-name">{panelState?.title || "Выбор чата"}</h4>
|
||||
<ChatHeaderText panel={panel} />
|
||||
</div>
|
||||
{panel?.isDm() && (
|
||||
<MaterialIconButton onClick={handleCallClick} icon="call--filled" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{panel?.isDm() && (
|
||||
<mdui-button-icon onClick={handleCallClick} icon="call--filled" />
|
||||
|
||||
{panelState?.isLoading ? (
|
||||
<div className="chat-messages" id="chat-messages">
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
color: "var(--mdui-color-on-surface-variant)"
|
||||
}}>
|
||||
Загрузка сообщений...
|
||||
</div>
|
||||
</div>
|
||||
) : panelState && panel ? (
|
||||
<ChatMessages
|
||||
messages={panelState.messages}
|
||||
isDm={panel.isDm()}
|
||||
dmRecipientPublicKey={(panel as DMPanel).dmData?.publicKey}
|
||||
onReplySelect={(message) => {
|
||||
if (editMessage || editVisible) {
|
||||
setPendingAction({ type: "reply", message: message });
|
||||
setEditVisible(false); // onCloseEdit will apply pending
|
||||
} else {
|
||||
setReplyTo(message);
|
||||
}
|
||||
}}
|
||||
onEditSelect={(message) => {
|
||||
if (replyTo || replyToVisible) {
|
||||
setPendingAction({ type: "edit", message: message });
|
||||
setReplyToVisible(false); // onCloseReply will apply pending
|
||||
} else {
|
||||
setEditMessage(message);
|
||||
}
|
||||
}}
|
||||
onDelete={(id) => panel.handleDeleteMessage(id)}
|
||||
onRetryMessage={(id) => panel.retryMessage(id)}
|
||||
>
|
||||
<div ref={messagesEndRef} />
|
||||
</ChatMessages>
|
||||
) : (
|
||||
<div className="chat-messages" id="chat-messages">
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
color: "var(--mdui-color-on-surface-variant)"
|
||||
}}>
|
||||
Выберите чат на боковой панели, чтобы начать переписку
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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
|
||||
onSendMessage={(text, files) => {
|
||||
panel.handleSendMessage(text, replyTo?.id, files);
|
||||
setReplyTo(null);
|
||||
}}
|
||||
onSaveEdit={(content) => {
|
||||
if (editMessage) {
|
||||
panel.handleEditMessage(editMessage.id, content);
|
||||
setEditMessage(null);
|
||||
}
|
||||
}}
|
||||
replyTo={replyTo}
|
||||
replyToVisible={replyToVisible}
|
||||
onClearReply={() => {
|
||||
setPendingAction(null);
|
||||
setReplyToVisible(false);
|
||||
}}
|
||||
onCloseReply={() => {
|
||||
setReplyTo(null);
|
||||
if (pendingAction && pendingAction.type === "edit") {
|
||||
setEditMessage(pendingAction.message);
|
||||
setPendingAction(null);
|
||||
}
|
||||
}}
|
||||
editingMessage={editMessage}
|
||||
editVisible={editVisible}
|
||||
onClearEdit={() => {
|
||||
setPendingAction(null);
|
||||
setEditVisible(false);
|
||||
}}
|
||||
onCloseEdit={() => {
|
||||
setEditMessage(null);
|
||||
if (pendingAction && pendingAction.type === "reply") {
|
||||
setReplyTo(pendingAction.message);
|
||||
setPendingAction(null);
|
||||
}
|
||||
}}
|
||||
onProvideFileAdder={(adder) => { addFilesRef.current = adder; }}
|
||||
messagePanelRef={messagePanelRef}
|
||||
onTyping={() => {
|
||||
if (panel.isDm()) {
|
||||
const dmPanel = panel as DMPanel;
|
||||
dmPanel.handleTyping();
|
||||
} else {
|
||||
typingManager.sendTyping();
|
||||
}
|
||||
}}
|
||||
onStopTyping={() => {
|
||||
if (panel.isDm()) {
|
||||
const dmPanel = panel as DMPanel;
|
||||
typingManager.stopDmTypingOnMessage(dmPanel.getRecipientId()!);
|
||||
} else {
|
||||
typingManager.stopTypingOnMessage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{panelState?.isLoading ? (
|
||||
<div className="chat-messages" id="chat-messages">
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
color: "var(--mdui-color-on-surface-variant)"
|
||||
}}>
|
||||
Загрузка сообщений...
|
||||
</div>
|
||||
</div>
|
||||
) : panelState && panel ? (
|
||||
<ChatMessages
|
||||
messages={panelState.messages}
|
||||
isDm={panel.isDm()}
|
||||
dmRecipientPublicKey={(panel as DMPanel).dmData?.publicKey}
|
||||
onReplySelect={(message) => {
|
||||
if (editMessage || editVisible) {
|
||||
setPendingAction({ type: "reply", message: message });
|
||||
setEditVisible(false); // onCloseEdit will apply pending
|
||||
} else {
|
||||
setReplyTo(message);
|
||||
}
|
||||
}}
|
||||
onEditSelect={(message) => {
|
||||
if (replyTo || replyToVisible) {
|
||||
setPendingAction({ type: "edit", message: message });
|
||||
setReplyToVisible(false); // onCloseReply will apply pending
|
||||
} else {
|
||||
setEditMessage(message);
|
||||
}
|
||||
}}
|
||||
onDelete={(id) => panel.handleDeleteMessage(id)}
|
||||
onRetryMessage={(id) => panel.retryMessage(id)}
|
||||
>
|
||||
<div ref={messagesEndRef} />
|
||||
</ChatMessages>
|
||||
) : (
|
||||
<div className="chat-messages" id="chat-messages">
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
color: "var(--mdui-color-on-surface-variant)"
|
||||
}}>
|
||||
Выберите чат на боковой панели, чтобы начать переписку
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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">
|
||||
<mdui-icon name="upload_file" />
|
||||
<span>Отпустите файл(ы) для добавления</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
|
||||
<ChatInputWrapper
|
||||
onSendMessage={(text, files) => {
|
||||
panel.handleSendMessage(text, replyTo?.id, files);
|
||||
setReplyTo(null);
|
||||
}}
|
||||
onSaveEdit={(content) => {
|
||||
if (editMessage) {
|
||||
panel.handleEditMessage(editMessage.id, content);
|
||||
setEditMessage(null);
|
||||
}
|
||||
}}
|
||||
replyTo={replyTo}
|
||||
replyToVisible={replyToVisible}
|
||||
onClearReply={() => {
|
||||
setPendingAction(null);
|
||||
setReplyToVisible(false);
|
||||
}}
|
||||
onCloseReply={() => {
|
||||
setReplyTo(null);
|
||||
if (pendingAction && pendingAction.type === "edit") {
|
||||
setEditMessage(pendingAction.message);
|
||||
setPendingAction(null);
|
||||
}
|
||||
}}
|
||||
editingMessage={editMessage}
|
||||
editVisible={editVisible}
|
||||
onClearEdit={() => {
|
||||
setPendingAction(null);
|
||||
setEditVisible(false);
|
||||
}}
|
||||
onCloseEdit={() => {
|
||||
setEditMessage(null);
|
||||
if (pendingAction && pendingAction.type === "reply") {
|
||||
setReplyTo(pendingAction.message);
|
||||
setPendingAction(null);
|
||||
}
|
||||
}}
|
||||
onProvideFileAdder={(adder) => { addFilesRef.current = adder; }}
|
||||
messagePanelRef={messagePanelRef}
|
||||
onTyping={() => {
|
||||
if (panel.isDm()) {
|
||||
const dmPanel = panel as DMPanel;
|
||||
dmPanel.handleTyping();
|
||||
} else {
|
||||
typingManager.sendTyping();
|
||||
}
|
||||
}}
|
||||
onStopTyping={() => {
|
||||
if (panel.isDm()) {
|
||||
const dmPanel = panel as DMPanel;
|
||||
typingManager.stopDmTypingOnMessage(dmPanel.getRecipientId()!);
|
||||
} else {
|
||||
typingManager.stopTypingOnMessage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
|
||||
export function CallWindow() {
|
||||
const { chat, toggleCallMinimize, user } = useAppState();
|
||||
@@ -196,8 +197,8 @@ export function CallWindow() {
|
||||
onMouseDown={(e) => {
|
||||
if (call.isMinimized) {
|
||||
// Only start dragging if not clicking on a button
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest("mdui-button-icon")) {
|
||||
// TODO change it to e.stopPropagation() on the buttons
|
||||
if (!e.target.closest("mdui-button-icon")) {
|
||||
setIsDragging(true);
|
||||
setDragOffset({
|
||||
x: e.clientX - pipPosition.x,
|
||||
@@ -209,7 +210,7 @@ export function CallWindow() {
|
||||
>
|
||||
<div className="call-header">
|
||||
<div className="window-controls">
|
||||
<mdui-button-icon
|
||||
<MaterialIconButton
|
||||
onClick={toggleCallMinimize}
|
||||
icon={call.isMinimized ? "open_in_full" : "close_fullscreen"}
|
||||
className="window-control-btn"
|
||||
@@ -302,15 +303,15 @@ export function CallWindow() {
|
||||
<div className="call-controls">
|
||||
{status === "calling" && !isInitiator ? (
|
||||
<>
|
||||
<mdui-button-icon onClick={acceptCall} icon="call" />
|
||||
<mdui-button-icon onClick={rejectCall} icon="call_end" />
|
||||
<MaterialIconButton onClick={acceptCall} icon="call" />
|
||||
<MaterialIconButton onClick={rejectCall} icon="call_end" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<mdui-button-icon onClick={toggleMute} icon={isMuted ? "mic_off" : "mic"} />
|
||||
<mdui-button-icon onClick={toggleVideo} icon={call.isVideoEnabled ? "videocam" : "videocam_off"} />
|
||||
<mdui-button-icon onClick={toggleScreenShare} icon={call.isSharingScreen ? "stop_screen_share" : "screen_share"} />
|
||||
<mdui-button-icon onClick={endCall} icon="call_end" />
|
||||
<MaterialIconButton onClick={toggleMute} icon={isMuted ? "mic_off" : "mic"} />
|
||||
<MaterialIconButton onClick={toggleVideo} icon={call.isVideoEnabled ? "videocam" : "videocam_off"} />
|
||||
<MaterialIconButton onClick={toggleScreenShare} icon={call.isSharingScreen ? "stop_screen_share" : "screen_share"} />
|
||||
<MaterialIconButton onClick={endCall} icon="call_end" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import useCall from "@/pages/chat/hooks/useCall";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
import { MaterialIconButton } from "@/utils/material";
|
||||
|
||||
export function MinimizedCallBar() {
|
||||
const { chat, toggleCallMinimize } = useAppState();
|
||||
@@ -49,11 +50,11 @@ export function MinimizedCallBar() {
|
||||
|
||||
<div className="call-actions" onClick={(e) => e.stopPropagation()}>
|
||||
{call.status === "calling" && !call.isInitiator ? (
|
||||
<mdui-button-icon onClick={endCall} icon="call_end" />
|
||||
<MaterialIconButton onClick={endCall} icon="call_end" />
|
||||
) : (
|
||||
<>
|
||||
<mdui-button-icon onClick={toggleMute} icon={call.isMuted ? "mic_off" : "mic"} />
|
||||
<mdui-button-icon onClick={endCall} icon="call_end" />
|
||||
<MaterialIconButton onClick={toggleMute} icon={call.isMuted ? "mic_off" : "mic"} />
|
||||
<MaterialIconButton onClick={endCall} icon="call_end" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user