Implement basic reactions

This commit is contained in:
2025-10-05 22:56:43 +03:00
Unverified
parent a6e272e5b4
commit d6b2849e46
14 changed files with 923 additions and 60 deletions
+35 -1
View File
@@ -1,5 +1,5 @@
from sqlalchemy.ext.declarative import declarative_base 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 sqlalchemy.orm import relationship
from datetime import datetime from datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel
@@ -36,6 +36,7 @@ class Message(Base):
author = relationship("User", back_populates="messages") author = relationship("User", back_populates="messages")
reply_to = relationship("Message", remote_side=[id]) reply_to = relationship("Message", remote_side=[id])
files = relationship("MessageFile", back_populates="message", cascade="all, delete-orphan", lazy="select") files = relationship("MessageFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
reactions = relationship("Reaction", cascade="all, delete-orphan", lazy="select")
class MessageFile(Base): class MessageFile(Base):
@@ -106,6 +107,22 @@ class PushSubscription(Base):
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) 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 модели # Pydantic модели
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str username: str
@@ -166,5 +183,22 @@ class MessageResponse(BaseModel):
from_attributes = True 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 # Tables are now created through Alembic migrations
# Base.metadata.create_all(bind=engine) # Base.metadata.create_all(bind=engine)
+106 -1
View File
@@ -10,7 +10,7 @@ from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from dependencies import get_current_user, get_db from dependencies import get_current_user, get_db
from constants import OWNER_USERNAME 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 push_service import push_service
from PIL import Image from PIL import Image
import io import io
@@ -31,6 +31,23 @@ os.makedirs(FILES_ENCRYPTED_DIR, exist_ok=True)
def convert_message(msg: Message) -> dict: 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 { return {
"id": msg.id, "id": msg.id,
"content": msg.content, "content": msg.content,
@@ -40,6 +57,7 @@ def convert_message(msg: Message) -> dict:
"username": msg.author.username, "username": msg.author.username,
"profile_picture": msg.author.profile_picture, "profile_picture": msg.author.profile_picture,
"reply_to": convert_message(msg.reply_to) if msg.reply_to else None, "reply_to": convert_message(msg.reply_to) if msg.reply_to else None,
"reactions": list(reactions_dict.values()),
"files": [ "files": [
{ {
"path": f"/api/uploads/files/normal/{Path(f.path).name}", "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} 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: class MessaggingSocketManager:
def __init__(self) -> None: def __init__(self) -> None:
self.connections: list[WebSocket] = [] self.connections: list[WebSocket] = []
@@ -670,6 +745,36 @@ class MessaggingSocketManager:
"data": {"message_id": message_id} "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}) await websocket.send_json({"type": type, "data": response})
except HTTPException as e: except HTTPException as e:
await self.send_error(websocket, type, e) await self.send_error(websocket, type, e)
+32 -1
View File
@@ -51,6 +51,15 @@ export interface Rect extends Size2D {
* @property {string} [profile_picture] - URL to sender's profile picture * @property {string} [profile_picture] - URL to sender's profile picture
* @property {Message} [reply_to] - The message this is replying to * @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 { export interface Message {
id: number; id: number;
username: string; username: string;
@@ -61,6 +70,7 @@ export interface Message {
profile_picture?: string; profile_picture?: string;
reply_to?: Message; reply_to?: Message;
files?: Attachment[]; files?: Attachment[];
reactions?: Reaction[];
runtimeData?: { runtimeData?: {
dmEnvelope?: DmEnvelope; 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 // Messages
export interface DMNewWebSocketMessage extends WebSocketMessage { export interface DMNewWebSocketMessage extends WebSocketMessage {
type: "dmNew", type: "dmNew",
@@ -348,9 +367,21 @@ export interface NewMessageWebSocketMessage extends WebSocketMessage {
data: Message 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 // Shared types
export type DMWebSocketMessage = DMNewWebSocketMessage | DMEditedWebSocketMessage | DMDeletedWebSocketMessage 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) // Encrypted message JSON (plaintext structure before encryption)
@@ -213,7 +213,7 @@
height: 32px; height: 32px;
flex-shrink: 0; flex-shrink: 0;
margin-bottom: 4px; margin-bottom: 4px;
margin: 8px; margin: 10px;
img { img {
width: 100%; width: 100%;
@@ -234,7 +234,7 @@
margin-bottom: 0.3rem; margin-bottom: 0.3rem;
font-size: 0.9rem; font-size: 0.9rem;
transition: color 0.2s ease; transition: color 0.2s ease;
margin: 8px; margin: 10px;
&:hover { &:hover {
color: $color-dark-primary; color: $color-dark-primary;
@@ -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;
}
}
@@ -31,13 +31,13 @@ button, input {
} }
.context-menu { .context-menu {
position: fixed; position: relative;
background-color: $color-dark-surface-container; background-color: $color-dark-surface-container;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
padding: 0.5rem 0; padding: 0.5rem 0;
z-index: 1000; z-index: 1000;
display: none; display: block;
min-width: 150px; min-width: 150px;
max-width: 200px; max-width: 200px;
white-space: nowrap; white-space: nowrap;
@@ -12,6 +12,7 @@
@use "download-app"; @use "download-app";
@use "404" as not-found; @use "404" as not-found;
@use "homepage"; @use "homepage";
@use "reactions";
@use "lib/fonts/montserrat"; @use "lib/fonts/montserrat";
@use "lib/fonts/material-symbols"; @use "lib/fonts/material-symbols";
@@ -4,10 +4,13 @@ import type { Message as MessageType } from "../../../core/types";
import type { UserProfile } from "../../../core/types"; import type { UserProfile } from "../../../core/types";
import { UserProfileDialog } from "./UserProfileDialog"; import { UserProfileDialog } from "./UserProfileDialog";
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu"; import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
import { EmojiMenu } from "./EmojiMenu";
import { fetchUserProfile } from "../../../api/profileApi"; import { fetchUserProfile } from "../../../api/profileApi";
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { delay } from "../../../utils/utils"; import { delay } from "../../../utils/utils";
import { MaterialDialog } from "../core/Dialog"; import { MaterialDialog } from "../core/Dialog";
import { request } from "../../../core/websocket";
import type { AddReactionRequest } from "../../../core/types";
interface ChatMessagesProps { interface ChatMessagesProps {
messages?: MessageType[]; messages?: MessageType[];
@@ -39,6 +42,17 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [toBeDeleted, setToBeDeleted] = useState<{ id: number; isDm: boolean } | null>(null); 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(() => { useEffect(() => {
if (!deleteDialogOpen) { if (!deleteDialogOpen) {
setToBeDeleted(null); 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 ( return (
<> <>
<div className="chat-messages" id="chat-messages"> <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} isAuthor={message.username === user.currentUser?.username}
onProfileClick={handleProfileClick} onProfileClick={handleProfileClick}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
onReactionClick={handleReactionClick}
isLoadingProfile={isLoadingProfile} isLoadingProfile={isLoadingProfile}
isDm={isDm} isDm={isDm}
dmRecipientPublicKey={dmRecipientPublicKey} /> dmRecipientPublicKey={dmRecipientPublicKey} />
@@ -153,11 +208,22 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
onReply={handleReply} onReply={handleReply}
onDelete={handleDelete} onDelete={handleDelete}
onRetry={handleRetry} onRetry={handleRetry}
onReactionClick={handleReactionClick}
onExpandEmojiMenu={handleExpandEmojiMenu}
position={contextMenu.position} position={contextMenu.position}
isOpen={contextMenu.isOpen} isOpen={contextMenu.isOpen}
onOpenChange={handleContextMenuOpenChange} 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 { ub64 } from "../../../utils/utils";
import { useImmer } from "use-immer"; import { useImmer } from "use-immer";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { MessageReactions } from "./MessageReactions";
interface MessageProps { interface MessageProps {
message: MessageType; message: MessageType;
isAuthor: boolean; isAuthor: boolean;
onProfileClick: (username: string) => void; onProfileClick: (username: string) => void;
onContextMenu: (e: React.MouseEvent, message: MessageType) => void; onContextMenu: (e: React.MouseEvent, message: MessageType) => void;
onReactionClick?: (messageId: number, emoji: string) => void;
isLoadingProfile?: boolean; isLoadingProfile?: boolean;
isDm?: boolean; isDm?: boolean;
dmRecipientPublicKey?: string; dmRecipientPublicKey?: string;
@@ -31,7 +33,7 @@ interface Rect {
height: number 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 [formattedMessage, setFormattedMessage] = useState({ __html: "" });
const [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map()); const [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map());
const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set()); const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set());
@@ -373,6 +375,12 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
</mdui-list> </mdui-list>
)} )}
<MessageReactions
reactions={message.reactions}
onReactionClick={(emoji) => onReactionClick?.(message.id, emoji)}
messageId={message.id}
/>
<div className="message-time"> <div className="message-time">
{formatTime(message.timestamp)} {formatTime(message.timestamp)}
{message.is_edited ? " (edited)" : undefined} {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"; import type { Message, Size2D } from "../../../core/types";
interface MessageContextMenuProps { interface MessageContextMenuProps {
@@ -8,6 +8,8 @@ interface MessageContextMenuProps {
onReply: (message: Message) => void; onReply: (message: Message) => void;
onDelete: (message: Message) => void; onDelete: (message: Message) => void;
onRetry?: (message: Message) => void; onRetry?: (message: Message) => void;
onReactionClick?: (messageId: number, emoji: string) => Promise<void>;
onExpandEmojiMenu?: (message: Message) => void;
position: Size2D; position: Size2D;
isOpen: boolean; isOpen: boolean;
onOpenChange: (isOpen: boolean) => void; onOpenChange: (isOpen: boolean) => void;
@@ -26,6 +28,8 @@ export function MessageContextMenu({
onReply, onReply,
onDelete, onDelete,
onRetry, onRetry,
onReactionClick,
onExpandEmojiMenu,
position, position,
isOpen, isOpen,
onOpenChange onOpenChange
@@ -34,64 +38,86 @@ export function MessageContextMenu({
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const [calculatedPosition, setCalculatedPosition] = useState(position); const [calculatedPosition, setCalculatedPosition] = useState(position);
const [animationClass, setAnimationClass] = useState('entering'); 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 // Calculate smart positioning when component opens
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
const menuWidth = 160; // min-width from CSS // Use a small delay to ensure elements are rendered before measuring
const menuHeight = isAuthor ? 120 : 60; // Approximate height based on items const frameId = requestAnimationFrame(() => {
const padding = 10; // Padding from viewport edges 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 viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight; const viewportHeight = window.innerHeight;
let x = position.x; // Calculate shared/combined rect dimensions
let y = position.y; const sharedRect = {
let animation = 'entering'; width: Math.max(reactionBarRect.width, contextMenuRect.width),
height: reactionBarRect.height + contextMenuRect.height
};
// Check if menu would overflow right edge let x = position.x;
if (x + menuWidth + padding > viewportWidth) { let y = position.y;
x = viewportWidth - menuWidth - padding; let animation = 'entering';
animation = 'entering-left'; // Animation from left side let reactionPosition: 'left' | 'right' = 'left';
}
// Check if menu would overflow bottom edge // Check if shared rect would overflow and adjust position
if (y + menuHeight + padding > viewportHeight) { if (x + sharedRect.width > viewportWidth) {
y = viewportHeight - menuHeight - padding; x = position.x - contextMenuRect.width - 25;
animation = 'entering-up'; // Animation from bottom animation = 'entering-left';
} reactionPosition = 'right';
} else {
reactionPosition = 'left';
}
// If both edges would overflow, use top-left positioning // Ensure menu doesn't go off the left edge
if (x + menuWidth + padding > viewportWidth && y + menuHeight + padding > viewportHeight) { if (x < 0) {
x = Math.max(padding, position.x - menuWidth); x = 0;
y = Math.max(padding, position.y - menuHeight); }
animation = 'entering-up-left';
}
setCalculatedPosition({ x, y }); // Check if shared rect would overflow bottom edge
setAnimationClass(animation); if (y + sharedRect.height > viewportHeight) {
y = viewportHeight - sharedRect.height;
animation = 'entering-up';
}
setCalculatedPosition({ x, y });
setAnimationClass(animation);
setReactionBarPosition(reactionPosition);
}
});
return () => cancelAnimationFrame(frameId);
} }
}, [isOpen, position, isAuthor]); }, [isOpen, position, isAuthor]);
// Effect to handle clicks outside the context menu // Effect to handle clicks outside the context menu
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { function handleClickOutside(event: MouseEvent) {
if (isOpen && !isClosing) { 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; const target = event.target as Element;
if (!target.closest('.context-menu')) { if (!target.closest('.context-menu') && !target.closest('.context-menu-reaction-bar')) {
handleClose(); handleClose();
} }
} }
}; };
const handleKeyDown = (event: KeyboardEvent) => { function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape' && isOpen && !isClosing) { if (event.key === 'Escape' && isOpen && !isClosing) {
handleClose(); handleClose();
} }
}; };
const handleWindowBlur = () => { function handleWindowBlur() {
// Close context menu when browser window loses focus // Close context menu when browser window loses focus
if (isOpen && !isClosing) { if (isOpen && !isClosing) {
handleClose(); handleClose();
@@ -123,6 +149,7 @@ export function MessageContextMenu({
setIsClosing(false); setIsClosing(false);
setAnimationClass('entering'); // Reset for next opening setAnimationClass('entering'); // Reset for next opening
}, 200); // Match the animation duration from _animations.scss }, 200); // Match the animation duration from _animations.scss
// TODO no hardcoded delays
} }
interface Action { 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 && ( return isOpen && (
<div <div
className={`context-menu ${animationClass}`} ref={wrapperRef}
className={`context-menu-wrapper ${animationClass}`}
style={{ style={{
position: "fixed", position: "fixed",
display: "block",
top: calculatedPosition.y, top: calculatedPosition.y,
left: calculatedPosition.x, left: calculatedPosition.x,
zIndex: 1000 zIndex: 1000
}} }}
onClick={(e) => e.stopPropagation()}> onClick={(e) => e.stopPropagation()}>
{actions.map((action, i) => (
action.show && ( {/* Reaction Bar */}
<div <div
className="context-menu-item" ref={reactionBarRef}
onClick={action.onClick} className={`context-menu-reaction-bar ${reactionBarPosition}`}>
key={i} {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> {emoji}
{action.label} </button>
</div> ))}
) <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> </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 { protected clearMessages(): void {
this.updateState({ messages: [] }); this.updateState({ messages: [] });
} }
@@ -2,7 +2,7 @@ import { MessagePanel } from "./MessagePanel";
import { API_BASE_URL } from "../../core/config"; import { API_BASE_URL } from "../../core/config";
import { getAuthHeaders } from "../../auth/api"; import { getAuthHeaders } from "../../auth/api";
import { request } from "../../core/websocket"; 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"; import type { UserState } from "../state";
export class PublicChatPanel extends MessagePanel { export class PublicChatPanel extends MessagePanel {
@@ -104,7 +104,7 @@ export class PublicChatPanel extends MessagePanel {
} }
// Handle incoming WebSocket messages // Handle incoming WebSocket messages
async handleWebSocketMessage(response: ChatWebSocketMessage): Promise<void> { async handleWebSocketMessage(response: ChatWebSocketMessage | ReactionUpdateWebSocketMessage): Promise<void> {
switch (response.type) { switch (response.type) {
case 'messageEdited': case 'messageEdited':
if (response.data) { if (response.data) {
@@ -136,6 +136,11 @@ export class PublicChatPanel extends MessagePanel {
this.addMessage(newMsg); this.addMessage(newMsg);
} }
break; break;
case 'reactionUpdate':
if (response.data) {
this.updateMessageReactions(response.data.message_id, response.data.reactions);
}
break;
} }
}; };