Restructure code

This commit is contained in:
2025-10-02 18:18:08 +03:00
Unverified
parent 0d33c344b8
commit a24270b37a
85 changed files with 17 additions and 15 deletions
@@ -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,177 @@
import { useState, useEffect } 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";
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;
}
export function ChatInputWrapper(
{
onSendMessage,
onSaveEdit,
replyTo,
replyToVisible,
onClearReply,
onCloseReply,
editingMessage,
editVisible = false,
onClearEdit,
onCloseEdit,
onProvideFileAdder
}: ChatInputWrapperProps
) {
const [message, setMessage] = useState("");
const [selectedFiles, setSelectedFiles] = useImmer<File[]>([]);
const [attachmentsVisible, setAttachmentsVisible] = useState(false);
const [errorOpen, setErrorOpen] = useState(false);
// 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]);
const handleSubmit = async (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">
<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">
<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>
</div>
);
}
@@ -0,0 +1,21 @@
import { useChat } from "../../hooks/useChat";
import defaultAvatar from "../../../resources/images/default-avatar.png";
export function ChatMainHeader() {
const { currentChat } = useChat();
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,180 @@
import { useChat } from "../../hooks/useChat";
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";
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: propMessages, children, isDm = false, onReplySelect, onEditSelect, onDelete, onRetryMessage, dmRecipientPublicKey }: ChatMessagesProps) {
const { messages: hookMessages } = useChat();
const { user } = useAppState();
// Use prop messages if provided, otherwise use hook messages
const messages = propMessages || hookMessages;
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);
// if (toBeDeleted.isDm) {
// // For DM, send dmDelete
// await request({
// type: "dmDelete",
// data: { id: toBeDeleted.id },
// credentials: { scheme: "Bearer", credentials: user.authToken }
// });
// } else {
// await request({
// type: "deleteMessage",
// data: { message_id: toBeDeleted.id },
// credentials: { scheme: "Bearer", credentials: user.authToken }
// });
// }
} 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);
}
}
return (
<>
<div className="chat-messages" id="chat-messages">
{messages.map((message) => (
<Message
key={message.id}
message={message}
isAuthor={message.username === user.currentUser?.username}
onProfileClick={handleProfileClick}
onContextMenu={handleContextMenu}
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}
position={contextMenu.position}
isOpen={contextMenu.isOpen}
onOpenChange={handleContextMenuOpenChange}
/>
)}
</>
);
}
@@ -0,0 +1,56 @@
import { useChat } from "../../hooks/useChat";
export function ChatTabs() {
const { activeTab, setActiveTab, setCurrentChat } = useChat();
const handleChatClick = (chatName: string) => {
setCurrentChat(chatName);
};
return (
<div className="chat-tabs">
<mdui-tabs value={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={() => handleChatClick("Общий чат")}
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={() => handleChatClick("Общий чат 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 } from "../../hooks/useDM";
import { useAppState } from "../../state";
import { fetchUserPublicKey } from "../../../api/dmApi";
import defaultAvatar from "../../../resources/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>
);
}
const handleUserClick = async (user: any) => {
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) => (
<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,117 @@
import { PRODUCT_NAME } from "../../../core/config";
import { useAppState } from "../../state";
import defaultAvatar from "../../../resources/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, switchToTab, switchToPublicChat } = useAppState();
const { activeTab } = chat;
const handleChatClick = async (chatName: string) => {
await switchToPublicChat(chatName);
};
const handleTabChange = async (e: FormEvent<Tabs>) => {
const tab = (e.target as Tabs).value as ChatTabs;
await switchToTab(tab);
};
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,431 @@
import { formatTime } from "../../../utils/utils";
import type { Attachment, Message as MessageType } from "../../../core/types";
import defaultAvatar from "../../../resources/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 "../../../auth/crypto";
import { ecdhSharedSecret, deriveWrappingKey } from "../../../utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmDecrypt } from "../../../utils/crypto/symmetric";
import { getAuthHeaders } from "../../../auth/api";
import { useAppState } from "../../state";
import { ub64 } from "../../../utils/utils";
import { useImmer } from "use-immer";
interface MessageProps {
message: MessageType;
isAuthor: boolean;
onProfileClick: (username: string) => void;
onContextMenu: (e: React.MouseEvent, message: MessageType) => void;
isLoadingProfile?: boolean;
isDm?: boolean;
dmRecipientPublicKey?: string;
}
interface Rect {
left: number;
top: number;
width: number;
height: number
}
export function Message({ message, isAuthor, onProfileClick, onContextMenu, 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>
)}
<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 && (
<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>
)}
</>
);
}
@@ -0,0 +1,206 @@
import { useState, useEffect } from "react";
import type { Message, Size2D } from "../../../core/types";
interface MessageContextMenuProps {
message: Message;
isAuthor: boolean;
onEdit: (message: Message) => void;
onReply: (message: Message) => void;
onDelete: (message: Message) => void;
onRetry?: (message: Message) => 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,
position,
isOpen,
onOpenChange
}: MessageContextMenuProps) {
// Internal state for closing animation
const [isClosing, setIsClosing] = useState(false);
const [calculatedPosition, setCalculatedPosition] = useState(position);
const [animationClass, setAnimationClass] = useState('entering');
// Calculate smart positioning when component opens
useEffect(() => {
if (isOpen) {
const menuWidth = 160; // min-width from CSS
const menuHeight = isAuthor ? 120 : 60; // Approximate height based on items
const padding = 10; // Padding from viewport edges
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let x = position.x;
let y = position.y;
let animation = 'entering';
// Check if menu would overflow right edge
if (x + menuWidth + padding > viewportWidth) {
x = viewportWidth - menuWidth - padding;
animation = 'entering-left'; // Animation from left side
}
// Check if menu would overflow bottom edge
if (y + menuHeight + padding > viewportHeight) {
y = viewportHeight - menuHeight - padding;
animation = 'entering-up'; // Animation from bottom
}
// If both edges would overflow, use top-left positioning
if (x + menuWidth + padding > viewportWidth && y + menuHeight + padding > viewportHeight) {
x = Math.max(padding, position.x - menuWidth);
y = Math.max(padding, position.y - menuHeight);
animation = 'entering-up-left';
}
setCalculatedPosition({ x, y });
setAnimationClass(animation);
}
}, [isOpen, position, isAuthor]);
// Effect to handle clicks outside the context menu
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (isOpen && !isClosing) {
// Check if the click is on a context menu element
const target = event.target as Element;
if (!target.closest('.context-menu')) {
handleClose();
}
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen && !isClosing) {
handleClose();
}
};
const 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
}, 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
},
];
return isOpen && (
<div
className={`context-menu ${animationClass}`}
style={{
position: "fixed",
display: "block",
top: calculatedPosition.y,
left: calculatedPosition.x,
zIndex: 1000
}}
onClick={(e) => e.stopPropagation()}>
{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>
)
}
@@ -0,0 +1,291 @@
import { useState, useEffect, useRef } from "react";
import { MessagePanel, type MessagePanelState } from "../../panels/MessagePanel";
import { ChatMessages } from "./ChatMessages";
import { ChatInputWrapper } from "./ChatInputWrapper";
import { setGlobalMessageHandler } from "../../../core/websocket";
import type { Message } from "../../../core/types";
import defaultAvatar from "../../../resources/images/default-avatar.png";
import AnimatedOpacity from "../core/animations/AnimatedOpacity";
import type { DMPanel } from "../../panels/DMPanel";
interface MessagePanelRendererProps {
panel: MessagePanel | null;
isChatSwitching: boolean;
}
export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRendererProps) {
const [panelState, setPanelState] = useState<MessagePanelState | null>(null);
const [switchIn, setSwitchIn] = useState(false);
const [switchOut, setSwitchOut] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
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());
// Set up state change listener
const handleStateChange = (newState: MessagePanelState) => {
setPanelState(newState);
};
// Store the handler for cleanup
panel.onStateChange = handleStateChange;
// Set up WebSocket message handler for this panel
if (panel.handleWebSocketMessage) {
setGlobalMessageHandler(panel.handleWebSocketMessage);
}
} else {
setPanelState(null);
// Clear global message handler when no panel is active
setGlobalMessageHandler(null);
}
// Cleanup function
return () => {
if (panel) {
if (panel.onStateChange) {
panel.onStateChange = null;
}
// Call destroy to clean up pending timeouts
if (typeof panel.destroy === 'function') {
panel.destroy();
}
}
};
}, [panel]);
// Handle chat switching animation
useEffect(() => {
if (isChatSwitching) {
setSwitchOut(true);
setTimeout(() => {
setSwitchOut(false);
setSwitchIn(true);
setTimeout(() => setSwitchIn(false), 200);
}, 250);
}
}, [isChatSwitching]);
// Scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [panelState?.messages]);
if (!panel || !panelState) {
return (
<div className="chat-container">
<div className="chat-main" id="chat-inner">
<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">Выбор чата</h4>
<p>
<span className="online-status"></span>
Выберите чат, чтобы начать переписку
</p>
</div>
</div>
</div>
<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>
</div>
</div>
);
}
return (
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
<div
className="chat-main"
id="chat-inner"
onDragEnter={(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);
}}
onDragOver={(e) => {
if (!e.dataTransfer) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
}}
onDragLeave={(e) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
if (dragCounterRef.current === 0) setIsDragging(false);
}}
onDrop={(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;
}}>
<div className="chat-header">
<img
src={panelState.profilePicture || defaultAvatar}
alt="Avatar"
className="chat-header-avatar"
onClick={panel.handleProfileClick}
style={{ cursor: "pointer" }}
/>
<div className="chat-header-info">
<div className="info-chat">
<h4 id="chat-name">{panelState.title}</h4>
<p>
<span className={`online-status ${panelState.online ? "online" : "offline"}`}></span>
{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>
): (
<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>
)}
<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; }}
/>
</div>
</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,13 @@
import { useAppState } from "../../state";
import { MessagePanelRenderer } from "./MessagePanelRenderer";
export function RightPanel() {
const { chat } = useAppState();
return (
<MessagePanelRenderer
panel={chat.activePanel}
isChatSwitching={chat.isChatSwitching}
/>
);
}
@@ -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 "../../../resources/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,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,213 @@
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);
const getStyleValue = (computedStyle: CSSStyleDeclaration, prop: keyof CSSStyleDeclaration): number => {
const raw = (computedStyle as any)[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]);
const useEnhancedEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
useEnhancedEffect(() => {
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]);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
// Keep height responsive during rapid uncontrolled input bursts
syncHeight();
onTextChange(e.target.value);
};
const 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 "../../../resources/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 "../../../auth/api";
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>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useCallback, useRef } from "react";
import { useAppState } from "../state";
import { request } from "../../core/websocket";
import { API_BASE_URL } from "../../core/config";
import type { Message } from "../../core/types";
import { getAuthHeaders } from "../../auth/api";
import { delay } from "../../utils/utils";
export function useChat() {
const {
chat,
addMessage,
updateMessage,
removeMessage,
clearMessages,
setCurrentChat,
setActiveTab,
setDmUsers,
setActiveDm,
setIsChatSwitching,
user
} = useAppState();
const messagesLoadedRef = useRef(false);
// Load messages for the current chat
const loadMessages = useCallback(async () => {
if (!user.authToken || messagesLoadedRef.current) return;
try {
const response = await fetch(`${API_BASE_URL}/get_messages`, {
headers: getAuthHeaders(user.authToken)
});
if (response.ok) {
const data = await response.json();
if (data.messages && data.messages.length > 0) {
// Clear existing messages and add new ones
clearMessages();
data.messages.forEach((msg: Message) => {
addMessage(msg);
});
}
}
messagesLoadedRef.current = true;
} catch (error) {
console.error("Error loading messages:", error);
}
}, [user.authToken, addMessage, clearMessages]);
// Send a message
const sendMessage = useCallback(async (content: string) => {
if (!user.authToken || !content.trim()) return;
try {
const response = await request({
data: { content: content.trim() },
credentials: {
scheme: "Bearer",
credentials: user.authToken
},
type: "sendMessage"
});
if (response.error) {
console.error("Error sending message:", response.error);
}
} catch (error) {
console.error("Error sending message:", error);
}
}, [user.authToken]);
// WebSocket messages are now handled by the active panel
// No need for duplicate handling here
// Load messages only once when component mounts and user is authenticated
useEffect(() => {
if (user.authToken && !messagesLoadedRef.current) {
loadMessages();
}
}, [user.authToken, loadMessages]);
// Reset messages loaded flag and clear messages when chat changes
useEffect(() => {
(async () => {
setIsChatSwitching(true);
await delay(250);
messagesLoadedRef.current = false;
clearMessages(); // Clear messages when switching chats
loadMessages();
setIsChatSwitching(false);
})();
}, [chat.currentChat, clearMessages]);
return {
messages: chat.messages,
currentChat: chat.currentChat,
activeTab: chat.activeTab,
dmUsers: chat.dmUsers,
activeDm: chat.activeDm,
isChatSwitching: chat.isChatSwitching,
setIsChatSwitching,
sendMessage,
updateMessage,
removeMessage,
clearMessages,
setCurrentChat,
setActiveTab,
setDmUsers,
setActiveDm
};
}
@@ -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];
}
+299
View File
@@ -0,0 +1,299 @@
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";
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(() => {
const handleWebSocketMessage = async (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 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
}
}
+301
View File
@@ -0,0 +1,301 @@
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> {
if (this.dmData && !this.messagesLoaded) {
await this.loadMessages();
}
}
deactivate(): void {
// DM doesn't need special cleanup
}
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} }) || [],
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
handleWebSocketMessage = async (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);
}
};
// 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 {}
}
@@ -0,0 +1,347 @@
import type { Message } 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;
// Optional WebSocket message handler (can be overridden by subclasses)
handleWebSocketMessage?: (response: any) => 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 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,192 @@
import { MessagePanel } from "./MessagePanel";
import { API_BASE_URL } from "../../core/config";
import { getAuthHeaders } from "../../auth/api";
import { request } from "../../core/websocket";
import type { ChatWebSocketMessage, Message, SendMessageRequest } 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> {
if (!this.messagesLoaded) {
await this.loadMessages();
}
}
deactivate(): void {
// Public chat doesn't need special cleanup
}
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
handleWebSocketMessage = (response: ChatWebSocketMessage): 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;
}
};
// 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,141 @@
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 "../../auth/crypto";
import { API_BASE_URL } from "../../core/config";
import { useRef } from "react";
import type { TextField } from "mdui/components/text-field";
import { useAppState } from "../state";
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 setCurrentPage = useAppState(state => state.setCurrentPage);
const setUser = useAppState(state => state.setUser);
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);
}
setCurrentPage("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={() => setCurrentPage("register")}>
Зарегистрируйтесь
</a>
</p>
</div>
</div>
</AuthContainer>
)
}
@@ -0,0 +1,147 @@
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 { MaterialTextField } from "../components/core/TextField";
import { ensureKeysOnLogin } from "../../auth/crypto";
export default function RegisterScreen() {
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
const setCurrentPage = useAppState(state => state.setCurrentPage);
const setUser = useAppState(state => state.setUser);
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);
}
setCurrentPage("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={() => setCurrentPage("login")}>
Войдите
</a>
</p>
</div>
</div>
</AuthContainer>
)
}
+363
View File
@@ -0,0 +1,363 @@
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 "../auth/api";
import { restoreKeys } from "../auth/crypto";
import { API_BASE_URL } from "../core/config";
import { initialize, subscribe, startElectronReceiver, isSupported } from "../utils/push-notifications";
import { isElectron } from "../electron/electron";
type Page = "login" | "register" | "chat"
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;
isChatSwitching: boolean;
activePanel: MessagePanel | null;
publicChatPanel: PublicChatPanel | null;
dmPanel: DMPanel | null;
}
export interface UserState {
currentUser: User | null;
authToken: string | null;
}
interface AppState {
currentPage: Page;
setCurrentPage: (page: Page) => void;
// 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;
setIsChatSwitching: (value: boolean) => void;
setActivePanel: (panel: MessagePanel | null) => void;
switchToPublicChat: (chatName: string) => Promise<void>;
switchToDM: (dmData: DMPanelData) => Promise<void>;
switchToTab: (tab: ChatTabs) => Promise<void>;
// User state
user: UserState;
setUser: (token: string, user: User) => void;
logout: () => void;
restoreUserFromStorage: () => void;
}
export const useAppState = create<AppState>((set, get) => ({
currentPage: "login", // default page
setCurrentPage: (page: Page) => set({ currentPage: page }),
// Chat state
chat: {
messages: [],
currentChat: "Общий чат",
activeTab: "chats",
dmUsers: [],
activeDm: null,
isChatSwitching: false,
activePanel: null,
publicChatPanel: null,
dmPanel: null
},
setIsChatSwitching: (value: boolean) => set((state) => ({
chat: {
...state.chat,
isChatSwitching: value
}
})),
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
},
currentPage: "login"
}));
},
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
},
currentPage: "chat"
}));
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
}
})),
switchToPublicChat: async (chatName: string) => {
const state = get();
const { user, chat } = state;
if (!user.authToken) return;
// Start chat switching animation
state.setIsChatSwitching(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);
}
// Wait for animation
await new Promise(resolve => setTimeout(resolve, 250));
// Activate panel
await publicChatPanel.activate();
// Update state
set((state) => ({
chat: {
...state.chat,
activePanel: publicChatPanel,
publicChatPanel: publicChatPanel,
currentChat: chatName,
activeTab: "chats"
}
}));
// End animation
state.setIsChatSwitching(false);
},
switchToDM: async (dmData: DMPanelData) => {
const state = get();
const { user, chat } = state;
if (!user.authToken) return;
// Start chat switching animation
state.setIsChatSwitching(true);
// Create or get DM panel
let dmPanel = chat.dmPanel;
if (!dmPanel) {
dmPanel = new DMPanel(user);
} else {
dmPanel.setAuthToken(user.authToken);
}
// Set DM data
dmPanel.setDMData(dmData);
// Wait for animation
await new Promise(resolve => setTimeout(resolve, 250));
// Activate panel
await dmPanel.activate();
// Update state
set((state) => ({
chat: {
...state.chat,
activePanel: dmPanel,
dmPanel: dmPanel,
activeDm: {
userId: dmData.userId,
username: dmData.username,
publicKey: dmData.publicKey
},
activeTab: "dms"
}
}));
// End animation
state.setIsChatSwitching(false);
},
switchToTab: async (tab: ChatTabs) => {
const state = get();
state.setActiveTab(tab);
if (tab === "chats") {
await state.switchToPublicChat("Общий чат");
} else if (tab === "dms") {
// DM tab - no specific panel until user is selected
state.setActivePanel(null);
}
}
}));