mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-23 19:45:05 +03:00
Implement basic reactions
This commit is contained in:
@@ -4,10 +4,13 @@ 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 { EmojiMenu } from "./EmojiMenu";
|
||||
import { fetchUserProfile } from "../../../api/profileApi";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { delay } from "../../../utils/utils";
|
||||
import { MaterialDialog } from "../core/Dialog";
|
||||
import { request } from "../../../core/websocket";
|
||||
import type { AddReactionRequest } from "../../../core/types";
|
||||
|
||||
interface ChatMessagesProps {
|
||||
messages?: MessageType[];
|
||||
@@ -39,6 +42,17 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [toBeDeleted, setToBeDeleted] = useState<{ id: number; isDm: boolean } | null>(null);
|
||||
|
||||
// Emoji menu state (for expanded emoji picker)
|
||||
const [emojiMenu, setEmojiMenu] = useState<{
|
||||
isOpen: boolean;
|
||||
message: MessageType | null;
|
||||
position: { x: number; y: number };
|
||||
}>({
|
||||
isOpen: false,
|
||||
message: null,
|
||||
position: { x: 0, y: 0 }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!deleteDialogOpen) {
|
||||
setToBeDeleted(null);
|
||||
@@ -107,6 +121,46 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReactionClick(messageId: number, emoji: string) {
|
||||
if (!user.authToken) return;
|
||||
|
||||
try {
|
||||
await request<AddReactionRequest["data"], any>({
|
||||
type: "addReaction",
|
||||
credentials: { scheme: "Bearer", credentials: user.authToken },
|
||||
data: {
|
||||
message_id: messageId,
|
||||
emoji: emoji
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to add reaction:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleEmojiMenuClose() {
|
||||
setEmojiMenu(prev => ({ ...prev, isOpen: false }));
|
||||
}
|
||||
|
||||
function handleExpandEmojiMenu(message: MessageType) {
|
||||
const messageElement = document.querySelector(`[data-id="${message.id}"]`);
|
||||
if (messageElement) {
|
||||
const rect = messageElement.getBoundingClientRect();
|
||||
setEmojiMenu({
|
||||
isOpen: true,
|
||||
message,
|
||||
position: { x: rect.left + rect.width / 2, y: rect.bottom + 10 }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleEmojiSelect(emoji: string) {
|
||||
if (emojiMenu.message) {
|
||||
handleReactionClick(emojiMenu.message.id, emoji);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="chat-messages" id="chat-messages">
|
||||
@@ -117,6 +171,7 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
isAuthor={message.username === user.currentUser?.username}
|
||||
onProfileClick={handleProfileClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onReactionClick={handleReactionClick}
|
||||
isLoadingProfile={isLoadingProfile}
|
||||
isDm={isDm}
|
||||
dmRecipientPublicKey={dmRecipientPublicKey} />
|
||||
@@ -153,11 +208,22 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
onReply={handleReply}
|
||||
onDelete={handleDelete}
|
||||
onRetry={handleRetry}
|
||||
onReactionClick={handleReactionClick}
|
||||
onExpandEmojiMenu={handleExpandEmojiMenu}
|
||||
position={contextMenu.position}
|
||||
isOpen={contextMenu.isOpen}
|
||||
onOpenChange={handleContextMenuOpenChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{/* Emoji Menu */}
|
||||
<EmojiMenu
|
||||
isOpen={emojiMenu.isOpen}
|
||||
onClose={handleEmojiMenuClose}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
position={emojiMenu.position}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,12 +13,14 @@ import { useAppState } from "../../state";
|
||||
import { ub64 } from "../../../utils/utils";
|
||||
import { useImmer } from "use-immer";
|
||||
import { createPortal } from "react-dom";
|
||||
import { MessageReactions } from "./MessageReactions";
|
||||
|
||||
interface MessageProps {
|
||||
message: MessageType;
|
||||
isAuthor: boolean;
|
||||
onProfileClick: (username: string) => void;
|
||||
onContextMenu: (e: React.MouseEvent, message: MessageType) => void;
|
||||
onReactionClick?: (messageId: number, emoji: string) => void;
|
||||
isLoadingProfile?: boolean;
|
||||
isDm?: boolean;
|
||||
dmRecipientPublicKey?: string;
|
||||
@@ -31,7 +33,7 @@ interface Rect {
|
||||
height: number
|
||||
}
|
||||
|
||||
export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false, dmRecipientPublicKey }: MessageProps) {
|
||||
export function Message({ message, isAuthor, onProfileClick, onContextMenu, onReactionClick, isLoadingProfile = false, isDm = false, dmRecipientPublicKey }: MessageProps) {
|
||||
const [formattedMessage, setFormattedMessage] = useState({ __html: "" });
|
||||
const [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map());
|
||||
const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set());
|
||||
@@ -373,6 +375,12 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
||||
</mdui-list>
|
||||
)}
|
||||
|
||||
<MessageReactions
|
||||
reactions={message.reactions}
|
||||
onReactionClick={(emoji) => onReactionClick?.(message.id, emoji)}
|
||||
messageId={message.id}
|
||||
/>
|
||||
|
||||
<div className="message-time">
|
||||
{formatTime(message.timestamp)}
|
||||
{message.is_edited ? " (edited)" : undefined}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { Message, Size2D } from "../../../core/types";
|
||||
|
||||
interface MessageContextMenuProps {
|
||||
@@ -8,6 +8,8 @@ interface MessageContextMenuProps {
|
||||
onReply: (message: Message) => void;
|
||||
onDelete: (message: Message) => void;
|
||||
onRetry?: (message: Message) => void;
|
||||
onReactionClick?: (messageId: number, emoji: string) => Promise<void>;
|
||||
onExpandEmojiMenu?: (message: Message) => void;
|
||||
position: Size2D;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
@@ -26,6 +28,8 @@ export function MessageContextMenu({
|
||||
onReply,
|
||||
onDelete,
|
||||
onRetry,
|
||||
onReactionClick,
|
||||
onExpandEmojiMenu,
|
||||
position,
|
||||
isOpen,
|
||||
onOpenChange
|
||||
@@ -34,64 +38,86 @@ export function MessageContextMenu({
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [calculatedPosition, setCalculatedPosition] = useState(position);
|
||||
const [animationClass, setAnimationClass] = useState('entering');
|
||||
const [reactionBarPosition, setReactionBarPosition] = useState<'left' | 'right'>('left');
|
||||
|
||||
// Refs for measuring actual dimensions
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const reactionBarRef = useRef<HTMLDivElement>(null);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 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
|
||||
// Use a small delay to ensure elements are rendered before measuring
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
if (wrapperRef.current && reactionBarRef.current && contextMenuRef.current) {
|
||||
// Get actual dimensions from DOM elements
|
||||
const reactionBarRect = reactionBarRef.current.getBoundingClientRect();
|
||||
const contextMenuRect = contextMenuRef.current.getBoundingClientRect();
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Calculate shared/combined rect dimensions
|
||||
const sharedRect = {
|
||||
width: Math.max(reactionBarRect.width, contextMenuRect.width),
|
||||
height: reactionBarRect.height + contextMenuRect.height
|
||||
};
|
||||
|
||||
let x = position.x;
|
||||
let y = position.y;
|
||||
let animation = 'entering';
|
||||
let reactionPosition: 'left' | 'right' = 'left';
|
||||
|
||||
// Check if shared rect would overflow and adjust position
|
||||
if (x + sharedRect.width > viewportWidth) {
|
||||
x = position.x - contextMenuRect.width - 25;
|
||||
animation = 'entering-left';
|
||||
reactionPosition = 'right';
|
||||
} else {
|
||||
reactionPosition = 'left';
|
||||
}
|
||||
|
||||
// Ensure menu doesn't go off the left edge
|
||||
if (x < 0) {
|
||||
x = 0;
|
||||
}
|
||||
|
||||
// Check if shared rect would overflow bottom edge
|
||||
if (y + sharedRect.height > viewportHeight) {
|
||||
y = viewportHeight - sharedRect.height;
|
||||
animation = 'entering-up';
|
||||
}
|
||||
|
||||
setCalculatedPosition({ x, y });
|
||||
setAnimationClass(animation);
|
||||
setReactionBarPosition(reactionPosition);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
}
|
||||
}, [isOpen, position, isAuthor]);
|
||||
|
||||
// Effect to handle clicks outside the context menu
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (isOpen && !isClosing) {
|
||||
// Check if the click is on a context menu element
|
||||
// Check if the click is on a context menu element or reaction bar
|
||||
const target = event.target as Element;
|
||||
if (!target.closest('.context-menu')) {
|
||||
if (!target.closest('.context-menu') && !target.closest('.context-menu-reaction-bar')) {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && isOpen && !isClosing) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleWindowBlur = () => {
|
||||
function handleWindowBlur() {
|
||||
// Close context menu when browser window loses focus
|
||||
if (isOpen && !isClosing) {
|
||||
handleClose();
|
||||
@@ -123,6 +149,7 @@ export function MessageContextMenu({
|
||||
setIsClosing(false);
|
||||
setAnimationClass('entering'); // Reset for next opening
|
||||
}, 200); // Match the animation duration from _animations.scss
|
||||
// TODO no hardcoded delays
|
||||
}
|
||||
|
||||
interface Action {
|
||||
@@ -178,29 +205,75 @@ export function MessageContextMenu({
|
||||
},
|
||||
];
|
||||
|
||||
// Quick reactions for the reaction bar
|
||||
const QUICK_REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "😡"];
|
||||
|
||||
async function handleReactionClick(emoji: string) {
|
||||
if (onReactionClick) {
|
||||
await onReactionClick(message.id, emoji);
|
||||
}
|
||||
handleClose();
|
||||
}
|
||||
|
||||
function handleExpandClick() {
|
||||
if (onExpandEmojiMenu) {
|
||||
onExpandEmojiMenu(message);
|
||||
}
|
||||
handleClose();
|
||||
}
|
||||
|
||||
return isOpen && (
|
||||
<div
|
||||
className={`context-menu ${animationClass}`}
|
||||
ref={wrapperRef}
|
||||
className={`context-menu-wrapper ${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}
|
||||
|
||||
{/* Reaction Bar */}
|
||||
<div
|
||||
ref={reactionBarRef}
|
||||
className={`context-menu-reaction-bar ${reactionBarPosition}`}>
|
||||
{QUICK_REACTIONS.map((emoji, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="reaction-emoji-button"
|
||||
onClick={async () => await handleReactionClick(emoji)}
|
||||
title={emoji}
|
||||
>
|
||||
<span className="material-symbols">{action.icon}</span>
|
||||
{action.label}
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="reaction-expand-button"
|
||||
onClick={handleExpandClick}
|
||||
title="More emojis"
|
||||
>
|
||||
<span className="material-symbols">add</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Context Menu */}
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="context-menu">
|
||||
{actions.map((action, i) => (
|
||||
action.show && (
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={action.onClick}
|
||||
key={i}
|
||||
>
|
||||
<span className="material-symbols">{action.icon}</span>
|
||||
{action.label}
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
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());
|
||||
|
||||
// Handle reactions with animation
|
||||
useEffect(() => {
|
||||
if (!reactions || reactions.length === 0) {
|
||||
// Animate out all visible reactions
|
||||
visibleReactions.forEach(reaction => {
|
||||
setAnimatingReactions(prev => new Set(prev).add(reaction.emoji));
|
||||
setTimeout(() => {
|
||||
setVisibleReactions([]);
|
||||
setAnimatingReactions(new Set());
|
||||
}, 200);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 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]);
|
||||
|
||||
if (!reactions || reactions.length === 0) {
|
||||
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);
|
||||
|
||||
// Create a unique key that includes messageId, emoji, count, and index to prevent duplicates
|
||||
const uniqueKey = `${messageId || 'unknown'}-${reaction.emoji}-${reaction.count}-${index}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={uniqueKey}
|
||||
className={`reaction-button ${hasUserReacted ? "reacted" : ""} ${isAnimating ? "removing" : ""}`}
|
||||
onClick={() => onReactionClick(reaction.emoji)}
|
||||
title={reaction.users.map(u => u.username).join(", ")}
|
||||
>
|
||||
<span className="reaction-emoji">{reaction.emoji}</span>
|
||||
<span className="reaction-count">{reaction.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { Size2D } from "../../../core/types";
|
||||
|
||||
interface ReactionBarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onEmojiSelect: (emoji: string) => void;
|
||||
onExpandClick: () => void;
|
||||
position: Size2D;
|
||||
}
|
||||
|
||||
// Most common emojis for quick reactions
|
||||
const QUICK_REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "😡"];
|
||||
|
||||
export function ReactionBar({ isOpen, onClose, onEmojiSelect, onExpandClick, position }: ReactionBarProps) {
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [calculatedPosition, setCalculatedPosition] = useState(position);
|
||||
|
||||
function handleEmojiClick(emoji: string) {
|
||||
onEmojiSelect(emoji);
|
||||
handleClose();
|
||||
}
|
||||
|
||||
// Smart positioning logic to avoid screen edge clipping
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const barWidth = 240; // Approximate width of reaction bar (6 emojis + expand button)
|
||||
const barHeight = 48; // Approximate height
|
||||
const padding = 10; // Padding from viewport edges
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
|
||||
let x = position.x;
|
||||
let y = position.y - barHeight - 20; // 20px above the position
|
||||
|
||||
// Check if bar would overflow right edge
|
||||
if (x + barWidth + padding > viewportWidth) {
|
||||
x = viewportWidth - barWidth - padding;
|
||||
}
|
||||
|
||||
// Check if bar would overflow left edge
|
||||
if (x < padding) {
|
||||
x = padding;
|
||||
}
|
||||
|
||||
// Check if bar would overflow top edge
|
||||
if (y < padding) {
|
||||
y = position.y + 40; // Position below instead of above
|
||||
}
|
||||
|
||||
setCalculatedPosition({ x, y });
|
||||
}
|
||||
}, [isOpen, position]);
|
||||
|
||||
function handleClose() {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
setIsClosing(false);
|
||||
}, 150);
|
||||
}
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`reaction-bar ${isClosing ? "closing" : ""}`}
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: calculatedPosition.x,
|
||||
top: calculatedPosition.y,
|
||||
zIndex: 1001 // Higher than context menu to appear above it
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="reaction-bar-content">
|
||||
{QUICK_REACTIONS.map((emoji, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="reaction-emoji-button"
|
||||
onClick={() => handleEmojiClick(emoji)}
|
||||
title={emoji}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="reaction-expand-button"
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
onExpandClick();
|
||||
}}
|
||||
title="More emojis"
|
||||
>
|
||||
<span className="material-symbols">add</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -86,6 +86,14 @@ export abstract class MessagePanel {
|
||||
});
|
||||
}
|
||||
|
||||
protected updateMessageReactions(messageId: number, reactions: any[]): void {
|
||||
this.updateState({
|
||||
messages: this.state.messages.map(msg =>
|
||||
msg.id === messageId ? { ...msg, reactions } : msg
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
protected clearMessages(): void {
|
||||
this.updateState({ messages: [] });
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { ChatWebSocketMessage, Message, SendMessageRequest, ReactionUpdateWebSocketMessage } from "../../core/types";
|
||||
import type { UserState } from "../state";
|
||||
|
||||
export class PublicChatPanel extends MessagePanel {
|
||||
@@ -104,7 +104,7 @@ export class PublicChatPanel extends MessagePanel {
|
||||
}
|
||||
|
||||
// Handle incoming WebSocket messages
|
||||
async handleWebSocketMessage(response: ChatWebSocketMessage): Promise<void> {
|
||||
async handleWebSocketMessage(response: ChatWebSocketMessage | ReactionUpdateWebSocketMessage): Promise<void> {
|
||||
switch (response.type) {
|
||||
case 'messageEdited':
|
||||
if (response.data) {
|
||||
@@ -136,6 +136,11 @@ export class PublicChatPanel extends MessagePanel {
|
||||
this.addMessage(newMsg);
|
||||
}
|
||||
break;
|
||||
case 'reactionUpdate':
|
||||
if (response.data) {
|
||||
this.updateMessageReactions(response.data.message_id, response.data.reactions);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user