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