Implement basic reactions

This commit is contained in:
2025-10-05 22:56:43 +03:00
Unverified
parent a6e272e5b4
commit d6b2849e46
14 changed files with 923 additions and 60 deletions
+32 -1
View File
@@ -51,6 +51,15 @@ export interface Rect extends Size2D {
* @property {string} [profile_picture] - URL to sender's profile picture
* @property {Message} [reply_to] - The message this is replying to
*/
export interface Reaction {
emoji: string;
count: number;
users: Array<{
id: number;
username: string;
}>;
}
export interface Message {
id: number;
username: string;
@@ -61,6 +70,7 @@ export interface Message {
profile_picture?: string;
reply_to?: Message;
files?: Attachment[];
reactions?: Reaction[];
runtimeData?: {
dmEnvelope?: DmEnvelope;
@@ -313,6 +323,15 @@ export interface SendMessageRequest extends WebSocketMessage {
}
}
export interface AddReactionRequest extends WebSocketMessage {
type: "addReaction",
credentials: WebSocketCredentials;
data: {
message_id: number;
emoji: string;
}
}
// Messages
export interface DMNewWebSocketMessage extends WebSocketMessage {
type: "dmNew",
@@ -348,9 +367,21 @@ export interface NewMessageWebSocketMessage extends WebSocketMessage {
data: Message
}
export interface ReactionUpdateWebSocketMessage extends WebSocketMessage {
type: "reactionUpdate",
data: {
message_id: number;
emoji: string;
action: "added" | "removed";
user_id: number;
username: string;
reactions: Reaction[];
}
}
// Shared types
export type DMWebSocketMessage = DMNewWebSocketMessage | DMEditedWebSocketMessage | DMDeletedWebSocketMessage
export type ChatWebSocketMessage = MessageEditedWebSocketMessage | MessageDeletedWebSocketMessage | NewMessageWebSocketMessage
export type ChatWebSocketMessage = MessageEditedWebSocketMessage | MessageDeletedWebSocketMessage | NewMessageWebSocketMessage | ReactionUpdateWebSocketMessage
// -----------
// Encrypted message JSON (plaintext structure before encryption)
@@ -213,7 +213,7 @@
height: 32px;
flex-shrink: 0;
margin-bottom: 4px;
margin: 8px;
margin: 10px;
img {
width: 100%;
@@ -234,7 +234,7 @@
margin-bottom: 0.3rem;
font-size: 0.9rem;
transition: color 0.2s ease;
margin: 8px;
margin: 10px;
&:hover {
color: $color-dark-primary;
@@ -0,0 +1,324 @@
@use "common/material" as *;
@use "sass:color";
// Reaction styles
.message-reactions {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
margin-left: 10px;
margin-right: 10px;
}
.reaction-button {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: none;
border-radius: 16px;
background-color: $color-dark-surface-container;
cursor: pointer;
transition: transform 0.2s ease, background-color 0.2s ease;
font-size: 1px;
min-height: 28px;
animation: reactionFadeIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
&.removing {
animation: reactionFadeOut 0.2s ease forwards;
}
&:hover {
background-color: $color-dark-surface-container-high;
transform: scale(1.05);
}
&.reacted {
background-color: $color-dark-primary-container;
border-color: $color-dark-primary;
color: $color-dark-on-primary-container;
&:hover {
background-color: color.adjust($color-dark-primary-container, $lightness: 20%);
}
}
}
.reaction-emoji {
font-size: 17px;
line-height: 1;
}
.reaction-count {
font-size: 12px;
font-weight: 500;
line-height: 1;
}
// Reaction bar styles (standalone)
.reaction-bar {
background: $color-dark-surface-container;
border: 1px solid $color-dark-outline;
border-radius: 24px;
padding: 8px;
opacity: 1;
transition: all 0.15s ease;
backdrop-filter: blur(8px);
transform: translateY(0);
&.closing {
opacity: 0;
transform: scale(0.8);
}
}
// Context menu wrapper with animations
.context-menu-wrapper {
position: relative;
display: block;
// Animation states
&.entering {
opacity: 0;
transform: scale(0.8);
animation: contextMenuEnter 0.2s ease forwards;
}
&.entering-left {
opacity: 0;
transform: translateX(-20px) scale(0.8);
animation: contextMenuEnterLeft 0.2s ease forwards;
}
&.entering-up {
opacity: 0;
transform: translateY(20px) scale(0.8);
animation: contextMenuEnterUp 0.2s ease forwards;
}
&.entering-up-left {
opacity: 0;
transform: translateX(-20px) translateY(20px) scale(0.8);
animation: contextMenuEnterUpLeft 0.2s ease forwards;
}
&.closing {
opacity: 1;
transform: scale(1);
animation: contextMenuClose 0.2s ease forwards;
}
&.closing-left {
opacity: 1;
transform: translateX(0) scale(1);
animation: contextMenuCloseLeft 0.2s ease forwards;
}
&.closing-up {
opacity: 1;
transform: translateY(0) scale(1);
animation: contextMenuCloseUp 0.2s ease forwards;
}
&.closing-up-left {
opacity: 1;
transform: translateX(0) translateY(0) scale(1);
animation: contextMenuCloseUpLeft 0.2s ease forwards;
}
}
// Reaction bar inside context menu wrapper
.context-menu-reaction-bar {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
background: $color-dark-surface-container;
border: 1px solid $color-dark-outline;
border-radius: 16px;
position: absolute;
bottom: 100%;
min-width: 200px;
justify-content: center;
margin-bottom: 10px;
&.left {
left: 0;
transform: translateX(0);
}
&.right {
right: 0;
transform: translateX(0);
}
}
.reaction-bar-content {
display: flex;
align-items: center;
gap: 4px;
}
.reaction-emoji-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 16px;
background: transparent;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
font-size: 18px;
&:hover {
background: var(--mdui-color-surface-container-high);
transform: scale(1.3);
box-shadow: var(--mdui-elevation-1);
}
&:active {
transform: scale(0.95);
transition: transform 0.1s ease;
}
}
.reaction-expand-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--mdui-color-outline);
border-radius: 16px;
background: var(--mdui-color-surface);
cursor: pointer;
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
&:hover {
background: var(--mdui-color-surface-container-high);
border-color: var(--mdui-color-primary);
transform: scale(1.1);
box-shadow: var(--mdui-elevation-1);
}
&:active {
transform: scale(0.95);
transition: transform 0.1s ease;
}
.material-symbols {
font-size: 18px;
color: var(--mdui-color-on-surface);
transition: transform 0.2s ease;
}
&:hover .material-symbols {
transform: rotate(90deg);
}
}
// Animation for reactions appearing/disappearing
@keyframes reactionFadeIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes reactionFadeOut {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.8);
}
}
// Context menu wrapper animations
@keyframes contextMenuEnter {
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes contextMenuEnterLeft {
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes contextMenuEnterUp {
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes contextMenuEnterUpLeft {
to {
opacity: 1;
transform: translateX(0) translateY(0) scale(1);
}
}
@keyframes contextMenuClose {
to {
opacity: 0;
transform: scale(0.8);
}
}
@keyframes contextMenuCloseLeft {
to {
opacity: 0;
transform: translateX(-20px) scale(0.8);
}
}
@keyframes contextMenuCloseUp {
to {
opacity: 0;
transform: translateY(20px) scale(0.8);
}
}
@keyframes contextMenuCloseUpLeft {
to {
opacity: 0;
transform: translateX(-20px) translateY(20px) scale(0.8);
}
}
// Mobile responsive
@media (max-width: 768px) {
.reaction-bar {
padding: 6px;
}
.reaction-emoji-button,
.reaction-expand-button {
width: 28px;
height: 28px;
}
.reaction-emoji-button {
font-size: 16px;
}
.reaction-expand-button .material-symbols {
font-size: 16px;
}
}
@@ -31,13 +31,13 @@ button, input {
}
.context-menu {
position: fixed;
position: relative;
background-color: $color-dark-surface-container;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
padding: 0.5rem 0;
z-index: 1000;
display: none;
display: block;
min-width: 150px;
max-width: 200px;
white-space: nowrap;
@@ -12,6 +12,7 @@
@use "download-app";
@use "404" as not-found;
@use "homepage";
@use "reactions";
@use "lib/fonts/montserrat";
@use "lib/fonts/material-symbols";
@@ -4,10 +4,13 @@ import type { Message as MessageType } from "../../../core/types";
import type { UserProfile } from "../../../core/types";
import { UserProfileDialog } from "./UserProfileDialog";
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
import { EmojiMenu } from "./EmojiMenu";
import { fetchUserProfile } from "../../../api/profileApi";
import { useEffect, useState, type ReactNode } from "react";
import { delay } from "../../../utils/utils";
import { MaterialDialog } from "../core/Dialog";
import { request } from "../../../core/websocket";
import type { AddReactionRequest } from "../../../core/types";
interface ChatMessagesProps {
messages?: MessageType[];
@@ -39,6 +42,17 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [toBeDeleted, setToBeDeleted] = useState<{ id: number; isDm: boolean } | null>(null);
// Emoji menu state (for expanded emoji picker)
const [emojiMenu, setEmojiMenu] = useState<{
isOpen: boolean;
message: MessageType | null;
position: { x: number; y: number };
}>({
isOpen: false,
message: null,
position: { x: 0, y: 0 }
});
useEffect(() => {
if (!deleteDialogOpen) {
setToBeDeleted(null);
@@ -107,6 +121,46 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
}
}
async function handleReactionClick(messageId: number, emoji: string) {
if (!user.authToken) return;
try {
await request<AddReactionRequest["data"], any>({
type: "addReaction",
credentials: { scheme: "Bearer", credentials: user.authToken },
data: {
message_id: messageId,
emoji: emoji
}
});
} catch (error) {
console.error("Failed to add reaction:", error);
}
}
function handleEmojiMenuClose() {
setEmojiMenu(prev => ({ ...prev, isOpen: false }));
}
function handleExpandEmojiMenu(message: MessageType) {
const messageElement = document.querySelector(`[data-id="${message.id}"]`);
if (messageElement) {
const rect = messageElement.getBoundingClientRect();
setEmojiMenu({
isOpen: true,
message,
position: { x: rect.left + rect.width / 2, y: rect.bottom + 10 }
});
}
}
function handleEmojiSelect(emoji: string) {
if (emojiMenu.message) {
handleReactionClick(emojiMenu.message.id, emoji);
}
}
return (
<>
<div className="chat-messages" id="chat-messages">
@@ -117,6 +171,7 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
isAuthor={message.username === user.currentUser?.username}
onProfileClick={handleProfileClick}
onContextMenu={handleContextMenu}
onReactionClick={handleReactionClick}
isLoadingProfile={isLoadingProfile}
isDm={isDm}
dmRecipientPublicKey={dmRecipientPublicKey} />
@@ -153,11 +208,22 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
onReply={handleReply}
onDelete={handleDelete}
onRetry={handleRetry}
onReactionClick={handleReactionClick}
onExpandEmojiMenu={handleExpandEmojiMenu}
position={contextMenu.position}
isOpen={contextMenu.isOpen}
onOpenChange={handleContextMenuOpenChange}
/>
)}
{/* Emoji Menu */}
<EmojiMenu
isOpen={emojiMenu.isOpen}
onClose={handleEmojiMenuClose}
onEmojiSelect={handleEmojiSelect}
position={emojiMenu.position}
/>
</>
);
}
@@ -13,12 +13,14 @@ import { useAppState } from "../../state";
import { ub64 } from "../../../utils/utils";
import { useImmer } from "use-immer";
import { createPortal } from "react-dom";
import { MessageReactions } from "./MessageReactions";
interface MessageProps {
message: MessageType;
isAuthor: boolean;
onProfileClick: (username: string) => void;
onContextMenu: (e: React.MouseEvent, message: MessageType) => void;
onReactionClick?: (messageId: number, emoji: string) => void;
isLoadingProfile?: boolean;
isDm?: boolean;
dmRecipientPublicKey?: string;
@@ -31,7 +33,7 @@ interface Rect {
height: number
}
export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false, dmRecipientPublicKey }: MessageProps) {
export function Message({ message, isAuthor, onProfileClick, onContextMenu, onReactionClick, isLoadingProfile = false, isDm = false, dmRecipientPublicKey }: MessageProps) {
const [formattedMessage, setFormattedMessage] = useState({ __html: "" });
const [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map());
const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set());
@@ -373,6 +375,12 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
</mdui-list>
)}
<MessageReactions
reactions={message.reactions}
onReactionClick={(emoji) => onReactionClick?.(message.id, emoji)}
messageId={message.id}
/>
<div className="message-time">
{formatTime(message.timestamp)}
{message.is_edited ? " (edited)" : undefined}
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import type { Message, Size2D } from "../../../core/types";
interface MessageContextMenuProps {
@@ -8,6 +8,8 @@ interface MessageContextMenuProps {
onReply: (message: Message) => void;
onDelete: (message: Message) => void;
onRetry?: (message: Message) => void;
onReactionClick?: (messageId: number, emoji: string) => Promise<void>;
onExpandEmojiMenu?: (message: Message) => void;
position: Size2D;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
@@ -26,6 +28,8 @@ export function MessageContextMenu({
onReply,
onDelete,
onRetry,
onReactionClick,
onExpandEmojiMenu,
position,
isOpen,
onOpenChange
@@ -34,64 +38,86 @@ export function MessageContextMenu({
const [isClosing, setIsClosing] = useState(false);
const [calculatedPosition, setCalculatedPosition] = useState(position);
const [animationClass, setAnimationClass] = useState('entering');
const [reactionBarPosition, setReactionBarPosition] = useState<'left' | 'right'>('left');
// Refs for measuring actual dimensions
const wrapperRef = useRef<HTMLDivElement>(null);
const reactionBarRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
// Calculate smart positioning when component opens
useEffect(() => {
if (isOpen) {
const menuWidth = 160; // min-width from CSS
const menuHeight = isAuthor ? 120 : 60; // Approximate height based on items
const padding = 10; // Padding from viewport edges
// Use a small delay to ensure elements are rendered before measuring
const frameId = requestAnimationFrame(() => {
if (wrapperRef.current && reactionBarRef.current && contextMenuRef.current) {
// Get actual dimensions from DOM elements
const reactionBarRect = reactionBarRef.current.getBoundingClientRect();
const contextMenuRect = contextMenuRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Calculate shared/combined rect dimensions
const sharedRect = {
width: Math.max(reactionBarRect.width, contextMenuRect.width),
height: reactionBarRect.height + contextMenuRect.height
};
let x = position.x;
let y = position.y;
let animation = 'entering';
let reactionPosition: 'left' | 'right' = 'left';
// Check if shared rect would overflow and adjust position
if (x + sharedRect.width > viewportWidth) {
x = position.x - contextMenuRect.width - 25;
animation = 'entering-left';
reactionPosition = 'right';
} else {
reactionPosition = 'left';
}
// Ensure menu doesn't go off the left edge
if (x < 0) {
x = 0;
}
// Check if shared rect would overflow bottom edge
if (y + sharedRect.height > viewportHeight) {
y = viewportHeight - sharedRect.height;
animation = 'entering-up';
}
setCalculatedPosition({ x, y });
setAnimationClass(animation);
setReactionBarPosition(reactionPosition);
}
});
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let x = position.x;
let y = position.y;
let animation = 'entering';
// Check if menu would overflow right edge
if (x + menuWidth + padding > viewportWidth) {
x = viewportWidth - menuWidth - padding;
animation = 'entering-left'; // Animation from left side
}
// Check if menu would overflow bottom edge
if (y + menuHeight + padding > viewportHeight) {
y = viewportHeight - menuHeight - padding;
animation = 'entering-up'; // Animation from bottom
}
// If both edges would overflow, use top-left positioning
if (x + menuWidth + padding > viewportWidth && y + menuHeight + padding > viewportHeight) {
x = Math.max(padding, position.x - menuWidth);
y = Math.max(padding, position.y - menuHeight);
animation = 'entering-up-left';
}
setCalculatedPosition({ x, y });
setAnimationClass(animation);
return () => cancelAnimationFrame(frameId);
}
}, [isOpen, position, isAuthor]);
// Effect to handle clicks outside the context menu
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
function handleClickOutside(event: MouseEvent) {
if (isOpen && !isClosing) {
// Check if the click is on a context menu element
// Check if the click is on a context menu element or reaction bar
const target = event.target as Element;
if (!target.closest('.context-menu')) {
if (!target.closest('.context-menu') && !target.closest('.context-menu-reaction-bar')) {
handleClose();
}
}
};
const handleKeyDown = (event: KeyboardEvent) => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape' && isOpen && !isClosing) {
handleClose();
}
};
const handleWindowBlur = () => {
function handleWindowBlur() {
// Close context menu when browser window loses focus
if (isOpen && !isClosing) {
handleClose();
@@ -123,6 +149,7 @@ export function MessageContextMenu({
setIsClosing(false);
setAnimationClass('entering'); // Reset for next opening
}, 200); // Match the animation duration from _animations.scss
// TODO no hardcoded delays
}
interface Action {
@@ -178,29 +205,75 @@ export function MessageContextMenu({
},
];
// Quick reactions for the reaction bar
const QUICK_REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "😡"];
async function handleReactionClick(emoji: string) {
if (onReactionClick) {
await onReactionClick(message.id, emoji);
}
handleClose();
}
function handleExpandClick() {
if (onExpandEmojiMenu) {
onExpandEmojiMenu(message);
}
handleClose();
}
return isOpen && (
<div
className={`context-menu ${animationClass}`}
ref={wrapperRef}
className={`context-menu-wrapper ${animationClass}`}
style={{
position: "fixed",
display: "block",
top: calculatedPosition.y,
left: calculatedPosition.x,
zIndex: 1000
}}
onClick={(e) => e.stopPropagation()}>
{actions.map((action, i) => (
action.show && (
<div
className="context-menu-item"
onClick={action.onClick}
key={i}
{/* Reaction Bar */}
<div
ref={reactionBarRef}
className={`context-menu-reaction-bar ${reactionBarPosition}`}>
{QUICK_REACTIONS.map((emoji, index) => (
<button
key={index}
className="reaction-emoji-button"
onClick={async () => await handleReactionClick(emoji)}
title={emoji}
>
<span className="material-symbols">{action.icon}</span>
{action.label}
</div>
)
))}
{emoji}
</button>
))}
<button
className="reaction-expand-button"
onClick={handleExpandClick}
title="More emojis"
>
<span className="material-symbols">add</span>
</button>
</div>
{/* Context Menu */}
<div
ref={contextMenuRef}
className="context-menu">
{actions.map((action, i) => (
action.show && (
<div
className="context-menu-item"
onClick={action.onClick}
key={i}
>
<span className="material-symbols">{action.icon}</span>
{action.label}
</div>
)
))}
</div>
</div>
)
}
@@ -0,0 +1,108 @@
import { useAppState } from "../../state";
import { useState, useEffect } from "react";
import type { Reaction } from "../../../core/types";
interface MessageReactionsProps {
reactions?: Reaction[];
onReactionClick: (emoji: string) => void;
messageId?: number; // Add messageId to ensure unique keys
}
export function MessageReactions({ reactions, onReactionClick, messageId }: MessageReactionsProps) {
const { user } = useAppState();
const [visibleReactions, setVisibleReactions] = useState<Reaction[]>([]);
const [animatingReactions, setAnimatingReactions] = useState<Set<string>>(new Set());
// Handle reactions with animation
useEffect(() => {
if (!reactions || reactions.length === 0) {
// Animate out all visible reactions
visibleReactions.forEach(reaction => {
setAnimatingReactions(prev => new Set(prev).add(reaction.emoji));
setTimeout(() => {
setVisibleReactions([]);
setAnimatingReactions(new Set());
}, 200);
});
return;
}
// Deduplicate reactions by emoji (safety measure)
const uniqueReactions = reactions.reduce((acc, reaction) => {
const existing = acc.find(r => r.emoji === reaction.emoji);
if (existing) {
// Keep the one with the higher count
if (reaction.count > existing.count) {
acc[acc.indexOf(existing)] = reaction;
}
} else {
acc.push(reaction);
}
return acc;
}, [] as Reaction[]);
// Animate out removed reactions
visibleReactions.forEach(reaction => {
if (!uniqueReactions.some(r => r.emoji === reaction.emoji)) {
setAnimatingReactions(prev => new Set(prev).add(reaction.emoji));
setTimeout(() => {
setVisibleReactions(prev => prev.filter(r => r.emoji !== reaction.emoji));
setAnimatingReactions(prev => {
const newSet = new Set(prev);
newSet.delete(reaction.emoji);
return newSet;
});
}, 200);
}
});
// Update existing reactions and add new ones
setVisibleReactions(prev => {
const updated = [...prev];
// Update existing reactions
uniqueReactions.forEach(reaction => {
const existingIndex = updated.findIndex(r => r.emoji === reaction.emoji);
if (existingIndex !== -1) {
updated[existingIndex] = reaction;
} else {
// Add new reaction only if it doesn't already exist
if (!updated.some(r => r.emoji === reaction.emoji)) {
updated.push(reaction);
}
}
});
return updated;
});
}, [reactions]);
if (!reactions || reactions.length === 0) {
return null;
}
return (
<div className="message-reactions">
{visibleReactions.map((reaction, index) => {
const hasUserReacted = reaction.users.some(u => u.id === user.currentUser?.id);
const isAnimating = animatingReactions.has(reaction.emoji);
// Create a unique key that includes messageId, emoji, count, and index to prevent duplicates
const uniqueKey = `${messageId || 'unknown'}-${reaction.emoji}-${reaction.count}-${index}`;
return (
<button
key={uniqueKey}
className={`reaction-button ${hasUserReacted ? "reacted" : ""} ${isAnimating ? "removing" : ""}`}
onClick={() => onReactionClick(reaction.emoji)}
title={reaction.users.map(u => u.username).join(", ")}
>
<span className="reaction-emoji">{reaction.emoji}</span>
<span className="reaction-count">{reaction.count}</span>
</button>
);
})}
</div>
);
}
@@ -0,0 +1,100 @@
import { useState, useEffect } from "react";
import type { Size2D } from "../../../core/types";
interface ReactionBarProps {
isOpen: boolean;
onClose: () => void;
onEmojiSelect: (emoji: string) => void;
onExpandClick: () => void;
position: Size2D;
}
// Most common emojis for quick reactions
const QUICK_REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "😡"];
export function ReactionBar({ isOpen, onClose, onEmojiSelect, onExpandClick, position }: ReactionBarProps) {
const [isClosing, setIsClosing] = useState(false);
const [calculatedPosition, setCalculatedPosition] = useState(position);
function handleEmojiClick(emoji: string) {
onEmojiSelect(emoji);
handleClose();
}
// Smart positioning logic to avoid screen edge clipping
useEffect(() => {
if (isOpen) {
const barWidth = 240; // Approximate width of reaction bar (6 emojis + expand button)
const barHeight = 48; // Approximate height
const padding = 10; // Padding from viewport edges
const viewportWidth = window.innerWidth;
let x = position.x;
let y = position.y - barHeight - 20; // 20px above the position
// Check if bar would overflow right edge
if (x + barWidth + padding > viewportWidth) {
x = viewportWidth - barWidth - padding;
}
// Check if bar would overflow left edge
if (x < padding) {
x = padding;
}
// Check if bar would overflow top edge
if (y < padding) {
y = position.y + 40; // Position below instead of above
}
setCalculatedPosition({ x, y });
}
}, [isOpen, position]);
function handleClose() {
setIsClosing(true);
setTimeout(() => {
onClose();
setIsClosing(false);
}, 150);
}
if (!isOpen) return null;
return (
<div
className={`reaction-bar ${isClosing ? "closing" : ""}`}
style={{
position: "fixed",
left: calculatedPosition.x,
top: calculatedPosition.y,
zIndex: 1001 // Higher than context menu to appear above it
}}
onClick={(e) => e.stopPropagation()}
>
<div className="reaction-bar-content">
{QUICK_REACTIONS.map((emoji, index) => (
<button
key={index}
className="reaction-emoji-button"
onClick={() => handleEmojiClick(emoji)}
title={emoji}
>
{emoji}
</button>
))}
<button
className="reaction-expand-button"
onClick={() => {
handleClose();
onExpandClick();
}}
title="More emojis"
>
<span className="material-symbols">add</span>
</button>
</div>
</div>
);
}
@@ -86,6 +86,14 @@ export abstract class MessagePanel {
});
}
protected updateMessageReactions(messageId: number, reactions: any[]): void {
this.updateState({
messages: this.state.messages.map(msg =>
msg.id === messageId ? { ...msg, reactions } : msg
)
});
}
protected clearMessages(): void {
this.updateState({ messages: [] });
}
@@ -2,7 +2,7 @@ import { MessagePanel } from "./MessagePanel";
import { API_BASE_URL } from "../../core/config";
import { getAuthHeaders } from "../../auth/api";
import { request } from "../../core/websocket";
import type { ChatWebSocketMessage, Message, SendMessageRequest } from "../../core/types";
import type { ChatWebSocketMessage, Message, SendMessageRequest, ReactionUpdateWebSocketMessage } from "../../core/types";
import type { UserState } from "../state";
export class PublicChatPanel extends MessagePanel {
@@ -104,7 +104,7 @@ export class PublicChatPanel extends MessagePanel {
}
// Handle incoming WebSocket messages
async handleWebSocketMessage(response: ChatWebSocketMessage): Promise<void> {
async handleWebSocketMessage(response: ChatWebSocketMessage | ReactionUpdateWebSocketMessage): Promise<void> {
switch (response.type) {
case 'messageEdited':
if (response.data) {
@@ -136,6 +136,11 @@ export class PublicChatPanel extends MessagePanel {
this.addMessage(newMsg);
}
break;
case 'reactionUpdate':
if (response.data) {
this.updateMessageReactions(response.data.message_id, response.data.reactions);
}
break;
}
};