mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement basic reactions
This commit is contained in:
+35
-1
@@ -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
@@ -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
@@ -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;
|
||||||
|
|
||||||
|
// Calculate shared/combined rect dimensions
|
||||||
|
const sharedRect = {
|
||||||
|
width: Math.max(reactionBarRect.width, contextMenuRect.width),
|
||||||
|
height: reactionBarRect.height + contextMenuRect.height
|
||||||
|
};
|
||||||
|
|
||||||
let x = position.x;
|
let x = position.x;
|
||||||
let y = position.y;
|
let y = position.y;
|
||||||
let animation = 'entering';
|
let animation = 'entering';
|
||||||
|
let reactionPosition: 'left' | 'right' = 'left';
|
||||||
|
|
||||||
// Check if menu would overflow right edge
|
// Check if shared rect would overflow and adjust position
|
||||||
if (x + menuWidth + padding > viewportWidth) {
|
if (x + sharedRect.width > viewportWidth) {
|
||||||
x = viewportWidth - menuWidth - padding;
|
x = position.x - contextMenuRect.width - 25;
|
||||||
animation = 'entering-left'; // Animation from left side
|
animation = 'entering-left';
|
||||||
|
reactionPosition = 'right';
|
||||||
|
} else {
|
||||||
|
reactionPosition = 'left';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if menu would overflow bottom edge
|
// Ensure menu doesn't go off the left edge
|
||||||
if (y + menuHeight + padding > viewportHeight) {
|
if (x < 0) {
|
||||||
y = viewportHeight - menuHeight - padding;
|
x = 0;
|
||||||
animation = 'entering-up'; // Animation from bottom
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If both edges would overflow, use top-left positioning
|
// Check if shared rect would overflow bottom edge
|
||||||
if (x + menuWidth + padding > viewportWidth && y + menuHeight + padding > viewportHeight) {
|
if (y + sharedRect.height > viewportHeight) {
|
||||||
x = Math.max(padding, position.x - menuWidth);
|
y = viewportHeight - sharedRect.height;
|
||||||
y = Math.max(padding, position.y - menuHeight);
|
animation = 'entering-up';
|
||||||
animation = 'entering-up-left';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setCalculatedPosition({ x, y });
|
setCalculatedPosition({ x, y });
|
||||||
setAnimationClass(animation);
|
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,17 +205,62 @@ 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()}>
|
||||||
|
|
||||||
|
{/* 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}
|
||||||
|
>
|
||||||
|
{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) => (
|
{actions.map((action, i) => (
|
||||||
action.show && (
|
action.show && (
|
||||||
<div
|
<div
|
||||||
@@ -202,5 +274,6 @@ export function MessageContextMenu({
|
|||||||
)
|
)
|
||||||
))}
|
))}
|
||||||
</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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user