Merge branch 'feature/reactions'

This commit is contained in:
2025-10-07 23:35:22 +03:00
Unverified
19 changed files with 1233 additions and 88 deletions
+4
View File
@@ -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.
+1 -1
View File
@@ -19,7 +19,7 @@ When working with this project, follow these rules:
## File Operations
- If possible, try to update files in a single edit when making multiple changes.
- Do NOT "cd" to the project directory.å
- Do NOT "cd" to the project directory.
## Testing & Validation
- Do NOT "test the implementation" when you are done. The only exception is when you
+70 -1
View File
@@ -1,5 +1,5 @@
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, null, text
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, null, text, UniqueConstraint
from sqlalchemy.orm import relationship
from datetime import datetime
from pydantic import BaseModel
@@ -36,6 +36,7 @@ class Message(Base):
author = relationship("User", back_populates="messages")
reply_to = relationship("Message", remote_side=[id])
files = relationship("MessageFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
reactions = relationship("Reaction", cascade="all, delete-orphan", lazy="select")
class MessageFile(Base):
@@ -79,6 +80,7 @@ class DMEnvelope(Base):
reply_to_id = Column(Integer, nullable=True)
timestamp = Column(DateTime, default=datetime.now)
files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
reactions = relationship("DMReaction", cascade="all, delete-orphan", lazy="select")
class DMFile(Base):
@@ -106,6 +108,39 @@ class PushSubscription(Base):
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class Reaction(Base):
__tablename__ = "reaction"
id = Column(Integer, primary_key=True, index=True)
message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
emoji = Column(String(10), nullable=False) # Store emoji as string
timestamp = Column(DateTime, default=datetime.now)
# Relationships
user = relationship("User")
# Ensure unique combination of message, user, and emoji
__table_args__ = (UniqueConstraint('message_id', 'user_id', 'emoji', name='unique_reaction'),)
class DMReaction(Base):
__tablename__ = "dm_reaction"
id = Column(Integer, primary_key=True, index=True)
dm_envelope_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
emoji = Column(String(10), nullable=False) # Store emoji as string
timestamp = Column(DateTime, default=datetime.now)
# Relationships
user = relationship("User")
dm_envelope = relationship("DMEnvelope")
# Ensure unique combination of dm_envelope, user, and emoji
__table_args__ = (UniqueConstraint('dm_envelope_id', 'user_id', 'emoji', name='unique_dm_reaction'),)
# Pydantic модели
class LoginRequest(BaseModel):
username: str
@@ -166,5 +201,39 @@ class MessageResponse(BaseModel):
from_attributes = True
class ReactionRequest(BaseModel):
message_id: int
emoji: str
class ReactionResponse(BaseModel):
id: int
message_id: int
user_id: int
emoji: str
timestamp: datetime
username: str
class Config:
from_attributes = True
class DMReactionRequest(BaseModel):
dm_envelope_id: int
emoji: str
class DMReactionResponse(BaseModel):
id: int
dm_envelope_id: int
user_id: int
emoji: str
timestamp: datetime
username: str
class Config:
from_attributes = True
# Tables are now created through Alembic migrations
# Base.metadata.create_all(bind=engine)
+239 -1
View File
@@ -10,7 +10,7 @@ from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from dependencies import get_current_user, get_db
from constants import OWNER_USERNAME
from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile
from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse
from push_service import push_service
from PIL import Image
import io
@@ -31,6 +31,23 @@ os.makedirs(FILES_ENCRYPTED_DIR, exist_ok=True)
def convert_message(msg: Message) -> dict:
# Group reactions by emoji
reactions_dict = {}
if msg.reactions:
for reaction in msg.reactions:
emoji = reaction.emoji
if emoji not in reactions_dict:
reactions_dict[emoji] = {
"emoji": emoji,
"count": 0,
"users": []
}
reactions_dict[emoji]["count"] += 1
reactions_dict[emoji]["users"].append({
"id": reaction.user_id,
"username": reaction.user.username
})
return {
"id": msg.id,
"content": msg.content,
@@ -40,6 +57,7 @@ def convert_message(msg: Message) -> dict:
"username": msg.author.username,
"profile_picture": msg.author.profile_picture,
"reply_to": convert_message(msg.reply_to) if msg.reply_to else None,
"reactions": list(reactions_dict.values()),
"files": [
{
"path": f"/api/uploads/files/normal/{Path(f.path).name}",
@@ -51,6 +69,47 @@ def convert_message(msg: Message) -> dict:
]
}
def convert_dm_envelope(envelope: DMEnvelope) -> dict:
# Group reactions by emoji
reactions_dict = {}
if envelope.reactions:
for reaction in envelope.reactions:
emoji = reaction.emoji
if emoji not in reactions_dict:
reactions_dict[emoji] = {
"emoji": emoji,
"count": 0,
"users": []
}
reactions_dict[emoji]["count"] += 1
reactions_dict[emoji]["users"].append({
"id": reaction.user_id,
"username": reaction.user.username
})
return {
"id": envelope.id,
"senderId": envelope.sender_id,
"recipientId": envelope.recipient_id,
"iv": envelope.iv_b64,
"ciphertext": envelope.ciphertext_b64,
"salt": envelope.salt_b64,
"iv2": envelope.iv2_b64,
"wrappedMk": envelope.wrapped_mk_b64,
"timestamp": envelope.timestamp.isoformat(),
"reactions": list(reactions_dict.values()),
"files": [
{
"path": f"/api/uploads/files/encrypted/{Path(f.path).name}",
"id": f.id,
"name": f.name,
"dm_envelope_id": f.dm_envelope_id
}
for f in (envelope.files or [])
]
}
# для тех кто читает этот код я эти маты не писал
# мат писал ии а я сам не матерюсь))
# - denis0001-dev
@@ -436,6 +495,125 @@ async def delete_message(
return {"status": "success", "message_id": message_id}
@router.post("/add_reaction")
async def add_reaction(
request: ReactionRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# Check if message exists
message = db.query(Message).filter(Message.id == request.message_id).first()
if not message:
raise HTTPException(status_code=404, detail="Message not found")
# Check if reaction already exists
existing_reaction = db.query(Reaction).filter(
Reaction.message_id == request.message_id,
Reaction.user_id == current_user.id,
Reaction.emoji == request.emoji
).first()
if existing_reaction:
# Remove existing reaction (toggle off)
db.delete(existing_reaction)
action = "removed"
else:
# Add new reaction
new_reaction = Reaction(
message_id=request.message_id,
user_id=current_user.id,
emoji=request.emoji
)
db.add(new_reaction)
action = "added"
db.commit()
# Refresh message to get updated reactions
db.refresh(message)
# Broadcast reaction update
try:
from .messaging import messagingManager
await messagingManager.broadcast({
"type": "reactionUpdate",
"data": {
"message_id": request.message_id,
"emoji": request.emoji,
"action": action,
"user_id": current_user.id,
"username": current_user.username,
"reactions": convert_message(message)["reactions"]
}
})
except Exception:
pass
return {"status": "success", "action": action, "reactions": convert_message(message)["reactions"]}
@router.post("/dm/add_reaction")
async def add_dm_reaction(
request: DMReactionRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# Check if DM envelope exists
envelope = db.query(DMEnvelope).filter(DMEnvelope.id == request.dm_envelope_id).first()
if not envelope:
raise HTTPException(status_code=404, detail="DM envelope not found")
# Check if user is part of this DM conversation
if current_user.id not in [envelope.sender_id, envelope.recipient_id]:
raise HTTPException(status_code=403, detail="Not authorized to react to this message")
# Check if reaction already exists
existing_reaction = db.query(DMReaction).filter(
DMReaction.dm_envelope_id == request.dm_envelope_id,
DMReaction.user_id == current_user.id,
DMReaction.emoji == request.emoji
).first()
if existing_reaction:
# Remove existing reaction (toggle off)
db.delete(existing_reaction)
action = "removed"
else:
# Add new reaction
new_reaction = DMReaction(
dm_envelope_id=request.dm_envelope_id,
user_id=current_user.id,
emoji=request.emoji
)
db.add(new_reaction)
action = "added"
db.commit()
# Refresh envelope to get updated reactions
db.refresh(envelope)
# Broadcast reaction update to both participants
try:
from .messaging import messagingManager
await messagingManager.broadcast({
"type": "dmReactionUpdate",
"data": {
"dm_envelope_id": request.dm_envelope_id,
"emoji": request.emoji,
"action": action,
"user_id": current_user.id,
"username": current_user.username,
"reactions": convert_dm_envelope(envelope)["reactions"]
}
})
except Exception:
pass
return {"status": "success", "action": action, "reactions": convert_dm_envelope(envelope)["reactions"]}
class MessaggingSocketManager:
def __init__(self) -> None:
self.connections: list[WebSocket] = []
@@ -670,6 +848,66 @@ class MessaggingSocketManager:
"data": {"message_id": message_id}
})
await websocket.send_json({"type": type, "data": response})
except HTTPException as e:
await self.send_error(websocket, type, e)
elif type == "addReaction":
try:
current_user = get_current_user_inner()
if not current_user:
raise HTTPException(401)
request_data = data["data"]
reaction_request = ReactionRequest(
message_id=request_data["message_id"],
emoji=request_data["emoji"]
)
response = await add_reaction(reaction_request, current_user, db)
# Broadcast reaction update
await self.broadcast({
"type": "reactionUpdate",
"data": {
"message_id": request_data["message_id"],
"emoji": request_data["emoji"],
"action": response["action"],
"user_id": current_user.id,
"username": current_user.username,
"reactions": response["reactions"]
}
})
await websocket.send_json({"type": type, "data": response})
except HTTPException as e:
await self.send_error(websocket, type, e)
elif type == "addDmReaction":
try:
current_user = get_current_user_inner()
if not current_user:
raise HTTPException(401)
request_data = data["data"]
reaction_request = DMReactionRequest(
dm_envelope_id=request_data["dm_envelope_id"],
emoji=request_data["emoji"]
)
response = await add_dm_reaction(reaction_request, current_user, db)
# Broadcast reaction update
await self.broadcast({
"type": "dmReactionUpdate",
"data": {
"dm_envelope_id": request_data["dm_envelope_id"],
"emoji": request_data["emoji"],
"action": response["action"],
"user_id": current_user.id,
"username": current_user.username,
"reactions": response["reactions"]
}
})
await websocket.send_json({"type": type, "data": response})
except HTTPException as e:
await self.send_error(websocket, type, e)
+55 -2
View File
@@ -51,6 +51,15 @@ export interface Rect extends Size2D {
* @property {string} [profile_picture] - URL to sender's profile picture
* @property {Message} [reply_to] - The message this is replying to
*/
export interface Reaction {
emoji: string;
count: number;
users: Array<{
id: number;
username: string;
}>;
}
export interface Message {
id: number;
username: string;
@@ -61,6 +70,7 @@ export interface Message {
profile_picture?: string;
reply_to?: Message;
files?: Attachment[];
reactions?: Reaction[];
runtimeData?: {
dmEnvelope?: DmEnvelope;
@@ -202,6 +212,7 @@ export interface DmEnvelope extends BaseDmEnvelope {
senderId: number;
files?: DmFile[];
timestamp: string;
reactions?: Reaction[];
}
export interface DmFile {
@@ -313,6 +324,24 @@ export interface SendMessageRequest extends WebSocketMessage {
}
}
export interface AddReactionRequest extends WebSocketMessage {
type: "addReaction",
credentials: WebSocketCredentials;
data: {
message_id: number;
emoji: string;
}
}
export interface AddDmReactionRequest extends WebSocketMessage {
type: "addDmReaction",
credentials: WebSocketCredentials;
data: {
dm_envelope_id: number;
emoji: string;
}
}
// Messages
export interface DMNewWebSocketMessage extends WebSocketMessage {
type: "dmNew",
@@ -348,9 +377,33 @@ export interface NewMessageWebSocketMessage extends WebSocketMessage {
data: Message
}
export interface ReactionUpdateWebSocketMessage extends WebSocketMessage {
type: "reactionUpdate",
data: {
message_id: number;
emoji: string;
action: "added" | "removed";
user_id: number;
username: string;
reactions: Reaction[];
}
}
export interface DMReactionUpdateWebSocketMessage extends WebSocketMessage {
type: "dmReactionUpdate",
data: {
dm_envelope_id: number;
emoji: string;
action: "added" | "removed";
user_id: number;
username: string;
reactions: Reaction[];
}
}
// Shared types
export type DMWebSocketMessage = DMNewWebSocketMessage | DMEditedWebSocketMessage | DMDeletedWebSocketMessage
export type ChatWebSocketMessage = MessageEditedWebSocketMessage | MessageDeletedWebSocketMessage | NewMessageWebSocketMessage
export type DMWebSocketMessage = DMNewWebSocketMessage | DMEditedWebSocketMessage | DMDeletedWebSocketMessage | DMReactionUpdateWebSocketMessage
export type ChatWebSocketMessage = MessageEditedWebSocketMessage | MessageDeletedWebSocketMessage | NewMessageWebSocketMessage | ReactionUpdateWebSocketMessage
// -----------
// Encrypted message JSON (plaintext structure before encryption)
@@ -213,7 +213,7 @@
height: 32px;
flex-shrink: 0;
margin-bottom: 4px;
margin: 8px;
margin: 10px;
img {
width: 100%;
@@ -234,7 +234,7 @@
margin-bottom: 0.3rem;
font-size: 0.9rem;
transition: color 0.2s ease;
margin: 8px;
margin: 10px;
&:hover {
color: $color-dark-primary;
@@ -968,4 +968,17 @@
color: $color-dark-on-surface-variant;
font-size: 0.9rem;
}
// Integrated mode styles (inside reaction bar)
&.integrated {
position: relative !important;
width: 320px !important;
height: 400px !important;
transform: none !important;
opacity: 1 !important;
box-shadow: none;
border: none;
background: $color-dark-surface-container;
overflow: visible;
}
}
@@ -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 {
position: fixed;
position: relative;
background-color: $color-dark-surface-container;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
padding: 0.5rem 0;
z-index: 1000;
display: none;
display: block;
min-width: 150px;
max-width: 200px;
white-space: nowrap;
@@ -82,6 +82,11 @@ button, input {
&.open {
animation: context-menu-open 0.25s ease;
}
&.faded {
opacity: 0;
transition: opacity 0.3s ease-out;
}
@keyframes context-menu-open {
0% {
@@ -12,6 +12,7 @@
@use "download-app";
@use "404" as not-found;
@use "homepage";
@use "reactions";
@use "lib/fonts/montserrat";
@use "lib/fonts/material-symbols";
@@ -66,18 +66,24 @@ export function ChatInputWrapper(
setAttachmentsVisible(selectedFiles.length > 0);
}, [selectedFiles]);
function handleEmojiButtonClick() {
if (chatInputWrapperRef.current && messagePanelRef?.current) {
const inputRect = chatInputWrapperRef.current.getBoundingClientRect();
const panelRect = messagePanelRef.current.getBoundingClientRect();
// Position menu 10px from message panel edge and 10px above the chat input
// The animation will start 30px below this position
setEmojiMenuPosition({
x: panelRect.left + 10, // 10px from message panel edge
y: window.innerHeight - inputRect.top + 10 // 10px above the top of chat input
});
setEmojiMenuOpen(true);
function handleEmojiButtonClick(e: React.MouseEvent<HTMLButtonElement>) {
e.stopPropagation();
if (!emojiMenuOpen) {
if (chatInputWrapperRef.current && messagePanelRef?.current) {
const inputRect = chatInputWrapperRef.current.getBoundingClientRect();
const panelRect = messagePanelRef.current.getBoundingClientRect();
// Position menu 10px from message panel edge and 10px above the chat input
// The animation will start 30px below this position
setEmojiMenuPosition({
x: panelRect.left + 10, // 10px from message panel edge
y: window.innerHeight - inputRect.top + 10 // 10px above the top of chat input
});
setEmojiMenuOpen(true);
}
} else {
setEmojiMenuOpen(false);
}
};
@@ -176,7 +182,12 @@ export function ChatInputWrapper(
</AnimatedHeight>
<div className="chat-input">
<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>
<RichTextArea
className="message-input"
@@ -206,6 +217,7 @@ export function ChatInputWrapper(
onClose={() => setEmojiMenuOpen(false)}
onEmojiSelect={handleEmojiSelect}
position={emojiMenuPosition}
mode="standalone"
/>
</div>
);
@@ -8,6 +8,8 @@ import { fetchUserProfile } from "../../../api/profileApi";
import { useEffect, useState, type ReactNode } from "react";
import { delay } from "../../../utils/utils";
import { MaterialDialog } from "../core/Dialog";
import { request } from "../../../core/websocket";
import type { AddReactionRequest, AddDmReactionRequest } from "../../../core/types";
interface ChatMessagesProps {
messages?: MessageType[];
@@ -39,6 +41,7 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [toBeDeleted, setToBeDeleted] = useState<{ id: number; isDm: boolean } | null>(null);
useEffect(() => {
if (!deleteDialogOpen) {
setToBeDeleted(null);
@@ -107,6 +110,43 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
}
}
async function handleReactionClick(messageId: number, emoji: string) {
if (!user.authToken) return;
try {
if (isDm) {
// For DM messages, we need to find the dm_envelope_id from the message
const message = messages.find(m => m.id === messageId);
const dmEnvelopeId = message?.runtimeData?.dmEnvelope?.id;
if (dmEnvelopeId) {
await request<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 (
<>
<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}
onProfileClick={handleProfileClick}
onContextMenu={handleContextMenu}
onReactionClick={handleReactionClick}
isLoadingProfile={isLoadingProfile}
isDm={isDm}
dmRecipientPublicKey={dmRecipientPublicKey} />
@@ -153,11 +194,14 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
onReply={handleReply}
onDelete={handleDelete}
onRetry={handleRetry}
onReactionClick={handleReactionClick}
position={contextMenu.position}
isOpen={contextMenu.isOpen}
onOpenChange={handleContextMenuOpenChange}
/>
)}
</>
);
}
@@ -2,14 +2,26 @@ import { useState, useEffect, useRef, useCallback } from "react";
import { EMOJI_CATEGORIES, getRecentEmojis, addRecentEmoji } from "./emojiData";
import type { Size2D } from "../../../core/types";
interface EmojiMenuProps {
interface BaseEmojiMenuProps {
isOpen: boolean;
onClose: () => void;
onEmojiSelect: (emoji: string) => void;
position: Size2D;
}
export function EmojiMenu({ isOpen, onClose, onEmojiSelect, position }: EmojiMenuProps) {
interface StandaloneEmojiMenuProps extends BaseEmojiMenuProps {
position: Size2D;
mode: "standalone";
}
interface IntegratedEmojiMenuProps extends BaseEmojiMenuProps {
mode: "integrated";
}
type EmojiMenuProps = StandaloneEmojiMenuProps | IntegratedEmojiMenuProps;
export function EmojiMenu(props: EmojiMenuProps) {
const { isOpen, onClose, onEmojiSelect, mode } = props;
const position = mode === "standalone" ? props.position : undefined;
const [activeCategory, setActiveCategory] = useState("recent");
const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
const menuRef = useRef<HTMLDivElement>(null);
@@ -106,13 +118,15 @@ export function EmojiMenu({ isOpen, onClose, onEmojiSelect, position }: EmojiMen
return (
<div
ref={menuRef}
className={`emoji-menu ${isOpen ? "open" : ""}`}
style={{
className={`emoji-menu ${isOpen ? "open" : ""} ${mode}`}
style={mode === "standalone" && position ? {
position: "fixed",
left: position.x,
bottom: position.y,
zIndex: 1000,
pointerEvents: isOpen ? "auto" : "none"
} : {
pointerEvents: isOpen ? "auto" : "none"
}}
>
<div className="emoji-menu-header">
@@ -13,12 +13,14 @@ import { useAppState } from "../../state";
import { ub64 } from "../../../utils/utils";
import { useImmer } from "use-immer";
import { createPortal } from "react-dom";
import { MessageReactions } from "./MessageReactions";
interface MessageProps {
message: MessageType;
isAuthor: boolean;
onProfileClick: (username: string) => void;
onContextMenu: (e: React.MouseEvent, message: MessageType) => void;
onReactionClick?: (messageId: number, emoji: string) => void;
isLoadingProfile?: boolean;
isDm?: boolean;
dmRecipientPublicKey?: string;
@@ -31,7 +33,7 @@ interface Rect {
height: number
}
export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false, dmRecipientPublicKey }: MessageProps) {
export function Message({ message, isAuthor, onProfileClick, onContextMenu, onReactionClick, isLoadingProfile = false, isDm = false, dmRecipientPublicKey }: MessageProps) {
const [formattedMessage, setFormattedMessage] = useState({ __html: "" });
const [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map());
const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set());
@@ -373,6 +375,12 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
</mdui-list>
)}
<MessageReactions
reactions={message.reactions}
onReactionClick={(emoji) => onReactionClick?.(message.id, emoji)}
messageId={message.id}
/>
<div className="message-time">
{formatTime(message.timestamp)}
{message.is_edited ? " (edited)" : undefined}
@@ -1,5 +1,6 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import type { Message, Size2D } from "../../../core/types";
import { EmojiMenu } from "./EmojiMenu";
interface MessageContextMenuProps {
message: Message;
@@ -8,6 +9,7 @@ interface MessageContextMenuProps {
onReply: (message: Message) => void;
onDelete: (message: Message) => void;
onRetry?: (message: Message) => void;
onReactionClick?: (messageId: number, emoji: string) => Promise<void>;
position: Size2D;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
@@ -26,6 +28,7 @@ export function MessageContextMenu({
onReply,
onDelete,
onRetry,
onReactionClick,
position,
isOpen,
onOpenChange
@@ -34,64 +37,91 @@ export function MessageContextMenu({
const [isClosing, setIsClosing] = useState(false);
const [calculatedPosition, setCalculatedPosition] = useState(position);
const [animationClass, setAnimationClass] = useState('entering');
const [reactionBarPosition, setReactionBarPosition] = useState<'left' | 'right'>('left');
const [isEmojiMenuExpanded, setIsEmojiMenuExpanded] = useState(false);
const [initialDimensions, setInitialDimensions] = useState<{ width: number; height: number } | null>(null);
const [expandUpward, setExpandUpward] = useState(false);
const [contextMenuHeight, setContextMenuHeight] = useState<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
useEffect(() => {
if (isOpen) {
const menuWidth = 160; // min-width from CSS
const menuHeight = isAuthor ? 120 : 60; // Approximate height based on items
const padding = 10; // Padding from viewport edges
// Use a small delay to ensure elements are rendered before measuring
const frameId = requestAnimationFrame(() => {
if (wrapperRef.current && reactionBarRef.current && contextMenuRef.current) {
// Get actual dimensions from DOM elements
const reactionBarRect = reactionBarRef.current.getBoundingClientRect();
const contextMenuRect = contextMenuRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Calculate shared/combined rect dimensions
const sharedRect = {
width: Math.max(reactionBarRect.width, contextMenuRect.width),
height: reactionBarRect.height + contextMenuRect.height
};
let x = position.x;
let y = position.y;
let animation = 'entering';
let reactionPosition: 'left' | 'right' = 'left';
// Check if shared rect would overflow and adjust position
if (x + sharedRect.width > viewportWidth) {
x = position.x - contextMenuRect.width - 25;
animation = 'entering-left';
reactionPosition = 'right';
} else {
reactionPosition = 'left';
}
// Ensure menu doesn't go off the left edge
if (x < 0) {
x = 0;
}
// Check if shared rect would overflow bottom edge
if (y + sharedRect.height > viewportHeight) {
y = viewportHeight - sharedRect.height;
animation = 'entering-up';
}
setCalculatedPosition({ x, y });
setAnimationClass(animation);
setReactionBarPosition(reactionPosition);
}
});
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let x = position.x;
let y = position.y;
let animation = 'entering';
// Check if menu would overflow right edge
if (x + menuWidth + padding > viewportWidth) {
x = viewportWidth - menuWidth - padding;
animation = 'entering-left'; // Animation from left side
}
// Check if menu would overflow bottom edge
if (y + menuHeight + padding > viewportHeight) {
y = viewportHeight - menuHeight - padding;
animation = 'entering-up'; // Animation from bottom
}
// If both edges would overflow, use top-left positioning
if (x + menuWidth + padding > viewportWidth && y + menuHeight + padding > viewportHeight) {
x = Math.max(padding, position.x - menuWidth);
y = Math.max(padding, position.y - menuHeight);
animation = 'entering-up-left';
}
setCalculatedPosition({ x, y });
setAnimationClass(animation);
return () => cancelAnimationFrame(frameId);
}
}, [isOpen, position, isAuthor]);
// Effect to handle clicks outside the context menu
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
function handleClickOutside(event: MouseEvent) {
if (isOpen && !isClosing) {
// Check if the click is on a context menu element
// Check if the click is on a context menu element or reaction bar
const target = event.target as Element;
if (!target.closest('.context-menu')) {
if (!target.closest('.context-menu') && !target.closest('.context-menu-reaction-bar')) {
handleClose();
}
}
};
const handleKeyDown = (event: KeyboardEvent) => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape' && isOpen && !isClosing) {
handleClose();
}
};
const handleWindowBlur = () => {
function handleWindowBlur() {
// Close context menu when browser window loses focus
if (isOpen && !isClosing) {
handleClose();
@@ -122,6 +152,11 @@ export function MessageContextMenu({
onOpenChange(false);
setIsClosing(false);
setAnimationClass('entering'); // Reset for next opening
// Reset emoji menu state after context menu animation completes
setIsEmojiMenuExpanded(false);
setInitialDimensions(null);
setExpandUpward(false);
setContextMenuHeight(null);
}, 200); // Match the animation duration from _animations.scss
}
@@ -178,29 +213,127 @@ export function MessageContextMenu({
},
];
// Quick reactions for the reaction bar
const QUICK_REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "😡"];
async function handleReactionClick(emoji: string) {
if (onReactionClick) {
await onReactionClick(message.id, emoji);
}
handleClose();
}
function handleExpandClick() {
if (!reactionBarRef.current || !wrapperRef.current) return;
// Measure the actual dimensions of the reaction bar content
const reactionBarRect = reactionBarRef.current.getBoundingClientRect();
const wrapperRect = wrapperRef.current.getBoundingClientRect();
setInitialDimensions({ width: reactionBarRect.width, height: reactionBarRect.height });
setContextMenuHeight(wrapperRect.height);
// Check if expanding downward would cause overflow
// Calculate space from the reaction bar's bottom edge downward
const viewportHeight = window.innerHeight;
const spaceBelow = viewportHeight - reactionBarRect.bottom;
const emojiMenuHeight = 400;
// Only expand upward if there's not enough space below for the emoji menu
const shouldExpandUpward = spaceBelow < emojiMenuHeight;
setExpandUpward(shouldExpandUpward);
// Use requestAnimationFrame to ensure the dimensions are applied before expansion
requestAnimationFrame(() => {
setIsEmojiMenuExpanded(true);
});
}
function handleEmojiSelect(emoji: string) {
if (onReactionClick) {
onReactionClick(message.id, emoji);
}
handleClose();
}
return isOpen && (
<div
className={`context-menu ${animationClass}`}
ref={wrapperRef}
className={`context-menu-wrapper ${animationClass}`}
style={{
position: "fixed",
display: "block",
top: calculatedPosition.y,
left: calculatedPosition.x,
zIndex: 1000
}}
onClick={(e) => e.stopPropagation()}>
{actions.map((action, i) => (
action.show && (
<div
className="context-menu-item"
onClick={action.onClick}
key={i}
>
<span className="material-symbols">{action.icon}</span>
{action.label}
{/* Reaction Bar */}
<div
ref={reactionBarRef}
className={`context-menu-reaction-bar ${reactionBarPosition} ${isEmojiMenuExpanded ? "expanded" : ""} ${expandUpward ? "expand-upward" : ""}`}
style={isEmojiMenuExpanded && !expandUpward ? {
position: 'fixed',
top: `${(-(contextMenuHeight || 0) + 5)}px`,
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
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>
)
}
@@ -20,6 +20,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
const [switchIn, setSwitchIn] = useState(false);
const [switchOut, setSwitchOut] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const previousMessageCountRef = useRef(0);
const [replyTo, setReplyTo] = useState<Message | null>(null);
const [replyToVisible, setReplyToVisible] = useState(Boolean(replyTo));
const [editMessage, setEditMessage] = useState<Message | null>(null);
@@ -127,19 +128,32 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
}
}, [chat.activePanel, chat.isSwitching, switchOut, switchIn]);
// Scroll to bottom when messages change, but only when no animation is running
// Scroll to bottom only when new messages are added
useEffect(() => {
if (!panelState || chat.isSwitching || switchOut || switchIn || panelState.isLoading) return;
if (!panelState || chat.isSwitching || switchOut || switchIn) return;
const currentMessageCount = panelState.messages.length;
const previousMessageCount = previousMessageCountRef.current;
const el = messagesEndRef.current;
if (!el) return;
// Defer to next frame to ensure layout is stable
const id = requestAnimationFrame(() => {
el.scrollIntoView({ behavior: "smooth", block: "end" });
});
return () => cancelAnimationFrame(id);
// Scroll without animation when messages are initially loaded
if (previousMessageCount === 0 && currentMessageCount > 0 && !panelState.isLoading) {
el.scrollIntoView({ behavior: "instant", block: "end" });
}
// Scroll with animation when a new message is added
else if (currentMessageCount > previousMessageCount && previousMessageCount > 0) {
// Defer to next frame to ensure layout is stable
const id = requestAnimationFrame(() => {
el.scrollIntoView({ behavior: "smooth", block: "end" });
});
return () => cancelAnimationFrame(id);
}
// Update the previous message count
previousMessageCountRef.current = currentMessageCount;
}, [panelState?.messages, panelState?.isLoading, chat.isSwitching, switchOut, switchIn]);
return (
@@ -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_edited: false,
files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [],
reactions: env.reactions || [],
runtimeData: {
dmEnvelope: env
@@ -238,6 +239,10 @@ export class DMPanel extends MessagePanel {
const { id } = response.data;
this.removeMessage(id);
}
if (response.type === "dmReactionUpdate" && this.dmData) {
const { dm_envelope_id, reactions } = response.data;
this.updateMessageReactions(dm_envelope_id, reactions);
}
};
// Reset for DM switching
@@ -302,4 +307,17 @@ export class DMPanel extends MessagePanel {
}
handleProfileClick(): void {}
updateMessageReactions(dmEnvelopeId: number, reactions: any[]): void {
const messages = this.getMessages();
const messageIndex = messages.findIndex(msg =>
msg.runtimeData?.dmEnvelope?.id === dmEnvelopeId
);
if (messageIndex !== -1) {
const updatedMessage = { ...messages[messageIndex] };
updatedMessage.reactions = reactions;
this.updateMessage(updatedMessage.id, { reactions: reactions });
}
}
}
@@ -86,6 +86,14 @@ export abstract class MessagePanel {
});
}
protected updateMessageReactions(messageId: number, reactions: any[]): void {
this.updateState({
messages: this.state.messages.map(msg =>
msg.id === messageId ? { ...msg, reactions } : msg
)
});
}
protected clearMessages(): void {
this.updateState({ messages: [] });
}
@@ -2,7 +2,7 @@ import { MessagePanel } from "./MessagePanel";
import { API_BASE_URL } from "../../core/config";
import { getAuthHeaders } from "../../auth/api";
import { request } from "../../core/websocket";
import type { ChatWebSocketMessage, Message, SendMessageRequest } from "../../core/types";
import type { ChatWebSocketMessage, Message, SendMessageRequest, ReactionUpdateWebSocketMessage } from "../../core/types";
import type { UserState } from "../state";
export class PublicChatPanel extends MessagePanel {
@@ -104,7 +104,7 @@ export class PublicChatPanel extends MessagePanel {
}
// Handle incoming WebSocket messages
async handleWebSocketMessage(response: ChatWebSocketMessage): Promise<void> {
async handleWebSocketMessage(response: ChatWebSocketMessage | ReactionUpdateWebSocketMessage): Promise<void> {
switch (response.type) {
case 'messageEdited':
if (response.data) {
@@ -136,6 +136,11 @@ export class PublicChatPanel extends MessagePanel {
this.addMessage(newMsg);
}
break;
case 'reactionUpdate':
if (response.data) {
this.updateMessageReactions(response.data.message_id, response.data.reactions);
}
break;
}
};