diff --git a/.cursor/commands/clean-up.md b/.cursor/commands/clean-up.md new file mode 100644 index 0000000..b18b60b --- /dev/null +++ b/.cursor/commands/clean-up.md @@ -0,0 +1,4 @@ +View git diff between the branch i specified and HEAD. If no branch is specified, +default to main. Identify code that needs to be cleaned up, like debug logs, +unused variables etc. Think twice before removing or adding code, because you +mustn't alter the behavior. \ No newline at end of file diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc index 48cf9bd..0eefb7f 100644 --- a/.cursor/rules/general.mdc +++ b/.cursor/rules/general.mdc @@ -19,7 +19,7 @@ When working with this project, follow these rules: ## File Operations - If possible, try to update files in a single edit when making multiple changes. -- Do NOT "cd" to the project directory.å +- Do NOT "cd" to the project directory. ## Testing & Validation - Do NOT "test the implementation" when you are done. The only exception is when you diff --git a/backend/models.py b/backend/models.py index 8fcd58d..53123f9 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): @@ -79,6 +80,7 @@ class DMEnvelope(Base): reply_to_id = Column(Integer, nullable=True) timestamp = Column(DateTime, default=datetime.now) files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select") + reactions = relationship("DMReaction", cascade="all, delete-orphan", lazy="select") class DMFile(Base): @@ -106,6 +108,39 @@ 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'),) + + +class DMReaction(Base): + __tablename__ = "dm_reaction" + + id = Column(Integer, primary_key=True, index=True) + dm_envelope_id = Column(Integer, ForeignKey("dm_envelope.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") + dm_envelope = relationship("DMEnvelope") + + # Ensure unique combination of dm_envelope, user, and emoji + __table_args__ = (UniqueConstraint('dm_envelope_id', 'user_id', 'emoji', name='unique_dm_reaction'),) + + # Pydantic модели class LoginRequest(BaseModel): username: str @@ -166,5 +201,39 @@ 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 + + +class DMReactionRequest(BaseModel): + dm_envelope_id: int + emoji: str + + +class DMReactionResponse(BaseModel): + id: int + dm_envelope_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..f99f8d7 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, DMReaction, DMReactionRequest, DMReactionResponse 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}", @@ -51,6 +69,47 @@ def convert_message(msg: Message) -> dict: ] } + +def convert_dm_envelope(envelope: DMEnvelope) -> dict: + # Group reactions by emoji + reactions_dict = {} + if envelope.reactions: + for reaction in envelope.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": envelope.id, + "senderId": envelope.sender_id, + "recipientId": envelope.recipient_id, + "iv": envelope.iv_b64, + "ciphertext": envelope.ciphertext_b64, + "salt": envelope.salt_b64, + "iv2": envelope.iv2_b64, + "wrappedMk": envelope.wrapped_mk_b64, + "timestamp": envelope.timestamp.isoformat(), + "reactions": list(reactions_dict.values()), + "files": [ + { + "path": f"/api/uploads/files/encrypted/{Path(f.path).name}", + "id": f.id, + "name": f.name, + "dm_envelope_id": f.dm_envelope_id + } + for f in (envelope.files or []) + ] + } + # для тех кто читает этот код я эти маты не писал # мат писал ии а я сам не матерюсь)) # - denis0001-dev @@ -436,6 +495,125 @@ 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"]} + + +@router.post("/dm/add_reaction") +async def add_dm_reaction( + request: DMReactionRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + # Check if DM envelope exists + envelope = db.query(DMEnvelope).filter(DMEnvelope.id == request.dm_envelope_id).first() + if not envelope: + raise HTTPException(status_code=404, detail="DM envelope not found") + + # Check if user is part of this DM conversation + if current_user.id not in [envelope.sender_id, envelope.recipient_id]: + raise HTTPException(status_code=403, detail="Not authorized to react to this message") + + # Check if reaction already exists + existing_reaction = db.query(DMReaction).filter( + DMReaction.dm_envelope_id == request.dm_envelope_id, + DMReaction.user_id == current_user.id, + DMReaction.emoji == request.emoji + ).first() + + if existing_reaction: + # Remove existing reaction (toggle off) + db.delete(existing_reaction) + action = "removed" + else: + # Add new reaction + new_reaction = DMReaction( + dm_envelope_id=request.dm_envelope_id, + user_id=current_user.id, + emoji=request.emoji + ) + db.add(new_reaction) + action = "added" + + db.commit() + + # Refresh envelope to get updated reactions + db.refresh(envelope) + + # Broadcast reaction update to both participants + try: + from .messaging import messagingManager + await messagingManager.broadcast({ + "type": "dmReactionUpdate", + "data": { + "dm_envelope_id": request.dm_envelope_id, + "emoji": request.emoji, + "action": action, + "user_id": current_user.id, + "username": current_user.username, + "reactions": convert_dm_envelope(envelope)["reactions"] + } + }) + except Exception: + pass + + return {"status": "success", "action": action, "reactions": convert_dm_envelope(envelope)["reactions"]} + + class MessaggingSocketManager: def __init__(self) -> None: self.connections: list[WebSocket] = [] @@ -670,6 +848,66 @@ 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) + elif type == "addDmReaction": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + request_data = data["data"] + reaction_request = DMReactionRequest( + dm_envelope_id=request_data["dm_envelope_id"], + emoji=request_data["emoji"] + ) + + response = await add_dm_reaction(reaction_request, current_user, db) + + # Broadcast reaction update + await self.broadcast({ + "type": "dmReactionUpdate", + "data": { + "dm_envelope_id": request_data["dm_envelope_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..c913612 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; @@ -202,6 +212,7 @@ export interface DmEnvelope extends BaseDmEnvelope { senderId: number; files?: DmFile[]; timestamp: string; + reactions?: Reaction[]; } export interface DmFile { @@ -313,6 +324,24 @@ export interface SendMessageRequest extends WebSocketMessage { } } +export interface AddReactionRequest extends WebSocketMessage { + type: "addReaction", + credentials: WebSocketCredentials; + data: { + message_id: number; + emoji: string; + } +} + +export interface AddDmReactionRequest extends WebSocketMessage { + type: "addDmReaction", + credentials: WebSocketCredentials; + data: { + dm_envelope_id: number; + emoji: string; + } +} + // Messages export interface DMNewWebSocketMessage extends WebSocketMessage { type: "dmNew", @@ -348,9 +377,33 @@ 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[]; + } +} + +export interface DMReactionUpdateWebSocketMessage extends WebSocketMessage { + type: "dmReactionUpdate", + data: { + dm_envelope_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 DMWebSocketMessage = DMNewWebSocketMessage | DMEditedWebSocketMessage | DMDeletedWebSocketMessage | DMReactionUpdateWebSocketMessage +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..a333156 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; @@ -968,4 +968,17 @@ color: $color-dark-on-surface-variant; font-size: 0.9rem; } + + // Integrated mode styles (inside reaction bar) + &.integrated { + position: relative !important; + width: 320px !important; + height: 400px !important; + transform: none !important; + opacity: 1 !important; + box-shadow: none; + border: none; + background: $color-dark-surface-container; + overflow: visible; + } } 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..f64402d --- /dev/null +++ b/frontend/src/pages/app/resources/css/_reactions.scss @@ -0,0 +1,389 @@ +@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; + animation: messageReactionsFadeIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.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); + } +} + +// Emoji menu wrapper inside reaction bar +.emoji-menu-wrapper { + width: 320px; + height: 400px; + display: flex; + align-items: center; + justify-content: center; +} + +// 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%; + justify-content: center; + margin-bottom: 10px; + transition: width 0.3s ease-out, height 0.3s ease-out; + + &.left { + left: 0; + transform: translateX(0); + } + + &.right { + right: 0; + transform: translateX(0); + } + + &.expanded { + padding: 0; + overflow: hidden; + width: 320px; + height: 400px; + border-radius: 16px; + + // Default: expand downward from the reaction bar's bottom edge + position: absolute; + bottom: auto; + top: 0; + left: 0; + transform: translateY(0); + + &.expand-upward { + // Expand upward from the reaction bar's top edge + bottom: 100%; + top: auto; + margin-bottom: 10px; + margin-top: 0; + transform: translateY(0); + } + + .emoji-menu-wrapper { + animation: emojiMenuEnter 0.5s ease; + } + } +} + +@keyframes emojiMenuEnter { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.reaction-bar-content { + display: flex; + align-items: center; + gap: 4px; + transition: opacity 0.3s ease-out; + + &.faded { + opacity: 0; + } +} + +.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 messageReactionsFadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@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..4567775 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; @@ -82,6 +82,11 @@ button, input { &.open { animation: context-menu-open 0.25s ease; } + + &.faded { + opacity: 0; + transition: opacity 0.3s ease-out; + } @keyframes context-menu-open { 0% { 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/ChatInputWrapper.tsx b/frontend/src/pages/app/ui/components/chat/ChatInputWrapper.tsx index 502d932..89df0f9 100644 --- a/frontend/src/pages/app/ui/components/chat/ChatInputWrapper.tsx +++ b/frontend/src/pages/app/ui/components/chat/ChatInputWrapper.tsx @@ -66,18 +66,24 @@ export function ChatInputWrapper( setAttachmentsVisible(selectedFiles.length > 0); }, [selectedFiles]); - function handleEmojiButtonClick() { - 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({ - x: panelRect.left + 10, // 10px from message panel edge - y: window.innerHeight - inputRect.top + 10 // 10px above the top of chat input - }); - setEmojiMenuOpen(true); + function handleEmojiButtonClick(e: React.MouseEvent) { + e.stopPropagation(); + + if (!emojiMenuOpen) { + 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({ + x: panelRect.left + 10, // 10px from message panel edge + y: window.innerHeight - inputRect.top + 10 // 10px above the top of chat input + }); + setEmojiMenuOpen(true); + } + } else { + setEmojiMenuOpen(false); } }; @@ -176,7 +182,12 @@ export function ChatInputWrapper(
- + e.stopPropagation()} + onMouseUp={e => e.stopPropagation()} + className="emoji-btn" />
setEmojiMenuOpen(false)} onEmojiSelect={handleEmojiSelect} position={emojiMenuPosition} + mode="standalone" />
); diff --git a/frontend/src/pages/app/ui/components/chat/ChatMessages.tsx b/frontend/src/pages/app/ui/components/chat/ChatMessages.tsx index a275492..efdf97c 100644 --- a/frontend/src/pages/app/ui/components/chat/ChatMessages.tsx +++ b/frontend/src/pages/app/ui/components/chat/ChatMessages.tsx @@ -8,6 +8,8 @@ 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, AddDmReactionRequest } from "../../../core/types"; interface ChatMessagesProps { messages?: MessageType[]; @@ -39,6 +41,7 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [toBeDeleted, setToBeDeleted] = useState<{ id: number; isDm: boolean } | null>(null); + useEffect(() => { if (!deleteDialogOpen) { setToBeDeleted(null); @@ -107,6 +110,43 @@ 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({ + type: "addDmReaction", + credentials: { scheme: "Bearer", credentials: user.authToken }, + data: { + dm_envelope_id: dmEnvelopeId, + emoji: emoji + } + }); + } + } else { + // For regular chat messages + 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); + } + } + + + return ( <>
@@ -117,6 +157,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 +194,14 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel onReply={handleReply} onDelete={handleDelete} onRetry={handleRetry} + onReactionClick={handleReactionClick} position={contextMenu.position} isOpen={contextMenu.isOpen} onOpenChange={handleContextMenuOpenChange} /> )} + + ); } diff --git a/frontend/src/pages/app/ui/components/chat/EmojiMenu.tsx b/frontend/src/pages/app/ui/components/chat/EmojiMenu.tsx index 076b099..c701c4c 100644 --- a/frontend/src/pages/app/ui/components/chat/EmojiMenu.tsx +++ b/frontend/src/pages/app/ui/components/chat/EmojiMenu.tsx @@ -2,14 +2,26 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { EMOJI_CATEGORIES, getRecentEmojis, addRecentEmoji } from "./emojiData"; import type { Size2D } from "../../../core/types"; -interface EmojiMenuProps { +interface BaseEmojiMenuProps { isOpen: boolean; onClose: () => void; onEmojiSelect: (emoji: string) => void; - position: Size2D; } -export function EmojiMenu({ isOpen, onClose, onEmojiSelect, position }: EmojiMenuProps) { +interface StandaloneEmojiMenuProps extends BaseEmojiMenuProps { + position: Size2D; + mode: "standalone"; +} + +interface IntegratedEmojiMenuProps extends BaseEmojiMenuProps { + mode: "integrated"; +} + +type EmojiMenuProps = StandaloneEmojiMenuProps | IntegratedEmojiMenuProps; + +export function EmojiMenu(props: EmojiMenuProps) { + const { isOpen, onClose, onEmojiSelect, mode } = props; + const position = mode === "standalone" ? props.position : undefined; const [activeCategory, setActiveCategory] = useState("recent"); const [recentEmojis, setRecentEmojis] = useState([]); const menuRef = useRef(null); @@ -106,13 +118,15 @@ export function EmojiMenu({ isOpen, onClose, onEmojiSelect, position }: EmojiMen return (
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..81fe39a 100644 --- a/frontend/src/pages/app/ui/components/chat/MessageContextMenu.tsx +++ b/frontend/src/pages/app/ui/components/chat/MessageContextMenu.tsx @@ -1,5 +1,6 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import type { Message, Size2D } from "../../../core/types"; +import { EmojiMenu } from "./EmojiMenu"; interface MessageContextMenuProps { message: Message; @@ -8,6 +9,7 @@ interface MessageContextMenuProps { onReply: (message: Message) => void; onDelete: (message: Message) => void; onRetry?: (message: Message) => void; + onReactionClick?: (messageId: number, emoji: string) => Promise; position: Size2D; isOpen: boolean; onOpenChange: (isOpen: boolean) => void; @@ -26,6 +28,7 @@ export function MessageContextMenu({ onReply, onDelete, onRetry, + onReactionClick, position, isOpen, onOpenChange @@ -34,64 +37,91 @@ 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'); + const [isEmojiMenuExpanded, setIsEmojiMenuExpanded] = useState(false); + const [initialDimensions, setInitialDimensions] = useState<{ width: number; height: number } | null>(null); + const [expandUpward, setExpandUpward] = useState(false); + const [contextMenuHeight, setContextMenuHeight] = useState(null); + + // Refs for measuring actual dimensions + const wrapperRef = useRef(null); + const reactionBarRef = useRef(null); + const contextMenuRef = useRef(null); + const emojiMenuRef = 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(); @@ -122,6 +152,11 @@ export function MessageContextMenu({ onOpenChange(false); setIsClosing(false); setAnimationClass('entering'); // Reset for next opening + // Reset emoji menu state after context menu animation completes + setIsEmojiMenuExpanded(false); + setInitialDimensions(null); + setExpandUpward(false); + setContextMenuHeight(null); }, 200); // Match the animation duration from _animations.scss } @@ -178,29 +213,127 @@ 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 (!reactionBarRef.current || !wrapperRef.current) return; + + // 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); + + // Check if expanding downward would cause overflow + // Calculate space from the reaction bar's bottom edge downward + const viewportHeight = window.innerHeight; + const spaceBelow = viewportHeight - reactionBarRect.bottom; + const emojiMenuHeight = 400; + + // Only expand upward if there's not enough space below for the emoji menu + const shouldExpandUpward = spaceBelow < emojiMenuHeight; + setExpandUpward(shouldExpandUpward); + + // Use requestAnimationFrame to ensure the dimensions are applied before expansion + requestAnimationFrame(() => { + setIsEmojiMenuExpanded(true); + }); + } + + function handleEmojiSelect(emoji: string) { + if (onReactionClick) { + onReactionClick(message.id, emoji); + } + handleClose(); + } + return isOpen && (
e.stopPropagation()}> - {actions.map((action, i) => ( - action.show && ( -
- {action.icon} - {action.label} + + {/* Reaction Bar */} +
+ {!isEmojiMenuExpanded ? ( +
+ {QUICK_REACTIONS.map((emoji, index) => ( + + ))} +
- ) - ))} + ) : ( +
+ +
+ )} +
+ + {/* Context Menu */} +
+ {actions.map((action, i) => ( + action.show && ( +
+ {action.icon} + {action.label} +
+ ) + ))} +
+
) } diff --git a/frontend/src/pages/app/ui/components/chat/MessagePanelRenderer.tsx b/frontend/src/pages/app/ui/components/chat/MessagePanelRenderer.tsx index 4bd3607..da64a2f 100644 --- a/frontend/src/pages/app/ui/components/chat/MessagePanelRenderer.tsx +++ b/frontend/src/pages/app/ui/components/chat/MessagePanelRenderer.tsx @@ -20,6 +20,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { const [switchIn, setSwitchIn] = useState(false); const [switchOut, setSwitchOut] = useState(false); const messagesEndRef = useRef(null); + const previousMessageCountRef = useRef(0); const [replyTo, setReplyTo] = useState(null); const [replyToVisible, setReplyToVisible] = useState(Boolean(replyTo)); const [editMessage, setEditMessage] = useState(null); @@ -127,19 +128,32 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { } }, [chat.activePanel, chat.isSwitching, switchOut, switchIn]); - // Scroll to bottom when messages change, but only when no animation is running + // Scroll to bottom only when new messages are added useEffect(() => { - if (!panelState || chat.isSwitching || switchOut || switchIn || panelState.isLoading) return; + if (!panelState || chat.isSwitching || switchOut || switchIn) return; + + const currentMessageCount = panelState.messages.length; + const previousMessageCount = previousMessageCountRef.current; const el = messagesEndRef.current; if (!el) return; - // Defer to next frame to ensure layout is stable - const id = requestAnimationFrame(() => { - el.scrollIntoView({ behavior: "smooth", block: "end" }); - }); - - return () => cancelAnimationFrame(id); + // Scroll without animation when messages are initially loaded + if (previousMessageCount === 0 && currentMessageCount > 0 && !panelState.isLoading) { + el.scrollIntoView({ behavior: "instant", block: "end" }); + } + // Scroll with animation when a new message is added + else if (currentMessageCount > previousMessageCount && previousMessageCount > 0) { + // Defer to next frame to ensure layout is stable + const id = requestAnimationFrame(() => { + el.scrollIntoView({ behavior: "smooth", block: "end" }); + }); + + return () => cancelAnimationFrame(id); + } + + // Update the previous message count + previousMessageCountRef.current = currentMessageCount; }, [panelState?.messages, panelState?.isLoading, chat.isSwitching, switchOut, switchIn]); return ( 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..808a82e --- /dev/null +++ b/frontend/src/pages/app/ui/components/chat/MessageReactions.tsx @@ -0,0 +1,117 @@ +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()); + 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 ( +
+ {visibleReactions.map((reaction, index) => { + const hasUserReacted = reaction.users.some(u => u.id === user.currentUser?.id); + const isAnimating = animatingReactions.has(reaction.emoji); + + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/pages/app/ui/panels/DMPanel.ts b/frontend/src/pages/app/ui/panels/DMPanel.ts index 5897708..b528138 100644 --- a/frontend/src/pages/app/ui/panels/DMPanel.ts +++ b/frontend/src/pages/app/ui/panels/DMPanel.ts @@ -70,6 +70,7 @@ export class DMPanel extends MessagePanel { is_read: false, is_edited: false, files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [], + reactions: env.reactions || [], runtimeData: { dmEnvelope: env @@ -238,6 +239,10 @@ export class DMPanel extends MessagePanel { const { id } = response.data; this.removeMessage(id); } + if (response.type === "dmReactionUpdate" && this.dmData) { + const { dm_envelope_id, reactions } = response.data; + this.updateMessageReactions(dm_envelope_id, reactions); + } }; // Reset for DM switching @@ -302,4 +307,17 @@ export class DMPanel extends MessagePanel { } handleProfileClick(): void {} + + updateMessageReactions(dmEnvelopeId: number, reactions: any[]): void { + const messages = this.getMessages(); + const messageIndex = messages.findIndex(msg => + msg.runtimeData?.dmEnvelope?.id === dmEnvelopeId + ); + + if (messageIndex !== -1) { + const updatedMessage = { ...messages[messageIndex] }; + updatedMessage.reactions = reactions; + this.updateMessage(updatedMessage.id, { reactions: reactions }); + } + } } 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; } };