From d6b2849e46265f82d56933a28b7f6746655ea795 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sun, 5 Oct 2025 22:56:43 +0300 Subject: [PATCH] Implement basic reactions --- backend/models.py | 36 +- backend/routes/messaging.py | 107 +++++- frontend/src/pages/app/core/types.d.ts | 33 +- .../src/pages/app/resources/css/_chat.scss | 4 +- .../pages/app/resources/css/_reactions.scss | 324 ++++++++++++++++++ .../app/resources/css/common/_components.scss | 4 +- .../src/pages/app/resources/css/style.scss | 1 + .../app/ui/components/chat/ChatMessages.tsx | 66 ++++ .../pages/app/ui/components/chat/Message.tsx | 10 +- .../ui/components/chat/MessageContextMenu.tsx | 173 +++++++--- .../ui/components/chat/MessageReactions.tsx | 108 ++++++ .../app/ui/components/chat/ReactionBar.tsx | 100 ++++++ .../src/pages/app/ui/panels/MessagePanel.ts | 8 + .../pages/app/ui/panels/PublicChatPanel.ts | 9 +- 14 files changed, 923 insertions(+), 60 deletions(-) create mode 100644 frontend/src/pages/app/resources/css/_reactions.scss create mode 100644 frontend/src/pages/app/ui/components/chat/MessageReactions.tsx create mode 100644 frontend/src/pages/app/ui/components/chat/ReactionBar.tsx diff --git a/backend/models.py b/backend/models.py index 8fcd58d..311c77d 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,5 +1,5 @@ from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, null, text +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, null, text, UniqueConstraint from sqlalchemy.orm import relationship from datetime import datetime from pydantic import BaseModel @@ -36,6 +36,7 @@ class Message(Base): author = relationship("User", back_populates="messages") reply_to = relationship("Message", remote_side=[id]) files = relationship("MessageFile", back_populates="message", cascade="all, delete-orphan", lazy="select") + reactions = relationship("Reaction", cascade="all, delete-orphan", lazy="select") class MessageFile(Base): @@ -106,6 +107,22 @@ class PushSubscription(Base): updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) +class Reaction(Base): + __tablename__ = "reaction" + + id = Column(Integer, primary_key=True, index=True) + message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True) + user_id = Column(Integer, ForeignKey("user.id"), nullable=False) + emoji = Column(String(10), nullable=False) # Store emoji as string + timestamp = Column(DateTime, default=datetime.now) + + # Relationships + user = relationship("User") + + # Ensure unique combination of message, user, and emoji + __table_args__ = (UniqueConstraint('message_id', 'user_id', 'emoji', name='unique_reaction'),) + + # Pydantic модели class LoginRequest(BaseModel): username: str @@ -166,5 +183,22 @@ class MessageResponse(BaseModel): from_attributes = True +class ReactionRequest(BaseModel): + message_id: int + emoji: str + + +class ReactionResponse(BaseModel): + id: int + message_id: int + user_id: int + emoji: str + timestamp: datetime + username: str + + class Config: + from_attributes = True + + # Tables are now created through Alembic migrations # Base.metadata.create_all(bind=engine) \ No newline at end of file diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 51ca55f..00ff7d0 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -10,7 +10,7 @@ from fastapi.security import HTTPAuthorizationCredentials from sqlalchemy.orm import Session from dependencies import get_current_user, get_db from constants import OWNER_USERNAME -from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile +from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse from push_service import push_service from PIL import Image import io @@ -31,6 +31,23 @@ os.makedirs(FILES_ENCRYPTED_DIR, exist_ok=True) def convert_message(msg: Message) -> dict: + # Group reactions by emoji + reactions_dict = {} + if msg.reactions: + for reaction in msg.reactions: + emoji = reaction.emoji + if emoji not in reactions_dict: + reactions_dict[emoji] = { + "emoji": emoji, + "count": 0, + "users": [] + } + reactions_dict[emoji]["count"] += 1 + reactions_dict[emoji]["users"].append({ + "id": reaction.user_id, + "username": reaction.user.username + }) + return { "id": msg.id, "content": msg.content, @@ -40,6 +57,7 @@ def convert_message(msg: Message) -> dict: "username": msg.author.username, "profile_picture": msg.author.profile_picture, "reply_to": convert_message(msg.reply_to) if msg.reply_to else None, + "reactions": list(reactions_dict.values()), "files": [ { "path": f"/api/uploads/files/normal/{Path(f.path).name}", @@ -436,6 +454,63 @@ async def delete_message( return {"status": "success", "message_id": message_id} + +@router.post("/add_reaction") +async def add_reaction( + request: ReactionRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + # Check if message exists + message = db.query(Message).filter(Message.id == request.message_id).first() + if not message: + raise HTTPException(status_code=404, detail="Message not found") + + # Check if reaction already exists + existing_reaction = db.query(Reaction).filter( + Reaction.message_id == request.message_id, + Reaction.user_id == current_user.id, + Reaction.emoji == request.emoji + ).first() + + if existing_reaction: + # Remove existing reaction (toggle off) + db.delete(existing_reaction) + action = "removed" + else: + # Add new reaction + new_reaction = Reaction( + message_id=request.message_id, + user_id=current_user.id, + emoji=request.emoji + ) + db.add(new_reaction) + action = "added" + + db.commit() + + # Refresh message to get updated reactions + db.refresh(message) + + # Broadcast reaction update + try: + from .messaging import messagingManager + await messagingManager.broadcast({ + "type": "reactionUpdate", + "data": { + "message_id": request.message_id, + "emoji": request.emoji, + "action": action, + "user_id": current_user.id, + "username": current_user.username, + "reactions": convert_message(message)["reactions"] + } + }) + except Exception: + pass + + return {"status": "success", "action": action, "reactions": convert_message(message)["reactions"]} + class MessaggingSocketManager: def __init__(self) -> None: self.connections: list[WebSocket] = [] @@ -670,6 +745,36 @@ class MessaggingSocketManager: "data": {"message_id": message_id} }) + await websocket.send_json({"type": type, "data": response}) + except HTTPException as e: + await self.send_error(websocket, type, e) + elif type == "addReaction": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + request_data = data["data"] + reaction_request = ReactionRequest( + message_id=request_data["message_id"], + emoji=request_data["emoji"] + ) + + response = await add_reaction(reaction_request, current_user, db) + + # Broadcast reaction update + await self.broadcast({ + "type": "reactionUpdate", + "data": { + "message_id": request_data["message_id"], + "emoji": request_data["emoji"], + "action": response["action"], + "user_id": current_user.id, + "username": current_user.username, + "reactions": response["reactions"] + } + }) + await websocket.send_json({"type": type, "data": response}) except HTTPException as e: await self.send_error(websocket, type, e) diff --git a/frontend/src/pages/app/core/types.d.ts b/frontend/src/pages/app/core/types.d.ts index 00d2ecc..351be24 100644 --- a/frontend/src/pages/app/core/types.d.ts +++ b/frontend/src/pages/app/core/types.d.ts @@ -51,6 +51,15 @@ export interface Rect extends Size2D { * @property {string} [profile_picture] - URL to sender's profile picture * @property {Message} [reply_to] - The message this is replying to */ +export interface Reaction { + emoji: string; + count: number; + users: Array<{ + id: number; + username: string; + }>; +} + export interface Message { id: number; username: string; @@ -61,6 +70,7 @@ export interface Message { profile_picture?: string; reply_to?: Message; files?: Attachment[]; + reactions?: Reaction[]; runtimeData?: { dmEnvelope?: DmEnvelope; @@ -313,6 +323,15 @@ export interface SendMessageRequest extends WebSocketMessage { } } +export interface AddReactionRequest extends WebSocketMessage { + type: "addReaction", + credentials: WebSocketCredentials; + data: { + message_id: number; + emoji: string; + } +} + // Messages export interface DMNewWebSocketMessage extends WebSocketMessage { type: "dmNew", @@ -348,9 +367,21 @@ export interface NewMessageWebSocketMessage extends WebSocketMessage { data: Message } +export interface ReactionUpdateWebSocketMessage extends WebSocketMessage { + type: "reactionUpdate", + data: { + message_id: number; + emoji: string; + action: "added" | "removed"; + user_id: number; + username: string; + reactions: Reaction[]; + } +} + // Shared types export type DMWebSocketMessage = DMNewWebSocketMessage | DMEditedWebSocketMessage | DMDeletedWebSocketMessage -export type ChatWebSocketMessage = MessageEditedWebSocketMessage | MessageDeletedWebSocketMessage | NewMessageWebSocketMessage +export type ChatWebSocketMessage = MessageEditedWebSocketMessage | MessageDeletedWebSocketMessage | NewMessageWebSocketMessage | ReactionUpdateWebSocketMessage // ----------- // Encrypted message JSON (plaintext structure before encryption) diff --git a/frontend/src/pages/app/resources/css/_chat.scss b/frontend/src/pages/app/resources/css/_chat.scss index 23dfe5a..016fda1 100644 --- a/frontend/src/pages/app/resources/css/_chat.scss +++ b/frontend/src/pages/app/resources/css/_chat.scss @@ -213,7 +213,7 @@ height: 32px; flex-shrink: 0; margin-bottom: 4px; - margin: 8px; + margin: 10px; img { width: 100%; @@ -234,7 +234,7 @@ margin-bottom: 0.3rem; font-size: 0.9rem; transition: color 0.2s ease; - margin: 8px; + margin: 10px; &:hover { color: $color-dark-primary; diff --git a/frontend/src/pages/app/resources/css/_reactions.scss b/frontend/src/pages/app/resources/css/_reactions.scss new file mode 100644 index 0000000..aa97cef --- /dev/null +++ b/frontend/src/pages/app/resources/css/_reactions.scss @@ -0,0 +1,324 @@ +@use "common/material" as *; +@use "sass:color"; + +// Reaction styles +.message-reactions { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 8px; + margin-left: 10px; + margin-right: 10px; +} + +.reaction-button { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border: none; + border-radius: 16px; + background-color: $color-dark-surface-container; + cursor: pointer; + transition: transform 0.2s ease, background-color 0.2s ease; + font-size: 1px; + min-height: 28px; + animation: reactionFadeIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + + &.removing { + animation: reactionFadeOut 0.2s ease forwards; + } + + &:hover { + background-color: $color-dark-surface-container-high; + transform: scale(1.05); + } + + &.reacted { + background-color: $color-dark-primary-container; + border-color: $color-dark-primary; + color: $color-dark-on-primary-container; + + &:hover { + background-color: color.adjust($color-dark-primary-container, $lightness: 20%); + } + } +} + +.reaction-emoji { + font-size: 17px; + line-height: 1; +} + +.reaction-count { + font-size: 12px; + font-weight: 500; + line-height: 1; +} + +// Reaction bar styles (standalone) +.reaction-bar { + background: $color-dark-surface-container; + border: 1px solid $color-dark-outline; + border-radius: 24px; + padding: 8px; + opacity: 1; + transition: all 0.15s ease; + backdrop-filter: blur(8px); + transform: translateY(0); + + &.closing { + opacity: 0; + transform: scale(0.8); + } +} + +// Context menu wrapper with animations +.context-menu-wrapper { + position: relative; + display: block; + + // Animation states + &.entering { + opacity: 0; + transform: scale(0.8); + animation: contextMenuEnter 0.2s ease forwards; + } + + &.entering-left { + opacity: 0; + transform: translateX(-20px) scale(0.8); + animation: contextMenuEnterLeft 0.2s ease forwards; + } + + &.entering-up { + opacity: 0; + transform: translateY(20px) scale(0.8); + animation: contextMenuEnterUp 0.2s ease forwards; + } + + &.entering-up-left { + opacity: 0; + transform: translateX(-20px) translateY(20px) scale(0.8); + animation: contextMenuEnterUpLeft 0.2s ease forwards; + } + + &.closing { + opacity: 1; + transform: scale(1); + animation: contextMenuClose 0.2s ease forwards; + } + + &.closing-left { + opacity: 1; + transform: translateX(0) scale(1); + animation: contextMenuCloseLeft 0.2s ease forwards; + } + + &.closing-up { + opacity: 1; + transform: translateY(0) scale(1); + animation: contextMenuCloseUp 0.2s ease forwards; + } + + &.closing-up-left { + opacity: 1; + transform: translateX(0) translateY(0) scale(1); + animation: contextMenuCloseUpLeft 0.2s ease forwards; + } +} + +// Reaction bar inside context menu wrapper +.context-menu-reaction-bar { + display: flex; + align-items: center; + gap: 4px; + padding: 8px 12px; + background: $color-dark-surface-container; + border: 1px solid $color-dark-outline; + border-radius: 16px; + position: absolute; + bottom: 100%; + min-width: 200px; + justify-content: center; + margin-bottom: 10px; + + &.left { + left: 0; + transform: translateX(0); + } + + &.right { + right: 0; + transform: translateX(0); + } +} + +.reaction-bar-content { + display: flex; + align-items: center; + gap: 4px; +} + +.reaction-emoji-button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: 16px; + background: transparent; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); + font-size: 18px; + + &:hover { + background: var(--mdui-color-surface-container-high); + transform: scale(1.3); + box-shadow: var(--mdui-elevation-1); + } + + &:active { + transform: scale(0.95); + transition: transform 0.1s ease; + } +} + +.reaction-expand-button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid var(--mdui-color-outline); + border-radius: 16px; + background: var(--mdui-color-surface); + cursor: pointer; + transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); + + &:hover { + background: var(--mdui-color-surface-container-high); + border-color: var(--mdui-color-primary); + transform: scale(1.1); + box-shadow: var(--mdui-elevation-1); + } + + &:active { + transform: scale(0.95); + transition: transform 0.1s ease; + } + + .material-symbols { + font-size: 18px; + color: var(--mdui-color-on-surface); + transition: transform 0.2s ease; + } + + &:hover .material-symbols { + transform: rotate(90deg); + } +} + +// Animation for reactions appearing/disappearing +@keyframes reactionFadeIn { + from { + opacity: 0; + transform: scale(0.8); + } + + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes reactionFadeOut { + from { + opacity: 1; + transform: scale(1); + } + + to { + opacity: 0; + transform: scale(0.8); + } +} + +// Context menu wrapper animations +@keyframes contextMenuEnter { + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes contextMenuEnterLeft { + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +@keyframes contextMenuEnterUp { + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes contextMenuEnterUpLeft { + to { + opacity: 1; + transform: translateX(0) translateY(0) scale(1); + } +} + +@keyframes contextMenuClose { + to { + opacity: 0; + transform: scale(0.8); + } +} + +@keyframes contextMenuCloseLeft { + to { + opacity: 0; + transform: translateX(-20px) scale(0.8); + } +} + +@keyframes contextMenuCloseUp { + to { + opacity: 0; + transform: translateY(20px) scale(0.8); + } +} + +@keyframes contextMenuCloseUpLeft { + to { + opacity: 0; + transform: translateX(-20px) translateY(20px) scale(0.8); + } +} + +// Mobile responsive +@media (max-width: 768px) { + .reaction-bar { + padding: 6px; + } + + .reaction-emoji-button, + .reaction-expand-button { + width: 28px; + height: 28px; + } + + .reaction-emoji-button { + font-size: 16px; + } + + .reaction-expand-button .material-symbols { + font-size: 16px; + } +} diff --git a/frontend/src/pages/app/resources/css/common/_components.scss b/frontend/src/pages/app/resources/css/common/_components.scss index 8bf680e..4005247 100644 --- a/frontend/src/pages/app/resources/css/common/_components.scss +++ b/frontend/src/pages/app/resources/css/common/_components.scss @@ -31,13 +31,13 @@ button, input { } .context-menu { - position: fixed; + position: relative; background-color: $color-dark-surface-container; border-radius: 8px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); padding: 0.5rem 0; z-index: 1000; - display: none; + display: block; min-width: 150px; max-width: 200px; white-space: nowrap; diff --git a/frontend/src/pages/app/resources/css/style.scss b/frontend/src/pages/app/resources/css/style.scss index 225262c..e129c88 100644 --- a/frontend/src/pages/app/resources/css/style.scss +++ b/frontend/src/pages/app/resources/css/style.scss @@ -12,6 +12,7 @@ @use "download-app"; @use "404" as not-found; @use "homepage"; +@use "reactions"; @use "lib/fonts/montserrat"; @use "lib/fonts/material-symbols"; diff --git a/frontend/src/pages/app/ui/components/chat/ChatMessages.tsx b/frontend/src/pages/app/ui/components/chat/ChatMessages.tsx index a275492..d73f795 100644 --- a/frontend/src/pages/app/ui/components/chat/ChatMessages.tsx +++ b/frontend/src/pages/app/ui/components/chat/ChatMessages.tsx @@ -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({ + 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 ( <>
@@ -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 */} + ); } diff --git a/frontend/src/pages/app/ui/components/chat/Message.tsx b/frontend/src/pages/app/ui/components/chat/Message.tsx index ba555ff..5e88677 100644 --- a/frontend/src/pages/app/ui/components/chat/Message.tsx +++ b/frontend/src/pages/app/ui/components/chat/Message.tsx @@ -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>(new Map()); const [loadedImages, updateLoadedImages] = useImmer>(new Set()); @@ -373,6 +375,12 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo )} + onReactionClick?.(message.id, emoji)} + messageId={message.id} + /> +
{formatTime(message.timestamp)} {message.is_edited ? " (edited)" : undefined} diff --git a/frontend/src/pages/app/ui/components/chat/MessageContextMenu.tsx b/frontend/src/pages/app/ui/components/chat/MessageContextMenu.tsx index 58f7f89..41ebb14 100644 --- a/frontend/src/pages/app/ui/components/chat/MessageContextMenu.tsx +++ b/frontend/src/pages/app/ui/components/chat/MessageContextMenu.tsx @@ -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; + 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(null); + const reactionBarRef = useRef(null); + const contextMenuRef = useRef(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 && (
e.stopPropagation()}> - {actions.map((action, i) => ( - action.show && ( -
+ {QUICK_REACTIONS.map((emoji, index) => ( +
- ) - ))} + {emoji} + + ))} + +
+ + {/* Context Menu */} +
+ {actions.map((action, i) => ( + action.show && ( +
+ {action.icon} + {action.label} +
+ ) + ))} +
) } diff --git a/frontend/src/pages/app/ui/components/chat/MessageReactions.tsx b/frontend/src/pages/app/ui/components/chat/MessageReactions.tsx new file mode 100644 index 0000000..06d4246 --- /dev/null +++ b/frontend/src/pages/app/ui/components/chat/MessageReactions.tsx @@ -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([]); + const [animatingReactions, setAnimatingReactions] = useState>(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 ( +
+ {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 ( + + ); + })} +
+ ); +} diff --git a/frontend/src/pages/app/ui/components/chat/ReactionBar.tsx b/frontend/src/pages/app/ui/components/chat/ReactionBar.tsx new file mode 100644 index 0000000..aa2ae75 --- /dev/null +++ b/frontend/src/pages/app/ui/components/chat/ReactionBar.tsx @@ -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 ( +
e.stopPropagation()} + > +
+ {QUICK_REACTIONS.map((emoji, index) => ( + + ))} + +
+
+ ); +} diff --git a/frontend/src/pages/app/ui/panels/MessagePanel.ts b/frontend/src/pages/app/ui/panels/MessagePanel.ts index c0c69bb..53d4872 100644 --- a/frontend/src/pages/app/ui/panels/MessagePanel.ts +++ b/frontend/src/pages/app/ui/panels/MessagePanel.ts @@ -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: [] }); } diff --git a/frontend/src/pages/app/ui/panels/PublicChatPanel.ts b/frontend/src/pages/app/ui/panels/PublicChatPanel.ts index 90c7d45..9d3d29a 100644 --- a/frontend/src/pages/app/ui/panels/PublicChatPanel.ts +++ b/frontend/src/pages/app/ui/panels/PublicChatPanel.ts @@ -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 { + async handleWebSocketMessage(response: ChatWebSocketMessage | ReactionUpdateWebSocketMessage): Promise { 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; } };