mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Merge branch 'feature/reactions'
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
View git diff between the branch i specified and HEAD. If no branch is specified,
|
||||||
|
default to main. Identify code that needs to be cleaned up, like debug logs,
|
||||||
|
unused variables etc. Think twice before removing or adding code, because you
|
||||||
|
mustn't alter the behavior.
|
||||||
@@ -19,7 +19,7 @@ When working with this project, follow these rules:
|
|||||||
|
|
||||||
## File Operations
|
## File Operations
|
||||||
- If possible, try to update files in a single edit when making multiple changes.
|
- If possible, try to update files in a single edit when making multiple changes.
|
||||||
- Do NOT "cd" to the project directory.å
|
- Do NOT "cd" to the project directory.
|
||||||
|
|
||||||
## Testing & Validation
|
## Testing & Validation
|
||||||
- Do NOT "test the implementation" when you are done. The only exception is when you
|
- Do NOT "test the implementation" when you are done. The only exception is when you
|
||||||
|
|||||||
+70
-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):
|
||||||
@@ -79,6 +80,7 @@ class DMEnvelope(Base):
|
|||||||
reply_to_id = Column(Integer, nullable=True)
|
reply_to_id = Column(Integer, nullable=True)
|
||||||
timestamp = Column(DateTime, default=datetime.now)
|
timestamp = Column(DateTime, default=datetime.now)
|
||||||
files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
|
files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
|
||||||
|
reactions = relationship("DMReaction", cascade="all, delete-orphan", lazy="select")
|
||||||
|
|
||||||
|
|
||||||
class DMFile(Base):
|
class DMFile(Base):
|
||||||
@@ -106,6 +108,39 @@ 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'),)
|
||||||
|
|
||||||
|
|
||||||
|
class DMReaction(Base):
|
||||||
|
__tablename__ = "dm_reaction"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
dm_envelope_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||||
|
emoji = Column(String(10), nullable=False) # Store emoji as string
|
||||||
|
timestamp = Column(DateTime, default=datetime.now)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = relationship("User")
|
||||||
|
dm_envelope = relationship("DMEnvelope")
|
||||||
|
|
||||||
|
# Ensure unique combination of dm_envelope, user, and emoji
|
||||||
|
__table_args__ = (UniqueConstraint('dm_envelope_id', 'user_id', 'emoji', name='unique_dm_reaction'),)
|
||||||
|
|
||||||
|
|
||||||
# Pydantic модели
|
# Pydantic модели
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
@@ -166,5 +201,39 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
class DMReactionRequest(BaseModel):
|
||||||
|
dm_envelope_id: int
|
||||||
|
emoji: str
|
||||||
|
|
||||||
|
|
||||||
|
class DMReactionResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
dm_envelope_id: int
|
||||||
|
user_id: int
|
||||||
|
emoji: str
|
||||||
|
timestamp: datetime
|
||||||
|
username: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
# Tables are now created through Alembic migrations
|
# Tables are now created through Alembic migrations
|
||||||
# Base.metadata.create_all(bind=engine)
|
# Base.metadata.create_all(bind=engine)
|
||||||
+239
-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, DMReaction, DMReactionRequest, DMReactionResponse
|
||||||
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}",
|
||||||
@@ -51,6 +69,47 @@ def convert_message(msg: Message) -> dict:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def convert_dm_envelope(envelope: DMEnvelope) -> dict:
|
||||||
|
# Group reactions by emoji
|
||||||
|
reactions_dict = {}
|
||||||
|
if envelope.reactions:
|
||||||
|
for reaction in envelope.reactions:
|
||||||
|
emoji = reaction.emoji
|
||||||
|
if emoji not in reactions_dict:
|
||||||
|
reactions_dict[emoji] = {
|
||||||
|
"emoji": emoji,
|
||||||
|
"count": 0,
|
||||||
|
"users": []
|
||||||
|
}
|
||||||
|
reactions_dict[emoji]["count"] += 1
|
||||||
|
reactions_dict[emoji]["users"].append({
|
||||||
|
"id": reaction.user_id,
|
||||||
|
"username": reaction.user.username
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": envelope.id,
|
||||||
|
"senderId": envelope.sender_id,
|
||||||
|
"recipientId": envelope.recipient_id,
|
||||||
|
"iv": envelope.iv_b64,
|
||||||
|
"ciphertext": envelope.ciphertext_b64,
|
||||||
|
"salt": envelope.salt_b64,
|
||||||
|
"iv2": envelope.iv2_b64,
|
||||||
|
"wrappedMk": envelope.wrapped_mk_b64,
|
||||||
|
"timestamp": envelope.timestamp.isoformat(),
|
||||||
|
"reactions": list(reactions_dict.values()),
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": f"/api/uploads/files/encrypted/{Path(f.path).name}",
|
||||||
|
"id": f.id,
|
||||||
|
"name": f.name,
|
||||||
|
"dm_envelope_id": f.dm_envelope_id
|
||||||
|
}
|
||||||
|
for f in (envelope.files or [])
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
# для тех кто читает этот код я эти маты не писал
|
# для тех кто читает этот код я эти маты не писал
|
||||||
# мат писал ии а я сам не матерюсь))
|
# мат писал ии а я сам не матерюсь))
|
||||||
# - denis0001-dev
|
# - denis0001-dev
|
||||||
@@ -436,6 +495,125 @@ 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"]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dm/add_reaction")
|
||||||
|
async def add_dm_reaction(
|
||||||
|
request: DMReactionRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
# Check if DM envelope exists
|
||||||
|
envelope = db.query(DMEnvelope).filter(DMEnvelope.id == request.dm_envelope_id).first()
|
||||||
|
if not envelope:
|
||||||
|
raise HTTPException(status_code=404, detail="DM envelope not found")
|
||||||
|
|
||||||
|
# Check if user is part of this DM conversation
|
||||||
|
if current_user.id not in [envelope.sender_id, envelope.recipient_id]:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized to react to this message")
|
||||||
|
|
||||||
|
# Check if reaction already exists
|
||||||
|
existing_reaction = db.query(DMReaction).filter(
|
||||||
|
DMReaction.dm_envelope_id == request.dm_envelope_id,
|
||||||
|
DMReaction.user_id == current_user.id,
|
||||||
|
DMReaction.emoji == request.emoji
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_reaction:
|
||||||
|
# Remove existing reaction (toggle off)
|
||||||
|
db.delete(existing_reaction)
|
||||||
|
action = "removed"
|
||||||
|
else:
|
||||||
|
# Add new reaction
|
||||||
|
new_reaction = DMReaction(
|
||||||
|
dm_envelope_id=request.dm_envelope_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
emoji=request.emoji
|
||||||
|
)
|
||||||
|
db.add(new_reaction)
|
||||||
|
action = "added"
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Refresh envelope to get updated reactions
|
||||||
|
db.refresh(envelope)
|
||||||
|
|
||||||
|
# Broadcast reaction update to both participants
|
||||||
|
try:
|
||||||
|
from .messaging import messagingManager
|
||||||
|
await messagingManager.broadcast({
|
||||||
|
"type": "dmReactionUpdate",
|
||||||
|
"data": {
|
||||||
|
"dm_envelope_id": request.dm_envelope_id,
|
||||||
|
"emoji": request.emoji,
|
||||||
|
"action": action,
|
||||||
|
"user_id": current_user.id,
|
||||||
|
"username": current_user.username,
|
||||||
|
"reactions": convert_dm_envelope(envelope)["reactions"]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {"status": "success", "action": action, "reactions": convert_dm_envelope(envelope)["reactions"]}
|
||||||
|
|
||||||
|
|
||||||
class MessaggingSocketManager:
|
class MessaggingSocketManager:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.connections: list[WebSocket] = []
|
self.connections: list[WebSocket] = []
|
||||||
@@ -670,6 +848,66 @@ 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})
|
||||||
|
except HTTPException as e:
|
||||||
|
await self.send_error(websocket, type, e)
|
||||||
|
elif type == "addDmReaction":
|
||||||
|
try:
|
||||||
|
current_user = get_current_user_inner()
|
||||||
|
if not current_user:
|
||||||
|
raise HTTPException(401)
|
||||||
|
|
||||||
|
request_data = data["data"]
|
||||||
|
reaction_request = DMReactionRequest(
|
||||||
|
dm_envelope_id=request_data["dm_envelope_id"],
|
||||||
|
emoji=request_data["emoji"]
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await add_dm_reaction(reaction_request, current_user, db)
|
||||||
|
|
||||||
|
# Broadcast reaction update
|
||||||
|
await self.broadcast({
|
||||||
|
"type": "dmReactionUpdate",
|
||||||
|
"data": {
|
||||||
|
"dm_envelope_id": request_data["dm_envelope_id"],
|
||||||
|
"emoji": request_data["emoji"],
|
||||||
|
"action": response["action"],
|
||||||
|
"user_id": current_user.id,
|
||||||
|
"username": current_user.username,
|
||||||
|
"reactions": response["reactions"]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
await websocket.send_json({"type": type, "data": response})
|
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)
|
||||||
|
|||||||
+55
-2
@@ -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;
|
||||||
@@ -202,6 +212,7 @@ export interface DmEnvelope extends BaseDmEnvelope {
|
|||||||
senderId: number;
|
senderId: number;
|
||||||
files?: DmFile[];
|
files?: DmFile[];
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
|
reactions?: Reaction[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DmFile {
|
export interface DmFile {
|
||||||
@@ -313,6 +324,24 @@ export interface SendMessageRequest extends WebSocketMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AddReactionRequest extends WebSocketMessage {
|
||||||
|
type: "addReaction",
|
||||||
|
credentials: WebSocketCredentials;
|
||||||
|
data: {
|
||||||
|
message_id: number;
|
||||||
|
emoji: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddDmReactionRequest extends WebSocketMessage {
|
||||||
|
type: "addDmReaction",
|
||||||
|
credentials: WebSocketCredentials;
|
||||||
|
data: {
|
||||||
|
dm_envelope_id: number;
|
||||||
|
emoji: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Messages
|
// Messages
|
||||||
export interface DMNewWebSocketMessage extends WebSocketMessage {
|
export interface DMNewWebSocketMessage extends WebSocketMessage {
|
||||||
type: "dmNew",
|
type: "dmNew",
|
||||||
@@ -348,9 +377,33 @@ 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[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DMReactionUpdateWebSocketMessage extends WebSocketMessage {
|
||||||
|
type: "dmReactionUpdate",
|
||||||
|
data: {
|
||||||
|
dm_envelope_id: number;
|
||||||
|
emoji: string;
|
||||||
|
action: "added" | "removed";
|
||||||
|
user_id: number;
|
||||||
|
username: string;
|
||||||
|
reactions: Reaction[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Shared types
|
// Shared types
|
||||||
export type DMWebSocketMessage = DMNewWebSocketMessage | DMEditedWebSocketMessage | DMDeletedWebSocketMessage
|
export type DMWebSocketMessage = DMNewWebSocketMessage | DMEditedWebSocketMessage | DMDeletedWebSocketMessage | DMReactionUpdateWebSocketMessage
|
||||||
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;
|
||||||
@@ -968,4 +968,17 @@
|
|||||||
color: $color-dark-on-surface-variant;
|
color: $color-dark-on-surface-variant;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Integrated mode styles (inside reaction bar)
|
||||||
|
&.integrated {
|
||||||
|
position: relative !important;
|
||||||
|
width: 320px !important;
|
||||||
|
height: 400px !important;
|
||||||
|
transform: none !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
box-shadow: none;
|
||||||
|
border: none;
|
||||||
|
background: $color-dark-surface-container;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
@use "common/material" as *;
|
||||||
|
@use "sass:color";
|
||||||
|
|
||||||
|
// Reaction styles
|
||||||
|
.message-reactions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-left: 10px;
|
||||||
|
margin-right: 10px;
|
||||||
|
animation: messageReactionsFadeIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 16px;
|
||||||
|
background-color: $color-dark-surface-container;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, background-color 0.2s ease;
|
||||||
|
font-size: 1px;
|
||||||
|
min-height: 28px;
|
||||||
|
animation: reactionFadeIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
|
||||||
|
&.removing {
|
||||||
|
animation: reactionFadeOut 0.2s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $color-dark-surface-container-high;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.reacted {
|
||||||
|
background-color: $color-dark-primary-container;
|
||||||
|
border-color: $color-dark-primary;
|
||||||
|
color: $color-dark-on-primary-container;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: color.adjust($color-dark-primary-container, $lightness: 20%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-emoji {
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-count {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reaction bar styles (standalone)
|
||||||
|
.reaction-bar {
|
||||||
|
background: $color-dark-surface-container;
|
||||||
|
border: 1px solid $color-dark-outline;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 8px;
|
||||||
|
opacity: 1;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
transform: translateY(0);
|
||||||
|
|
||||||
|
&.closing {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emoji menu wrapper inside reaction bar
|
||||||
|
.emoji-menu-wrapper {
|
||||||
|
width: 320px;
|
||||||
|
height: 400px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context menu wrapper with animations
|
||||||
|
.context-menu-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
// Animation states
|
||||||
|
&.entering {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.8);
|
||||||
|
animation: contextMenuEnter 0.2s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.entering-left {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-20px) scale(0.8);
|
||||||
|
animation: contextMenuEnterLeft 0.2s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.entering-up {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px) scale(0.8);
|
||||||
|
animation: contextMenuEnterUp 0.2s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.entering-up-left {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-20px) translateY(20px) scale(0.8);
|
||||||
|
animation: contextMenuEnterUpLeft 0.2s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.closing {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
animation: contextMenuClose 0.2s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.closing-left {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0) scale(1);
|
||||||
|
animation: contextMenuCloseLeft 0.2s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.closing-up {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
animation: contextMenuCloseUp 0.2s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.closing-up-left {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0) translateY(0) scale(1);
|
||||||
|
animation: contextMenuCloseUpLeft 0.2s ease forwards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reaction bar inside context menu wrapper
|
||||||
|
.context-menu-reaction-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: $color-dark-surface-container;
|
||||||
|
border: 1px solid $color-dark-outline;
|
||||||
|
border-radius: 16px;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
transition: width 0.3s ease-out, height 0.3s ease-out;
|
||||||
|
|
||||||
|
&.left {
|
||||||
|
left: 0;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.right {
|
||||||
|
right: 0;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.expanded {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 320px;
|
||||||
|
height: 400px;
|
||||||
|
border-radius: 16px;
|
||||||
|
|
||||||
|
// Default: expand downward from the reaction bar's bottom edge
|
||||||
|
position: absolute;
|
||||||
|
bottom: auto;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
transform: translateY(0);
|
||||||
|
|
||||||
|
&.expand-upward {
|
||||||
|
// Expand upward from the reaction bar's top edge
|
||||||
|
bottom: 100%;
|
||||||
|
top: auto;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
margin-top: 0;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-menu-wrapper {
|
||||||
|
animation: emojiMenuEnter 0.5s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes emojiMenuEnter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-bar-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
transition: opacity 0.3s ease-out;
|
||||||
|
|
||||||
|
&.faded {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-emoji-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
font-size: 18px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--mdui-color-surface-container-high);
|
||||||
|
transform: scale(1.3);
|
||||||
|
box-shadow: var(--mdui-elevation-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-expand-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid var(--mdui-color-outline);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--mdui-color-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--mdui-color-surface-container-high);
|
||||||
|
border-color: var(--mdui-color-primary);
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: var(--mdui-elevation-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-symbols {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--mdui-color-on-surface);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .material-symbols {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animation for reactions appearing/disappearing
|
||||||
|
@keyframes messageReactionsFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes reactionFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes reactionFadeOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context menu wrapper animations
|
||||||
|
@keyframes contextMenuEnter {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes contextMenuEnterLeft {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes contextMenuEnterUp {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes contextMenuEnterUpLeft {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0) translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes contextMenuClose {
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes contextMenuCloseLeft {
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-20px) scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes contextMenuCloseUp {
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px) scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes contextMenuCloseUpLeft {
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-20px) translateY(20px) scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile responsive
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.reaction-bar {
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-emoji-button,
|
||||||
|
.reaction-expand-button {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-emoji-button {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-expand-button .material-symbols {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -82,6 +82,11 @@ button, input {
|
|||||||
&.open {
|
&.open {
|
||||||
animation: context-menu-open 0.25s ease;
|
animation: context-menu-open 0.25s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.faded {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes context-menu-open {
|
@keyframes context-menu-open {
|
||||||
0% {
|
0% {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -66,18 +66,24 @@ export function ChatInputWrapper(
|
|||||||
setAttachmentsVisible(selectedFiles.length > 0);
|
setAttachmentsVisible(selectedFiles.length > 0);
|
||||||
}, [selectedFiles]);
|
}, [selectedFiles]);
|
||||||
|
|
||||||
function handleEmojiButtonClick() {
|
function handleEmojiButtonClick(e: React.MouseEvent<HTMLButtonElement>) {
|
||||||
if (chatInputWrapperRef.current && messagePanelRef?.current) {
|
e.stopPropagation();
|
||||||
const inputRect = chatInputWrapperRef.current.getBoundingClientRect();
|
|
||||||
const panelRect = messagePanelRef.current.getBoundingClientRect();
|
if (!emojiMenuOpen) {
|
||||||
|
if (chatInputWrapperRef.current && messagePanelRef?.current) {
|
||||||
// Position menu 10px from message panel edge and 10px above the chat input
|
const inputRect = chatInputWrapperRef.current.getBoundingClientRect();
|
||||||
// The animation will start 30px below this position
|
const panelRect = messagePanelRef.current.getBoundingClientRect();
|
||||||
setEmojiMenuPosition({
|
|
||||||
x: panelRect.left + 10, // 10px from message panel edge
|
// Position menu 10px from message panel edge and 10px above the chat input
|
||||||
y: window.innerHeight - inputRect.top + 10 // 10px above the top of chat input
|
// The animation will start 30px below this position
|
||||||
});
|
setEmojiMenuPosition({
|
||||||
setEmojiMenuOpen(true);
|
x: panelRect.left + 10, // 10px from message panel edge
|
||||||
|
y: window.innerHeight - inputRect.top + 10 // 10px above the top of chat input
|
||||||
|
});
|
||||||
|
setEmojiMenuOpen(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setEmojiMenuOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -176,7 +182,12 @@ export function ChatInputWrapper(
|
|||||||
</AnimatedHeight>
|
</AnimatedHeight>
|
||||||
<div className="chat-input">
|
<div className="chat-input">
|
||||||
<div className="left-buttons">
|
<div className="left-buttons">
|
||||||
<mdui-button-icon icon="mood" onClick={handleEmojiButtonClick} className="emoji-btn"></mdui-button-icon>
|
<mdui-button-icon
|
||||||
|
icon="mood"
|
||||||
|
onClick={handleEmojiButtonClick}
|
||||||
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
onMouseUp={e => e.stopPropagation()}
|
||||||
|
className="emoji-btn" />
|
||||||
</div>
|
</div>
|
||||||
<RichTextArea
|
<RichTextArea
|
||||||
className="message-input"
|
className="message-input"
|
||||||
@@ -206,6 +217,7 @@ export function ChatInputWrapper(
|
|||||||
onClose={() => setEmojiMenuOpen(false)}
|
onClose={() => setEmojiMenuOpen(false)}
|
||||||
onEmojiSelect={handleEmojiSelect}
|
onEmojiSelect={handleEmojiSelect}
|
||||||
position={emojiMenuPosition}
|
position={emojiMenuPosition}
|
||||||
|
mode="standalone"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ 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, AddDmReactionRequest } from "../../../core/types";
|
||||||
|
|
||||||
interface ChatMessagesProps {
|
interface ChatMessagesProps {
|
||||||
messages?: MessageType[];
|
messages?: MessageType[];
|
||||||
@@ -39,6 +41,7 @@ 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);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!deleteDialogOpen) {
|
if (!deleteDialogOpen) {
|
||||||
setToBeDeleted(null);
|
setToBeDeleted(null);
|
||||||
@@ -107,6 +110,43 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleReactionClick(messageId: number, emoji: string) {
|
||||||
|
if (!user.authToken) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isDm) {
|
||||||
|
// For DM messages, we need to find the dm_envelope_id from the message
|
||||||
|
const message = messages.find(m => m.id === messageId);
|
||||||
|
const dmEnvelopeId = message?.runtimeData?.dmEnvelope?.id;
|
||||||
|
|
||||||
|
if (dmEnvelopeId) {
|
||||||
|
await request<AddDmReactionRequest["data"]>({
|
||||||
|
type: "addDmReaction",
|
||||||
|
credentials: { scheme: "Bearer", credentials: user.authToken },
|
||||||
|
data: {
|
||||||
|
dm_envelope_id: dmEnvelopeId,
|
||||||
|
emoji: emoji
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For regular chat messages
|
||||||
|
await request<AddReactionRequest["data"]>({
|
||||||
|
type: "addReaction",
|
||||||
|
credentials: { scheme: "Bearer", credentials: user.authToken },
|
||||||
|
data: {
|
||||||
|
message_id: messageId,
|
||||||
|
emoji: emoji
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to add reaction:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="chat-messages" id="chat-messages">
|
<div className="chat-messages" id="chat-messages">
|
||||||
@@ -117,6 +157,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 +194,14 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
|||||||
onReply={handleReply}
|
onReply={handleReply}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onRetry={handleRetry}
|
onRetry={handleRetry}
|
||||||
|
onReactionClick={handleReactionClick}
|
||||||
position={contextMenu.position}
|
position={contextMenu.position}
|
||||||
isOpen={contextMenu.isOpen}
|
isOpen={contextMenu.isOpen}
|
||||||
onOpenChange={handleContextMenuOpenChange}
|
onOpenChange={handleContextMenuOpenChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,26 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
|||||||
import { EMOJI_CATEGORIES, getRecentEmojis, addRecentEmoji } from "./emojiData";
|
import { EMOJI_CATEGORIES, getRecentEmojis, addRecentEmoji } from "./emojiData";
|
||||||
import type { Size2D } from "../../../core/types";
|
import type { Size2D } from "../../../core/types";
|
||||||
|
|
||||||
interface EmojiMenuProps {
|
interface BaseEmojiMenuProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onEmojiSelect: (emoji: string) => void;
|
onEmojiSelect: (emoji: string) => void;
|
||||||
position: Size2D;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmojiMenu({ isOpen, onClose, onEmojiSelect, position }: EmojiMenuProps) {
|
interface StandaloneEmojiMenuProps extends BaseEmojiMenuProps {
|
||||||
|
position: Size2D;
|
||||||
|
mode: "standalone";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IntegratedEmojiMenuProps extends BaseEmojiMenuProps {
|
||||||
|
mode: "integrated";
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmojiMenuProps = StandaloneEmojiMenuProps | IntegratedEmojiMenuProps;
|
||||||
|
|
||||||
|
export function EmojiMenu(props: EmojiMenuProps) {
|
||||||
|
const { isOpen, onClose, onEmojiSelect, mode } = props;
|
||||||
|
const position = mode === "standalone" ? props.position : undefined;
|
||||||
const [activeCategory, setActiveCategory] = useState("recent");
|
const [activeCategory, setActiveCategory] = useState("recent");
|
||||||
const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
|
const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -106,13 +118,15 @@ export function EmojiMenu({ isOpen, onClose, onEmojiSelect, position }: EmojiMen
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
className={`emoji-menu ${isOpen ? "open" : ""}`}
|
className={`emoji-menu ${isOpen ? "open" : ""} ${mode}`}
|
||||||
style={{
|
style={mode === "standalone" && position ? {
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
left: position.x,
|
left: position.x,
|
||||||
bottom: position.y,
|
bottom: position.y,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
pointerEvents: isOpen ? "auto" : "none"
|
pointerEvents: isOpen ? "auto" : "none"
|
||||||
|
} : {
|
||||||
|
pointerEvents: isOpen ? "auto" : "none"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="emoji-menu-header">
|
<div className="emoji-menu-header">
|
||||||
|
|||||||
@@ -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,5 +1,6 @@
|
|||||||
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";
|
||||||
|
import { EmojiMenu } from "./EmojiMenu";
|
||||||
|
|
||||||
interface MessageContextMenuProps {
|
interface MessageContextMenuProps {
|
||||||
message: Message;
|
message: Message;
|
||||||
@@ -8,6 +9,7 @@ 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>;
|
||||||
position: Size2D;
|
position: Size2D;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onOpenChange: (isOpen: boolean) => void;
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
@@ -26,6 +28,7 @@ export function MessageContextMenu({
|
|||||||
onReply,
|
onReply,
|
||||||
onDelete,
|
onDelete,
|
||||||
onRetry,
|
onRetry,
|
||||||
|
onReactionClick,
|
||||||
position,
|
position,
|
||||||
isOpen,
|
isOpen,
|
||||||
onOpenChange
|
onOpenChange
|
||||||
@@ -34,64 +37,91 @@ 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');
|
||||||
|
const [isEmojiMenuExpanded, setIsEmojiMenuExpanded] = useState(false);
|
||||||
|
const [initialDimensions, setInitialDimensions] = useState<{ width: number; height: number } | null>(null);
|
||||||
|
const [expandUpward, setExpandUpward] = useState(false);
|
||||||
|
const [contextMenuHeight, setContextMenuHeight] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Refs for measuring actual dimensions
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const reactionBarRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const emojiMenuRef = 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 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;
|
return () => cancelAnimationFrame(frameId);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}, [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();
|
||||||
@@ -122,6 +152,11 @@ export function MessageContextMenu({
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
setIsClosing(false);
|
setIsClosing(false);
|
||||||
setAnimationClass('entering'); // Reset for next opening
|
setAnimationClass('entering'); // Reset for next opening
|
||||||
|
// Reset emoji menu state after context menu animation completes
|
||||||
|
setIsEmojiMenuExpanded(false);
|
||||||
|
setInitialDimensions(null);
|
||||||
|
setExpandUpward(false);
|
||||||
|
setContextMenuHeight(null);
|
||||||
}, 200); // Match the animation duration from _animations.scss
|
}, 200); // Match the animation duration from _animations.scss
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,29 +213,127 @@ export function MessageContextMenu({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Quick reactions for the reaction bar
|
||||||
|
const QUICK_REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "😡"];
|
||||||
|
|
||||||
|
async function handleReactionClick(emoji: string) {
|
||||||
|
if (onReactionClick) {
|
||||||
|
await onReactionClick(message.id, emoji);
|
||||||
|
}
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExpandClick() {
|
||||||
|
if (!reactionBarRef.current || !wrapperRef.current) return;
|
||||||
|
|
||||||
|
// Measure the actual dimensions of the reaction bar content
|
||||||
|
const reactionBarRect = reactionBarRef.current.getBoundingClientRect();
|
||||||
|
const wrapperRect = wrapperRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
setInitialDimensions({ width: reactionBarRect.width, height: reactionBarRect.height });
|
||||||
|
setContextMenuHeight(wrapperRect.height);
|
||||||
|
|
||||||
|
// Check if expanding downward would cause overflow
|
||||||
|
// Calculate space from the reaction bar's bottom edge downward
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const spaceBelow = viewportHeight - reactionBarRect.bottom;
|
||||||
|
const emojiMenuHeight = 400;
|
||||||
|
|
||||||
|
// Only expand upward if there's not enough space below for the emoji menu
|
||||||
|
const shouldExpandUpward = spaceBelow < emojiMenuHeight;
|
||||||
|
setExpandUpward(shouldExpandUpward);
|
||||||
|
|
||||||
|
// Use requestAnimationFrame to ensure the dimensions are applied before expansion
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setIsEmojiMenuExpanded(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEmojiSelect(emoji: string) {
|
||||||
|
if (onReactionClick) {
|
||||||
|
onReactionClick(message.id, emoji);
|
||||||
|
}
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
|
||||||
return isOpen && (
|
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} ${isEmojiMenuExpanded ? "expanded" : ""} ${expandUpward ? "expand-upward" : ""}`}
|
||||||
key={i}
|
style={isEmojiMenuExpanded && !expandUpward ? {
|
||||||
>
|
position: 'fixed',
|
||||||
<span className="material-symbols">{action.icon}</span>
|
top: `${(-(contextMenuHeight || 0) + 5)}px`,
|
||||||
{action.label}
|
width: '320px',
|
||||||
|
height: '400px',
|
||||||
|
zIndex: 1001
|
||||||
|
} : initialDimensions && !isEmojiMenuExpanded ? {
|
||||||
|
width: `${initialDimensions.width}px`,
|
||||||
|
height: `${initialDimensions.height}px`
|
||||||
|
} : {}}>
|
||||||
|
{!isEmojiMenuExpanded ? (
|
||||||
|
<div className="reaction-bar-content">
|
||||||
|
{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>
|
</div>
|
||||||
)
|
) : (
|
||||||
))}
|
<div
|
||||||
|
ref={emojiMenuRef}
|
||||||
|
className="emoji-menu-wrapper">
|
||||||
|
<EmojiMenu
|
||||||
|
isOpen={true}
|
||||||
|
onClose={handleClose}
|
||||||
|
onEmojiSelect={handleEmojiSelect}
|
||||||
|
mode="integrated"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Context Menu */}
|
||||||
|
<div
|
||||||
|
ref={contextMenuRef}
|
||||||
|
className={`context-menu ${isEmojiMenuExpanded ? "faded" : ""}`}>
|
||||||
|
{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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
|||||||
const [switchIn, setSwitchIn] = useState(false);
|
const [switchIn, setSwitchIn] = useState(false);
|
||||||
const [switchOut, setSwitchOut] = useState(false);
|
const [switchOut, setSwitchOut] = useState(false);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const previousMessageCountRef = useRef(0);
|
||||||
const [replyTo, setReplyTo] = useState<Message | null>(null);
|
const [replyTo, setReplyTo] = useState<Message | null>(null);
|
||||||
const [replyToVisible, setReplyToVisible] = useState(Boolean(replyTo));
|
const [replyToVisible, setReplyToVisible] = useState(Boolean(replyTo));
|
||||||
const [editMessage, setEditMessage] = useState<Message | null>(null);
|
const [editMessage, setEditMessage] = useState<Message | null>(null);
|
||||||
@@ -127,19 +128,32 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
|||||||
}
|
}
|
||||||
}, [chat.activePanel, chat.isSwitching, switchOut, switchIn]);
|
}, [chat.activePanel, chat.isSwitching, switchOut, switchIn]);
|
||||||
|
|
||||||
// Scroll to bottom when messages change, but only when no animation is running
|
// Scroll to bottom only when new messages are added
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!panelState || chat.isSwitching || switchOut || switchIn || panelState.isLoading) return;
|
if (!panelState || chat.isSwitching || switchOut || switchIn) return;
|
||||||
|
|
||||||
|
const currentMessageCount = panelState.messages.length;
|
||||||
|
const previousMessageCount = previousMessageCountRef.current;
|
||||||
|
|
||||||
const el = messagesEndRef.current;
|
const el = messagesEndRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
// Defer to next frame to ensure layout is stable
|
// Scroll without animation when messages are initially loaded
|
||||||
const id = requestAnimationFrame(() => {
|
if (previousMessageCount === 0 && currentMessageCount > 0 && !panelState.isLoading) {
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "end" });
|
el.scrollIntoView({ behavior: "instant", block: "end" });
|
||||||
});
|
}
|
||||||
|
// Scroll with animation when a new message is added
|
||||||
return () => cancelAnimationFrame(id);
|
else if (currentMessageCount > previousMessageCount && previousMessageCount > 0) {
|
||||||
|
// Defer to next frame to ensure layout is stable
|
||||||
|
const id = requestAnimationFrame(() => {
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the previous message count
|
||||||
|
previousMessageCountRef.current = currentMessageCount;
|
||||||
}, [panelState?.messages, panelState?.isLoading, chat.isSwitching, switchOut, switchIn]);
|
}, [panelState?.messages, panelState?.isLoading, chat.isSwitching, switchOut, switchIn]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { useAppState } from "../../state";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import type { Reaction } from "../../../core/types";
|
||||||
|
|
||||||
|
interface MessageReactionsProps {
|
||||||
|
reactions?: Reaction[];
|
||||||
|
onReactionClick: (emoji: string) => void;
|
||||||
|
messageId?: number; // Add messageId to ensure unique keys
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageReactions({ reactions, onReactionClick, messageId }: MessageReactionsProps) {
|
||||||
|
const { user } = useAppState();
|
||||||
|
const [visibleReactions, setVisibleReactions] = useState<Reaction[]>([]);
|
||||||
|
const [animatingReactions, setAnimatingReactions] = useState<Set<string>>(new Set());
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
// Handle reactions with animation
|
||||||
|
useEffect(() => {
|
||||||
|
if (!reactions || reactions.length === 0) {
|
||||||
|
// If we have visible reactions, animate them out
|
||||||
|
if (visibleReactions.length > 0) {
|
||||||
|
visibleReactions.forEach(reaction => {
|
||||||
|
setAnimatingReactions(prev => new Set(prev).add(reaction.emoji));
|
||||||
|
});
|
||||||
|
// After animation completes, hide the component
|
||||||
|
setTimeout(() => {
|
||||||
|
setVisibleReactions([]);
|
||||||
|
setAnimatingReactions(new Set());
|
||||||
|
setIsVisible(false);
|
||||||
|
}, 200);
|
||||||
|
} else {
|
||||||
|
// No visible reactions, hide immediately
|
||||||
|
setIsVisible(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the component when we have reactions
|
||||||
|
setIsVisible(true);
|
||||||
|
|
||||||
|
// Deduplicate reactions by emoji (safety measure)
|
||||||
|
const uniqueReactions = reactions.reduce((acc, reaction) => {
|
||||||
|
const existing = acc.find(r => r.emoji === reaction.emoji);
|
||||||
|
if (existing) {
|
||||||
|
// Keep the one with the higher count
|
||||||
|
if (reaction.count > existing.count) {
|
||||||
|
acc[acc.indexOf(existing)] = reaction;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
acc.push(reaction);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [] as Reaction[]);
|
||||||
|
|
||||||
|
|
||||||
|
// Animate out removed reactions
|
||||||
|
visibleReactions.forEach(reaction => {
|
||||||
|
if (!uniqueReactions.some(r => r.emoji === reaction.emoji)) {
|
||||||
|
setAnimatingReactions(prev => new Set(prev).add(reaction.emoji));
|
||||||
|
setTimeout(() => {
|
||||||
|
setVisibleReactions(prev => prev.filter(r => r.emoji !== reaction.emoji));
|
||||||
|
setAnimatingReactions(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(reaction.emoji);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update existing reactions and add new ones
|
||||||
|
setVisibleReactions(prev => {
|
||||||
|
const updated = [...prev];
|
||||||
|
|
||||||
|
// Update existing reactions
|
||||||
|
uniqueReactions.forEach(reaction => {
|
||||||
|
const existingIndex = updated.findIndex(r => r.emoji === reaction.emoji);
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
updated[existingIndex] = reaction;
|
||||||
|
} else {
|
||||||
|
// Add new reaction only if it doesn't already exist
|
||||||
|
if (!updated.some(r => r.emoji === reaction.emoji)) {
|
||||||
|
updated.push(reaction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, [reactions]);
|
||||||
|
|
||||||
|
// Don't render if not visible
|
||||||
|
if (!isVisible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${messageId || 'unknown'}-${reaction.emoji}-${reaction.count}-${index}`}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -70,6 +70,7 @@ export class DMPanel extends MessagePanel {
|
|||||||
is_read: false,
|
is_read: false,
|
||||||
is_edited: false,
|
is_edited: false,
|
||||||
files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [],
|
files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [],
|
||||||
|
reactions: env.reactions || [],
|
||||||
|
|
||||||
runtimeData: {
|
runtimeData: {
|
||||||
dmEnvelope: env
|
dmEnvelope: env
|
||||||
@@ -238,6 +239,10 @@ export class DMPanel extends MessagePanel {
|
|||||||
const { id } = response.data;
|
const { id } = response.data;
|
||||||
this.removeMessage(id);
|
this.removeMessage(id);
|
||||||
}
|
}
|
||||||
|
if (response.type === "dmReactionUpdate" && this.dmData) {
|
||||||
|
const { dm_envelope_id, reactions } = response.data;
|
||||||
|
this.updateMessageReactions(dm_envelope_id, reactions);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reset for DM switching
|
// Reset for DM switching
|
||||||
@@ -302,4 +307,17 @@ export class DMPanel extends MessagePanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleProfileClick(): void {}
|
handleProfileClick(): void {}
|
||||||
|
|
||||||
|
updateMessageReactions(dmEnvelopeId: number, reactions: any[]): void {
|
||||||
|
const messages = this.getMessages();
|
||||||
|
const messageIndex = messages.findIndex(msg =>
|
||||||
|
msg.runtimeData?.dmEnvelope?.id === dmEnvelopeId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (messageIndex !== -1) {
|
||||||
|
const updatedMessage = { ...messages[messageIndex] };
|
||||||
|
updatedMessage.reactions = reactions;
|
||||||
|
this.updateMessage(updatedMessage.id, { reactions: reactions });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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