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