mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement basic reactions
This commit is contained in:
+32
-1
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user