mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Change the structure
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
export type AlertType = "success" | "danger"
|
||||
|
||||
export interface Alert {
|
||||
type: AlertType;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function AlertsContainer({ alerts }: { alerts: Alert[]}) {
|
||||
return (
|
||||
<div>
|
||||
{alerts.slice(-3).map((alert, i) => {
|
||||
return <div className={`alert alert-${alert.type}`} key={i}>{alert.message}</div>
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type React from "react";
|
||||
|
||||
export function AuthContainer({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-card fade-in">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type IconType = "filled" | "outlined";
|
||||
|
||||
export interface AuthHeaderIcon {
|
||||
name: string;
|
||||
type: IconType
|
||||
}
|
||||
|
||||
export interface AuthHeaderProps {
|
||||
title: string;
|
||||
icon: string | AuthHeaderIcon;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export function AuthHeader({ title, icon, subtitle }: AuthHeaderProps) {
|
||||
const iconType = typeof icon == "string" ? "filled" : icon.type;
|
||||
const iconName = typeof icon == "string" ? icon : icon.name;
|
||||
|
||||
return (
|
||||
<div className="auth-header">
|
||||
<h2>
|
||||
<span className={`material-symbols ${iconType} large`}>{iconName}</span>
|
||||
{title}
|
||||
</h2>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { PRODUCT_NAME } from "../../core/config";
|
||||
import { isElectron } from "../../electron/electron";
|
||||
|
||||
export function ElectronTitleBar() {
|
||||
return isElectron && (
|
||||
<div id="electron-title-bar">
|
||||
{window.electronInterface.platform == "darwin" && <div className="macos-padding"></div>}
|
||||
<div id="window-title">{PRODUCT_NAME}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { PRODUCT_NAME } from "../../../core/config";
|
||||
import useProfile from "../../hooks/useProfile";
|
||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||
import { useState } from "react";
|
||||
import { ProfileDialog } from "../profile/ProfileDialog";
|
||||
|
||||
export function ChatHeader() {
|
||||
const { profileData } = useProfile();
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||
|
||||
const handleProfileClick = () => {
|
||||
setIsProfileOpen(true);
|
||||
};
|
||||
|
||||
const profilePictureUrl = profileData?.profile_picture || defaultAvatar;
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="chat-header-left">
|
||||
<div className="product-name">{PRODUCT_NAME}</div>
|
||||
<div className="profile">
|
||||
<a href="#" id="profile-open" onClick={handleProfileClick}>
|
||||
<img
|
||||
src={profilePictureUrl}
|
||||
alt=""
|
||||
id="preview1"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<ProfileDialog isOpen={isProfileOpen} onOpenChange={setIsProfileOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { MaterialDialog } from "../core/Dialog";
|
||||
import { RichTextArea } from "../core/RichTextArea";
|
||||
import type { Message } from "../../../core/types";
|
||||
import Quote from "../core/Quote";
|
||||
import AnimatedHeight from "../core/animations/AnimatedHeight";
|
||||
import { useImmer } from "use-immer";
|
||||
import { EmojiMenu } from "./EmojiMenu";
|
||||
|
||||
interface ChatInputWrapperProps {
|
||||
onSendMessage: (message: string, files: File[]) => void;
|
||||
onSaveEdit?: (content: string) => void;
|
||||
replyTo?: Message | null;
|
||||
replyToVisible: boolean;
|
||||
onClearReply?: () => void;
|
||||
onCloseReply?: () => void;
|
||||
editingMessage?: Message | null;
|
||||
editVisible?: boolean;
|
||||
onClearEdit?: () => void;
|
||||
onCloseEdit?: () => void;
|
||||
onProvideFileAdder?: (adder: (files: File[]) => void) => void;
|
||||
messagePanelRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function ChatInputWrapper(
|
||||
{
|
||||
onSendMessage,
|
||||
onSaveEdit,
|
||||
replyTo,
|
||||
replyToVisible,
|
||||
onClearReply,
|
||||
onCloseReply,
|
||||
editingMessage,
|
||||
editVisible = false,
|
||||
onClearEdit,
|
||||
onCloseEdit,
|
||||
onProvideFileAdder,
|
||||
messagePanelRef
|
||||
}: ChatInputWrapperProps
|
||||
) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [selectedFiles, setSelectedFiles] = useImmer<File[]>([]);
|
||||
const [attachmentsVisible, setAttachmentsVisible] = useState(false);
|
||||
const [errorOpen, setErrorOpen] = useState(false);
|
||||
const [emojiMenuOpen, setEmojiMenuOpen] = useState(false);
|
||||
const [emojiMenuPosition, setEmojiMenuPosition] = useState({ x: 0, y: 0 });
|
||||
const chatInputWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Expose a way for parent to programmatically add files
|
||||
useEffect(() => {
|
||||
if (onProvideFileAdder) {
|
||||
const addFiles = (files: File[]) => {
|
||||
if (!files || files.length === 0) return;
|
||||
setSelectedFiles(draft => { draft.push(...files) });
|
||||
};
|
||||
onProvideFileAdder(addFiles);
|
||||
}
|
||||
}, [onProvideFileAdder]);
|
||||
|
||||
// When entering edit mode, preload the message content
|
||||
useEffect(() => {
|
||||
setMessage(editingMessage ? editingMessage.content || "" : "");
|
||||
}, [editingMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
setAttachmentsVisible(selectedFiles.length > 0);
|
||||
}, [selectedFiles]);
|
||||
|
||||
function handleEmojiButtonClick(e: React.MouseEvent<HTMLButtonElement>) {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!emojiMenuOpen) {
|
||||
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({
|
||||
x: panelRect.left + 10, // 10px from message panel edge
|
||||
y: window.innerHeight - inputRect.top + 10 // 10px above the top of chat input
|
||||
});
|
||||
setEmojiMenuOpen(true);
|
||||
}
|
||||
} else {
|
||||
setEmojiMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
function handleEmojiSelect(emoji: string) {
|
||||
setMessage(prev => prev + emoji);
|
||||
};
|
||||
|
||||
async function handleSubmit(e: React.FormEvent | Event) {
|
||||
e.preventDefault();
|
||||
const hasText = Boolean(message.trim());
|
||||
const hasFiles = selectedFiles.length > 0;
|
||||
if (hasText || hasFiles) {
|
||||
const totalSize = selectedFiles.reduce((acc, f) => acc + f.size, 0);
|
||||
const limit = 4 * 1024 * 1024 * 1024; // 4GB
|
||||
if (totalSize > limit) {
|
||||
setErrorOpen(true);
|
||||
return;
|
||||
}
|
||||
if (editingMessage && onSaveEdit) {
|
||||
onSaveEdit(message);
|
||||
setMessage("");
|
||||
if (onClearEdit) onClearEdit();
|
||||
} else {
|
||||
onSendMessage(message, selectedFiles);
|
||||
setMessage("");
|
||||
setAttachmentsVisible(false);
|
||||
if (onClearReply) onClearReply();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function handleAttachClick() {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.addEventListener("change", () => {
|
||||
setSelectedFiles(draft => { draft.push(...Array.from(input.files || [])) });
|
||||
});
|
||||
input.click();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-input-wrapper" ref={chatInputWrapperRef}>
|
||||
<form className="input-group" id="message-form" onSubmit={handleSubmit}>
|
||||
<AnimatedHeight visible={editVisible} onFinish={onCloseEdit}>
|
||||
{editingMessage && (
|
||||
<div className="reply-preview contextual-preview">
|
||||
<mdui-icon 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>
|
||||
</div>
|
||||
)}
|
||||
</AnimatedHeight>
|
||||
<AnimatedHeight visible={replyToVisible} onFinish={onCloseReply}>
|
||||
{replyTo && (
|
||||
<div className="reply-preview contextual-preview">
|
||||
<mdui-icon 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>
|
||||
</div>
|
||||
)}
|
||||
</AnimatedHeight>
|
||||
<AnimatedHeight visible={attachmentsVisible} onFinish={() => setSelectedFiles([])}>
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="attachments-preview contextual-preview">
|
||||
<mdui-icon name="attach_file" />
|
||||
<div className="attachments-chips">
|
||||
{selectedFiles.map((file, i) => (
|
||||
<mdui-chip
|
||||
key={i}
|
||||
variant="input"
|
||||
end-icon="close"
|
||||
title={`${file.name} (${Math.round(file.size/1024/1024)} MB)`}
|
||||
onClick={() => {
|
||||
if (selectedFiles.length == 1) {
|
||||
setAttachmentsVisible(false);
|
||||
} else {
|
||||
setSelectedFiles(draft => { draft.splice(i) })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<mdui-icon slot="icon" name="attach_file"></mdui-icon>
|
||||
<span className="name">{file.name}</span>
|
||||
</mdui-chip>
|
||||
))}
|
||||
</div>
|
||||
<mdui-button-icon icon="close" className="reply-cancel" onClick={() => setAttachmentsVisible(false)}></mdui-button-icon>
|
||||
</div>
|
||||
)}
|
||||
</AnimatedHeight>
|
||||
<div className="chat-input">
|
||||
<div className="left-buttons">
|
||||
<mdui-button-icon
|
||||
icon="mood"
|
||||
onClick={handleEmojiButtonClick}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onMouseUp={e => e.stopPropagation()}
|
||||
className="emoji-btn" />
|
||||
</div>
|
||||
<RichTextArea
|
||||
className="message-input"
|
||||
id="message-input"
|
||||
placeholder="Напишите сообщение..."
|
||||
autoComplete="off"
|
||||
text={message}
|
||||
rows={1}
|
||||
onTextChange={(value) => setMessage(value)}
|
||||
onEnter={handleSubmit} />
|
||||
<div className="buttons">
|
||||
<mdui-button-icon icon="attach_file" onClick={handleAttachClick} className="attach-btn"></mdui-button-icon>
|
||||
<button type="submit" className="send-btn">
|
||||
<span className="material-symbols filled">{editingMessage ? "check" : "send"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<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>
|
||||
</MaterialDialog>
|
||||
|
||||
<EmojiMenu
|
||||
isOpen={emojiMenuOpen}
|
||||
onClose={() => setEmojiMenuOpen(false)}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
position={emojiMenuPosition}
|
||||
mode="standalone"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useAppState } from "../../state";
|
||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||
|
||||
export function ChatMainHeader() {
|
||||
const { currentChat } = useAppState().chat;
|
||||
|
||||
return (
|
||||
<div className="chat-header">
|
||||
<img src={defaultAvatar} alt="Avatar" className="chat-header-avatar" />
|
||||
<div className="chat-header-info">
|
||||
<div className="info-chat">
|
||||
<h4 id="chat-name">{currentChat}</h4>
|
||||
<p>
|
||||
<span className="online-status"></span>
|
||||
Онлайн
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { Message } from "./Message";
|
||||
import { useAppState } from "../../state";
|
||||
import type { Message as MessageType } from "../../../core/types";
|
||||
import type { UserProfile } from "../../../core/types";
|
||||
import { UserProfileDialog } from "./UserProfileDialog";
|
||||
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
|
||||
import { fetchUserProfile } from "../../../../../api/profileApi";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { delay } from "../../../utils/utils";
|
||||
import { MaterialDialog } from "../core/Dialog";
|
||||
import { request } from "../../../core/websocket";
|
||||
import type { AddReactionRequest, AddDmReactionRequest } from "../../../core/types";
|
||||
|
||||
interface ChatMessagesProps {
|
||||
messages?: MessageType[];
|
||||
isDm?: boolean;
|
||||
children?: ReactNode;
|
||||
onReplySelect?: (message: MessageType) => void;
|
||||
onEditSelect?: (message: MessageType) => void;
|
||||
onDelete?: (id: number) => void;
|
||||
onRetryMessage?: (messageId: number) => void;
|
||||
dmRecipientPublicKey?: string;
|
||||
}
|
||||
|
||||
export function ChatMessages({ messages = [], children, isDm = false, onReplySelect, onEditSelect, onDelete, onRetryMessage, dmRecipientPublicKey }: ChatMessagesProps) {
|
||||
const { user } = useAppState();
|
||||
|
||||
// Use prop messages (panels provide their own messages)
|
||||
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
|
||||
const [selectedUserProfile, setSelectedUserProfile] = useState<UserProfile | null>(null);
|
||||
const [isLoadingProfile, setIsLoadingProfile] = useState(false);
|
||||
|
||||
// Context menu state
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
||||
isOpen: false,
|
||||
message: null,
|
||||
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]);
|
||||
|
||||
async function handleProfileClick(username: string) {
|
||||
if (!user.authToken) return;
|
||||
|
||||
setIsLoadingProfile(true);
|
||||
try {
|
||||
const profile = await fetchUserProfile(user.authToken, username);
|
||||
if (profile) {
|
||||
setSelectedUserProfile(profile);
|
||||
setProfileDialogOpen(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user profile:", error);
|
||||
} finally {
|
||||
setIsLoadingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
function handleContextMenu(e: React.MouseEvent, message: MessageType) {
|
||||
e.preventDefault();
|
||||
setContextMenu({
|
||||
isOpen: true,
|
||||
message,
|
||||
position: { x: e.clientX, y: e.clientY }
|
||||
});
|
||||
};
|
||||
|
||||
function handleContextMenuOpenChange(isOpen: boolean) {
|
||||
setContextMenu(prev => ({
|
||||
...prev,
|
||||
isOpen
|
||||
}));
|
||||
};
|
||||
|
||||
function handleEdit(message: MessageType) {
|
||||
if (onEditSelect) onEditSelect(message);
|
||||
};
|
||||
|
||||
function handleReply(message: MessageType) {
|
||||
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);
|
||||
}
|
||||
|
||||
function handleRetry(message: MessageType) {
|
||||
if (onRetryMessage) {
|
||||
onRetryMessage(message.id);
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
credentials: { scheme: "Bearer", credentials: user.authToken },
|
||||
data: {
|
||||
dm_envelope_id: dmEnvelopeId,
|
||||
emoji: emoji
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// For regular chat messages
|
||||
await request<AddReactionRequest["data"]>({
|
||||
type: "addReaction",
|
||||
credentials: { scheme: "Bearer", credentials: user.authToken },
|
||||
data: {
|
||||
message_id: messageId,
|
||||
emoji: emoji
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to add reaction:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="chat-messages" id="chat-messages">
|
||||
{messages.map((message: MessageType) => (
|
||||
<Message
|
||||
key={message.id}
|
||||
message={message}
|
||||
isAuthor={message.username === user.currentUser?.username}
|
||||
onProfileClick={handleProfileClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onReactionClick={handleReactionClick}
|
||||
isLoadingProfile={isLoadingProfile}
|
||||
isDm={isDm}
|
||||
dmRecipientPublicKey={dmRecipientPublicKey} />
|
||||
))}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<UserProfileDialog
|
||||
isOpen={profileDialogOpen}
|
||||
onOpenChange={async (value) => {
|
||||
setProfileDialogOpen(value);
|
||||
if (!value) {
|
||||
await delay(1000);
|
||||
setSelectedUserProfile(null);
|
||||
}
|
||||
}}
|
||||
userProfile={selectedUserProfile}
|
||||
/>
|
||||
|
||||
<MaterialDialog
|
||||
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>
|
||||
</MaterialDialog>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu.message && (
|
||||
<MessageContextMenu
|
||||
message={contextMenu.message}
|
||||
isAuthor={contextMenu.message.username === user.currentUser?.username}
|
||||
onEdit={handleEdit}
|
||||
onReply={handleReply}
|
||||
onDelete={handleDelete}
|
||||
onRetry={handleRetry}
|
||||
onReactionClick={handleReactionClick}
|
||||
position={contextMenu.position}
|
||||
isOpen={contextMenu.isOpen}
|
||||
onOpenChange={handleContextMenuOpenChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useAppState } from "../../state";
|
||||
|
||||
export function ChatTabs() {
|
||||
const { chat, setActiveTab, switchToPublicChat } = useAppState();
|
||||
|
||||
return (
|
||||
<div className="chat-tabs">
|
||||
<mdui-tabs value={chat.activeTab} full-width onChange={(e: any) => setActiveTab(e.detail.value)}>
|
||||
<mdui-tab value="chats">
|
||||
Чаты
|
||||
</mdui-tab>
|
||||
<mdui-tab value="channels">
|
||||
Каналы
|
||||
</mdui-tab>
|
||||
<mdui-tab value="contacts">
|
||||
Контакты
|
||||
</mdui-tab>
|
||||
<mdui-tab value="dms">
|
||||
ЛС
|
||||
</mdui-tab>
|
||||
|
||||
<mdui-tab-panel slot="panel" value="chats">
|
||||
<mdui-list>
|
||||
<mdui-list-item
|
||||
headline="Общий чат"
|
||||
description="Вы: Последнее сообщение"
|
||||
id="chat-list-chat-1"
|
||||
onClick={async () => await switchToPublicChat("Общий чат")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<img src="./src/resources/images/default-avatar.png" alt="" slot="icon" />
|
||||
</mdui-list-item>
|
||||
<mdui-list-item
|
||||
headline="Общий чат 2"
|
||||
description="Вы: Последнее сообщение"
|
||||
id="chat-list-chat-2"
|
||||
onClick={async () => await switchToPublicChat("Общий чат 2")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<img src="./src/resources/images/default-avatar.png" alt="" slot="icon" />
|
||||
</mdui-list-item>
|
||||
</mdui-list>
|
||||
</mdui-tab-panel>
|
||||
<mdui-tab-panel slot="panel" value="channels">Скоро будет...</mdui-tab-panel>
|
||||
<mdui-tab-panel slot="panel" value="contacts">Скоро будет...</mdui-tab-panel>
|
||||
<mdui-tab-panel slot="panel" value="dms">
|
||||
<mdui-list id="dm-users"></mdui-list>
|
||||
</mdui-tab-panel>
|
||||
</mdui-tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect } from "react";
|
||||
import { useDM, type DMUser } from "../../hooks/useDM";
|
||||
import { useAppState } from "../../state";
|
||||
import { fetchUserPublicKey } from "../../../../../api/dmApi";
|
||||
import defaultAvatar from "../../../../../images/default-avatar.png";
|
||||
|
||||
export function DMUsersList() {
|
||||
const { dmUsers, isLoadingUsers, loadUsers } = useDM();
|
||||
const { chat, switchToDM } = useAppState();
|
||||
|
||||
useEffect(() => {
|
||||
if (chat.activeTab === "dms") {
|
||||
loadUsers();
|
||||
}
|
||||
}, [chat.activeTab, loadUsers]);
|
||||
|
||||
if (isLoadingUsers) {
|
||||
return (
|
||||
<mdui-list>
|
||||
<mdui-list-item headline="Загрузка..." description="Получение списка пользователей...">
|
||||
<img src={defaultAvatar} alt="" slot="icon" />
|
||||
</mdui-list-item>
|
||||
</mdui-list>
|
||||
);
|
||||
}
|
||||
|
||||
if (dmUsers.length === 0) {
|
||||
return (
|
||||
<mdui-list>
|
||||
<mdui-list-item headline="Нет пользователей" description="Пользователи не найдены">
|
||||
<img src={defaultAvatar} alt="" slot="icon" />
|
||||
</mdui-list-item>
|
||||
</mdui-list>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleUserClick(user: DMUser) {
|
||||
if (!user.publicKey) {
|
||||
// Get public key if not already loaded
|
||||
const authToken = useAppState.getState().user.authToken;
|
||||
if (!authToken) return;
|
||||
|
||||
const publicKey = await fetchUserPublicKey(user.id, authToken);
|
||||
if (publicKey) {
|
||||
user.publicKey = publicKey;
|
||||
} else {
|
||||
console.error("Failed to get public key for user:", user.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await switchToDM({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
publicKey: user.publicKey,
|
||||
profilePicture: user.profile_picture,
|
||||
online: user.online || false
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<mdui-list>
|
||||
{dmUsers.map((user: DMUser) => (
|
||||
<mdui-list-item
|
||||
key={user.id}
|
||||
headline={user.username}
|
||||
description={user.lastMessage || "Нет сообщений"}
|
||||
onClick={() => handleUserClick(user)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<img
|
||||
src={user.profile_picture || defaultAvatar}
|
||||
alt={user.username}
|
||||
slot="icon"
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover"
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
{user.unreadCount > 0 && (
|
||||
<mdui-badge slot="end-icon">
|
||||
{user.unreadCount}
|
||||
</mdui-badge>
|
||||
)}
|
||||
</mdui-list-item>
|
||||
))}
|
||||
</mdui-list>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { EMOJI_CATEGORIES, getRecentEmojis, addRecentEmoji } from "./emojiData";
|
||||
import type { Size2D } from "../../../core/types";
|
||||
|
||||
interface BaseEmojiMenuProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onEmojiSelect: (emoji: string) => void;
|
||||
}
|
||||
|
||||
interface StandaloneEmojiMenuProps extends BaseEmojiMenuProps {
|
||||
position: Size2D;
|
||||
mode: "standalone";
|
||||
}
|
||||
|
||||
interface IntegratedEmojiMenuProps extends BaseEmojiMenuProps {
|
||||
mode: "integrated";
|
||||
}
|
||||
|
||||
type EmojiMenuProps = StandaloneEmojiMenuProps | IntegratedEmojiMenuProps;
|
||||
|
||||
export function EmojiMenu(props: EmojiMenuProps) {
|
||||
const { isOpen, onClose, onEmojiSelect, mode } = props;
|
||||
const position = mode === "standalone" ? props.position : undefined;
|
||||
const [activeCategory, setActiveCategory] = useState("recent");
|
||||
const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const categoryRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const tabsRef = useRef<HTMLDivElement>(null);
|
||||
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setRecentEmojis(getRecentEmojis());
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
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) {
|
||||
setActiveCategory(categoryName);
|
||||
scrollTabIntoView(categoryName);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [activeCategory]);
|
||||
|
||||
function scrollToCategory(categoryName: string) {
|
||||
const element = categoryRefs.current.get(categoryName);
|
||||
if (element && scrollRef.current) {
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function scrollTabIntoView(categoryName: string) {
|
||||
const tabElement = tabRefs.current.get(categoryName);
|
||||
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({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'center'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
function handleEmojiClick(emoji: string) {
|
||||
addRecentEmoji(emoji);
|
||||
onEmojiSelect(emoji);
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
||||
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"
|
||||
}}
|
||||
>
|
||||
<div className="emoji-menu-header">
|
||||
<div ref={tabsRef} className="emoji-category-tabs">
|
||||
{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" : ""}`}
|
||||
onClick={() => scrollToCategory(category.name)}
|
||||
title={category.name}
|
||||
>
|
||||
<span>{category.icon}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="emoji-grid"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{EMOJI_CATEGORIES.map((category) => {
|
||||
const emojis = category.name === "recent" ? recentEmojis : category.emojis;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={category.name}
|
||||
ref={(el) => {
|
||||
if (el) categoryRefs.current.set(category.name, el);
|
||||
}}
|
||||
className="emoji-category-section"
|
||||
>
|
||||
<h3 className="emoji-category-title">
|
||||
{category.name.charAt(0).toUpperCase() + category.name.slice(1)}
|
||||
</h3>
|
||||
{emojis.length > 0 ? (
|
||||
<div className="emoji-category-grid">
|
||||
{emojis.map((emoji, index) => (
|
||||
<button
|
||||
key={`${category.name}-${index}`}
|
||||
className="emoji-item"
|
||||
onClick={() => handleEmojiClick(emoji)}
|
||||
title={emoji}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="emoji-empty-state">
|
||||
<span>No {category.name} emojis</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { PRODUCT_NAME } from "../../../core/config";
|
||||
import { useAppState } from "../../state";
|
||||
import defaultAvatar from "../../../../../images/default-avatar.png";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { ProfileDialog } from "../profile/ProfileDialog";
|
||||
import { SettingsDialog } from "../settings/SettingsDialog";
|
||||
import { DMUsersList } from "./DMUsersList";
|
||||
import type { Tabs } from "mdui";
|
||||
import type { ChatTabs } from "../../state";
|
||||
|
||||
function BottomAppBar() {
|
||||
const [settingsOpen, onSettingsOpenChange] = useState(false);
|
||||
const { logout } = useAppState();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<mdui-bottom-app-bar>
|
||||
<mdui-button-icon icon="settings--filled" id="settings-open" onClick={() => onSettingsOpenChange(true)}></mdui-button-icon>
|
||||
<mdui-button-icon icon="group_add--filled"></mdui-button-icon>
|
||||
<div style={{ flexGrow: 1 }}></div>
|
||||
<mdui-button-icon
|
||||
icon="logout--filled"
|
||||
id="logout-btn"
|
||||
onClick={handleLogout}
|
||||
title="Выйти"
|
||||
></mdui-button-icon>
|
||||
<mdui-fab icon="edit--filled"></mdui-fab>
|
||||
</mdui-bottom-app-bar>
|
||||
<SettingsDialog isOpen={settingsOpen} onOpenChange={onSettingsOpenChange} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function ChatTabs() {
|
||||
const { chat, setActiveTab, switchToPublicChat } = useAppState();
|
||||
const { activeTab } = chat;
|
||||
|
||||
async function handleChatClick(chatName: string) {
|
||||
await switchToPublicChat(chatName);
|
||||
}
|
||||
|
||||
function handleTabChange(e: FormEvent<Tabs>) {
|
||||
setActiveTab((e.target as Tabs).value as ChatTabs);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-tabs">
|
||||
<mdui-tabs value={activeTab} full-width onChange={handleTabChange}>
|
||||
<mdui-tab value="chats">Чаты</mdui-tab>
|
||||
<mdui-tab value="channels">Каналы</mdui-tab>
|
||||
<mdui-tab value="contacts">Контакты</mdui-tab>
|
||||
<mdui-tab value="dms">ЛС</mdui-tab>
|
||||
|
||||
<mdui-tab-panel slot="panel" value="chats">
|
||||
<mdui-list>
|
||||
<mdui-list-item
|
||||
headline="Общий чат"
|
||||
description="Вы: Последнее сообщение"
|
||||
id="chat-list-chat-1"
|
||||
onClick={() => handleChatClick("Общий чат")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<img src={defaultAvatar} alt="" slot="icon" />
|
||||
</mdui-list-item>
|
||||
<mdui-list-item
|
||||
headline="Общий чат 2"
|
||||
description="Вы: Последнее сообщение"
|
||||
id="chat-list-chat-2"
|
||||
onClick={() => handleChatClick("Общий чат 2")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<img src={defaultAvatar} alt="" slot="icon" />
|
||||
</mdui-list-item>
|
||||
</mdui-list>
|
||||
</mdui-tab-panel>
|
||||
<mdui-tab-panel slot="panel" value="channels">Скоро будет...</mdui-tab-panel>
|
||||
<mdui-tab-panel slot="panel" value="contacts">Скоро будет...</mdui-tab-panel>
|
||||
<mdui-tab-panel slot="panel" value="dms">
|
||||
<DMUsersList />
|
||||
</mdui-tab-panel>
|
||||
</mdui-tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function ChatHeader() {
|
||||
const [isProfileOpen, setProfileOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="chat-header-left">
|
||||
<div className="product-name">{PRODUCT_NAME}</div>
|
||||
<div className="profile">
|
||||
<a href="#" id="profile-open" onClick={() => setProfileOpen(true)}>
|
||||
<img src={defaultAvatar} alt="" id="preview1" />
|
||||
</a>
|
||||
</div>
|
||||
<ProfileDialog isOpen={isProfileOpen} onOpenChange={setProfileOpen} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function LeftPanel() {
|
||||
return (
|
||||
<div className="chat-list" id="chat-list">
|
||||
<ChatHeader />
|
||||
<ChatTabs />
|
||||
<BottomAppBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import { formatTime, id } from "../../../utils/utils";
|
||||
import type { Attachment, Message as MessageType } from "../../../core/types";
|
||||
import defaultAvatar from "../../../../../images/default-avatar.png";
|
||||
import Quote from "../core/Quote";
|
||||
import { parse } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { getCurrentKeys } from "../../../../../api/authApi";
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "../../../utils/crypto/asymmetric";
|
||||
import { importAesGcmKey, aesGcmDecrypt } from "../../../utils/crypto/symmetric";
|
||||
import { getAuthHeaders } from "../../../../../api/authApi";
|
||||
import { useAppState } from "../../state";
|
||||
import { ub64 } from "../../../utils/utils";
|
||||
import { useImmer } from "use-immer";
|
||||
import { createPortal } from "react-dom";
|
||||
import { MessageReactions } from "./MessageReactions";
|
||||
|
||||
interface MessageProps {
|
||||
message: MessageType;
|
||||
isAuthor: boolean;
|
||||
onProfileClick: (username: string) => void;
|
||||
onContextMenu: (e: React.MouseEvent, message: MessageType) => void;
|
||||
onReactionClick?: (messageId: number, emoji: string) => void;
|
||||
isLoadingProfile?: boolean;
|
||||
isDm?: boolean;
|
||||
dmRecipientPublicKey?: string;
|
||||
}
|
||||
|
||||
interface Rect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number
|
||||
}
|
||||
|
||||
export function Message({ message, isAuthor, onProfileClick, onContextMenu, onReactionClick, isLoadingProfile = false, isDm = false, dmRecipientPublicKey }: MessageProps) {
|
||||
const [formattedMessage, setFormattedMessage] = useState({ __html: "" });
|
||||
const [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map());
|
||||
const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set());
|
||||
const [downloadingPaths, updateDownloadingPaths] = useImmer<Set<string>>(new Set());
|
||||
const [isDownloadingFullscreen, setIsDownloadingFullscreen] = useState(false);
|
||||
const [fullscreenImage, setFullscreenImage] = useState<{
|
||||
src: string;
|
||||
name: string;
|
||||
element: HTMLImageElement;
|
||||
startRect: Rect;
|
||||
endRect: Rect;
|
||||
} | null>(null);
|
||||
const [isAnimatingOpen, setIsAnimatingOpen] = useState(false);
|
||||
const { user } = useAppState();
|
||||
const imageRefs = useRef<Map<string, HTMLImageElement>>(new Map());
|
||||
const dmEnvelope = message.runtimeData?.dmEnvelope;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setFormattedMessage({
|
||||
__html: DOMPurify.sanitize(
|
||||
await parse(message.content)
|
||||
).trim()
|
||||
});
|
||||
})();
|
||||
}, [message]);
|
||||
|
||||
// Auto-decrypt images in DMs
|
||||
useEffect(() => {
|
||||
if (isDm && message.files) {
|
||||
message.files.forEach(async (file) => {
|
||||
console.log(file);
|
||||
const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || "");
|
||||
if (isImage && file.encrypted && !decryptedFiles.has(file.path)) {
|
||||
console.log("Decrypting...");
|
||||
const decryptedUrl = await decryptFile(file);
|
||||
console.log(decryptedUrl);
|
||||
if (decryptedUrl) {
|
||||
updateDecryptedFiles(draft => {
|
||||
draft.set(file.path, decryptedUrl);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [message.files, isDm, decryptedFiles]);
|
||||
|
||||
async function decryptFile(file: Attachment): Promise<string | null> {
|
||||
if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) {
|
||||
debugger;
|
||||
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
|
||||
const response = await fetch(file.path, {
|
||||
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);
|
||||
});
|
||||
return url;
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt file:", error);
|
||||
return null;
|
||||
} finally {
|
||||
// no-op decrypt indicator removed from UI
|
||||
}
|
||||
};
|
||||
|
||||
async function handleImageClick(file: Attachment, imageElement: HTMLImageElement) {
|
||||
// Use decrypted URL if available, otherwise decrypt first
|
||||
const decryptedUrl = decryptedFiles.get(file.path);
|
||||
if (decryptedUrl) {
|
||||
openFullscreenFromThumb(imageElement, decryptedUrl, file.name || "image");
|
||||
} else if (file.encrypted && isDm) {
|
||||
const newDecryptedUrl = await decryptFile(file);
|
||||
if (newDecryptedUrl) {
|
||||
openFullscreenFromThumb(imageElement, newDecryptedUrl, file.name || "image");
|
||||
}
|
||||
} else {
|
||||
openFullscreenFromThumb(imageElement, file.path, file.name || "image");
|
||||
}
|
||||
};
|
||||
|
||||
function computeEndRect(naturalWidth: number, naturalHeight: number): Rect {
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const maxWidth = Math.floor(viewportWidth * 0.9);
|
||||
const maxHeight = Math.floor(viewportHeight * 0.9);
|
||||
const widthRatio = maxWidth / naturalWidth;
|
||||
const heightRatio = maxHeight / naturalHeight;
|
||||
const scale = Math.min(widthRatio, heightRatio, 1);
|
||||
const width = Math.round(naturalWidth * scale);
|
||||
const height = Math.round(naturalHeight * scale);
|
||||
const left = Math.round((viewportWidth - width) / 2);
|
||||
const top = Math.round((viewportHeight - height) / 2);
|
||||
return { left, top, width, height };
|
||||
};
|
||||
|
||||
function openFullscreenFromThumb(imgEl: HTMLImageElement, src: string, name: string) {
|
||||
const rect = imgEl.getBoundingClientRect();
|
||||
const startRect = { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
|
||||
const tempImg = new Image();
|
||||
tempImg.src = src;
|
||||
// Hide original while animating
|
||||
imgEl.style.visibility = "hidden";
|
||||
tempImg.onload = () => {
|
||||
const endRect = computeEndRect(tempImg.naturalWidth, tempImg.naturalHeight);
|
||||
setFullscreenImage({
|
||||
src,
|
||||
name,
|
||||
element: imgEl,
|
||||
startRect,
|
||||
endRect
|
||||
});
|
||||
// Start animation on next frame to ensure DOM has overlay mounted
|
||||
requestAnimationFrame(() => setIsAnimatingOpen(true));
|
||||
};
|
||||
};
|
||||
|
||||
function closeFullscreen() {
|
||||
// Reverse animation
|
||||
setIsAnimatingOpen(false);
|
||||
// Wait for transition to finish
|
||||
setTimeout(() => {
|
||||
if (fullscreenImage?.element) {
|
||||
fullscreenImage.element.style.visibility = "visible";
|
||||
}
|
||||
setFullscreenImage(null);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
async function downloadImage() {
|
||||
if (!fullscreenImage) return;
|
||||
const { src, name } = fullscreenImage;
|
||||
try {
|
||||
setIsDownloadingFullscreen(true);
|
||||
if (src.startsWith("blob:")) {
|
||||
const link = document.createElement("a");
|
||||
link.href = src;
|
||||
link.download = name;
|
||||
link.click();
|
||||
setIsDownloadingFullscreen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch with credentials/headers when not a blob URL
|
||||
const response = await fetch(src, {
|
||||
headers: user.authToken ? getAuthHeaders(user.authToken) : undefined,
|
||||
credentials: "include"
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to download image");
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = name;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsDownloadingFullscreen(false);
|
||||
}
|
||||
};
|
||||
|
||||
async function downloadFile(file: Attachment) {
|
||||
try {
|
||||
updateDownloadingPaths(draft => {
|
||||
draft.add(file.path);
|
||||
});
|
||||
// Prefer decrypted URL if present (DM encrypted case)
|
||||
const decrypted = decryptedFiles.get(file.path);
|
||||
if (decrypted) {
|
||||
const link = document.createElement("a");
|
||||
link.href = decrypted;
|
||||
link.download = file.name || "file";
|
||||
link.click();
|
||||
updateDownloadingPaths(draft => {
|
||||
draft.delete(file.path);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If not decrypted or public file, fetch with credentials/headers
|
||||
const response = await fetch(file.path, {
|
||||
headers: user.authToken ? getAuthHeaders(user.authToken) : undefined,
|
||||
credentials: "include"
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to download file");
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = file.name || "file";
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
updateDownloadingPaths(draft => {
|
||||
draft.delete(file.path);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function handleContextMenu(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, message);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`message ${isAuthor ? "sent" : "received"}`}
|
||||
data-id={message.id}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<div className="message-inner">
|
||||
{!isAuthor && !isDm && (
|
||||
<div className="message-profile-pic">
|
||||
<img
|
||||
src={message.profile_picture || defaultAvatar}
|
||||
alt={message.username}
|
||||
onClick={() => !isLoadingProfile && onProfileClick(message.username)}
|
||||
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}
|
||||
className={isLoadingProfile ? "loading" : ""}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAuthor && !isDm && (
|
||||
<div
|
||||
className={`message-username ${isLoadingProfile ? "loading" : ""}`}
|
||||
onClick={() => !isLoadingProfile && onProfileClick(message.username)}
|
||||
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}>
|
||||
{message.username}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<div className="message-content" dangerouslySetInnerHTML={formattedMessage} />
|
||||
|
||||
{message.files && message.files.length > 0 && (
|
||||
<mdui-list 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);
|
||||
const decryptedUrl = decryptedFiles.get(file.path);
|
||||
const imageSrc = isImage ? (isEncryptedDm ? decryptedUrl : file.path) : undefined;
|
||||
const isDownloading = downloadingPaths.has(file.path);
|
||||
const isSending = message.runtimeData?.sendingState?.status === 'sending';
|
||||
|
||||
return (
|
||||
<div className="attachment" key={idx}>
|
||||
{isImage ? (
|
||||
<div className="image-wrapper">
|
||||
<img
|
||||
ref={(el) => {
|
||||
if (el) imageRefs.current.set(file.path, el);
|
||||
}}
|
||||
src={imageSrc}
|
||||
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"}`}
|
||||
/>
|
||||
{(!loadedImages.has(file.path) || isSending) && (
|
||||
<div className="loading-overlay">
|
||||
<mdui-circular-progress />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
href="#"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
await downloadFile(file);
|
||||
}}
|
||||
>
|
||||
<mdui-list-item>
|
||||
<span className="with-icon-gap">
|
||||
{isDownloading ? <mdui-circular-progress /> : null}
|
||||
{(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")}
|
||||
</span>
|
||||
</mdui-list-item>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</mdui-list>
|
||||
)}
|
||||
|
||||
<MessageReactions
|
||||
reactions={message.reactions}
|
||||
onReactionClick={(emoji) => onReactionClick?.(message.id, emoji)}
|
||||
messageId={message.id}
|
||||
/>
|
||||
|
||||
<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' && (
|
||||
<mdui-circular-progress style={{ width: '16px', height: '16px' }} />
|
||||
)}
|
||||
{message.runtimeData.sendingState.status === 'failed' && (
|
||||
<span className="material-symbols error-icon">error</span>
|
||||
)}
|
||||
{message.runtimeData.sendingState.status === 'sent' && (
|
||||
<span className="material-symbols success-icon">check</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen Image Viewer with shared-element like transition */}
|
||||
{fullscreenImage && createPortal(
|
||||
<div
|
||||
className={`fullscreen-image-overlay ${isAnimatingOpen ? "open" : "closing"}`}
|
||||
onClick={closeFullscreen}>
|
||||
<img
|
||||
src={fullscreenImage.src}
|
||||
alt={fullscreenImage.name}
|
||||
className={`fullscreen-animated-image ${isAnimatingOpen ? "to-end" : "to-start"}`}
|
||||
style={{
|
||||
left: `${isAnimatingOpen ? fullscreenImage.endRect.left : fullscreenImage.startRect.left}px`,
|
||||
top: `${isAnimatingOpen ? fullscreenImage.endRect.top : fullscreenImage.startRect.top}px`,
|
||||
width: `${isAnimatingOpen ? fullscreenImage.endRect.width : fullscreenImage.startRect.width}px`,
|
||||
height: `${isAnimatingOpen ? fullscreenImage.endRect.height : fullscreenImage.startRect.height}px`
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
<div className="fullscreen-controls top-right" onClick={e => e.stopPropagation()}>
|
||||
<mdui-button-icon icon="close" onClick={closeFullscreen} />
|
||||
{isDownloadingFullscreen ? (
|
||||
<div className="progress-wrapper">
|
||||
<mdui-circular-progress />
|
||||
</div>
|
||||
) : (
|
||||
<mdui-button-icon icon="download" onClick={downloadImage} />
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
id("root")
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { Message, Size2D } from "../../../core/types";
|
||||
import { EmojiMenu } from "./EmojiMenu";
|
||||
|
||||
interface MessageContextMenuProps {
|
||||
message: Message;
|
||||
isAuthor: boolean;
|
||||
onEdit: (message: Message) => void;
|
||||
onReply: (message: Message) => void;
|
||||
onDelete: (message: Message) => void;
|
||||
onRetry?: (message: Message) => void;
|
||||
onReactionClick?: (messageId: number, emoji: string) => Promise<void>;
|
||||
position: Size2D;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ContextMenuState {
|
||||
isOpen: boolean;
|
||||
message: Message | null;
|
||||
position: Size2D;
|
||||
}
|
||||
|
||||
export function MessageContextMenu({
|
||||
message,
|
||||
isAuthor,
|
||||
onEdit,
|
||||
onReply,
|
||||
onDelete,
|
||||
onRetry,
|
||||
onReactionClick,
|
||||
position,
|
||||
isOpen,
|
||||
onOpenChange
|
||||
}: MessageContextMenuProps) {
|
||||
// Internal state for closing animation
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [calculatedPosition, setCalculatedPosition] = useState(position);
|
||||
const [animationClass, setAnimationClass] = useState('entering');
|
||||
const [reactionBarPosition, setReactionBarPosition] = useState<'left' | 'right'>('left');
|
||||
const [isEmojiMenuExpanded, setIsEmojiMenuExpanded] = useState(false);
|
||||
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);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
const emojiMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Calculate smart positioning when component opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Use a small delay to ensure elements are rendered before measuring
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
if (wrapperRef.current && reactionBarRef.current && contextMenuRef.current) {
|
||||
// Get actual dimensions from DOM elements
|
||||
const reactionBarRect = reactionBarRef.current.getBoundingClientRect();
|
||||
const contextMenuRect = contextMenuRef.current.getBoundingClientRect();
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Calculate shared/combined rect dimensions
|
||||
const sharedRect = {
|
||||
width: Math.max(reactionBarRect.width, contextMenuRect.width),
|
||||
height: reactionBarRect.height + contextMenuRect.height
|
||||
};
|
||||
|
||||
let x = position.x;
|
||||
let y = position.y;
|
||||
let animation = 'entering';
|
||||
let reactionPosition: 'left' | 'right' = 'left';
|
||||
|
||||
// Check if shared rect would overflow and adjust position
|
||||
if (x + sharedRect.width > viewportWidth) {
|
||||
x = position.x - contextMenuRect.width - 25;
|
||||
animation = 'entering-left';
|
||||
reactionPosition = 'right';
|
||||
} else {
|
||||
reactionPosition = 'left';
|
||||
}
|
||||
|
||||
// Ensure menu doesn't go off the left edge
|
||||
if (x < 0) {
|
||||
x = 0;
|
||||
}
|
||||
|
||||
// Check if shared rect would overflow bottom edge
|
||||
if (y + sharedRect.height > viewportHeight) {
|
||||
y = viewportHeight - sharedRect.height;
|
||||
animation = 'entering-up';
|
||||
}
|
||||
|
||||
setCalculatedPosition({ x, y });
|
||||
setAnimationClass(animation);
|
||||
setReactionBarPosition(reactionPosition);
|
||||
}
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
}
|
||||
}, [isOpen, position, isAuthor]);
|
||||
|
||||
// Effect to handle clicks outside the context menu
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
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')) {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && isOpen && !isClosing) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
function handleWindowBlur() {
|
||||
// Close context menu when browser window loses focus
|
||||
if (isOpen && !isClosing) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('blur', handleWindowBlur);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('blur', handleWindowBlur);
|
||||
};
|
||||
}, [isOpen, isClosing]);
|
||||
|
||||
function handleClose() {
|
||||
setIsClosing(true);
|
||||
// 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);
|
||||
setIsClosing(false);
|
||||
setAnimationClass('entering'); // Reset for next opening
|
||||
// Reset emoji menu state after context menu animation completes
|
||||
setIsEmojiMenuExpanded(false);
|
||||
setInitialDimensions(null);
|
||||
setExpandUpward(false);
|
||||
setContextMenuHeight(null);
|
||||
}, 200); // Match the animation duration from _animations.scss
|
||||
}
|
||||
|
||||
interface Action {
|
||||
label: string;
|
||||
icon: string;
|
||||
onClick: () => void;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
// Check if message is sending or failed
|
||||
const isSending = message.runtimeData?.sendingState?.status === 'sending';
|
||||
const isFailed = message.runtimeData?.sendingState?.status === 'failed';
|
||||
const isSendingOrFailed = isSending || isFailed;
|
||||
|
||||
const actions: Action[] = [
|
||||
{
|
||||
label: "Reply",
|
||||
icon: "reply",
|
||||
onClick: () => {
|
||||
onReply(message);
|
||||
handleClose();
|
||||
},
|
||||
show: !isSendingOrFailed
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
icon: "edit",
|
||||
onClick: () => {
|
||||
onEdit(message);
|
||||
handleClose();
|
||||
},
|
||||
show: isAuthor && !isSendingOrFailed
|
||||
},
|
||||
{
|
||||
label: "Retry",
|
||||
icon: "refresh",
|
||||
onClick: () => {
|
||||
if (onRetry) {
|
||||
onRetry(message);
|
||||
}
|
||||
handleClose();
|
||||
},
|
||||
show: isAuthor && isFailed && !!onRetry
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: "delete",
|
||||
onClick: () => {
|
||||
onDelete(message);
|
||||
handleClose();
|
||||
},
|
||||
show: isAuthor
|
||||
},
|
||||
];
|
||||
|
||||
// Quick reactions for the reaction bar
|
||||
const QUICK_REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "😡"];
|
||||
|
||||
async function handleReactionClick(emoji: string) {
|
||||
if (onReactionClick) {
|
||||
await onReactionClick(message.id, emoji);
|
||||
}
|
||||
handleClose();
|
||||
}
|
||||
|
||||
function handleExpandClick() {
|
||||
if (!reactionBarRef.current || !wrapperRef.current) return;
|
||||
|
||||
// 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);
|
||||
|
||||
// Check if expanding downward would cause overflow
|
||||
// Calculate space from the reaction bar's bottom edge downward
|
||||
const viewportHeight = window.innerHeight;
|
||||
const spaceBelow = viewportHeight - reactionBarRect.bottom;
|
||||
const emojiMenuHeight = 400;
|
||||
|
||||
// Only expand upward if there's not enough space below for the emoji menu
|
||||
const shouldExpandUpward = spaceBelow < emojiMenuHeight;
|
||||
setExpandUpward(shouldExpandUpward);
|
||||
|
||||
// Use requestAnimationFrame to ensure the dimensions are applied before expansion
|
||||
requestAnimationFrame(() => {
|
||||
setIsEmojiMenuExpanded(true);
|
||||
});
|
||||
}
|
||||
|
||||
function handleEmojiSelect(emoji: string) {
|
||||
if (onReactionClick) {
|
||||
onReactionClick(message.id, emoji);
|
||||
}
|
||||
handleClose();
|
||||
}
|
||||
|
||||
return isOpen && (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={`context-menu-wrapper ${animationClass}`}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: calculatedPosition.y,
|
||||
left: calculatedPosition.x,
|
||||
zIndex: 1000
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
{/* Reaction Bar */}
|
||||
<div
|
||||
ref={reactionBarRef}
|
||||
className={`context-menu-reaction-bar ${reactionBarPosition} ${isEmojiMenuExpanded ? "expanded" : ""} ${expandUpward ? "expand-upward" : ""}`}
|
||||
style={isEmojiMenuExpanded && !expandUpward ? {
|
||||
position: 'fixed',
|
||||
top: `${(-(contextMenuHeight || 0) + 5)}px`,
|
||||
width: '320px',
|
||||
height: '400px',
|
||||
zIndex: 1001
|
||||
} : initialDimensions && !isEmojiMenuExpanded ? {
|
||||
width: `${initialDimensions.width}px`,
|
||||
height: `${initialDimensions.height}px`
|
||||
} : {}}>
|
||||
{!isEmojiMenuExpanded ? (
|
||||
<div className="reaction-bar-content">
|
||||
{QUICK_REACTIONS.map((emoji, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="reaction-emoji-button"
|
||||
onClick={async () => await handleReactionClick(emoji)}
|
||||
title={emoji}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="reaction-expand-button"
|
||||
onClick={handleExpandClick}
|
||||
title="More emojis"
|
||||
>
|
||||
<span className="material-symbols">add</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={emojiMenuRef}
|
||||
className="emoji-menu-wrapper">
|
||||
<EmojiMenu
|
||||
isOpen={true}
|
||||
onClose={handleClose}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
mode="integrated"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Context Menu */}
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className={`context-menu ${isEmojiMenuExpanded ? "faded" : ""}`}>
|
||||
{actions.map((action, i) => (
|
||||
action.show && (
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={action.onClick}
|
||||
key={i}
|
||||
>
|
||||
<span className="material-symbols">{action.icon}</span>
|
||||
{action.label}
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useAppState } from "../../state";
|
||||
import { MessagePanel, type MessagePanelState } from "../../panels/MessagePanel";
|
||||
import { ChatMessages } from "./ChatMessages";
|
||||
import { ChatInputWrapper } from "./ChatInputWrapper";
|
||||
import { setGlobalMessageHandler } from "../../../core/websocket";
|
||||
import type { Message, WebSocketMessage } from "../../../core/types";
|
||||
import defaultAvatar from "../../../../../images/default-avatar.png";
|
||||
import AnimatedOpacity from "../core/animations/AnimatedOpacity";
|
||||
import type { DMPanel } from "../../panels/DMPanel";
|
||||
|
||||
interface MessagePanelRendererProps {
|
||||
panel: MessagePanel | null;
|
||||
}
|
||||
|
||||
export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
const { applyPendingPanel, chat } = useAppState();
|
||||
const messagePanelRef = useRef<HTMLDivElement>(null);
|
||||
const [panelState, setPanelState] = useState<MessagePanelState | null>(null);
|
||||
const [switchIn, setSwitchIn] = useState(false);
|
||||
const [switchOut, setSwitchOut] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const previousMessageCountRef = useRef(0);
|
||||
const [replyTo, setReplyTo] = useState<Message | null>(null);
|
||||
const [replyToVisible, setReplyToVisible] = useState(Boolean(replyTo));
|
||||
const [editMessage, setEditMessage] = useState<Message | null>(null);
|
||||
const [editVisible, setEditVisible] = useState(Boolean(editMessage));
|
||||
const [pendingAction, setPendingAction] = useState<null | { type: "reply" | "edit"; message: Message }>(null);
|
||||
|
||||
|
||||
// Drag & drop
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const addFilesRef = useRef<null | ((files: File[]) => void)>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!panel || !panelState) return;
|
||||
|
||||
return () => {
|
||||
dragCounterRef.current = 0;
|
||||
setIsDragging(false);
|
||||
};
|
||||
}, [panel, panelState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (replyTo) {
|
||||
setReplyToVisible(true);
|
||||
}
|
||||
}, [replyTo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editMessage) {
|
||||
setEditVisible(true);
|
||||
}
|
||||
}, [editMessage]);
|
||||
|
||||
// Handle panel state changes
|
||||
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));
|
||||
}
|
||||
} else {
|
||||
setPanelState(null);
|
||||
setGlobalMessageHandler(null);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (panel) {
|
||||
if (panel.onStateChange) {
|
||||
panel.onStateChange = null;
|
||||
}
|
||||
|
||||
if (typeof panel.destroy === 'function') {
|
||||
panel.destroy();
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [panel]);
|
||||
|
||||
// Handle chat switching animation with event listeners
|
||||
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();
|
||||
setSwitchOut(false);
|
||||
setSwitchIn(true);
|
||||
} else if (animationEvent.animationName === 'fadeInDown') {
|
||||
setSwitchIn(false);
|
||||
// End the chat switching state
|
||||
chat.setIsSwitching(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listener to document to catch all animation events
|
||||
document.addEventListener('animationend', handleAnimationEnd);
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
document.removeEventListener('animationend', handleAnimationEnd);
|
||||
};
|
||||
}
|
||||
}, [chat.isSwitching]);
|
||||
|
||||
// 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();
|
||||
}
|
||||
}, [chat.activePanel, chat.isSwitching, switchOut, switchIn]);
|
||||
|
||||
// Scroll to bottom only when new messages are added
|
||||
useEffect(() => {
|
||||
if (!panelState || chat.isSwitching || switchOut || switchIn) return;
|
||||
|
||||
const currentMessageCount = panelState.messages.length;
|
||||
const previousMessageCount = previousMessageCountRef.current;
|
||||
|
||||
const el = messagesEndRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// Scroll without animation when messages are initially loaded
|
||||
if (previousMessageCount === 0 && currentMessageCount > 0 && !panelState.isLoading) {
|
||||
el.scrollIntoView({ behavior: "instant", block: "end" });
|
||||
}
|
||||
// Scroll with animation when a new message is added
|
||||
else if (currentMessageCount > previousMessageCount && previousMessageCount > 0) {
|
||||
// Defer to next frame to ensure layout is stable
|
||||
const id = requestAnimationFrame(() => {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(id);
|
||||
}
|
||||
|
||||
// Update the previous message count
|
||||
previousMessageCountRef.current = currentMessageCount;
|
||||
}, [panelState?.messages, panelState?.isLoading, chat.isSwitching, switchOut, switchIn]);
|
||||
|
||||
return (
|
||||
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
|
||||
<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={panel?.handleProfileClick}
|
||||
style={{ cursor: panel ? "pointer" : "default" }}
|
||||
/>
|
||||
<div className="chat-header-info">
|
||||
<div className="info-chat">
|
||||
<h4 id="chat-name">{panelState?.title || "Выбор чата"}</h4>
|
||||
<p>
|
||||
<span className={`online-status ${panelState?.online ? "online" : ""}`}></span>
|
||||
{panelState ? (
|
||||
<>
|
||||
{panelState.online ? "Online" : "Offline"}
|
||||
{panelState.isTyping && " • Typing..."}
|
||||
</>
|
||||
) : (
|
||||
"Выберите чат, чтобы начать переписку"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</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 && (
|
||||
<>
|
||||
<AnimatedOpacity
|
||||
visible={isDragging}
|
||||
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>
|
||||
</AnimatedOpacity>
|
||||
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useAppState } from "../../state";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { Reaction } from "../../../core/types";
|
||||
|
||||
interface MessageReactionsProps {
|
||||
reactions?: Reaction[];
|
||||
onReactionClick: (emoji: string) => void;
|
||||
messageId?: number; // Add messageId to ensure unique keys
|
||||
}
|
||||
|
||||
export function MessageReactions({ reactions, onReactionClick, messageId }: MessageReactionsProps) {
|
||||
const { user } = useAppState();
|
||||
const [visibleReactions, setVisibleReactions] = useState<Reaction[]>([]);
|
||||
const [animatingReactions, setAnimatingReactions] = useState<Set<string>>(new Set());
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// Handle reactions with animation
|
||||
useEffect(() => {
|
||||
if (!reactions || reactions.length === 0) {
|
||||
// If we have visible reactions, animate them out
|
||||
if (visibleReactions.length > 0) {
|
||||
visibleReactions.forEach(reaction => {
|
||||
setAnimatingReactions(prev => new Set(prev).add(reaction.emoji));
|
||||
});
|
||||
// After animation completes, hide the component
|
||||
setTimeout(() => {
|
||||
setVisibleReactions([]);
|
||||
setAnimatingReactions(new Set());
|
||||
setIsVisible(false);
|
||||
}, 200);
|
||||
} else {
|
||||
// No visible reactions, hide immediately
|
||||
setIsVisible(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the component when we have reactions
|
||||
setIsVisible(true);
|
||||
|
||||
// Deduplicate reactions by emoji (safety measure)
|
||||
const uniqueReactions = reactions.reduce((acc, reaction) => {
|
||||
const existing = acc.find(r => r.emoji === reaction.emoji);
|
||||
if (existing) {
|
||||
// Keep the one with the higher count
|
||||
if (reaction.count > existing.count) {
|
||||
acc[acc.indexOf(existing)] = reaction;
|
||||
}
|
||||
} else {
|
||||
acc.push(reaction);
|
||||
}
|
||||
return acc;
|
||||
}, [] as Reaction[]);
|
||||
|
||||
|
||||
// Animate out removed reactions
|
||||
visibleReactions.forEach(reaction => {
|
||||
if (!uniqueReactions.some(r => r.emoji === reaction.emoji)) {
|
||||
setAnimatingReactions(prev => new Set(prev).add(reaction.emoji));
|
||||
setTimeout(() => {
|
||||
setVisibleReactions(prev => prev.filter(r => r.emoji !== reaction.emoji));
|
||||
setAnimatingReactions(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(reaction.emoji);
|
||||
return newSet;
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
if (existingIndex !== -1) {
|
||||
updated[existingIndex] = reaction;
|
||||
} else {
|
||||
// Add new reaction only if it doesn't already exist
|
||||
if (!updated.some(r => r.emoji === reaction.emoji)) {
|
||||
updated.push(reaction);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
}, [reactions]);
|
||||
|
||||
// Don't render if not visible
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="message-reactions">
|
||||
{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}`}
|
||||
className={`reaction-button ${hasUserReacted ? "reacted" : ""} ${isAnimating ? "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>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { Message } from "../../../core/types";
|
||||
import { MaterialDialog } from "../core/Dialog";
|
||||
import { MaterialTextField } from "../core/TextField";
|
||||
|
||||
interface ReplyMessageDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
replyToMessage: Message | null;
|
||||
onSendReply: (content: string, replyToId: number) => void;
|
||||
}
|
||||
|
||||
export function ReplyMessageDialog({ isOpen, onOpenChange, replyToMessage, onSendReply }: ReplyMessageDialogProps) {
|
||||
const [replyContent, setReplyContent] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (replyToMessage) {
|
||||
setReplyContent("");
|
||||
}
|
||||
}, [replyToMessage]);
|
||||
|
||||
const handleSendReply = () => {
|
||||
if (replyToMessage && replyContent.trim()) {
|
||||
onSendReply(replyContent.trim(), replyToMessage.id);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onOpenChange(false);
|
||||
setReplyContent("");
|
||||
};
|
||||
|
||||
if (!replyToMessage) return null;
|
||||
|
||||
return (
|
||||
<MaterialDialog open={isOpen} onOpenChange={onOpenChange} close-on-overlay-click close-on-esc className="reply-dialog">
|
||||
<div className="dialog-content">
|
||||
<h3>Ответить на сообщение</h3>
|
||||
<div className="reply-preview-dialog">
|
||||
<div className="reply-content">
|
||||
<span className="reply-username">{replyToMessage.username}</span>
|
||||
<span className="reply-text">{replyToMessage.content}</span>
|
||||
</div>
|
||||
</div>
|
||||
<MaterialTextField
|
||||
value={replyContent}
|
||||
onInput={(e) => setReplyContent((e.target as HTMLInputElement).value)}
|
||||
label="Reply"
|
||||
variant="outlined"
|
||||
placeholder="Type your reply..."
|
||||
maxlength={1000} />
|
||||
<div className="dialog-actions">
|
||||
<mdui-button onClick={handleCancel} variant="outlined">Cancel</mdui-button>
|
||||
<mdui-button onClick={handleSendReply}>Send Reply</mdui-button>
|
||||
</div>
|
||||
</div>
|
||||
</MaterialDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useAppState } from "../../state";
|
||||
import { MessagePanelRenderer } from "./MessagePanelRenderer";
|
||||
|
||||
export function RightPanel() {
|
||||
const { chat } = useAppState();
|
||||
|
||||
return <MessagePanelRenderer panel={chat.activePanel} />
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { DialogProps } from "../../../core/types";
|
||||
import type { UserProfile } from "../../../core/types";
|
||||
import { MaterialDialog } from "../core/Dialog";
|
||||
import { formatTime } from "../../../utils/utils";
|
||||
import defaultAvatar from "../../../../../images/default-avatar.png";
|
||||
|
||||
interface UserProfileDialogProps extends DialogProps {
|
||||
userProfile: UserProfile | null;
|
||||
}
|
||||
|
||||
export function UserProfileDialog({ isOpen, onOpenChange, userProfile }: UserProfileDialogProps) {
|
||||
const content = userProfile ? (
|
||||
<div className="content">
|
||||
<div className="profile-picture-section">
|
||||
<img
|
||||
className="profile-picture"
|
||||
alt="Profile Picture"
|
||||
src={userProfile.profile_picture || defaultAvatar}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="profile-info">
|
||||
<div className="username-section">
|
||||
<h4 className="username">{userProfile.username}</h4>
|
||||
<div className={`online-status ${userProfile.online ? "online" : "offline"}`}>
|
||||
{userProfile.online ? (
|
||||
<>
|
||||
<span className="online-indicator"></span> Онлайн
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="offline-indicator"></span> Последний заход {formatTime(userProfile.last_seen)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bio-section">
|
||||
<label>О себе:</label>
|
||||
<div className="bio-display">
|
||||
{userProfile.bio || "No bio available."}
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-stats">
|
||||
<div className="stat">
|
||||
<span className="stat-label">Зарегистрирован:</span>
|
||||
<span className="stat-value member-since">{formatTime(userProfile.created_at)}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-label">Last seen:</span>
|
||||
<span className="stat-value last-seen">{formatTime(userProfile.last_seen)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-actions">
|
||||
<mdui-button id="dm-button" variant="filled">
|
||||
<mdui-icon slot="icon" name="chat--filled"></mdui-icon>
|
||||
Send Message
|
||||
</mdui-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<MaterialDialog open={isOpen} onOpenChange={onOpenChange} close-on-overlay-click close-on-esc id="user-profile-dialog">
|
||||
{content}
|
||||
</MaterialDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
export interface EmojiCategory {
|
||||
name: string;
|
||||
icon: string;
|
||||
emojis: string[];
|
||||
}
|
||||
|
||||
export const EMOJI_CATEGORIES: EmojiCategory[] = [
|
||||
{
|
||||
name: "recent",
|
||||
icon: "🕒",
|
||||
emojis: []
|
||||
},
|
||||
{
|
||||
name: "smileys",
|
||||
icon: "😀",
|
||||
emojis: [
|
||||
"😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃", "😉", "😊", "😇", "🥰", "😍", "🤩", "😘", "😗", "😚", "😙", "😋", "😛", "😜", "🤪", "😝", "🤑", "🤗", "🤭", "🤫", "🤔", "🤐", "🤨", "😐", "😑", "😶", "😏", "😒", "🙄", "😬", "🤥", "😔", "😪", "🤤", "😴", "😷", "🤒", "🤕", "🤢", "🤮", "🤧", "🥵", "🥶", "🥴", "😵", "🤯", "🤠", "🥳", "😎", "🤓", "🧐", "😕", "😟", "🙁", "☹️", "😮", "😯", "😲", "😳", "🥺", "😦", "😧", "😨", "😰", "😥", "😢", "😭", "😱", "😖", "😣", "😞", "😓", "😩", "😫", "🥱", "😤", "😡", "😠", "🤬", "😈", "👿", "💀", "☠️", "💩", "🤡", "👹", "👺", "👻", "👽", "👾", "🤖", "😺", "😸", "😹", "😻", "😼", "😽", "🙀", "😿", "😾"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "people",
|
||||
icon: "👋",
|
||||
emojis: [
|
||||
"👋", "🤚", "🖐", "✋", "🖖", "👌", "🤏", "✌️", "🤞", "🤟", "🤘", "🤙", "👈", "👉", "👆", "🖕", "👇", "☝️", "👍", "👎", "👊", "✊", "🤛", "🤜", "👏", "🙌", "👐", "🤲", "🤝", "🙏", "✍️", "💅", "🤳", "💪", "🦾", "🦿", "🦵", "🦶", "👂", "🦻", "👃", "🧠", "🦷", "🦴", "👀", "👁", "👅", "👄", "💋", "🩸", "👶", "🧒", "👦", "👧", "🧑", "👨", "👩", "🧓", "👴", "👵", "👱", "🧔", "👲", "🧕", "👳", "👮", "👷", "💂", "🕵️", "👩⚕️", "👨⚕️", "👩🌾", "👨🌾", "👩🍳", "👨🍳", "👩🎓", "👨🎓", "👩🎤", "👨🎤", "👩🏫", "👨🏫", "👩🏭", "👨🏭", "👩💻", "👨💻", "👩💼", "👨💼", "👩🔧", "👨🔧", "👩🔬", "👨🔬", "👩🎨", "👨🎨", "👩🚒", "👨🚒", "👩✈️", "👨✈️", "👩🚀", "👨🚀", "👩⚖️", "👨⚖️", "👰", "🤵", "👸", "🤴", "🦸", "🦹", "🤶", "🎅", "🧙", "🧚", "🧛", "🧜", "🧝", "🧞", "🧟", "💆", "💇", "🚶", "🏃", "💃", "🕺", "🕴", "👯", "🧘", "🛀", "🛌", "👭", "👫", "👬", "💏", "💑", "👪"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "animals",
|
||||
icon: "🐶",
|
||||
emojis: [
|
||||
"🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷", "🐽", "🐸", "🐵", "🙈", "🙉", "🙊", "🐒", "🐔", "🐧", "🐦", "🐤", "🐣", "🐥", "🦆", "🦅", "🦉", "🦇", "🐺", "🐗", "🐴", "🦄", "🐝", "🐛", "🦋", "🐌", "🐞", "🐜", "🦟", "🦗", "🕷", "🕸", "🦂", "🐢", "🐍", "🦎", "🦖", "🦕", "🐙", "🦑", "🦐", "🦞", "🦀", "🐡", "🐠", "🐟", "🐬", "🐳", "🐋", "🦈", "🐊", "🐅", "🐆", "🦓", "🦍", "🦧", "🐘", "🦛", "🦏", "🐪", "🐫", "🦒", "🦘", "🐃", "🐂", "🐄", "🐎", "🐖", "🐏", "🐑", "🦙", "🐐", "🦌", "🐕", "🐩", "🦮", "🐕🦺", "🐈", "🐓", "🦃", "🦚", "🦜", "🦢", "🦩", "🕊", "🐇", "🦝", "🦨", "🦡", "🦦", "🦥", "🐁", "🐀", "🐿", "🦔"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "food",
|
||||
icon: "🍎",
|
||||
emojis: [
|
||||
"🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🫐", "🍈", "🍒", "🍑", "🥭", "🍍", "🥥", "🥝", "🍅", "🍆", "🥑", "🥦", "🥬", "🥒", "🌶", "🫑", "🌽", "🥕", "🫒", "🧄", "🧅", "🥔", "🍠", "🥐", "🥯", "🍞", "🥖", "🥨", "🧀", "🥚", "🍳", "🧈", "🥞", "🧇", "🥓", "🥩", "🍗", "🍖", "🦴", "🌭", "🍔", "🍟", "🍕", "🫓", "🥙", "🌮", "🌯", "🫔", "🥗", "🥘", "🫕", "🥫", "🍝", "🍜", "🍲", "🍛", "🍣", "🍱", "🥟", "🦪", "🍤", "🍙", "🍚", "🍘", "🍥", "🥠", "🥮", "🍢", "🍡", "🍧", "🍨", "🍦", "🥧", "🧁", "🍰", "🎂", "🍮", "🍭", "🍬", "🍫", "🍿", "🍩", "🍪", "🌰", "🥜", "🍯", "🥛", "🍼", "☕", "🫖", "🍵", "🧃", "🥤", "🧋", "🍶", "🍺", "🍻", "🥂", "🍷", "🥃", "🍸", "🍹", "🧉", "🍾"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "travel",
|
||||
icon: "🚗",
|
||||
emojis: [
|
||||
"🚗", "🚕", "🚙", "🚌", "🚎", "🏎", "🚓", "🚑", "🚒", "🚐", "🛻", "🚚", "🚛", "🚜", "🏍", "🛵", "🚲", "🛴", "🛹", "🛼", "🚁", "✈️", "🛩", "🛫", "🛬", "🪂", "💺", "🚀", "🛸", "🚉", "🚊", "🚝", "🚞", "🚋", "🚃", "🚋", "🚋", "🚄", "🚅", "🚈", "🚂", "🚆", "🚇", "🚊", "🚍", "🚘", "🚖", "🚡", "🚠", "🚟", "🎢", "🎡", "🎠", "⛵", "🛥", "🚤", "⛴", "🛳", "🚢", "⚓", "🚧", "⛽", "🚨", "🚥", "🚦", "🛑", "🚏", "🗺", "🗿", "🗽", "🗼", "🏰", "🏯", "🏟", "🎡", "🎢", "🎠", "⛲", "⛱", "🏖", "🏝", "🏔", "⛰", "🌋", "🗻", "🏕", "⛺", "🏠", "🏡", "🏘", "🏚", "🏗", "🏭", "🏢", "🏬", "🏣", "🏤", "🏥", "🏦", "🏨", "🏪", "🏫", "🏩", "💒", "🏛", "⛪", "🕌", "🛕", "🕍", "🕋", "⛩", "🛤", "🛣", "🗾", "🎑", "🏞", "🌅", "🌄", "🌠", "🎇", "🎆", "🌇", "🌆", "🏙", "🌃", "🌌", "🌉", "🌁"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "activities",
|
||||
icon: "⚽",
|
||||
emojis: [
|
||||
"⚽", "🏀", "🏈", "⚾", "🥎", "🎾", "🏐", "🏉", "🎱", "🪀", "🏓", "🏸", "🏒", "🏑", "🥍", "🏏", "🪃", "🥅", "⛳", "🪁", "🏹", "🎣", "🤿", "🥊", "🥋", "🎽", "🛹", "🛷", "⛸", "🥌", "🎿", "⛷", "🏂", "🪂", "🏋️♀️", "🏋️♂️", "🤼♀️", "🤼♂️", "🤸♀️", "🤸♂️", "⛹️♀️", "⛹️♂️", "🤺", "🤾♀️", "🤾♂️", "🏌️♀️", "🏌️♂️", "🏇", "🧘♀️", "🧘♂️", "🏄♀️", "🏄♂️", "🏊♀️", "🏊♂️", "🤽♀️", "🤽♂️", "🚣♀️", "🚣♂️", "🧗♀️", "🧗♂️", "🚵♀️", "🚵♂️", "🚴♀️", "🚴♂️", "🏆", "🥇", "🥈", "🥉", "🏅", "🎖", "🏵", "🎗", "🎫", "🎟", "🎪", "🤹", "🤹♀️", "🤹♂️", "🎭", "🩰", "🎨", "🎬", "🎤", "🎧", "🎼", "🎹", "🥁", "🎷", "🎺", "🎸", "🪕", "🎻", "🎲", "♠️", "♥️", "♦️", "♣️", "♟", "🃏", "🀄", "🎴", "🎯", "🎳", "🎮", "🎰", "🧩"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "objects",
|
||||
icon: "📱",
|
||||
emojis: [
|
||||
"📱", "📲", "☎️", "📞", "📟", "📠", "🔋", "🔌", "💻", "🖥", "🖨", "⌨️", "🖱", "🖲", "💽", "💾", "💿", "📀", "🧮", "🎥", "📽", "📸", "📹", "📷", "🔍", "🔎", "🕯", "💡", "🔦", "🏮", "🪔", "📔", "📕", "📖", "📗", "📘", "📙", "📚", "📓", "📒", "📃", "📜", "📄", "📰", "🗞", "📑", "🔖", "🏷", "💰", "💴", "💵", "💶", "💷", "💸", "💳", "🧾", "💹", "💱", "💲", "✉️", "📧", "📨", "📩", "📤", "📥", "📦", "📫", "📪", "📬", "📭", "📮", "🗳", "✏️", "✒️", "🖋", "🖊", "🖌", "🖍", "📝", "💼", "📁", "📂", "🗂", "📅", "📆", "🗒", "🗓", "📇", "📈", "📉", "📊", "📋", "📌", "📍", "📎", "🖇", "📏", "📐", "✂️", "🗃", "🗄", "🗑", "🔒", "🔓", "🔏", "🔐", "🔑", "🗝", "🔨", "⛏", "⚒", "🛠", "🗡", "⚔️", "🔫", "🪃", "🏹", "🛡", "🪚", "🔧", "🪛", "🔩", "⚙️", "🗜", "⚖️", "🦯", "🔗", "⛓", "🧰", "🧲", "⚗️", "🧪", "🧫", "🧬", "🔬", "🔭", "📡", "💉", "💊", "🩹", "🩺", "🚪", "🛏", "🛋", "🚽", "🚿", "🛁", "🛀", "🧴", "🧷", "🧹", "🧺", "🧻", "🚰", "🚰", "🪒", "🧽", "🧯", "🛒"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "symbols",
|
||||
icon: "❤️",
|
||||
emojis: [
|
||||
"❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "🤎", "💔", "❣️", "💕", "💞", "💓", "💗", "💖", "💘", "💝", "💟", "☮️", "✝️", "☪️", "🕉", "☸️", "✡️", "🔯", "🕎", "☯️", "☦️", "🛐", "⛎", "♈", "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", "♑", "♒", "♓", "🆔", "⚛️", "🉑", "☢️", "☣️", "📴", "📳", "🈶", "🈚", "🈸", "🈺", "🈷️", "✴️", "🆚", "💮", "🉐", "㊙️", "㊗️", "🈴", "🈵", "🈹", "🈲", "🅰️", "🅱️", "🆎", "🅾️", "🆘", "❌", "⭕", "🛑", "⛔", "📛", "🚫", "💯", "💢", "♨️", "🚷", "🚯", "🚳", "🚱", "🔞", "📵", "🚭", "❗", "❕", "❓", "❔", "‼️", "⁉️", "🔅", "🔆", "〽️", "⚠️", "🚸", "🔱", "⚜️", "🔰", "♻️", "✅", "🈯", "💹", "❇️", "✳️", "❎", "🌐", "💠", "Ⓜ️", "🌀", "💤", "🏧", "🚾", "♿", "🅿️", "🛗", "🈳", "🈂️", "🛂", "🛃", "🛄", "🛅", "🚹", "🚺", "🚼", "⚧", "🚻", "🚮", "🎦", "📶", "🈁", "🔣", "ℹ️", "🔤", "🔡", "🔠", "🆖", "🆗", "🆙", "🆒", "🆕", "🆓", "0️⃣", "1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "flags",
|
||||
icon: "🏳️",
|
||||
emojis: [
|
||||
"🏳️", "🏴", "🏁", "🚩", "🏳️🌈", "🏳️⚧️", "🏴☠️", "🇦🇨", "🇦🇩", "🇦🇪", "🇦🇫", "🇦🇬", "🇦🇮", "🇦🇱", "🇦🇲", "🇦🇴", "🇦🇶", "🇦🇷", "🇦🇸", "🇦🇹", "🇦🇺", "🇦🇼", "🇦🇽", "🇦🇿", "🇧🇦", "🇧🇧", "🇧🇩", "🇧🇪", "🇧🇫", "🇧🇬", "🇧🇭", "🇧🇮", "🇧🇯", "🇧🇱", "🇧🇲", "🇧🇳", "🇧🇴", "🇧🇶", "🇧🇷", "🇧🇸", "🇧🇹", "🇧🇻", "🇧🇼", "🇧🇾", "🇧🇿", "🇨🇦", "🇨🇨", "🇨🇩", "🇨🇫", "🇨🇬", "🇨🇭", "🇨🇮", "🇨🇰", "🇨🇱", "🇨🇲", "🇨🇳", "🇨🇴", "🇨🇵", "🇨🇷", "🇨🇺", "🇨🇻", "🇨🇼", "🇨🇽", "🇨🇾", "🇨🇿", "🇩🇪", "🇩🇬", "🇩🇯", "🇩🇰", "🇩🇲", "🇩🇴", "🇩🇿", "🇪🇦", "🇪🇨", "🇪🇪", "🇪🇬", "🇪🇭", "🇪🇷", "🇪🇸", "🇪🇹", "🇪🇺", "🇫🇮", "🇫🇯", "🇫🇰", "🇫🇲", "🇫🇴", "🇫🇷", "🇬🇦", "🇬🇧", "🇬🇩", "🇬🇪", "🇬🇫", "🇬🇬", "🇬🇭", "🇬🇮", "🇬🇱", "🇬🇲", "🇬🇳", "🇬🇵", "🇬🇶", "🇬🇷", "🇬🇸", "🇬🇹", "🇬🇺", "🇬🇼", "🇬🇾", "🇭🇰", "🇭🇲", "🇭🇳", "🇭🇷", "🇭🇹", "🇭🇺", "🇮🇨", "🇮🇩", "🇮🇪", "🇮🇱", "🇮🇲", "🇮🇳", "🇮🇴", "🇮🇶", "🇮🇷", "🇮🇸", "🇮🇹", "🇯🇪", "🇯🇲", "🇯🇴", "🇯🇵", "🇰🇪", "🇰🇬", "🇰🇭", "🇰🇮", "🇰🇲", "🇰🇳", "🇰🇵", "🇰🇷", "🇰🇼", "🇰🇾", "🇰🇿", "🇱🇦", "🇱🇧", "🇱🇨", "🇱🇮", "🇱🇰", "🇱🇷", "🇱🇸", "🇱🇹", "🇱🇺", "🇱🇻", "🇱🇾", "🇲🇦", "🇲🇨", "🇲🇩", "🇲🇪", "🇲🇫", "🇲🇬", "🇲🇭", "🇲🇰", "🇲🇱", "🇲🇲", "🇲🇳", "🇲🇴", "🇲🇵", "🇲🇶", "🇲🇷", "🇲🇸", "🇲🇹", "🇲🇺", "🇲🇻", "🇲🇼", "🇲🇽", "🇲🇾", "🇲🇿", "🇳🇦", "🇳🇨", "🇳🇪", "🇳🇫", "🇳🇬", "🇳🇮", "🇳🇱", "🇳🇴", "🇳🇵", "🇳🇷", "🇳🇺", "🇳🇿", "🇴🇲", "🇵🇦", "🇵🇪", "🇵🇫", "🇵🇬", "🇵🇭", "🇵🇰", "🇵🇱", "🇵🇲", "🇵🇳", "🇵🇷", "🇵🇸", "🇵🇹", "🇵🇼", "🇵🇾", "🇶🇦", "🇷🇪", "🇷🇴", "🇷🇸", "🇷🇺", "🇷🇼", "🇸🇦", "🇸🇧", "🇸🇨", "🇸🇩", "🇸🇪", "🇸🇬", "🇸🇭", "🇸🇮", "🇸🇯", "🇸🇰", "🇸🇱", "🇸🇲", "🇸🇳", "🇸🇴", "🇸🇷", "🇸🇸", "🇸🇹", "🇸🇻", "🇸🇽", "🇸🇾", "🇸🇿", "🇹🇦", "🇹🇨", "🇹🇩", "🇹🇫", "🇹🇬", "🇹🇭", "🇹🇯", "🇹🇰", "🇹🇱", "🇹🇲", "🇹🇳", "🇹🇴", "🇹🇷", "🇹🇹", "🇹🇻", "🇹🇼", "🇹🇿", "🇺🇦", "🇺🇬", "🇺🇲", "🇺🇸", "🇺🇾", "🇺🇿", "🇻🇦", "🇻🇨", "🇻🇪", "🇻🇬", "🇻🇮", "🇻🇳", "🇻🇺", "🇼🇫", "🇼🇸", "🇾🇪", "🇾🇹", "🇿🇦", "🇿🇲", "🇿🇼"
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const RECENT_EMOJIS_KEY = "recentEmojis";
|
||||
|
||||
export function getRecentEmojis(): string[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(RECENT_EMOJIS_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function addRecentEmoji(emoji: string): void {
|
||||
try {
|
||||
let recentEmojis = getRecentEmojis();
|
||||
recentEmojis = recentEmojis.filter(e => e !== emoji);
|
||||
recentEmojis.unshift(emoji);
|
||||
recentEmojis = recentEmojis.slice(0, 50);
|
||||
localStorage.setItem(RECENT_EMOJIS_KEY, JSON.stringify(recentEmojis));
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Dialog as MduiDialog } from "mdui/components/dialog";
|
||||
import { useEffect, type Ref } from "react"
|
||||
import { createPortal } from "react-dom";
|
||||
import { id } from "../../../utils/utils";
|
||||
import useCombinedRefs from "../../hooks/useCombinedRefs";
|
||||
|
||||
export interface BaseDialogProps {
|
||||
onOpenChange: (value: boolean) => void;
|
||||
ref?: Ref<MduiDialog & HTMLElement>
|
||||
}
|
||||
|
||||
export type FullDialogProps = React.ComponentPropsWithoutRef<"mdui-dialog"> & BaseDialogProps;
|
||||
|
||||
export function MaterialDialog(props: FullDialogProps) {
|
||||
const [setDialogRef, dialogRef] = useCombinedRefs(props.ref);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === "attributes" && mutation.attributeName === "open") {
|
||||
const isOpen = dialog.hasAttribute("open");
|
||||
if (isOpen !== props.open) {
|
||||
props.onOpenChange(isOpen);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Start observing the dialog element for attribute changes
|
||||
observer.observe(dialog, {
|
||||
attributes: true,
|
||||
attributeFilter: ["open"]
|
||||
});
|
||||
|
||||
// Cleanup observer
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [dialogRef.current, props.open, props.onOpenChange]);
|
||||
|
||||
return createPortal(<mdui-dialog {...props} ref={setDialogRef} />, id("root"));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface QuoteProps {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
background?: "surfaceContainer" | "primaryContainer"
|
||||
}
|
||||
|
||||
export default function Quote({ className, children, background = "primaryContainer" }: QuoteProps) {
|
||||
return (
|
||||
<div className={`quote bg-${background} ${className}`}>
|
||||
<div className="quote-inner">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useEffect, useRef, useCallback, useLayoutEffect } from "react";
|
||||
|
||||
interface RichTextAreaProps {
|
||||
text: string;
|
||||
onTextChange: (value: string) => void;
|
||||
onEnter?: "newLine" | null | ((e: React.KeyboardEvent<HTMLTextAreaElement>) => void);
|
||||
onCtrlEnter?: ((e: React.KeyboardEvent<HTMLTextAreaElement>) => void) | null;
|
||||
placeholder?: string;
|
||||
id?: string;
|
||||
className?: string;
|
||||
rows?: number;
|
||||
autoComplete?: string;
|
||||
}
|
||||
|
||||
export function RichTextArea({
|
||||
text,
|
||||
onTextChange,
|
||||
onEnter = "newLine",
|
||||
onCtrlEnter = null,
|
||||
placeholder,
|
||||
className,
|
||||
rows = 1,
|
||||
autoComplete = "off",
|
||||
}: RichTextAreaProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const hiddenTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const heightRef = useRef<number | null>(null);
|
||||
|
||||
function getStyleValue(computedStyle: CSSStyleDeclaration, prop: keyof CSSStyleDeclaration): number {
|
||||
const raw = computedStyle[prop] as string | number | undefined;
|
||||
if (raw == null) return 0;
|
||||
const str = String(raw);
|
||||
return str.endsWith("px") ? parseFloat(str) : parseFloat(str) || 0;
|
||||
}
|
||||
|
||||
const calculateTextareaStyles = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
const hidden = hiddenTextareaRef.current;
|
||||
if (!textarea || !hidden) return undefined;
|
||||
|
||||
const computedStyle = window.getComputedStyle(textarea);
|
||||
if (computedStyle.width === "0px") {
|
||||
return { outerHeightStyle: 0, overflowing: false };
|
||||
}
|
||||
|
||||
// Ensure hidden textarea copies width but not percentage-based anomalies from parents
|
||||
// Normalize hidden textarea to avoid inherited constraints and copy critical metrics
|
||||
hidden.style.position = "fixed";
|
||||
hidden.style.top = "-9999px";
|
||||
hidden.style.left = "-9999px";
|
||||
hidden.style.visibility = "hidden";
|
||||
hidden.style.height = "auto";
|
||||
hidden.style.minHeight = "0";
|
||||
hidden.style.maxHeight = "none";
|
||||
hidden.style.overflow = "hidden";
|
||||
hidden.style.boxSizing = computedStyle.boxSizing;
|
||||
// Avoid counting vertical padding twice: keep 0 for measurement
|
||||
hidden.style.paddingTop = "0";
|
||||
hidden.style.paddingBottom = "0";
|
||||
hidden.style.paddingLeft = computedStyle.paddingLeft;
|
||||
hidden.style.paddingRight = computedStyle.paddingRight;
|
||||
// Do not include borders in the inner scrollHeight measurement
|
||||
hidden.style.borderTopWidth = "0";
|
||||
hidden.style.borderBottomWidth = "0";
|
||||
hidden.style.borderLeftWidth = computedStyle.borderLeftWidth;
|
||||
hidden.style.borderRightWidth = computedStyle.borderRightWidth;
|
||||
hidden.style.fontFamily = computedStyle.fontFamily;
|
||||
hidden.style.fontSize = computedStyle.fontSize;
|
||||
hidden.style.fontWeight = computedStyle.fontWeight;
|
||||
hidden.style.lineHeight = computedStyle.lineHeight;
|
||||
hidden.style.letterSpacing = computedStyle.letterSpacing;
|
||||
hidden.style.whiteSpace = computedStyle.whiteSpace;
|
||||
hidden.style.wordSpacing = computedStyle.wordSpacing;
|
||||
hidden.style.textIndent = computedStyle.textIndent;
|
||||
hidden.style.textTransform = computedStyle.textTransform;
|
||||
hidden.style.textDecoration = computedStyle.textDecoration;
|
||||
hidden.style.width = computedStyle.width;
|
||||
hidden.style.maxWidth = computedStyle.width;
|
||||
hidden.value = textarea.value || placeholder || "x";
|
||||
if (hidden.value.slice(-1) === "\n") {
|
||||
hidden.value += " ";
|
||||
}
|
||||
|
||||
const boxSizing = computedStyle.boxSizing;
|
||||
const padding = getStyleValue(computedStyle, "paddingBottom") + getStyleValue(computedStyle, "paddingTop");
|
||||
const border = getStyleValue(computedStyle, "borderBottomWidth") + getStyleValue(computedStyle, "borderTopWidth");
|
||||
|
||||
const innerHeight = hidden.scrollHeight;
|
||||
|
||||
hidden.value = "x";
|
||||
const singleRowHeight = hidden.scrollHeight;
|
||||
|
||||
let outerHeight = innerHeight;
|
||||
const minRows = Number(rows || 1);
|
||||
if (minRows) {
|
||||
outerHeight = Math.max(minRows * singleRowHeight, outerHeight);
|
||||
}
|
||||
outerHeight = Math.max(outerHeight, singleRowHeight);
|
||||
|
||||
// Use ceil to avoid sub-pixel gaps and subtract a tiny epsilon to reduce visual gap
|
||||
let outerHeightStyle = outerHeight + (boxSizing === "border-box" ? padding + border : 0);
|
||||
outerHeightStyle = Math.round(outerHeightStyle); // snap to pixel to avoid half-line gaps
|
||||
const overflowing = Math.abs(outerHeight - innerHeight) <= 1;
|
||||
return { outerHeightStyle, overflowing };
|
||||
}, [rows, placeholder]);
|
||||
|
||||
const syncHeight = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
const styles = calculateTextareaStyles();
|
||||
if (!textarea || !styles) return;
|
||||
const { outerHeightStyle, overflowing } = styles;
|
||||
if (heightRef.current !== outerHeightStyle) {
|
||||
heightRef.current = outerHeightStyle;
|
||||
textarea.style.height = `${outerHeightStyle}px`;
|
||||
}
|
||||
textarea.style.overflowY = overflowing ? "hidden" : "";
|
||||
}, [calculateTextareaStyles]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
syncHeight();
|
||||
}, [syncHeight, text]);
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
const onResize = () => syncHeight();
|
||||
window.addEventListener("resize", onResize);
|
||||
let ro: ResizeObserver | null = null;
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
ro = new ResizeObserver(() => {
|
||||
ro!.unobserve(textarea);
|
||||
syncHeight();
|
||||
requestAnimationFrame(() => ro && textarea && ro.observe(textarea));
|
||||
});
|
||||
ro.observe(textarea);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
if (ro) ro.disconnect();
|
||||
};
|
||||
}, [syncHeight]);
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||
// Keep height responsive during rapid uncontrolled input bursts
|
||||
syncHeight();
|
||||
onTextChange(e.target.value);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
const isCtrlEnter = e.key === "Enter" && (e.ctrlKey || e.metaKey);
|
||||
const isPlainEnter = e.key === "Enter" && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey;
|
||||
|
||||
if (isCtrlEnter) {
|
||||
e.preventDefault();
|
||||
if (typeof onCtrlEnter === "function") {
|
||||
onCtrlEnter(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlainEnter) {
|
||||
if (onEnter === "newLine") {
|
||||
// allow default
|
||||
return;
|
||||
}
|
||||
if (onEnter === null) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (typeof onEnter === "function") {
|
||||
e.preventDefault();
|
||||
onEnter(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<textarea
|
||||
className={`rich-text-area ${className}`}
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
autoComplete={autoComplete}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<textarea
|
||||
aria-hidden
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
ref={hiddenTextareaRef}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: "-9999px",
|
||||
left: "-9999px",
|
||||
visibility: "hidden",
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
height: "auto",
|
||||
minHeight: 0,
|
||||
maxHeight: "none",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
rows={1}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { TextField } from "mdui/components/text-field";
|
||||
|
||||
type TextFieldProps = React.ComponentPropsWithoutRef<"mdui-text-field">
|
||||
|
||||
export function MaterialTextField(props: TextFieldProps & { ref?: React.Ref<TextField> }) {
|
||||
return <mdui-text-field
|
||||
autocomplete="off"
|
||||
{...(props as TextFieldProps & { ref?: React.Ref<HTMLElement> })} />
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import type { AnimatedPropertyProps } from "./types";
|
||||
|
||||
export default function AnimatedHeight({ visible, duration = 0.25, onFinish, children, ...props }: AnimatedPropertyProps) {
|
||||
const [height, setHeight] = useState("0px");
|
||||
const [shouldRender, setShouldRender] = useState(!!visible);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setShouldRender(true);
|
||||
setIsAnimating(true);
|
||||
// Wait for content to render, then measure
|
||||
setTimeout(() => {
|
||||
if (measureRef.current) {
|
||||
const contentHeight = measureRef.current.scrollHeight;
|
||||
setHeight(`${contentHeight}px`);
|
||||
}
|
||||
// Animation complete
|
||||
setTimeout(() => {
|
||||
setHeight("auto");
|
||||
setIsAnimating(false);
|
||||
}, duration * 1000);
|
||||
}, 0);
|
||||
} else if (shouldRender) {
|
||||
setIsAnimating(true);
|
||||
if (measureRef.current) {
|
||||
const contentHeight = measureRef.current.scrollHeight;
|
||||
setHeight(`${contentHeight}px`);
|
||||
// Force a reflow before animating to 0
|
||||
requestAnimationFrame(() => {
|
||||
// Read layout to ensure the previous height assignment is flushed
|
||||
if (containerRef.current) {
|
||||
containerRef.current.offsetHeight;
|
||||
}
|
||||
// Use a second frame to ensure the measured pixel height is applied before collapsing
|
||||
requestAnimationFrame(() => {
|
||||
setHeight("0px");
|
||||
});
|
||||
});
|
||||
}
|
||||
// Hide content after animation completes
|
||||
setTimeout(() => {
|
||||
setShouldRender(false);
|
||||
setIsAnimating(false);
|
||||
if (onFinish) {
|
||||
onFinish();
|
||||
}
|
||||
}, duration * 1000);
|
||||
}
|
||||
}, [visible, shouldRender]);
|
||||
|
||||
return (visible || shouldRender || isAnimating) && (
|
||||
<div
|
||||
{...props}
|
||||
ref={containerRef}
|
||||
style={{
|
||||
height,
|
||||
transition: `height ${duration}s ease`,
|
||||
overflow: "hidden",
|
||||
...props.style
|
||||
}}
|
||||
>
|
||||
<div ref={measureRef} style={{ height: "auto" }}>
|
||||
{shouldRender && children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AnimatedPropertyProps } from "./types";
|
||||
|
||||
export default function AnimatedOpacity({ visible, duration = 0.5, onFinish, children, ...props }: AnimatedPropertyProps) {
|
||||
const [opacity, setOpacity] = useState(visible ? 1 : 0);
|
||||
const [shouldRender, setShouldRender] = useState(visible);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setShouldRender(true);
|
||||
setOpacity(0);
|
||||
|
||||
// Wait for content to render, then animate in
|
||||
const id = setTimeout(() => {
|
||||
setOpacity(1);
|
||||
}, 10);
|
||||
return () => clearTimeout(id);
|
||||
} else {
|
||||
setOpacity(0);
|
||||
|
||||
const id = setTimeout(() => {
|
||||
setShouldRender(false);
|
||||
if (onFinish) {
|
||||
onFinish();
|
||||
}
|
||||
}, duration * 1000);
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
}, [visible, duration, onFinish]);
|
||||
|
||||
return shouldRender && (
|
||||
<div
|
||||
{...props}
|
||||
style={{
|
||||
opacity,
|
||||
transition: `opacity ${duration}s ease`,
|
||||
...props.style
|
||||
}}
|
||||
>{children}</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface BaseAnimatedPropertyProps {
|
||||
visible: any;
|
||||
duration?: number;
|
||||
onFinish?: () => void
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export type AnimatedPropertyProps = BaseAnimatedPropertyProps & React.ComponentPropsWithRef<"div">
|
||||
@@ -0,0 +1,19 @@
|
||||
export function CropperDialog() {
|
||||
return (
|
||||
<mdui-dialog id="cropper-dialog" close-on-overlay-click close-on-esc>
|
||||
<div className="cropper-dialog-content">
|
||||
<div className="cropper-header">
|
||||
<h3>Обрезать фото профиля</h3>
|
||||
<mdui-button-icon icon="close" id="cropper-close"></mdui-button-icon>
|
||||
</div>
|
||||
<div className="cropper-container">
|
||||
<div id="cropper-area"></div>
|
||||
</div>
|
||||
<div className="cropper-actions">
|
||||
<mdui-button id="crop-cancel" variant="outlined">Отмена</mdui-button>
|
||||
<mdui-button id="crop-save">Сохранить</mdui-button>
|
||||
</div>
|
||||
</div>
|
||||
</mdui-dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Size2D, Rect } from "../../../core/types";
|
||||
|
||||
interface ImageCropperProps {
|
||||
onCrop: (croppedImageData: string) => void;
|
||||
onCancel: () => void;
|
||||
imageFile: File | null;
|
||||
}
|
||||
|
||||
export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const [src, setSrc] = useState<string | undefined>(undefined);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [cropArea, setCropArea] = useState<Rect>({ x: 0, y: 0, width: 200, height: 200 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState<Size2D>({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (imageFile) {
|
||||
const reader = new FileReader();
|
||||
|
||||
function handleImageLoad() {
|
||||
setIsLoaded(true);
|
||||
// Initialize crop area to center of image
|
||||
const img = imageRef.current;
|
||||
if (img) {
|
||||
const size = Math.min(img.naturalWidth, img.naturalHeight) * 0.8;
|
||||
setCropArea({
|
||||
x: (img.naturalWidth - size) / 2,
|
||||
y: (img.naturalHeight - size) / 2,
|
||||
width: size,
|
||||
height: size
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleReaderLoad() {
|
||||
if (imageRef.current) {
|
||||
setSrc(reader.result as string);
|
||||
imageRef.current.addEventListener("load", handleImageLoad);
|
||||
}
|
||||
}
|
||||
|
||||
reader.addEventListener("load", handleReaderLoad);
|
||||
reader.readAsDataURL(imageFile);
|
||||
|
||||
return () => {
|
||||
reader.abort();
|
||||
reader.removeEventListener("load", handleReaderLoad);
|
||||
imageRef.current?.removeEventListener("load", handleImageLoad);
|
||||
}
|
||||
}
|
||||
}, [imageFile]);
|
||||
|
||||
function handleMouseDown(e: React.MouseEvent) {
|
||||
if (!isLoaded) return;
|
||||
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
// Check if click is within crop area
|
||||
if (x >= cropArea.x && x <= cropArea.x + cropArea.width &&
|
||||
y >= cropArea.y && y <= cropArea.y + cropArea.height) {
|
||||
setIsDragging(true);
|
||||
setDragStart({ x: x - cropArea.x, y: y - cropArea.y });
|
||||
}
|
||||
};
|
||||
|
||||
function handleMouseMove(e: React.MouseEvent) {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (isDragging && isLoaded && rect && imageRef.current) {
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
const newX = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
x - dragStart.x,
|
||||
imageRef.current.naturalWidth - cropArea.width
|
||||
)
|
||||
);
|
||||
const newY = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
y - dragStart.y,
|
||||
imageRef.current.naturalHeight - cropArea.height
|
||||
)
|
||||
);
|
||||
|
||||
setCropArea(prev => ({ ...prev, x: newX, y: newY }));
|
||||
}
|
||||
};
|
||||
|
||||
function handleMouseUp() {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
function handleCrop() {
|
||||
if (!canvasRef.current || !imageRef.current || !isLoaded) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas size to crop area
|
||||
canvas.width = cropArea.width;
|
||||
canvas.height = cropArea.height;
|
||||
|
||||
// Draw cropped portion
|
||||
ctx.drawImage(
|
||||
imageRef.current,
|
||||
cropArea.x, cropArea.y, cropArea.width, cropArea.height,
|
||||
0, 0, cropArea.width, cropArea.height
|
||||
);
|
||||
|
||||
// Convert to data URL
|
||||
const croppedImageData = canvas.toDataURL('image/jpeg', 0.9);
|
||||
onCrop(croppedImageData);
|
||||
};
|
||||
|
||||
function drawCropArea() {
|
||||
if (!canvasRef.current || !imageRef.current || !isLoaded) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw image
|
||||
ctx.drawImage(imageRef.current, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw crop overlay
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Clear crop area
|
||||
ctx.globalCompositeOperation = 'destination-out';
|
||||
ctx.fillRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height);
|
||||
|
||||
// Draw crop border
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
drawCropArea();
|
||||
}, [cropArea, isLoaded]);
|
||||
|
||||
if (!imageFile) return null;
|
||||
|
||||
return (
|
||||
<div className="cropper-container">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={400}
|
||||
height={400}
|
||||
style={{
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
border: '1px solid #ccc',
|
||||
maxWidth: '100%',
|
||||
height: 'auto'
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
/>
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={src}
|
||||
style={{ display: 'none' }}
|
||||
alt="Crop source"
|
||||
/>
|
||||
<div className="cropper-actions">
|
||||
<mdui-button onClick={handleCrop} disabled={!isLoaded}>
|
||||
Обрезать
|
||||
</mdui-button>
|
||||
<mdui-button variant="outlined" onClick={onCancel}>
|
||||
Отмена
|
||||
</mdui-button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useState, useEffect, useRef, type FormEvent } from "react";
|
||||
import defaultAvatar from "../../../../../images/default-avatar.png";
|
||||
import type { TextField } from "mdui/components/text-field";
|
||||
import type { DialogProps } from "../../../core/types";
|
||||
import { MaterialDialog } from "../core/Dialog";
|
||||
import useProfile from "../../hooks/useProfile";
|
||||
import { ImageCropper } from "./ImageCropper";
|
||||
import { MaterialTextField } from "../core/TextField";
|
||||
|
||||
export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
|
||||
const { profileData, isLoading, isUpdating, updateProfileData, uploadProfilePictureData } = useProfile();
|
||||
|
||||
const [username, setUsername] = useState(profileData?.nickname ?? "");
|
||||
const [description, setDescription] = useState(profileData?.description ?? "");
|
||||
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||
const [showCropper, setShowCropper] = useState(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Update form fields when profile data changes
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
setUsername(profileData.nickname || "");
|
||||
setDescription(profileData.description || "");
|
||||
}
|
||||
}, [profileData]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const success = await updateProfileData({
|
||||
nickname: username.trim() || undefined,
|
||||
description: description.trim() || undefined
|
||||
});
|
||||
|
||||
if (success) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
setSelectedImage(file);
|
||||
setShowCropper(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCropComplete = async (croppedImageData: string) => {
|
||||
try {
|
||||
// Convert data URL to blob
|
||||
const response = await fetch(croppedImageData);
|
||||
const blob = await response.blob();
|
||||
|
||||
const success = await uploadProfilePictureData(blob);
|
||||
if (success) {
|
||||
setShowCropper(false);
|
||||
setSelectedImage(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing cropped image:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCropCancel = () => {
|
||||
setShowCropper(false);
|
||||
setSelectedImage(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const profilePictureUrl = profileData?.profile_picture || defaultAvatar;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MaterialDialog id="profile-dialog" close-on-overlay-click close-on-esc open={isOpen} onOpenChange={onOpenChange}>
|
||||
<div className="content">
|
||||
<div className="header-top">
|
||||
<div className="profile-picture-container">
|
||||
<img
|
||||
id="profile-picture"
|
||||
src={profilePictureUrl}
|
||||
alt="Ваше фото"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
<mdui-button-icon
|
||||
icon="camera_alt--filled"
|
||||
id="upload-pfp-btn"
|
||||
className="upload-overlay"
|
||||
variant="filled"
|
||||
onClick={handleUploadClick}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
id="pfp-file-input"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleImageSelect}
|
||||
/>
|
||||
</div>
|
||||
<MaterialTextField
|
||||
id="username-field"
|
||||
label="Имя пользователя"
|
||||
variant="outlined"
|
||||
value={username}
|
||||
onChange={(e: FormEvent<HTMLElement & TextField>) => setUsername((e.target as TextField).value)}
|
||||
autocomplete="username"
|
||||
disabled={isLoading || isUpdating} />
|
||||
</div>
|
||||
|
||||
<form id="profile-form" onSubmit={handleSubmit}>
|
||||
<MaterialTextField
|
||||
id="description-field"
|
||||
label="О себе"
|
||||
variant="outlined"
|
||||
value={description}
|
||||
onChange={(e: FormEvent<HTMLElement & TextField>) => setDescription((e.target as TextField).value)}
|
||||
placeholder="Расскажите о себе..."
|
||||
autocomplete="none"
|
||||
disabled={isLoading || isUpdating} />
|
||||
<div className="dialog-actions">
|
||||
<mdui-button
|
||||
type="submit"
|
||||
id="profile-submit"
|
||||
disabled={isLoading || isUpdating}
|
||||
>
|
||||
{isUpdating ? "Сохранение..." : "Сохранить изменения"}
|
||||
</mdui-button>
|
||||
<mdui-button
|
||||
id="profile-dialog-close"
|
||||
variant="outlined"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
Закрыть
|
||||
</mdui-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</MaterialDialog>
|
||||
|
||||
{/* Image Cropper Dialog */}
|
||||
<MaterialDialog
|
||||
id="cropper-dialog"
|
||||
close-on-overlay-click
|
||||
close-on-esc
|
||||
open={showCropper}
|
||||
onOpenChange={setShowCropper}
|
||||
>
|
||||
<div className="cropper-dialog-content">
|
||||
<div className="cropper-header">
|
||||
<h3>Обрезать фото профиля</h3>
|
||||
<mdui-button-icon icon="close" onClick={handleCropCancel} />
|
||||
</div>
|
||||
<div className="cropper-container">
|
||||
<ImageCropper
|
||||
imageFile={selectedImage}
|
||||
onCrop={handleCropComplete}
|
||||
onCancel={handleCropCancel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MaterialDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { PRODUCT_NAME, API_BASE_URL } from "../../../core/config";
|
||||
import type { DialogProps } from "../../../core/types";
|
||||
import { MaterialDialog } from "../core/Dialog";
|
||||
import { initialize, isSupported, startElectronReceiver, stopElectronReceiver, subscribe, unsubscribe } from "../../../utils/push-notifications";
|
||||
import { isElectron } from "../../../electron/electron";
|
||||
import { useAppState } from "../../state";
|
||||
import type { Switch } from "mdui/components/switch";
|
||||
import { getAuthHeaders } from "../../../../../api/authApi";
|
||||
|
||||
export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
|
||||
const [activePanel, setActivePanel] = useState("notifications-settings");
|
||||
const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(false);
|
||||
const [pushSupported, setPushSupported] = useState(false);
|
||||
const user = useAppState(state => state.user);
|
||||
|
||||
useEffect(() => {
|
||||
setPushSupported(isSupported());
|
||||
// For Electron, we assume notifications are enabled if supported
|
||||
// For web browsers, we check if there's a subscription
|
||||
setPushNotificationsEnabled(isSupported());
|
||||
}, []);
|
||||
|
||||
const handlePanelChange = (panelId: string) => {
|
||||
setActivePanel(panelId);
|
||||
};
|
||||
|
||||
const handlePushNotificationToggle = async (enabled: boolean) => {
|
||||
if (!user.authToken) return;
|
||||
|
||||
try {
|
||||
if (enabled) {
|
||||
const initialized = await initialize();
|
||||
if (initialized) {
|
||||
await subscribe(user.authToken);
|
||||
|
||||
// For Electron, start the notification receiver
|
||||
if (isElectron) {
|
||||
await startElectronReceiver();
|
||||
}
|
||||
|
||||
setPushNotificationsEnabled(true);
|
||||
}
|
||||
} else {
|
||||
await unsubscribe();
|
||||
|
||||
// For Electron, stop the notification receiver
|
||||
if (isElectron) {
|
||||
stopElectronReceiver();
|
||||
}
|
||||
|
||||
// Call API to unsubscribe on server (for web browsers)
|
||||
await fetch(`${API_BASE_URL}/push/unsubscribe`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(user.authToken)
|
||||
});
|
||||
setPushNotificationsEnabled(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle notifications:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MaterialDialog close-on-overlay-click close-on-esc fullscreen open={isOpen} onOpenChange={onOpenChange} id="settings-dialog">
|
||||
<div className="fullscreen-wrapper">
|
||||
<div id="settings-dialog-inner">
|
||||
<div className="header">
|
||||
<mdui-button-icon icon="close" id="settings-close" onClick={() => onOpenChange(false)}></mdui-button-icon>
|
||||
<mdui-top-app-bar-title>Настройки</mdui-top-app-bar-title>
|
||||
</div>
|
||||
<div id="settings-menu">
|
||||
<mdui-list>
|
||||
<mdui-list-item
|
||||
icon="notifications--filled"
|
||||
rounded
|
||||
active={activePanel === "notifications-settings"}
|
||||
onClick={() => handlePanelChange("notifications-settings")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Уведомления
|
||||
</mdui-list-item>
|
||||
<mdui-list-item
|
||||
icon="palette--filled"
|
||||
rounded
|
||||
active={activePanel === "appearance-settings"}
|
||||
onClick={() => handlePanelChange("appearance-settings")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Внешний вид
|
||||
</mdui-list-item>
|
||||
<mdui-list-item
|
||||
icon="security--filled"
|
||||
rounded
|
||||
active={activePanel === "security-settings"}
|
||||
onClick={() => handlePanelChange("security-settings")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Безопасность
|
||||
</mdui-list-item>
|
||||
<mdui-list-item
|
||||
icon="language--filled"
|
||||
rounded
|
||||
active={activePanel === "language-settings"}
|
||||
onClick={() => handlePanelChange("language-settings")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Язык
|
||||
</mdui-list-item>
|
||||
<mdui-list-item
|
||||
icon="storage--filled"
|
||||
rounded
|
||||
active={activePanel === "storage-settings"}
|
||||
onClick={() => handlePanelChange("storage-settings")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Хранилище
|
||||
</mdui-list-item>
|
||||
<mdui-list-item
|
||||
icon="help--filled"
|
||||
rounded
|
||||
active={activePanel === "help-settings"}
|
||||
onClick={() => handlePanelChange("help-settings")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Помощь
|
||||
</mdui-list-item>
|
||||
<mdui-list-item
|
||||
icon="info--filled"
|
||||
rounded
|
||||
active={activePanel === "about-settings"}
|
||||
onClick={() => handlePanelChange("about-settings")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
О приложении
|
||||
</mdui-list-item>
|
||||
</mdui-list>
|
||||
<div className="screen">
|
||||
<div id="notifications-settings" className={`settings-panel ${activePanel === "notifications-settings" ? "active" : ""}`}>
|
||||
<h3>Уведомления</h3>
|
||||
{pushSupported && (
|
||||
<mdui-switch
|
||||
checked={pushNotificationsEnabled}
|
||||
onInput={(e) => handlePushNotificationToggle((e.target as Switch).checked)}
|
||||
>
|
||||
Push уведомления
|
||||
</mdui-switch>
|
||||
)}
|
||||
<mdui-switch checked>Новые сообщения</mdui-switch>
|
||||
<mdui-switch checked>Звуковые уведомления</mdui-switch>
|
||||
<mdui-switch>Уведомления о статусе</mdui-switch>
|
||||
<mdui-switch checked>Email уведомления</mdui-switch>
|
||||
</div>
|
||||
|
||||
<div id="appearance-settings" className={`settings-panel ${activePanel === "appearance-settings" ? "active" : ""}`}>
|
||||
<h3>Внешний вид</h3>
|
||||
<mdui-select label="Тема" variant="outlined">
|
||||
<mdui-menu-item value="dark">Тёмная</mdui-menu-item>
|
||||
<mdui-menu-item value="light">Светлая</mdui-menu-item>
|
||||
<mdui-menu-item value="auto">Авто</mdui-menu-item>
|
||||
</mdui-select>
|
||||
<mdui-select label="Размер шрифта" variant="outlined">
|
||||
<mdui-menu-item value="small">Маленький</mdui-menu-item>
|
||||
<mdui-menu-item value="medium">Средний</mdui-menu-item>
|
||||
<mdui-menu-item value="large">Большой</mdui-menu-item>
|
||||
</mdui-select>
|
||||
</div>
|
||||
|
||||
<div id="security-settings" className={`settings-panel ${activePanel === "security-settings" ? "active" : ""}`}>
|
||||
<h3>Безопасность</h3>
|
||||
<mdui-button variant="outlined">Изменить пароль</mdui-button>
|
||||
<mdui-button variant="outlined">Двухфакторная аутентификация</mdui-button>
|
||||
<mdui-switch>Автоматический выход</mdui-switch>
|
||||
</div>
|
||||
|
||||
<div id="language-settings" className={`settings-panel ${activePanel === "language-settings" ? "active" : ""}`}>
|
||||
<h3>Язык</h3>
|
||||
<mdui-select label="Выберите язык" variant="outlined">
|
||||
<mdui-menu-item value="ru">Русский</mdui-menu-item>
|
||||
<mdui-menu-item value="en">English</mdui-menu-item>
|
||||
<mdui-menu-item value="es">Español</mdui-menu-item>
|
||||
</mdui-select>
|
||||
</div>
|
||||
|
||||
<div id="storage-settings" className={`settings-panel ${activePanel === "storage-settings" ? "active" : ""}`}>
|
||||
<h3>Хранилище</h3>
|
||||
<p>Использовано: 2.5 ГБ из 10 ГБ</p>
|
||||
<mdui-linear-progress value={25}></mdui-linear-progress>
|
||||
<mdui-button variant="outlined">Очистить кэш</mdui-button>
|
||||
</div>
|
||||
|
||||
<div id="help-settings" className={`settings-panel ${activePanel === "help-settings" ? "active" : ""}`}>
|
||||
<h3>Помощь</h3>
|
||||
<mdui-button variant="outlined">Руководство пользователя</mdui-button>
|
||||
<mdui-button variant="outlined">Связаться с поддержкой</mdui-button>
|
||||
<mdui-button variant="outlined">FAQ</mdui-button>
|
||||
</div>
|
||||
|
||||
<div id="about-settings" className={`settings-panel ${activePanel === "about-settings" ? "active" : ""}`}>
|
||||
<h3>О приложении</h3>
|
||||
<p>Версия: 1.0.0</p>
|
||||
<p>© 2025 <span className="product-name">{PRODUCT_NAME}</span>. Все права защищены.</p>
|
||||
<mdui-button variant="outlined">Политика конфиденциальности</mdui-button>
|
||||
<mdui-button variant="outlined">Условия использования</mdui-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MaterialDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useRef, useCallback, type RefCallback, type Ref } from 'react';
|
||||
|
||||
// Определяем тип для ref, который может быть либо функцией, либо объектом
|
||||
type PossibleRef<T> = Ref<T> | undefined;
|
||||
|
||||
export default function useCombinedRefs<T>(...refs: PossibleRef<T>[]): [RefCallback<T>, React.RefObject<T | null>] {
|
||||
const targetRef = useRef<T | null>(null);
|
||||
|
||||
const setRefs = useCallback((node: T | null) => {
|
||||
// Обновляем внутренний ref
|
||||
targetRef.current = node;
|
||||
|
||||
// Обновляем все переданные refs
|
||||
refs.forEach((ref) => {
|
||||
if (!ref) return;
|
||||
|
||||
if (typeof ref === 'function') {
|
||||
// Если ref - это функция, вызываем её
|
||||
ref(node);
|
||||
} else {
|
||||
// Если ref - это объект, обновляем его свойство .current
|
||||
// Используем проверку, чтобы убедиться, что это действительно MutableRefObject
|
||||
// (хотя в реальном коде это почти всегда так)
|
||||
ref.current = node;
|
||||
}
|
||||
});
|
||||
},
|
||||
// Убедитесь, что массив зависимостей всегда актуален
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[...refs]
|
||||
);
|
||||
|
||||
return [setRefs, targetRef];
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useAppState } from "../state";
|
||||
import {
|
||||
fetchUsers,
|
||||
fetchUserPublicKey,
|
||||
fetchDMHistory,
|
||||
decryptDm,
|
||||
sendDMViaWebSocket
|
||||
} from "../../../../api/dmApi";
|
||||
import type { User, Message, DmEncryptedJSON } from "../../core/types";
|
||||
import { websocket } from "../../core/websocket";
|
||||
|
||||
export interface DMUser extends User {
|
||||
lastMessage?: string;
|
||||
unreadCount: number;
|
||||
publicKey?: string | null;
|
||||
}
|
||||
|
||||
export function useDM() {
|
||||
const { user, chat, setDmUsers, setActiveDm, addMessage, clearMessages } = useAppState();
|
||||
const [dmUsers, setDmUsersState] = useState<DMUser[]>([]);
|
||||
const [isLoadingUsers, setIsLoadingUsers] = useState(false);
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
|
||||
const usersLoadedRef = useRef(false);
|
||||
|
||||
// Load last message and unread count for a specific user
|
||||
const loadUserLastMessage = useCallback(async (dmUser: DMUser) => {
|
||||
if (!user.authToken) return;
|
||||
|
||||
try {
|
||||
// Get public key
|
||||
const publicKey = await fetchUserPublicKey(dmUser.id, user.authToken);
|
||||
if (!publicKey) return;
|
||||
|
||||
// Get message history
|
||||
const messages = await fetchDMHistory(dmUser.id, user.authToken, 50);
|
||||
if (messages.length === 0) return;
|
||||
|
||||
// Find last message
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
let lastPlaintext: string | null = null;
|
||||
|
||||
try {
|
||||
lastPlaintext = (JSON.parse(await decryptDm(lastMessage, publicKey)) as DmEncryptedJSON).data.content;
|
||||
console.log(lastPlaintext);
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt last message:", error);
|
||||
}
|
||||
|
||||
// Calculate unread count
|
||||
const lastReadId = getLastReadId(dmUser.id);
|
||||
let unreadCount = 0;
|
||||
for (const msg of messages) {
|
||||
if (msg.senderId === dmUser.id && msg.id > lastReadId) {
|
||||
unreadCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update user state
|
||||
setDmUsersState(prev => prev.map(u =>
|
||||
u.id === dmUser.id
|
||||
? {
|
||||
...u,
|
||||
lastMessage: lastPlaintext ? lastPlaintext.split(/\r?\n/).slice(0, 2).join("\n") : undefined,
|
||||
unreadCount,
|
||||
publicKey
|
||||
}
|
||||
: u
|
||||
));
|
||||
} catch (error) {
|
||||
console.error("Failed to load last message for user:", dmUser.id, error);
|
||||
}
|
||||
}, [user.authToken]);
|
||||
|
||||
// Load users when DM tab is active
|
||||
const loadUsers = useCallback(async () => {
|
||||
if (!user.authToken || isLoadingUsers || usersLoadedRef.current) return;
|
||||
|
||||
usersLoadedRef.current = true;
|
||||
setIsLoadingUsers(true);
|
||||
try {
|
||||
const users = await fetchUsers(user.authToken);
|
||||
console.log("Fetched users:", users);
|
||||
const dmUsersWithState: DMUser[] = users.map(user => ({
|
||||
...user,
|
||||
unreadCount: 0,
|
||||
lastMessage: undefined,
|
||||
publicKey: null
|
||||
}));
|
||||
|
||||
setDmUsersState(dmUsersWithState);
|
||||
setDmUsers(users);
|
||||
|
||||
// Load last messages and unread counts for visible users
|
||||
// Call loadUserLastMessage directly without dependency
|
||||
for (const dmUser of dmUsersWithState) {
|
||||
await loadUserLastMessage(dmUser);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load DM users:", error);
|
||||
} finally {
|
||||
setIsLoadingUsers(false);
|
||||
}
|
||||
}, [user.authToken, isLoadingUsers]);
|
||||
|
||||
// Reset users loaded flag when user changes
|
||||
useEffect(() => {
|
||||
usersLoadedRef.current = false;
|
||||
}, [user.authToken]);
|
||||
|
||||
// Load DM history for active conversation
|
||||
const loadDMHistory = useCallback(async (userId: number, publicKey: string) => {
|
||||
if (!user.authToken || isLoadingHistory) return;
|
||||
|
||||
setIsLoadingHistory(true);
|
||||
try {
|
||||
const messages = await fetchDMHistory(userId, user.authToken, 50);
|
||||
const decryptedMessages: Message[] = [];
|
||||
let maxIncomingId = 0;
|
||||
|
||||
for (const env of messages) {
|
||||
try {
|
||||
const text = await decryptDm(env, publicKey);
|
||||
const isAuthor = env.senderId !== userId;
|
||||
const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User";
|
||||
|
||||
decryptedMessages.push({
|
||||
id: env.id,
|
||||
content: text,
|
||||
username: username,
|
||||
timestamp: env.timestamp,
|
||||
is_read: false,
|
||||
is_edited: false
|
||||
});
|
||||
|
||||
if (env.senderId === userId && env.id > maxIncomingId) {
|
||||
maxIncomingId = env.id;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error decrypting message:", error);
|
||||
}
|
||||
}
|
||||
|
||||
clearMessages();
|
||||
decryptedMessages.forEach(msg => addMessage(msg));
|
||||
|
||||
// Update last read ID
|
||||
if (maxIncomingId > 0) {
|
||||
setLastReadId(userId, maxIncomingId);
|
||||
// Clear unread count
|
||||
setDmUsersState(prev => prev.map(u =>
|
||||
u.id === userId ? { ...u, unreadCount: 0 } : u
|
||||
));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load DM history:", error);
|
||||
} finally {
|
||||
setIsLoadingHistory(false);
|
||||
}
|
||||
}, [user.authToken, user.currentUser, isLoadingHistory, clearMessages, addMessage]);
|
||||
|
||||
// Send DM message
|
||||
const sendDMMessage = useCallback(async (recipientId: number, publicKey: string, content: string) => {
|
||||
if (!user.authToken) return;
|
||||
|
||||
try {
|
||||
await sendDMViaWebSocket(recipientId, publicKey, content, user.authToken);
|
||||
} catch (error) {
|
||||
console.error("Failed to send DM:", error);
|
||||
}
|
||||
}, [user.authToken]);
|
||||
|
||||
// Start DM conversation
|
||||
const startDMConversation = useCallback(async (dmUser: DMUser) => {
|
||||
if (!user.authToken) return;
|
||||
|
||||
try {
|
||||
// Get public key if not already loaded
|
||||
let publicKey = dmUser.publicKey;
|
||||
if (!publicKey) {
|
||||
publicKey = await fetchUserPublicKey(dmUser.id, user.authToken);
|
||||
if (!publicKey) return;
|
||||
}
|
||||
|
||||
// Set active DM
|
||||
setActiveDm({
|
||||
userId: dmUser.id,
|
||||
username: dmUser.username,
|
||||
publicKey
|
||||
});
|
||||
|
||||
// Load conversation history
|
||||
await loadDMHistory(dmUser.id, publicKey);
|
||||
} catch (error) {
|
||||
console.error("Failed to start DM conversation:", error);
|
||||
}
|
||||
}, [user.authToken, setActiveDm, loadDMHistory]);
|
||||
|
||||
// WebSocket message handler
|
||||
useEffect(() => {
|
||||
async function handleWebSocketMessage(e: MessageEvent) {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === "dmNew") {
|
||||
const { senderId, recipientId, ...envelope } = msg.data;
|
||||
|
||||
// If this is for the active DM conversation
|
||||
if (chat.activeDm && (senderId === chat.activeDm.userId || recipientId === chat.activeDm.userId)) {
|
||||
try {
|
||||
const plaintext = await decryptDm(envelope, chat.activeDm.publicKey!);
|
||||
const isAuthor = senderId !== chat.activeDm.userId;
|
||||
|
||||
addMessage({
|
||||
id: envelope.id,
|
||||
content: plaintext,
|
||||
username: isAuthor ? (user.currentUser?.username || "Unknown") : (chat.activeDm.username || "Unknown"),
|
||||
timestamp: envelope.timestamp,
|
||||
is_read: false,
|
||||
is_edited: false
|
||||
});
|
||||
|
||||
// Update last read if it's from the other user
|
||||
if (senderId === chat.activeDm.userId) {
|
||||
setLastReadId(chat.activeDm.userId, Math.max(getLastReadId(chat.activeDm.userId), envelope.id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt incoming DM:", error);
|
||||
}
|
||||
} else {
|
||||
// Update unread count for other users
|
||||
const otherUserId = senderId;
|
||||
setDmUsersState(prev => prev.map(u =>
|
||||
u.id === otherUserId
|
||||
? { ...u, unreadCount: u.unreadCount + 1 }
|
||||
: u
|
||||
));
|
||||
|
||||
// Update last message preview
|
||||
try {
|
||||
const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!);
|
||||
if (publicKey) {
|
||||
const plaintext = await decryptDm(envelope, publicKey);
|
||||
setDmUsersState(prev => prev.map(u =>
|
||||
u.id === otherUserId
|
||||
? {
|
||||
...u,
|
||||
lastMessage: plaintext.split(/\r?\n/).slice(0, 2).join("\n"),
|
||||
publicKey
|
||||
}
|
||||
: u
|
||||
));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to update last message preview:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to handle WebSocket message:", error);
|
||||
}
|
||||
}
|
||||
|
||||
websocket.addEventListener("message", handleWebSocketMessage);
|
||||
|
||||
return () => websocket.removeEventListener("message", handleWebSocketMessage);
|
||||
}, [chat.activeDm, user.currentUser, addMessage]);
|
||||
|
||||
// Force reload users (useful for refreshing the list)
|
||||
const reloadUsers = useCallback(() => {
|
||||
usersLoadedRef.current = false;
|
||||
loadUsers();
|
||||
}, [loadUsers]);
|
||||
|
||||
return {
|
||||
dmUsers,
|
||||
isLoadingUsers,
|
||||
isLoadingHistory,
|
||||
loadUsers,
|
||||
reloadUsers,
|
||||
startDMConversation,
|
||||
sendDMMessage,
|
||||
loadUserLastMessage
|
||||
};
|
||||
}
|
||||
|
||||
// Helper functions for localStorage
|
||||
function getLastReadId(userId: number): number {
|
||||
try {
|
||||
const v = localStorage.getItem(`dmLastRead:${userId}`);
|
||||
return v ? Number(v) : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function setLastReadId(userId: number, id: number): void {
|
||||
try {
|
||||
localStorage.setItem(`dmLastRead:${userId}`, String(id));
|
||||
} catch {}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useAppState } from "../state";
|
||||
import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "../../../../api/profileApi";
|
||||
import { showSuccess, showError } from "../../utils/notification";
|
||||
|
||||
export default function useProfile() {
|
||||
const { user } = useAppState();
|
||||
const [profileData, setProfileData] = useState<ProfileData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
// Load profile data
|
||||
const loadProfileData = useCallback(async () => {
|
||||
if (!user.authToken) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await loadProfile(user.authToken);
|
||||
if (data) {
|
||||
setProfileData(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading profile:', error);
|
||||
showError('Ошибка при загрузке профиля');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [user.authToken]);
|
||||
|
||||
// Update profile
|
||||
const updateProfileData = useCallback(async (data: Partial<ProfileData>) => {
|
||||
if (!user.authToken) return false;
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const success = await updateProfile(user.authToken, data);
|
||||
if (success) {
|
||||
// Reload profile data to get updated information
|
||||
await loadProfileData();
|
||||
showSuccess('Профиль обновлен!');
|
||||
return true;
|
||||
} else {
|
||||
showError('Ошибка при обновлении профиля');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
showError('Ошибка при обновлении профиля');
|
||||
return false;
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [user.authToken, loadProfileData]);
|
||||
|
||||
// Upload profile picture
|
||||
const uploadProfilePictureData = useCallback(async (file: Blob) => {
|
||||
if (!user.authToken) return false;
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const result = await uploadProfilePicture(user.authToken, file);
|
||||
if (result) {
|
||||
// Update profile data with new picture URL
|
||||
setProfileData(prev => prev ? {
|
||||
...prev,
|
||||
profile_picture: result.profile_picture_url
|
||||
} : null);
|
||||
showSuccess('Фото профиля обновлено!');
|
||||
return true;
|
||||
} else {
|
||||
showError('Ошибка при загрузке фото');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading profile picture:', error);
|
||||
showError('Ошибка при загрузке фото');
|
||||
return false;
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [user.authToken]);
|
||||
|
||||
// Load profile data when user is authenticated
|
||||
useEffect(() => {
|
||||
if (user.authToken) {
|
||||
loadProfileData();
|
||||
}
|
||||
}, [user.authToken, loadProfileData]);
|
||||
|
||||
return {
|
||||
profileData,
|
||||
isLoading,
|
||||
isUpdating,
|
||||
loadProfileData,
|
||||
updateProfileData,
|
||||
uploadProfilePictureData
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface WindowSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export default function useWindowSize(): WindowSize {
|
||||
const [width, setWidth] = useState(innerWidth);
|
||||
const [height, setHeight] = useState(innerHeight);
|
||||
|
||||
useEffect(() => {
|
||||
function listener() {
|
||||
setWidth(innerWidth);
|
||||
setHeight(innerHeight);
|
||||
}
|
||||
|
||||
addEventListener("resize", listener);
|
||||
|
||||
return () => {
|
||||
removeEventListener("resize", listener);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
width: width,
|
||||
height: height
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { MessagePanel } from "./MessagePanel";
|
||||
import {
|
||||
fetchDMHistory,
|
||||
decryptDm,
|
||||
sendDMViaWebSocket,
|
||||
sendDmWithFiles,
|
||||
editDmEnvelope,
|
||||
deleteDmEnvelope
|
||||
} from "../../../../api/dmApi";
|
||||
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "../../core/types";
|
||||
import type { UserState } from "../state";
|
||||
|
||||
export interface DMPanelData {
|
||||
userId: number;
|
||||
username: string;
|
||||
publicKey: string;
|
||||
profilePicture?: string;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
export class DMPanel extends MessagePanel {
|
||||
public dmData: DMPanelData | null = null;
|
||||
private messagesLoaded: boolean = false;
|
||||
|
||||
constructor(
|
||||
user: UserState
|
||||
) {
|
||||
super("dm", user);
|
||||
}
|
||||
|
||||
isDm(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async activate(): Promise<void> {
|
||||
// Don't load messages immediately during activation to prevent animation freeze
|
||||
// Messages will be loaded after the animation completes
|
||||
}
|
||||
|
||||
deactivate(): void {
|
||||
// DM doesn't need special cleanup
|
||||
}
|
||||
|
||||
clearMessages(): void {
|
||||
super.clearMessages();
|
||||
this.messagesLoaded = false;
|
||||
}
|
||||
|
||||
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
|
||||
const plaintext = await decryptDm(env, this.dmData!.publicKey);
|
||||
const isAuthor = env.senderId !== this.dmData!.userId;
|
||||
const username = isAuthor ? this.currentUser.currentUser?.username ?? "You" : this.dmData!.username;
|
||||
|
||||
// Try parse JSON payload { type: "text", data: { content, files?, reply_to_id? } }
|
||||
let content = plaintext;
|
||||
let reply_to_id: number | undefined = undefined;
|
||||
try {
|
||||
const obj = JSON.parse(plaintext) as DmEncryptedJSON;
|
||||
if (obj && obj.type === "text" && obj.data) {
|
||||
content = obj.data.content;
|
||||
reply_to_id = Number(obj.data.reply_to_id) || undefined;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const dmMsg: Message = {
|
||||
id: env.id,
|
||||
content: content,
|
||||
username: username,
|
||||
timestamp: env.timestamp,
|
||||
is_read: false,
|
||||
is_edited: false,
|
||||
files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [],
|
||||
reactions: env.reactions || [],
|
||||
|
||||
runtimeData: {
|
||||
dmEnvelope: env
|
||||
}
|
||||
};
|
||||
|
||||
if (reply_to_id) {
|
||||
const referenced = decryptedMessages.find(m => m.id === reply_to_id);
|
||||
if (referenced) dmMsg.reply_to = referenced;
|
||||
}
|
||||
|
||||
return dmMsg;
|
||||
}
|
||||
|
||||
async loadMessages(): Promise<void> {
|
||||
if (!this.currentUser.authToken || !this.dmData || this.messagesLoaded) return;
|
||||
|
||||
this.setLoading(true);
|
||||
try {
|
||||
const messages = await fetchDMHistory(this.dmData.userId, this.currentUser.authToken, 50);
|
||||
const decryptedMessages: Message[] = [];
|
||||
let maxIncomingId = 0;
|
||||
|
||||
for (const env of messages) {
|
||||
try {
|
||||
const dmMsg = await this.parseTextPayload(env, decryptedMessages);
|
||||
decryptedMessages.push(dmMsg);
|
||||
|
||||
if (env.senderId === this.dmData!.userId && env.id > maxIncomingId) {
|
||||
maxIncomingId = env.id;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error decrypting message:", error);
|
||||
}
|
||||
}
|
||||
|
||||
this.clearMessages();
|
||||
decryptedMessages.forEach(msg => this.addMessage(msg));
|
||||
|
||||
// Update last read ID
|
||||
if (maxIncomingId > 0) {
|
||||
this.setLastReadId(this.dmData.userId, maxIncomingId);
|
||||
}
|
||||
this.messagesLoaded = true;
|
||||
} catch (error) {
|
||||
console.error("Failed to load DM history:", error);
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> {
|
||||
if (!this.currentUser.authToken || !this.dmData || !content.trim()) return;
|
||||
|
||||
try {
|
||||
const payload: DmEncryptedJSON = {
|
||||
type: "text",
|
||||
data: {
|
||||
content: content.trim(),
|
||||
reply_to_id: replyToId ?? undefined
|
||||
}
|
||||
}
|
||||
const json = JSON.stringify(payload);
|
||||
|
||||
if (files.length === 0) {
|
||||
await sendDMViaWebSocket(
|
||||
this.dmData.userId,
|
||||
this.dmData.publicKey,
|
||||
json,
|
||||
this.currentUser.authToken
|
||||
);
|
||||
} else {
|
||||
await sendDmWithFiles(
|
||||
this.dmData.userId,
|
||||
this.dmData.publicKey,
|
||||
json,
|
||||
files,
|
||||
this.currentUser.authToken
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to send DM:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Set DM conversation data
|
||||
setDMData(dmData: DMPanelData): void {
|
||||
this.dmData = dmData;
|
||||
this.messagesLoaded = false;
|
||||
this.updateState({
|
||||
id: `dm-${dmData.userId}`,
|
||||
title: dmData.username,
|
||||
profilePicture: dmData.profilePicture,
|
||||
online: dmData.online
|
||||
});
|
||||
}
|
||||
|
||||
// Handle incoming WebSocket DM messages
|
||||
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) {
|
||||
// This is our message being confirmed, find the temp message and replace it
|
||||
const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId);
|
||||
for (const tempMsg of tempMessages) {
|
||||
if (tempMsg.runtimeData?.sendingState?.retryData?.content === dmMsg.content) {
|
||||
this.handleMessageConfirmed(tempMsg.runtimeData.sendingState.tempId!, dmMsg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.addMessage(dmMsg);
|
||||
|
||||
// Update last read if it's from the other user
|
||||
if (envelope.senderId === this.dmData.userId) {
|
||||
this.setLastReadId(this.dmData.userId, Math.max(this.getLastReadId(this.dmData.userId), envelope.id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt incoming DM:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (response.type === "dmEdited" && this.dmData) {
|
||||
const { id, iv, ciphertext, salt, iv2, wrappedMk } = response.data;
|
||||
try {
|
||||
// Decrypt new content in-place
|
||||
const plaintext = await decryptDm(
|
||||
{
|
||||
id,
|
||||
senderId: 0,
|
||||
recipientId: 0,
|
||||
iv,
|
||||
ciphertext,
|
||||
salt,
|
||||
iv2,
|
||||
wrappedMk,
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
this.dmData.publicKey
|
||||
);
|
||||
let content = plaintext;
|
||||
let files: Message["files"] | undefined = undefined;
|
||||
try {
|
||||
const obj = JSON.parse(plaintext) as EncryptedMessageJson;
|
||||
if (obj.type === "text" && obj.data) {
|
||||
content = obj.data.content;
|
||||
files = obj.data.files;
|
||||
}
|
||||
} catch {}
|
||||
const updates: Partial<Message> = { content, is_edited: true, files };
|
||||
this.updateMessage(id, updates);
|
||||
} catch (e) {
|
||||
this.updateMessage(id, { is_edited: true });
|
||||
}
|
||||
}
|
||||
if (response.type === "dmDeleted" && this.dmData) {
|
||||
const { id } = response.data;
|
||||
this.removeMessage(id);
|
||||
}
|
||||
if (response.type === "dmReactionUpdate" && this.dmData) {
|
||||
const { dm_envelope_id, reactions } = response.data;
|
||||
this.updateMessageReactions(dm_envelope_id, reactions);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset for DM switching
|
||||
reset(): void {
|
||||
this.dmData = null;
|
||||
this.messagesLoaded = false;
|
||||
this.clearMessages();
|
||||
this.updateState({
|
||||
id: "dm",
|
||||
title: "Select a user",
|
||||
profilePicture: undefined,
|
||||
online: false
|
||||
});
|
||||
}
|
||||
|
||||
// Update auth token
|
||||
setAuthToken(authToken: string): void {
|
||||
this.currentUser.authToken = authToken;
|
||||
}
|
||||
|
||||
// Helper functions for localStorage
|
||||
private getLastReadId(userId: number): number {
|
||||
try {
|
||||
const v = localStorage.getItem(`dmLastRead:${userId}`);
|
||||
return v ? Number(v) : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private setLastReadId(userId: number, id: number): void {
|
||||
try {
|
||||
localStorage.setItem(`dmLastRead:${userId}`, String(id));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async handleEditMessage(messageId: number, content: string): Promise<void> {
|
||||
if (!this.currentUser.authToken || !this.dmData) return;
|
||||
const msg = this.getMessages().find(m => m.id === messageId);
|
||||
// Build encrypted JSON preserving files and reply_to if present
|
||||
const payload: EncryptedMessageJson = {
|
||||
type: "text",
|
||||
data: {
|
||||
content: content,
|
||||
files: msg?.files,
|
||||
reply_to_id: msg?.reply_to?.id ?? undefined
|
||||
}
|
||||
};
|
||||
editDmEnvelope(messageId, this.dmData.publicKey, JSON.stringify(payload), this.currentUser.authToken).catch((e) => {
|
||||
console.error("Failed to edit DM:", e);
|
||||
});
|
||||
}
|
||||
|
||||
handleProfileClick(): void {}
|
||||
|
||||
updateMessageReactions(dmEnvelopeId: number, reactions: any[]): void {
|
||||
const messages = this.getMessages();
|
||||
const messageIndex = messages.findIndex(msg =>
|
||||
msg.runtimeData?.dmEnvelope?.id === dmEnvelopeId
|
||||
);
|
||||
|
||||
if (messageIndex !== -1) {
|
||||
const updatedMessage = { ...messages[messageIndex] };
|
||||
updatedMessage.reactions = reactions;
|
||||
this.updateMessage(updatedMessage.id, { reactions: reactions });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import type { Message, WebSocketMessage } from "../../core/types";
|
||||
import type { UserState } from "../state";
|
||||
|
||||
export interface MessagePanelState {
|
||||
id: string;
|
||||
title: string;
|
||||
profilePicture?: string;
|
||||
online: boolean;
|
||||
messages: Message[];
|
||||
isLoading: boolean;
|
||||
isTyping: boolean;
|
||||
}
|
||||
|
||||
export interface MessagePanelCallbacks {
|
||||
onSendMessage: (content: string, files: File[]) => void;
|
||||
onEditMessage: (messageId: number, content: string) => void;
|
||||
onDeleteMessage: (messageId: number) => void;
|
||||
onReplyToMessage: (messageId: number, content: string) => void;
|
||||
onProfileClick: () => void;
|
||||
}
|
||||
|
||||
export abstract class MessagePanel {
|
||||
protected state: MessagePanelState;
|
||||
public onStateChange: ((state: MessagePanelState) => void) | null = () => {};
|
||||
protected readonly currentUser: UserState;
|
||||
private pendingMessages: Map<string, { timeoutId: NodeJS.Timeout; message: Message }> = new Map();
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
currentUser: UserState,
|
||||
) {
|
||||
this.state = {
|
||||
id,
|
||||
title: "",
|
||||
online: false,
|
||||
messages: [],
|
||||
isLoading: false,
|
||||
isTyping: false
|
||||
};
|
||||
this.currentUser = currentUser;
|
||||
}
|
||||
|
||||
// Abstract methods that must be implemented by subclasses
|
||||
abstract activate(): Promise<void>;
|
||||
abstract deactivate(): void;
|
||||
abstract loadMessages(): Promise<void>;
|
||||
protected abstract sendMessage(content: string, replyToId?: number, files?: File[]): Promise<void>;
|
||||
abstract isDm(): boolean;
|
||||
abstract handleWebSocketMessage(response: WebSocketMessage<any>): Promise<void>;
|
||||
|
||||
// Common methods
|
||||
protected updateState(updates: Partial<MessagePanelState>): void {
|
||||
this.state = { ...this.state, ...updates };
|
||||
if (this.onStateChange) {
|
||||
this.onStateChange(this.state);
|
||||
}
|
||||
}
|
||||
|
||||
protected addMessage(message: Message): void {
|
||||
const messageExists = this.state.messages.some(msg => msg.id === message.id);
|
||||
if (!messageExists) {
|
||||
this.updateState({
|
||||
messages: [...this.state.messages, message]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected updateMessage(messageId: number, updates: Partial<Message>): void {
|
||||
this.updateState({
|
||||
messages: this.state.messages.map(msg => {
|
||||
// Handle temporary messages (negative IDs) by matching temp ID
|
||||
if (messageId === -1 && msg.runtimeData?.sendingState?.tempId) {
|
||||
const pending = this.pendingMessages.get(msg.runtimeData.sendingState.tempId);
|
||||
if (pending) {
|
||||
return { ...pending.message, ...updates };
|
||||
}
|
||||
}
|
||||
return msg.id === messageId ? { ...msg, ...updates } : msg;
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
protected removeMessage(messageId: number): void {
|
||||
this.updateState({
|
||||
messages: this.state.messages.filter(msg => msg.id !== messageId)
|
||||
});
|
||||
}
|
||||
|
||||
protected updateMessageReactions(messageId: number, reactions: any[]): void {
|
||||
this.updateState({
|
||||
messages: this.state.messages.map(msg =>
|
||||
msg.id === messageId ? { ...msg, reactions } : msg
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
protected clearMessages(): void {
|
||||
this.updateState({ messages: [] });
|
||||
}
|
||||
|
||||
protected setLoading(loading: boolean): void {
|
||||
this.updateState({ isLoading: loading });
|
||||
}
|
||||
|
||||
protected setTyping(typing: boolean): void {
|
||||
this.updateState({ isTyping: typing });
|
||||
}
|
||||
|
||||
// Getters
|
||||
getState(): MessagePanelState {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return this.state.id;
|
||||
}
|
||||
|
||||
getTitle(): string {
|
||||
return this.state.title;
|
||||
}
|
||||
|
||||
getMessages(): Message[] {
|
||||
return [...this.state.messages];
|
||||
}
|
||||
|
||||
// ========== PUBLIC API ==========
|
||||
|
||||
// Event handlers
|
||||
handleSendMessage(content: string, replyToId?: number, files: File[] = []): void {
|
||||
this.sendMessageWithImmediateDisplay(content, replyToId, files);
|
||||
}
|
||||
|
||||
async retryMessage(messageId: number): Promise<void> {
|
||||
const message = this.getMessages().find(m => m.id === messageId);
|
||||
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,
|
||||
id: -1, // Temporary ID
|
||||
// Preserve existing files (which may have blob URLs for display)
|
||||
files: message.files,
|
||||
runtimeData: {
|
||||
...message.runtimeData,
|
||||
sendingState: {
|
||||
status: 'sending',
|
||||
tempId,
|
||||
retryData: {
|
||||
content,
|
||||
replyToId,
|
||||
files: files || []
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Update the existing message to sending state
|
||||
this.updateState({
|
||||
messages: this.state.messages.map(msg => {
|
||||
if (msg.id === messageId) {
|
||||
return retryMessage;
|
||||
}
|
||||
return msg;
|
||||
})
|
||||
});
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.handleMessageTimeout(tempId);
|
||||
}, 10000);
|
||||
|
||||
this.pendingMessages.set(tempId, { timeoutId, message: retryMessage });
|
||||
|
||||
try {
|
||||
await this.sendMessage(content, replyToId, files || []);
|
||||
// Note: Success will be handled by WebSocket confirmation
|
||||
} catch (error) {
|
||||
console.error("Failed to retry message:", error);
|
||||
// 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 => {
|
||||
if (msg.runtimeData?.sendingState?.tempId === tempId) {
|
||||
return {
|
||||
...msg,
|
||||
runtimeData: {
|
||||
...msg.runtimeData,
|
||||
sendingState: {
|
||||
...msg.runtimeData.sendingState,
|
||||
status: 'failed'
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
return msg;
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleMessageConfirmed(tempId: string, confirmedMessage: Message): void {
|
||||
const pending = this.pendingMessages.get(tempId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
this.pendingMessages.delete(tempId);
|
||||
|
||||
// Replace temporary message with confirmed one
|
||||
this.updateState({
|
||||
messages: this.state.messages.map(msg => {
|
||||
if (msg.runtimeData?.sendingState?.tempId === tempId) {
|
||||
return {
|
||||
...confirmedMessage,
|
||||
// Preserve files from the temporary message (which have blob URLs for immediate display)
|
||||
files: msg.files,
|
||||
runtimeData: {
|
||||
...confirmedMessage.runtimeData,
|
||||
sendingState: {
|
||||
status: 'sent'
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
return msg;
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected deleteMessageImmediately(messageId: number): void {
|
||||
this.updateState({
|
||||
messages: this.state.messages.filter(msg => msg.id !== messageId)
|
||||
});
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
// Clear all pending timeouts
|
||||
this.pendingMessages.forEach(({ timeoutId }) => {
|
||||
clearTimeout(timeoutId);
|
||||
});
|
||||
this.pendingMessages.clear();
|
||||
}
|
||||
|
||||
// ========== 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;
|
||||
|
||||
// Create temporary message for immediate display
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const tempMessage: Message = {
|
||||
id: -1, // Temporary negative ID
|
||||
username: this.currentUser.currentUser?.username ?? "You",
|
||||
content: content.trim(),
|
||||
is_read: false,
|
||||
is_edited: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
files: files.map(file => ({
|
||||
name: file.name,
|
||||
path: URL.createObjectURL(file),
|
||||
encrypted: false
|
||||
})),
|
||||
runtimeData: {
|
||||
sendingState: {
|
||||
status: 'sending',
|
||||
tempId,
|
||||
retryData: {
|
||||
content: content.trim(),
|
||||
replyToId,
|
||||
files: [...files]
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add reply reference if present
|
||||
if (replyToId) {
|
||||
const referencedMessage = this.getMessages().find(m => m.id === replyToId);
|
||||
if (referencedMessage) {
|
||||
tempMessage.reply_to = referencedMessage;
|
||||
}
|
||||
}
|
||||
|
||||
// Add message immediately
|
||||
this.addMessage(tempMessage);
|
||||
|
||||
// Set up timeout for failure
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.handleMessageTimeout(tempId);
|
||||
}, 10000); // 10 seconds timeout
|
||||
|
||||
// Store pending message
|
||||
this.pendingMessages.set(tempId, { timeoutId, message: tempMessage });
|
||||
|
||||
// Actually send the message
|
||||
try {
|
||||
await this.sendMessage(content, replyToId, files);
|
||||
// Message sent successfully - will be updated when WebSocket confirms
|
||||
} catch (error) {
|
||||
console.error("Failed to send message:", error);
|
||||
this.handleMessageFailed(tempId);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle message timeout (10 seconds)
|
||||
private handleMessageTimeout(tempId: string): void {
|
||||
this.updateMessageToFailed(tempId);
|
||||
}
|
||||
|
||||
// Handle message send failure
|
||||
private handleMessageFailed(tempId: string): void {
|
||||
this.updateMessageToFailed(tempId);
|
||||
}
|
||||
|
||||
// Helper method to update message to failed state
|
||||
private updateMessageToFailed(tempId: string): void {
|
||||
const pending = this.pendingMessages.get(tempId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
this.pendingMessages.delete(tempId);
|
||||
|
||||
// Update message to failed state
|
||||
this.updateState({
|
||||
messages: this.state.messages.map(msg => {
|
||||
if (msg.runtimeData?.sendingState?.tempId === tempId) {
|
||||
return {
|
||||
...msg,
|
||||
runtimeData: {
|
||||
...msg.runtimeData,
|
||||
sendingState: {
|
||||
...msg.runtimeData.sendingState,
|
||||
status: 'failed'
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
return msg;
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
abstract handleEditMessage(messageId: number, content: string): Promise<void>;
|
||||
abstract handleDeleteMessage(messageId: number): Promise<void>;
|
||||
abstract handleProfileClick(): void;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { MessagePanel } from "./MessagePanel";
|
||||
import { API_BASE_URL } from "../../core/config";
|
||||
import { getAuthHeaders } from "../../../../api/authApi";
|
||||
import { request } from "../../core/websocket";
|
||||
import type { ChatWebSocketMessage, Message, SendMessageRequest, ReactionUpdateWebSocketMessage } from "../../core/types";
|
||||
import type { UserState } from "../state";
|
||||
|
||||
export class PublicChatPanel extends MessagePanel {
|
||||
private messagesLoaded: boolean = false;
|
||||
|
||||
constructor(
|
||||
chatName: string,
|
||||
currentUser: UserState
|
||||
) {
|
||||
super(`public-${chatName}`, currentUser);
|
||||
this.updateState({
|
||||
title: chatName,
|
||||
online: true // Public chats are always "online"
|
||||
});
|
||||
}
|
||||
|
||||
isDm(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async activate(): Promise<void> {
|
||||
// Don't load messages immediately during activation to prevent animation freeze
|
||||
// Messages will be loaded after the animation completes
|
||||
}
|
||||
|
||||
deactivate(): void {
|
||||
// Public chat doesn't need special cleanup
|
||||
}
|
||||
|
||||
clearMessages(): void {
|
||||
super.clearMessages();
|
||||
this.messagesLoaded = false;
|
||||
}
|
||||
|
||||
async loadMessages(): Promise<void> {
|
||||
if (!this.currentUser.authToken || this.messagesLoaded) return;
|
||||
|
||||
this.setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/get_messages`, {
|
||||
headers: getAuthHeaders(this.currentUser.authToken)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.messages && data.messages.length > 0) {
|
||||
this.clearMessages();
|
||||
data.messages.forEach((msg: Message) => {
|
||||
this.addMessage(msg);
|
||||
});
|
||||
}
|
||||
}
|
||||
this.messagesLoaded = true;
|
||||
} catch (error) {
|
||||
console.error("Error loading public chat messages:", error);
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> {
|
||||
if (!this.currentUser.authToken || !content.trim()) return;
|
||||
|
||||
try {
|
||||
if (files.length === 0) {
|
||||
const response = await request({
|
||||
data: {
|
||||
content: content.trim(),
|
||||
reply_to_id: replyToId ?? null
|
||||
},
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.currentUser.authToken
|
||||
},
|
||||
type: "sendMessage"
|
||||
} satisfies SendMessageRequest);
|
||||
if (response.error) {
|
||||
console.error("Error sending message:", response.error);
|
||||
}
|
||||
} else {
|
||||
const form = new FormData();
|
||||
form.append("payload", JSON.stringify({
|
||||
content: content.trim(),
|
||||
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`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(this.currentUser.authToken, false),
|
||||
body: form
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error("Error sending message with files", await res.text());
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending message:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle incoming WebSocket messages
|
||||
async handleWebSocketMessage(response: ChatWebSocketMessage | ReactionUpdateWebSocketMessage): Promise<void> {
|
||||
switch (response.type) {
|
||||
case 'messageEdited':
|
||||
if (response.data) {
|
||||
this.updateMessage(response.data.id, response.data);
|
||||
}
|
||||
break;
|
||||
case 'messageDeleted':
|
||||
if (response.data && response.data.message_id) {
|
||||
this.removeMessage(response.data.message_id);
|
||||
}
|
||||
break;
|
||||
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) {
|
||||
// This is our message being confirmed, find the temp message and replace it
|
||||
const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId);
|
||||
for (const tempMsg of tempMessages) {
|
||||
if (tempMsg.runtimeData?.sendingState?.retryData?.content === newMsg.content) {
|
||||
this.handleMessageConfirmed(tempMsg.runtimeData.sendingState.tempId!, newMsg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.addMessage(newMsg);
|
||||
}
|
||||
break;
|
||||
case 'reactionUpdate':
|
||||
if (response.data) {
|
||||
this.updateMessageReactions(response.data.message_id, response.data.reactions);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Reset for chat switching
|
||||
reset(): void {
|
||||
this.messagesLoaded = false;
|
||||
this.clearMessages();
|
||||
}
|
||||
|
||||
// Update chat name
|
||||
setChatName(chatName: string): void {
|
||||
this.updateState({
|
||||
id: `public-${chatName}`,
|
||||
title: chatName
|
||||
});
|
||||
}
|
||||
|
||||
// Update auth token
|
||||
setAuthToken(authToken: string): void {
|
||||
this.currentUser.authToken = authToken;
|
||||
}
|
||||
|
||||
async handleEditMessage(messageId: number, content: string): Promise<void> {
|
||||
if (!this.currentUser.authToken) return;
|
||||
try {
|
||||
await request({
|
||||
type: "editMessage",
|
||||
data: {
|
||||
message_id: messageId,
|
||||
content: content
|
||||
},
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.currentUser.authToken
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to edit message:", error);
|
||||
}
|
||||
}
|
||||
|
||||
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!
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleProfileClick(): void {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { LeftPanel } from "../components/chat/LeftPanel";
|
||||
import { RightPanel } from "../components/chat/RightPanel";
|
||||
|
||||
export default function ChatScreen() {
|
||||
return (
|
||||
<div id="chat-interface">
|
||||
<div className="all-container">
|
||||
<LeftPanel />
|
||||
<RightPanel />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export default function DownloadAppScreen() {
|
||||
return (
|
||||
<div className="download-app-screen">
|
||||
<div className="inner">
|
||||
<h1>Чтобы пользоваться мессенджером, скачайте приложение</h1>
|
||||
<p>
|
||||
Этот сайт <b>не предназначен</b> для работы на маленьких экранах, поэтому
|
||||
вам нужно скачать приложение мессенджера.
|
||||
</p>
|
||||
|
||||
<a href="https://github.com/denis0001-dev/FromChat-android/releases/latest">
|
||||
<mdui-button>Скачать на GitHub</mdui-button>
|
||||
</a>
|
||||
|
||||
<p>
|
||||
Если возникнут сложности или есть вопросы, нажмите кнопку!
|
||||
</p>
|
||||
|
||||
<a href="https://t.me/denis0001-dev">
|
||||
<mdui-button>Написать в поддержку</mdui-button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useImmer } from "use-immer";
|
||||
import { AlertsContainer, type Alert, type AlertType } from "../components/Alerts";
|
||||
import { AuthContainer, AuthHeader } from "../components/Auth";
|
||||
import type { ErrorResponse, LoginRequest, LoginResponse } from "../../core/types";
|
||||
import { ensureKeysOnLogin } from "../../../../api/authApi";
|
||||
import { API_BASE_URL } from "../../core/config";
|
||||
import { useRef } from "react";
|
||||
import type { TextField } from "mdui/components/text-field";
|
||||
import { useAppState } from "../state";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { MaterialTextField } from "../components/core/TextField";
|
||||
import { initialize, isSupported, startElectronReceiver, subscribe } from "../../utils/push-notifications";
|
||||
import { isElectron } from "../../electron/electron";
|
||||
|
||||
export default function LoginScreen() {
|
||||
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
|
||||
const setUser = useAppState(state => state.setUser);
|
||||
const navigate = useNavigate();
|
||||
|
||||
function showAlert(type: AlertType, message: string) {
|
||||
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
|
||||
}
|
||||
|
||||
const usernameElement = useRef<TextField>(null);
|
||||
const passwordElement = useRef<TextField>(null);
|
||||
|
||||
return (
|
||||
<AuthContainer>
|
||||
<AuthHeader icon="login" title="Добро пожаловать!" subtitle="Войдите в свой аккаунт" />
|
||||
<div className="auth-body">
|
||||
<AlertsContainer alerts={alerts} />
|
||||
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const username = usernameElement.current!.value.trim();
|
||||
const password = passwordElement.current!.value.trim();
|
||||
|
||||
if (!username || !password) {
|
||||
showAlert("danger", "Пожалуйста, заполните все поля");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const request: LoginRequest = {
|
||||
username: username,
|
||||
password: password
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data: LoginResponse = await response.json();
|
||||
// Store the JWT token first
|
||||
setUser(data.token, data.user);
|
||||
|
||||
// Setup keys with the token we just received
|
||||
try {
|
||||
await ensureKeysOnLogin(password, data.token);
|
||||
} catch (e) {
|
||||
console.error("Key setup failed:", e);
|
||||
}
|
||||
|
||||
navigate("/chat");
|
||||
|
||||
// Initialize notifications
|
||||
try {
|
||||
if (isSupported()) {
|
||||
const initialized = await initialize();
|
||||
if (initialized) {
|
||||
await subscribe(data.token);
|
||||
|
||||
// For Electron, start the notification receiver
|
||||
if (isElectron) {
|
||||
await startElectronReceiver();
|
||||
}
|
||||
|
||||
console.log("Notifications enabled");
|
||||
} else {
|
||||
console.log("Notification permission denied");
|
||||
}
|
||||
} else {
|
||||
console.log("Notifications not supported");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Notification setup failed:", e);
|
||||
}
|
||||
} else {
|
||||
const data: ErrorResponse = await response.json();
|
||||
showAlert("danger", data.message || "Неверное имя пользователя или пароль");
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert("danger", "Ошибка соединения с сервером");
|
||||
}
|
||||
}}>
|
||||
<MaterialTextField
|
||||
label="Имя пользователя"
|
||||
id="login-username"
|
||||
name="username"
|
||||
variant="outlined"
|
||||
icon="person--filled"
|
||||
autocomplete="username"
|
||||
required
|
||||
ref={usernameElement} />
|
||||
|
||||
<MaterialTextField
|
||||
label="Пароль"
|
||||
id="login-password"
|
||||
name="password"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
toggle-password
|
||||
icon="password--filled"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
ref={passwordElement} />
|
||||
|
||||
<mdui-button type="submit">Войти</mdui-button>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
<p>
|
||||
Ещё нет аккаунта?
|
||||
<a
|
||||
href="#"
|
||||
className="link"
|
||||
onClick={() => navigate("/register")}>
|
||||
Зарегистрируйтесь
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AuthContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useImmer } from "use-immer";
|
||||
// import { showLogin } from "../../navigation";
|
||||
import { AuthContainer, AuthHeader } from "../components/Auth";
|
||||
import { AlertsContainer, type Alert, type AlertType } from "../components/Alerts";
|
||||
import { useRef } from "react";
|
||||
import { TextField } from "mdui/components/text-field";
|
||||
import type { ErrorResponse, RegisterRequest, LoginResponse } from "../../core/types";
|
||||
import { API_BASE_URL } from "../../core/config";
|
||||
import { useAppState } from "../state";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { MaterialTextField } from "../components/core/TextField";
|
||||
import { ensureKeysOnLogin } from "../../../../api/authApi";
|
||||
|
||||
export default function RegisterScreen() {
|
||||
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
|
||||
const setUser = useAppState(state => state.setUser);
|
||||
const navigate = useNavigate();
|
||||
|
||||
function showAlert(type: AlertType, message: string) {
|
||||
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
|
||||
}
|
||||
|
||||
const usernameElement = useRef<TextField>(null);
|
||||
const passwordElement = useRef<TextField>(null);
|
||||
const confirmPasswordElement = useRef<TextField>(null);
|
||||
|
||||
return (
|
||||
<AuthContainer>
|
||||
<AuthHeader icon="person_add" title="Регистрация" subtitle="Создайте новый аккаунт" />
|
||||
<div className="auth-body">
|
||||
<AlertsContainer alerts={alerts} />
|
||||
|
||||
<form onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const username = usernameElement.current!.value.trim();
|
||||
const password = passwordElement.current!.value.trim();
|
||||
const confirmPassword = confirmPasswordElement.current!.value.trim();
|
||||
|
||||
if (!username || !password || !confirmPassword) {
|
||||
showAlert("danger", "Пожалуйста, заполните все поля");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
showAlert("danger", "Пароли не совпадают");
|
||||
return;
|
||||
}
|
||||
|
||||
if (username.length < 3 || username.length > 20) {
|
||||
showAlert("danger", "Имя пользователя должно быть от 3 до 20 символов");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 5 || password.length > 50) {
|
||||
showAlert("danger", "Пароль должен быть от 5 до 50 символов");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const request: RegisterRequest = {
|
||||
username: username,
|
||||
password: password,
|
||||
confirm_password: confirmPassword
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data: LoginResponse = await response.json();
|
||||
// Store the JWT token first
|
||||
setUser(data.token, data.user);
|
||||
|
||||
// Setup keys with the token we just received
|
||||
try {
|
||||
await ensureKeysOnLogin(password, data.token);
|
||||
} catch (e) {
|
||||
console.error("Key setup failed:", e);
|
||||
}
|
||||
|
||||
navigate("/chat");
|
||||
} else {
|
||||
const data: ErrorResponse = await response.json();
|
||||
showAlert("danger", data.message || "Ошибка при регистрации");
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert("danger", "Ошибка соединения с сервером");
|
||||
}
|
||||
}}>
|
||||
<MaterialTextField
|
||||
label="Имя пользователя"
|
||||
id="register-username"
|
||||
name="username"
|
||||
variant="outlined"
|
||||
icon="person--filled"
|
||||
autocomplete="username"
|
||||
maxlength={20}
|
||||
counter
|
||||
required
|
||||
ref={usernameElement} />
|
||||
<MaterialTextField
|
||||
label="Пароль"
|
||||
id="register-password"
|
||||
name="password"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
toggle-password
|
||||
icon="password--filled"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
ref={passwordElement} />
|
||||
<MaterialTextField
|
||||
label="Подтвердите пароль"
|
||||
id="register-confirm-password"
|
||||
name="confirm_password"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
toggle-password
|
||||
icon="password--filled"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
ref={confirmPasswordElement} />
|
||||
|
||||
<mdui-button type="submit">Зарегистрироваться</mdui-button>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
<p>
|
||||
Уже есть аккаунт?
|
||||
<a
|
||||
href="#"
|
||||
id="login-link"
|
||||
className="link"
|
||||
onClick={() => navigate("/login")}>
|
||||
Войдите
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AuthContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { create } from "zustand";
|
||||
import type { Message, User } from "../core/types";
|
||||
import { request } from "../core/websocket";
|
||||
import { MessagePanel } from "./panels/MessagePanel";
|
||||
import { PublicChatPanel } from "./panels/PublicChatPanel";
|
||||
import { DMPanel, type DMPanelData } from "./panels/DMPanel";
|
||||
import { getAuthHeaders } from "../../../api/authApi";
|
||||
import { restoreKeys } from "../../../api/authApi";
|
||||
import { API_BASE_URL } from "../core/config";
|
||||
import { initialize, subscribe, startElectronReceiver, isSupported } from "../utils/push-notifications";
|
||||
import { isElectron } from "../electron/electron";
|
||||
|
||||
export type ChatTabs = "chats" | "channels" | "contacts" | "dms"
|
||||
|
||||
interface ActiveDM {
|
||||
userId: number;
|
||||
username: string;
|
||||
publicKey: string | null
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
messages: Message[];
|
||||
currentChat: string;
|
||||
activeTab: ChatTabs;
|
||||
dmUsers: User[];
|
||||
activeDm: ActiveDM | null;
|
||||
isSwitching: boolean;
|
||||
setIsSwitching: (value: boolean) => void;
|
||||
activePanel: MessagePanel | null;
|
||||
publicChatPanel: PublicChatPanel | null;
|
||||
dmPanel: DMPanel | null;
|
||||
pendingPanel?: MessagePanel | null;
|
||||
}
|
||||
|
||||
export interface UserState {
|
||||
currentUser: User | null;
|
||||
authToken: string | null;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
// Chat state
|
||||
chat: ChatState;
|
||||
addMessage: (message: Message) => void;
|
||||
updateMessage: (messageId: number, updatedMessage: Partial<Message>) => void;
|
||||
removeMessage: (messageId: number) => void;
|
||||
setCurrentChat: (chat: string) => void;
|
||||
setActiveTab: (tab: ChatState["activeTab"]) => void;
|
||||
setDmUsers: (users: User[]) => void;
|
||||
setActiveDm: (dm: ChatState["activeDm"]) => void;
|
||||
clearMessages: () => void;
|
||||
setActivePanel: (panel: MessagePanel | null) => void;
|
||||
setPendingPanel: (panel: MessagePanel | null) => void;
|
||||
applyPendingPanel: () => void;
|
||||
switchToPublicChat: (chatName: string) => Promise<void>;
|
||||
switchToDM: (dmData: DMPanelData) => Promise<void>;
|
||||
|
||||
// User state
|
||||
user: UserState;
|
||||
setUser: (token: string, user: User) => void;
|
||||
logout: () => void;
|
||||
restoreUserFromStorage: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAppState = create<AppState>((set, get) => ({
|
||||
// Chat state
|
||||
chat: {
|
||||
messages: [],
|
||||
currentChat: "Общий чат",
|
||||
activeTab: "chats",
|
||||
dmUsers: [],
|
||||
activeDm: null,
|
||||
isSwitching: false,
|
||||
setIsSwitching: (value: boolean) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
isSwitching: value
|
||||
}
|
||||
})),
|
||||
activePanel: null,
|
||||
publicChatPanel: null,
|
||||
dmPanel: null,
|
||||
pendingPanel: null
|
||||
},
|
||||
addMessage: (message: Message) => set((state) => {
|
||||
// Check if message already exists to prevent duplicates
|
||||
const messageExists = state.chat.messages.some(msg => msg.id === message.id);
|
||||
if (messageExists) {
|
||||
return state; // Return unchanged state if message already exists
|
||||
}
|
||||
|
||||
return {
|
||||
chat: {
|
||||
...state.chat,
|
||||
messages: [...state.chat.messages, message]
|
||||
}
|
||||
};
|
||||
}),
|
||||
updateMessage: (messageId: number, updatedMessage: Partial<Message>) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
messages: state.chat.messages.map(msg =>
|
||||
msg.id === messageId ? { ...msg, ...updatedMessage } : msg
|
||||
)
|
||||
}
|
||||
})),
|
||||
removeMessage: (messageId: number) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
messages: state.chat.messages.filter(msg => msg.id !== messageId)
|
||||
}
|
||||
})),
|
||||
clearMessages: () => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
messages: []
|
||||
}
|
||||
})),
|
||||
setCurrentChat: (chat: string) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
currentChat: chat
|
||||
}
|
||||
})),
|
||||
setActiveTab: (tab: ChatState["activeTab"]) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
activeTab: tab
|
||||
}
|
||||
})),
|
||||
setDmUsers: (users: User[]) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
dmUsers: users
|
||||
}
|
||||
})),
|
||||
setActiveDm: (dm: ChatState["activeDm"]) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
activeDm: dm
|
||||
}
|
||||
})),
|
||||
|
||||
// User state
|
||||
user: {
|
||||
currentUser: null,
|
||||
authToken: null
|
||||
},
|
||||
setUser: (token: string, user: User) => {
|
||||
set(() => ({
|
||||
user: {
|
||||
currentUser: user,
|
||||
authToken: token
|
||||
}
|
||||
}));
|
||||
|
||||
// Store credentials in localStorage
|
||||
try {
|
||||
localStorage.setItem('authToken', token);
|
||||
localStorage.setItem('currentUser', JSON.stringify(user));
|
||||
} catch (error) {
|
||||
console.error('Failed to store credentials in localStorage:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
request({
|
||||
type: "ping",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: token
|
||||
},
|
||||
data: {}
|
||||
}).then(() => {
|
||||
console.log("Ping succeeded")
|
||||
})
|
||||
} catch {}
|
||||
},
|
||||
logout: () => {
|
||||
// Clear localStorage
|
||||
try {
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('currentUser');
|
||||
} catch (error) {
|
||||
console.error('Failed to clear localStorage:', error);
|
||||
}
|
||||
|
||||
set(() => ({
|
||||
user: {
|
||||
currentUser: null,
|
||||
authToken: null
|
||||
}
|
||||
}));
|
||||
},
|
||||
restoreUserFromStorage: async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
|
||||
if (token) {
|
||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||
headers: getAuthHeaders(token)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const user: User = await response.json();
|
||||
restoreKeys();
|
||||
|
||||
set(() => ({
|
||||
user: {
|
||||
currentUser: user,
|
||||
authToken: token
|
||||
}
|
||||
}));
|
||||
|
||||
try {
|
||||
request({
|
||||
type: "ping",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: token
|
||||
},
|
||||
data: {}
|
||||
}).then(() => {
|
||||
console.log("Ping succeeded")
|
||||
})
|
||||
} catch {}
|
||||
|
||||
// Initialize notifications after successful credential restoration
|
||||
try {
|
||||
if (isSupported()) {
|
||||
const initialized = await initialize();
|
||||
if (initialized) {
|
||||
await subscribe(token);
|
||||
|
||||
// For Electron, start the notification receiver
|
||||
if (isElectron) {
|
||||
await startElectronReceiver();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Notification setup failed (restored):", e);
|
||||
}
|
||||
} else {
|
||||
throw new Error("Unable to authenticate");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to restore user from localStorage:', error);
|
||||
// Clear invalid data
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('currentUser');
|
||||
}
|
||||
},
|
||||
|
||||
// Panel management
|
||||
setActivePanel: (panel: MessagePanel | null) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
activePanel: panel
|
||||
}
|
||||
})),
|
||||
// Stash a panel to be applied after switch-out animation ends
|
||||
setPendingPanel: (panel: MessagePanel | null) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
pendingPanel: panel
|
||||
}
|
||||
})),
|
||||
// Apply pending panel atomically and update related fields
|
||||
applyPendingPanel: () => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
activePanel: state.chat.pendingPanel || state.chat.activePanel,
|
||||
// when switching to public chat, keep reference if type matches
|
||||
publicChatPanel: (state.chat.pendingPanel instanceof PublicChatPanel)
|
||||
? (state.chat.pendingPanel as PublicChatPanel)
|
||||
: state.chat.publicChatPanel,
|
||||
dmPanel: (state.chat.pendingPanel instanceof DMPanel)
|
||||
? (state.chat.pendingPanel as DMPanel)
|
||||
: state.chat.dmPanel,
|
||||
// update currentChat from panel title if available
|
||||
currentChat: state.chat.pendingPanel ? state.chat.pendingPanel.getState().title || state.chat.currentChat : state.chat.currentChat,
|
||||
pendingPanel: null
|
||||
}
|
||||
})),
|
||||
|
||||
switchToPublicChat: async (chatName: string) => {
|
||||
const { user, chat } = get();
|
||||
|
||||
if (!user.authToken) return;
|
||||
|
||||
// Start chat switching animation
|
||||
chat.setIsSwitching(true);
|
||||
|
||||
// Create or get public chat panel
|
||||
let publicChatPanel = chat.publicChatPanel;
|
||||
if (!publicChatPanel) {
|
||||
publicChatPanel = new PublicChatPanel(chatName, user);
|
||||
} else {
|
||||
publicChatPanel.setChatName(chatName);
|
||||
publicChatPanel.setAuthToken(user.authToken);
|
||||
// Reset messages for the new chat
|
||||
publicChatPanel.clearMessages();
|
||||
}
|
||||
|
||||
// Activate panel
|
||||
await publicChatPanel.activate();
|
||||
|
||||
// Defer panel swap until animation switch-out completes
|
||||
set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
pendingPanel: publicChatPanel,
|
||||
activeTab: "chats"
|
||||
}
|
||||
}));
|
||||
|
||||
// Let MessagePanelRenderer handle the animation timing completely
|
||||
// It will set isChatSwitching to false when the fadeInDown animation completes
|
||||
},
|
||||
|
||||
switchToDM: async (dmData: DMPanelData) => {
|
||||
const { user, chat } = get();
|
||||
|
||||
if (!user.authToken) return;
|
||||
|
||||
// Start chat switching animation
|
||||
chat.setIsSwitching(true);
|
||||
|
||||
// Create or get DM panel
|
||||
let dmPanel = chat.dmPanel;
|
||||
if (!dmPanel) {
|
||||
dmPanel = new DMPanel(user);
|
||||
} else {
|
||||
dmPanel.setAuthToken(user.authToken);
|
||||
// Reset messages for the new DM
|
||||
dmPanel.clearMessages();
|
||||
}
|
||||
|
||||
// Set DM data
|
||||
dmPanel.setDMData(dmData);
|
||||
|
||||
// Activate panel
|
||||
await dmPanel.activate();
|
||||
|
||||
// Defer panel swap until animation switch-out completes
|
||||
set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
pendingPanel: dmPanel,
|
||||
activeDm: {
|
||||
userId: dmData.userId,
|
||||
username: dmData.username,
|
||||
publicKey: dmData.publicKey
|
||||
},
|
||||
activeTab: "dms"
|
||||
}
|
||||
}));
|
||||
|
||||
// Let MessagePanelRenderer handle the animation timing completely
|
||||
// It will set isChatSwitching to false when the fadeInDown animation completes
|
||||
}
|
||||
}));
|
||||
Reference in New Issue
Block a user