mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Restructure
This commit is contained in:
@@ -1,17 +1,17 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { ElectronTitleBar } from "./pages/chat/ui/components/Electron";
|
||||
import { useAppState } from "./pages/chat/ui/state";
|
||||
import { ElectronTitleBar } from "./Electron";
|
||||
import { useAppState } from "./pages/chat/state";
|
||||
import { useEffect, useState } from "react";
|
||||
import HomePage from "./pages/home/HomePage";
|
||||
import LoginPage from "./pages/auth/LoginPage";
|
||||
import RegisterPage from "./pages/auth/RegisterPage";
|
||||
import ChatPage from "./pages/chat/ChatPage";
|
||||
import ChatPage from "./pages/chat/ui/ChatPage";
|
||||
import DownloadAppPage from "./pages/download-app/DownloadAppPage";
|
||||
import NotFoundPage from "./pages/not-found/NotFoundPage";
|
||||
import ProtectedRoute from "./pages/ProtectedRoute";
|
||||
import { isElectron } from "./core/electron/electron";
|
||||
import { MINIMUM_WIDTH } from "./core/config";
|
||||
import useWindowSize from "./pages/chat/ui/hooks/useWindowSize";
|
||||
import useWindowSize from "./core/hooks/useWindowSize";
|
||||
|
||||
export default function App() {
|
||||
const { restoreUserFromStorage } = useAppState();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PRODUCT_NAME } from "../../../../core/config";
|
||||
import { isElectron } from "../../../../core/electron/electron";
|
||||
import { PRODUCT_NAME } from "./core/config";
|
||||
import { isElectron } from "./core/electron/electron";
|
||||
|
||||
export function ElectronTitleBar() {
|
||||
return isElectron && (
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
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";
|
||||
import { id } from "../../utils/utils";
|
||||
import useCombinedRefs from "../hooks/useCombinedRefs";
|
||||
|
||||
export interface BaseDialogProps {
|
||||
onOpenChange: (value: boolean) => void;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAppState } from "./chat/ui/state";
|
||||
import { useAppState } from "./chat/state";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
|
||||
@@ -6,8 +6,8 @@ import { ensureKeysOnLogin } from "../../core/api/authApi";
|
||||
import { API_BASE_URL } from "../../core/config";
|
||||
import { useRef } from "react";
|
||||
import type { TextField } from "mdui/components/text-field";
|
||||
import { useAppState } from "../chat/ui/state";
|
||||
import { MaterialTextField } from "../chat/ui/components/core/TextField";
|
||||
import { useAppState } from "../chat/state";
|
||||
import { MaterialTextField } from "../../core/components/TextField";
|
||||
import { initialize, isSupported, startElectronReceiver, subscribe } from "../../core/push-notifications/push-notifications";
|
||||
import { isElectron } from "../../core/electron/electron";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -5,8 +5,8 @@ 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 "../chat/ui/state";
|
||||
import { MaterialTextField } from "../chat/ui/components/core/TextField";
|
||||
import { useAppState } from "../chat/state";
|
||||
import { MaterialTextField } from "../../core/components/TextField";
|
||||
import { ensureKeysOnLogin } from "../../core/api/authApi";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import "./auth.scss";
|
||||
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
fetchDMHistory,
|
||||
decryptDm,
|
||||
sendDMViaWebSocket
|
||||
} from "../../../../core/api/dmApi";
|
||||
import type { User, Message, DmEncryptedJSON } from "../../../../core/types";
|
||||
import { websocket } from "../../../../core/websocket";
|
||||
} from "../../../core/api/dmApi";
|
||||
import type { User, Message, DmEncryptedJSON } from "../../../core/types";
|
||||
import { websocket } from "../../../core/websocket";
|
||||
|
||||
export interface DMUser extends User {
|
||||
lastMessage?: string;
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useAppState } from "../state";
|
||||
import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "../../../../core/api/profileApi";
|
||||
import { showSuccess, showError } from "../../../../utils/notification";
|
||||
import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "../../../core/api/profileApi";
|
||||
import { showSuccess, showError } from "../../../utils/notification";
|
||||
|
||||
export default function useProfile() {
|
||||
const { user } = useAppState();
|
||||
@@ -1,14 +1,14 @@
|
||||
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 "../../../core/api/authApi";
|
||||
import { restoreKeys } from "../../../core/api/authApi";
|
||||
import { API_BASE_URL } from "../../../core/config";
|
||||
import { initialize, subscribe, startElectronReceiver, isSupported } from "../../../core/push-notifications/push-notifications";
|
||||
import { isElectron } from "../../../core/electron/electron";
|
||||
import type { Message, User } from "../../core/types";
|
||||
import { request } from "../../core/websocket";
|
||||
import { MessagePanel } from "./ui/right/panels/MessagePanel";
|
||||
import { PublicChatPanel } from "./ui/right/panels/PublicChatPanel";
|
||||
import { DMPanel, type DMPanelData } from "./ui/right/panels/DMPanel";
|
||||
import { getAuthHeaders } from "../../core/api/authApi";
|
||||
import { restoreKeys } from "../../core/api/authApi";
|
||||
import { API_BASE_URL } from "../../core/config";
|
||||
import { initialize, subscribe, startElectronReceiver, isSupported } from "../../core/push-notifications/push-notifications";
|
||||
import { isElectron } from "../../core/electron/electron";
|
||||
|
||||
export type ChatTabs = "chats" | "channels" | "contacts" | "dms"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LeftPanel } from "./ui/components/chat/LeftPanel";
|
||||
import { RightPanel } from "./ui/components/chat/RightPanel";
|
||||
import "./css/chat.scss";
|
||||
import { LeftPanel } from "./left/LeftPanel";
|
||||
import { RightPanel } from "./right/RightPanel";
|
||||
import "../css/chat.scss";
|
||||
|
||||
export default function ChatPage() {
|
||||
return (
|
||||
@@ -1,117 +0,0 @@
|
||||
import { useAppState } from "../../state";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { Reaction } from "../../../../../core/types";
|
||||
|
||||
interface MessageReactionsProps {
|
||||
reactions?: Reaction[];
|
||||
onReactionClick: (emoji: string) => void;
|
||||
messageId?: number; // Add messageId to ensure unique keys
|
||||
}
|
||||
|
||||
export function MessageReactions({ reactions, onReactionClick, messageId }: MessageReactionsProps) {
|
||||
const { user } = useAppState();
|
||||
const [visibleReactions, setVisibleReactions] = useState<Reaction[]>([]);
|
||||
const [animatingReactions, setAnimatingReactions] = useState<Set<string>>(new Set());
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// Handle reactions with animation
|
||||
useEffect(() => {
|
||||
if (!reactions || reactions.length === 0) {
|
||||
// If we have visible reactions, animate them out
|
||||
if (visibleReactions.length > 0) {
|
||||
visibleReactions.forEach(reaction => {
|
||||
setAnimatingReactions(prev => new Set(prev).add(reaction.emoji));
|
||||
});
|
||||
// After animation completes, hide the component
|
||||
setTimeout(() => {
|
||||
setVisibleReactions([]);
|
||||
setAnimatingReactions(new Set());
|
||||
setIsVisible(false);
|
||||
}, 200);
|
||||
} else {
|
||||
// No visible reactions, hide immediately
|
||||
setIsVisible(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the component when we have reactions
|
||||
setIsVisible(true);
|
||||
|
||||
// Deduplicate reactions by emoji (safety measure)
|
||||
const uniqueReactions = reactions.reduce((acc, reaction) => {
|
||||
const existing = acc.find(r => r.emoji === reaction.emoji);
|
||||
if (existing) {
|
||||
// Keep the one with the higher count
|
||||
if (reaction.count > existing.count) {
|
||||
acc[acc.indexOf(existing)] = reaction;
|
||||
}
|
||||
} else {
|
||||
acc.push(reaction);
|
||||
}
|
||||
return acc;
|
||||
}, [] as Reaction[]);
|
||||
|
||||
|
||||
// Animate out removed reactions
|
||||
visibleReactions.forEach(reaction => {
|
||||
if (!uniqueReactions.some(r => r.emoji === reaction.emoji)) {
|
||||
setAnimatingReactions(prev => new Set(prev).add(reaction.emoji));
|
||||
setTimeout(() => {
|
||||
setVisibleReactions(prev => prev.filter(r => r.emoji !== reaction.emoji));
|
||||
setAnimatingReactions(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(reaction.emoji);
|
||||
return newSet;
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
|
||||
// Update existing reactions and add new ones
|
||||
setVisibleReactions(prev => {
|
||||
const updated = [...prev];
|
||||
|
||||
// Update existing reactions
|
||||
uniqueReactions.forEach(reaction => {
|
||||
const existingIndex = updated.findIndex(r => r.emoji === reaction.emoji);
|
||||
if (existingIndex !== -1) {
|
||||
updated[existingIndex] = reaction;
|
||||
} else {
|
||||
// Add new reaction only if it doesn't already exist
|
||||
if (!updated.some(r => r.emoji === reaction.emoji)) {
|
||||
updated.push(reaction);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
}, [reactions]);
|
||||
|
||||
// Don't render if not visible
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="message-reactions">
|
||||
{visibleReactions.map((reaction, index) => {
|
||||
const hasUserReacted = reaction.users.some(u => u.id === user.currentUser?.id);
|
||||
const isAnimating = animatingReactions.has(reaction.emoji);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${messageId || 'unknown'}-${reaction.emoji}-${reaction.count}-${index}`}
|
||||
className={`reaction-button ${hasUserReacted ? "reacted" : ""} ${isAnimating ? "removing" : ""}`}
|
||||
onClick={() => onReactionClick(reaction.emoji)}
|
||||
title={reaction.users.map(u => u.username).join(", ")}
|
||||
>
|
||||
<span className="reaction-emoji">{reaction.emoji}</span>
|
||||
<span className="reaction-count">{reaction.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { PRODUCT_NAME } from "../../../../../core/config";
|
||||
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";
|
||||
import { ProfileDialog } from "./profile/ProfileDialog";
|
||||
|
||||
export function ChatHeader() {
|
||||
const { profileData } = useProfile();
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { useEffect } from "react";
|
||||
import { useDM, type DMUser } from "../../hooks/useDM";
|
||||
import { useAppState } from "../../state";
|
||||
import { fetchUserPublicKey } from "../../../../../core/api/dmApi";
|
||||
import defaultAvatar from "../../../../../images/default-avatar.png";
|
||||
import { fetchUserPublicKey } from "../../../../core/api/dmApi";
|
||||
import defaultAvatar from "../../../../images/default-avatar.png";
|
||||
|
||||
export function DMUsersList() {
|
||||
const { dmUsers, isLoadingUsers, loadUsers } = useDM();
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
import { PRODUCT_NAME } from "../../../../../core/config";
|
||||
import { PRODUCT_NAME } from "../../../../core/config";
|
||||
import { useAppState } from "../../state";
|
||||
import defaultAvatar from "../../../../../images/default-avatar.png";
|
||||
import defaultAvatar from "../../../../images/default-avatar.png";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { ProfileDialog } from "../profile/ProfileDialog";
|
||||
import { SettingsDialog } from "../settings/SettingsDialog";
|
||||
import { ProfileDialog } from "./profile/ProfileDialog";
|
||||
import { SettingsDialog } from "./settings/SettingsDialog";
|
||||
import { DMUsersList } from "./DMUsersList";
|
||||
import type { Tabs } from "mdui";
|
||||
import type { ChatTabs } from "../../state";
|
||||
+3
-3
@@ -2,10 +2,10 @@ import { useState, useEffect, useRef, type FormEvent } from "react";
|
||||
import defaultAvatar from "../../../../../images/default-avatar.png";
|
||||
import type { TextField } from "mdui/components/text-field";
|
||||
import type { DialogProps } from "../../../../../core/types";
|
||||
import { MaterialDialog } from "../core/Dialog";
|
||||
import useProfile from "../../hooks/useProfile";
|
||||
import { MaterialDialog } from "../../../../../core/components/Dialog";
|
||||
import useProfile from "../../../hooks/useProfile";
|
||||
import { ImageCropper } from "./ImageCropper";
|
||||
import { MaterialTextField } from "../core/TextField";
|
||||
import { MaterialTextField } from "../../../../../core/components/TextField";
|
||||
|
||||
export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
|
||||
const { profileData, isLoading, isUpdating, updateProfileData, uploadProfilePictureData } = useProfile();
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
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 { MaterialDialog } from "../../../../../core/components/Dialog";
|
||||
import { initialize, isSupported, startElectronReceiver, stopElectronReceiver, subscribe, unsubscribe } from "../../../../../core/push-notifications/push-notifications";
|
||||
import { isElectron } from "../../../../../core/electron/electron";
|
||||
import { useAppState } from "../../state";
|
||||
import { useAppState } from "../../../state";
|
||||
import type { Switch } from "mdui/components/switch";
|
||||
import { getAuthHeaders } from "../../../../../core/api/authApi";
|
||||
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { MaterialDialog } from "../core/Dialog";
|
||||
import { RichTextArea } from "../core/RichTextArea";
|
||||
import type { Message } from "../../../../../core/types";
|
||||
import Quote from "../core/Quote";
|
||||
import AnimatedHeight from "../core/animations/AnimatedHeight";
|
||||
import { MaterialDialog } from "../../../../core/components/Dialog";
|
||||
import { RichTextArea } from "../../../../core/components/RichTextArea";
|
||||
import type { Message } from "../../../../core/types";
|
||||
import Quote from "../../../../core/components/Quote";
|
||||
import AnimatedHeight from "../../../../core/components/animations/AnimatedHeight";
|
||||
import { useImmer } from "use-immer";
|
||||
import { EmojiMenu } from "./EmojiMenu";
|
||||
|
||||
+7
-7
@@ -1,15 +1,15 @@
|
||||
import { Message } from "./Message";
|
||||
import { useAppState } from "../../state";
|
||||
import type { Message as MessageType } from "../../../../../core/types";
|
||||
import type { UserProfile } from "../../../../../core/types";
|
||||
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 "../../../../../core/api/profileApi";
|
||||
import { fetchUserProfile } from "../../../../core/api/profileApi";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { delay } from "../../../../../utils/utils";
|
||||
import { MaterialDialog } from "../core/Dialog";
|
||||
import { request } from "../../../../../core/websocket";
|
||||
import type { AddReactionRequest, AddDmReactionRequest } from "../../../../../core/types";
|
||||
import { delay } from "../../../../utils/utils";
|
||||
import { MaterialDialog } from "../../../../core/components/Dialog";
|
||||
import { request } from "../../../../core/websocket";
|
||||
import type { AddReactionRequest, AddDmReactionRequest } from "../../../../core/types";
|
||||
|
||||
interface ChatMessagesProps {
|
||||
messages?: MessageType[];
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { EMOJI_CATEGORIES, getRecentEmojis, addRecentEmoji } from "./emojiData";
|
||||
import type { Size2D } from "../../../../../core/types";
|
||||
import type { Size2D } from "../../../../core/types";
|
||||
|
||||
interface BaseEmojiMenuProps {
|
||||
isOpen: boolean;
|
||||
+125
-11
@@ -1,19 +1,133 @@
|
||||
import { formatTime, id } from "../../../../../utils/utils";
|
||||
import type { Attachment, Message as MessageType } from "../../../../../core/types";
|
||||
import defaultAvatar from "../../../../../images/default-avatar.png";
|
||||
import Quote from "../core/Quote";
|
||||
import { formatTime, id } from "../../../../utils/utils";
|
||||
import type { Attachment, Message as MessageType, Reaction } from "../../../../core/types";
|
||||
import defaultAvatar from "../../../../images/default-avatar.png";
|
||||
import Quote from "../../../../core/components/Quote";
|
||||
import { parse } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { getCurrentKeys } from "../../../../../core/api/authApi";
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "../../../../../utils/crypto/asymmetric";
|
||||
import { importAesGcmKey, aesGcmDecrypt } from "../../../../../utils/crypto/symmetric";
|
||||
import { getAuthHeaders } from "../../../../../core/api/authApi";
|
||||
import { getCurrentKeys } from "../../../../core/api/authApi";
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "../../../../utils/crypto/asymmetric";
|
||||
import { importAesGcmKey, aesGcmDecrypt } from "../../../../utils/crypto/symmetric";
|
||||
import { getAuthHeaders } from "../../../../core/api/authApi";
|
||||
import { useAppState } from "../../state";
|
||||
import { ub64 } from "../../../../../utils/utils";
|
||||
import { ub64 } from "../../../../utils/utils";
|
||||
import { useImmer } from "use-immer";
|
||||
import { createPortal } from "react-dom";
|
||||
import { MessageReactions } from "./MessageReactions";
|
||||
|
||||
interface MessageReactionsProps {
|
||||
reactions?: Reaction[];
|
||||
onReactionClick: (emoji: string) => void;
|
||||
messageId?: number; // Add messageId to ensure unique keys
|
||||
}
|
||||
|
||||
function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsProps) {
|
||||
const { user } = useAppState();
|
||||
const [visibleReactions, setVisibleReactions] = useState<Reaction[]>([]);
|
||||
const [animatingReactions, setAnimatingReactions] = useState<Set<string>>(new Set());
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// Handle reactions with animation
|
||||
useEffect(() => {
|
||||
if (!reactions || reactions.length === 0) {
|
||||
// If we have visible reactions, animate them out
|
||||
if (visibleReactions.length > 0) {
|
||||
visibleReactions.forEach(reaction => {
|
||||
setAnimatingReactions(prev => new Set(prev).add(reaction.emoji));
|
||||
});
|
||||
// After animation completes, hide the component
|
||||
setTimeout(() => {
|
||||
setVisibleReactions([]);
|
||||
setAnimatingReactions(new Set());
|
||||
setIsVisible(false);
|
||||
}, 200);
|
||||
} else {
|
||||
// No visible reactions, hide immediately
|
||||
setIsVisible(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the component when we have reactions
|
||||
setIsVisible(true);
|
||||
|
||||
// Deduplicate reactions by emoji (safety measure)
|
||||
const uniqueReactions = reactions.reduce((acc, reaction) => {
|
||||
const existing = acc.find(r => r.emoji === reaction.emoji);
|
||||
if (existing) {
|
||||
// Keep the one with the higher count
|
||||
if (reaction.count > existing.count) {
|
||||
acc[acc.indexOf(existing)] = reaction;
|
||||
}
|
||||
} else {
|
||||
acc.push(reaction);
|
||||
}
|
||||
return acc;
|
||||
}, [] as Reaction[]);
|
||||
|
||||
|
||||
// Animate out removed reactions
|
||||
visibleReactions.forEach(reaction => {
|
||||
if (!uniqueReactions.some(r => r.emoji === reaction.emoji)) {
|
||||
setAnimatingReactions(prev => new Set(prev).add(reaction.emoji));
|
||||
setTimeout(() => {
|
||||
setVisibleReactions(prev => prev.filter(r => r.emoji !== reaction.emoji));
|
||||
setAnimatingReactions(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(reaction.emoji);
|
||||
return newSet;
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
|
||||
// Update existing reactions and add new ones
|
||||
setVisibleReactions(prev => {
|
||||
const updated = [...prev];
|
||||
|
||||
// Update existing reactions
|
||||
uniqueReactions.forEach(reaction => {
|
||||
const existingIndex = updated.findIndex(r => r.emoji === reaction.emoji);
|
||||
if (existingIndex !== -1) {
|
||||
updated[existingIndex] = reaction;
|
||||
} else {
|
||||
// Add new reaction only if it doesn't already exist
|
||||
if (!updated.some(r => r.emoji === reaction.emoji)) {
|
||||
updated.push(reaction);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
}, [reactions]);
|
||||
|
||||
// Don't render if not visible
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="message-reactions">
|
||||
{visibleReactions.map((reaction, index) => {
|
||||
const hasUserReacted = reaction.users.some(u => u.id === user.currentUser?.id);
|
||||
const isAnimating = animatingReactions.has(reaction.emoji);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${messageId || 'unknown'}-${reaction.emoji}-${reaction.count}-${index}`}
|
||||
className={`reaction-button ${hasUserReacted ? "reacted" : ""} ${isAnimating ? "removing" : ""}`}
|
||||
onClick={() => onReactionClick(reaction.emoji)}
|
||||
title={reaction.users.map(u => u.username).join(", ")}
|
||||
>
|
||||
<span className="reaction-emoji">{reaction.emoji}</span>
|
||||
<span className="reaction-count">{reaction.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
interface MessageProps {
|
||||
message: MessageType;
|
||||
@@ -375,7 +489,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
|
||||
</mdui-list>
|
||||
)}
|
||||
|
||||
<MessageReactions
|
||||
<Reactions
|
||||
reactions={message.reactions}
|
||||
onReactionClick={(emoji) => onReactionClick?.(message.id, emoji)}
|
||||
messageId={message.id}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { Message, Size2D } from "../../../../../core/types";
|
||||
import type { Message, Size2D } from "../../../../core/types";
|
||||
import { EmojiMenu } from "./EmojiMenu";
|
||||
|
||||
interface MessageContextMenuProps {
|
||||
+6
-6
@@ -1,13 +1,13 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useAppState } from "../../state";
|
||||
import { MessagePanel, type MessagePanelState } from "../../panels/MessagePanel";
|
||||
import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel";
|
||||
import { ChatMessages } from "./ChatMessages";
|
||||
import { ChatInputWrapper } from "./ChatInputWrapper";
|
||||
import { setGlobalMessageHandler } from "../../../../../core/websocket";
|
||||
import type { Message, WebSocketMessage } from "../../../../../core/types";
|
||||
import defaultAvatar from "../../../../../images/default-avatar.png";
|
||||
import AnimatedOpacity from "../core/animations/AnimatedOpacity";
|
||||
import type { DMPanel } from "../../panels/DMPanel";
|
||||
import { setGlobalMessageHandler } from "../../../../core/websocket";
|
||||
import type { Message, WebSocketMessage } from "../../../../core/types";
|
||||
import defaultAvatar from "../../../../images/default-avatar.png";
|
||||
import AnimatedOpacity from "../../../../core/components/animations/AnimatedOpacity";
|
||||
import type { DMPanel } from "./panels/DMPanel";
|
||||
|
||||
interface MessagePanelRendererProps {
|
||||
panel: MessagePanel | null;
|
||||
+5
-5
@@ -1,8 +1,8 @@
|
||||
import type { DialogProps } from "../../../../../core/types";
|
||||
import type { UserProfile } from "../../../../../core/types";
|
||||
import { MaterialDialog } from "../core/Dialog";
|
||||
import { formatTime } from "../../../../../utils/utils";
|
||||
import defaultAvatar from "../../../../../images/default-avatar.png";
|
||||
import type { DialogProps } from "../../../../core/types";
|
||||
import type { UserProfile } from "../../../../core/types";
|
||||
import { MaterialDialog } from "../../../../core/components/Dialog";
|
||||
import { formatTime } from "../../../../utils/utils";
|
||||
import defaultAvatar from "../../../../images/default-avatar.png";
|
||||
|
||||
interface UserProfileDialogProps extends DialogProps {
|
||||
userProfile: UserProfile | null;
|
||||
+3
-3
@@ -6,9 +6,9 @@ import {
|
||||
sendDmWithFiles,
|
||||
editDmEnvelope,
|
||||
deleteDmEnvelope
|
||||
} from "../../../../core/api/dmApi";
|
||||
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "../../../../core/types";
|
||||
import type { UserState } from "../state";
|
||||
} from "../../../../../core/api/dmApi";
|
||||
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "../../../../../core/types";
|
||||
import type { UserState } from "../../../state";
|
||||
|
||||
export interface DMPanelData {
|
||||
userId: number;
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import type { Message, WebSocketMessage } from "../../../../core/types";
|
||||
import type { UserState } from "../state";
|
||||
import type { Message, WebSocketMessage } from "../../../../../core/types";
|
||||
import type { UserState } from "../../../state";
|
||||
|
||||
export interface MessagePanelState {
|
||||
id: string;
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
import { MessagePanel } from "./MessagePanel";
|
||||
import { API_BASE_URL } from "../../../../core/config";
|
||||
import { getAuthHeaders } from "../../../../core/api/authApi";
|
||||
import { request } from "../../../../core/websocket";
|
||||
import type { ChatWebSocketMessage, Message, SendMessageRequest, ReactionUpdateWebSocketMessage } from "../../../../core/types";
|
||||
import type { UserState } from "../state";
|
||||
import { API_BASE_URL } from "../../../../../core/config";
|
||||
import { getAuthHeaders } from "../../../../../core/api/authApi";
|
||||
import { request } from "../../../../../core/websocket";
|
||||
import type { ChatWebSocketMessage, Message, SendMessageRequest, ReactionUpdateWebSocketMessage } from "../../../../../core/types";
|
||||
import type { UserState } from "../../../state";
|
||||
|
||||
export class PublicChatPanel extends MessagePanel {
|
||||
private messagesLoaded: boolean = false;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Navigate, useNavigate } from "react-router-dom";
|
||||
import { useAppState } from "../chat/ui/state";
|
||||
import { useAppState } from "../chat/state";
|
||||
import { isElectron } from "../../core/electron/electron";
|
||||
import "./home.scss";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user