mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Fix reactions bar expansion and positioning
This commit is contained in:
@@ -968,4 +968,17 @@
|
|||||||
color: $color-dark-on-surface-variant;
|
color: $color-dark-on-surface-variant;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Integrated mode styles (inside reaction bar)
|
||||||
|
&.integrated {
|
||||||
|
position: relative !important;
|
||||||
|
width: 320px !important;
|
||||||
|
height: 400px !important;
|
||||||
|
transform: none !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
box-shadow: none;
|
||||||
|
border: none;
|
||||||
|
background: $color-dark-surface-container;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 with animations
|
||||||
.context-menu-wrapper {
|
.context-menu-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -140,9 +149,9 @@
|
|||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 100%;
|
bottom: 100%;
|
||||||
min-width: 200px;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
transition: width 0.3s ease-out, height 0.3s ease-out;
|
||||||
|
|
||||||
&.left {
|
&.left {
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -153,12 +162,55 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
transform: translateX(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 {
|
.reaction-bar-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
transition: opacity 0.3s ease-out;
|
||||||
|
|
||||||
|
&.faded {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.reaction-emoji-button {
|
.reaction-emoji-button {
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ button, input {
|
|||||||
&.open {
|
&.open {
|
||||||
animation: context-menu-open 0.25s ease;
|
animation: context-menu-open 0.25s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.faded {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes context-menu-open {
|
@keyframes context-menu-open {
|
||||||
0% {
|
0% {
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ export function ChatInputWrapper(
|
|||||||
onClose={() => setEmojiMenuOpen(false)}
|
onClose={() => setEmojiMenuOpen(false)}
|
||||||
onEmojiSelect={handleEmojiSelect}
|
onEmojiSelect={handleEmojiSelect}
|
||||||
position={emojiMenuPosition}
|
position={emojiMenuPosition}
|
||||||
|
mode="standalone"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type { Message as MessageType } from "../../../core/types";
|
|||||||
import type { UserProfile } from "../../../core/types";
|
import type { UserProfile } from "../../../core/types";
|
||||||
import { UserProfileDialog } from "./UserProfileDialog";
|
import { UserProfileDialog } from "./UserProfileDialog";
|
||||||
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
|
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
|
||||||
import { EmojiMenu } from "./EmojiMenu";
|
|
||||||
import { fetchUserProfile } from "../../../api/profileApi";
|
import { fetchUserProfile } from "../../../api/profileApi";
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { delay } from "../../../utils/utils";
|
import { delay } from "../../../utils/utils";
|
||||||
@@ -42,16 +41,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
|||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [toBeDeleted, setToBeDeleted] = useState<{ id: number; isDm: boolean } | null>(null);
|
const [toBeDeleted, setToBeDeleted] = useState<{ id: number; isDm: boolean } | null>(null);
|
||||||
|
|
||||||
// Emoji menu state (for expanded emoji picker)
|
|
||||||
const [emojiMenu, setEmojiMenu] = useState<{
|
|
||||||
isOpen: boolean;
|
|
||||||
message: MessageType | null;
|
|
||||||
position: { x: number; y: number };
|
|
||||||
}>({
|
|
||||||
isOpen: false,
|
|
||||||
message: null,
|
|
||||||
position: { x: 0, y: 0 }
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!deleteDialogOpen) {
|
if (!deleteDialogOpen) {
|
||||||
@@ -157,27 +146,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function handleEmojiMenuClose() {
|
|
||||||
setEmojiMenu(prev => ({ ...prev, isOpen: false }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleExpandEmojiMenu(message: MessageType) {
|
|
||||||
const messageElement = document.querySelector(`[data-id="${message.id}"]`);
|
|
||||||
if (messageElement) {
|
|
||||||
const rect = messageElement.getBoundingClientRect();
|
|
||||||
setEmojiMenu({
|
|
||||||
isOpen: true,
|
|
||||||
message,
|
|
||||||
position: { x: rect.left + rect.width / 2, y: rect.bottom + 10 }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEmojiSelect(emoji: string) {
|
|
||||||
if (emojiMenu.message) {
|
|
||||||
handleReactionClick(emojiMenu.message.id, emoji);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -227,7 +195,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
|||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onRetry={handleRetry}
|
onRetry={handleRetry}
|
||||||
onReactionClick={handleReactionClick}
|
onReactionClick={handleReactionClick}
|
||||||
onExpandEmojiMenu={handleExpandEmojiMenu}
|
|
||||||
position={contextMenu.position}
|
position={contextMenu.position}
|
||||||
isOpen={contextMenu.isOpen}
|
isOpen={contextMenu.isOpen}
|
||||||
onOpenChange={handleContextMenuOpenChange}
|
onOpenChange={handleContextMenuOpenChange}
|
||||||
@@ -235,13 +202,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* Emoji Menu */}
|
|
||||||
<EmojiMenu
|
|
||||||
isOpen={emojiMenu.isOpen}
|
|
||||||
onClose={handleEmojiMenuClose}
|
|
||||||
onEmojiSelect={handleEmojiSelect}
|
|
||||||
position={emojiMenu.position}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,26 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
|||||||
import { EMOJI_CATEGORIES, getRecentEmojis, addRecentEmoji } from "./emojiData";
|
import { EMOJI_CATEGORIES, getRecentEmojis, addRecentEmoji } from "./emojiData";
|
||||||
import type { Size2D } from "../../../core/types";
|
import type { Size2D } from "../../../core/types";
|
||||||
|
|
||||||
interface EmojiMenuProps {
|
interface BaseEmojiMenuProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onEmojiSelect: (emoji: string) => void;
|
onEmojiSelect: (emoji: string) => void;
|
||||||
position: Size2D;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmojiMenu({ isOpen, onClose, onEmojiSelect, position }: EmojiMenuProps) {
|
interface StandaloneEmojiMenuProps extends BaseEmojiMenuProps {
|
||||||
|
position: Size2D;
|
||||||
|
mode: "standalone";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IntegratedEmojiMenuProps extends BaseEmojiMenuProps {
|
||||||
|
mode: "integrated";
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmojiMenuProps = StandaloneEmojiMenuProps | IntegratedEmojiMenuProps;
|
||||||
|
|
||||||
|
export function EmojiMenu(props: EmojiMenuProps) {
|
||||||
|
const { isOpen, onClose, onEmojiSelect, mode } = props;
|
||||||
|
const position = mode === "standalone" ? props.position : undefined;
|
||||||
const [activeCategory, setActiveCategory] = useState("recent");
|
const [activeCategory, setActiveCategory] = useState("recent");
|
||||||
const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
|
const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -106,13 +118,15 @@ export function EmojiMenu({ isOpen, onClose, onEmojiSelect, position }: EmojiMen
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
className={`emoji-menu ${isOpen ? "open" : ""}`}
|
className={`emoji-menu ${isOpen ? "open" : ""} ${mode}`}
|
||||||
style={{
|
style={mode === "standalone" && position ? {
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
left: position.x,
|
left: position.x,
|
||||||
bottom: position.y,
|
bottom: position.y,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
pointerEvents: isOpen ? "auto" : "none"
|
pointerEvents: isOpen ? "auto" : "none"
|
||||||
|
} : {
|
||||||
|
pointerEvents: isOpen ? "auto" : "none"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="emoji-menu-header">
|
<div className="emoji-menu-header">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import type { Message, Size2D } from "../../../core/types";
|
import type { Message, Size2D } from "../../../core/types";
|
||||||
|
import { EmojiMenu } from "./EmojiMenu";
|
||||||
|
|
||||||
interface MessageContextMenuProps {
|
interface MessageContextMenuProps {
|
||||||
message: Message;
|
message: Message;
|
||||||
@@ -9,7 +10,6 @@ interface MessageContextMenuProps {
|
|||||||
onDelete: (message: Message) => void;
|
onDelete: (message: Message) => void;
|
||||||
onRetry?: (message: Message) => void;
|
onRetry?: (message: Message) => void;
|
||||||
onReactionClick?: (messageId: number, emoji: string) => Promise<void>;
|
onReactionClick?: (messageId: number, emoji: string) => Promise<void>;
|
||||||
onExpandEmojiMenu?: (message: Message) => void;
|
|
||||||
position: Size2D;
|
position: Size2D;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onOpenChange: (isOpen: boolean) => void;
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
@@ -29,7 +29,6 @@ export function MessageContextMenu({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onRetry,
|
onRetry,
|
||||||
onReactionClick,
|
onReactionClick,
|
||||||
onExpandEmojiMenu,
|
|
||||||
position,
|
position,
|
||||||
isOpen,
|
isOpen,
|
||||||
onOpenChange
|
onOpenChange
|
||||||
@@ -39,11 +38,16 @@ export function MessageContextMenu({
|
|||||||
const [calculatedPosition, setCalculatedPosition] = useState(position);
|
const [calculatedPosition, setCalculatedPosition] = useState(position);
|
||||||
const [animationClass, setAnimationClass] = useState('entering');
|
const [animationClass, setAnimationClass] = useState('entering');
|
||||||
const [reactionBarPosition, setReactionBarPosition] = useState<'left' | 'right'>('left');
|
const [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
|
// Refs for measuring actual dimensions
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
const reactionBarRef = useRef<HTMLDivElement>(null);
|
const reactionBarRef = useRef<HTMLDivElement>(null);
|
||||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const emojiMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Calculate smart positioning when component opens
|
// Calculate smart positioning when component opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -148,6 +152,11 @@ export function MessageContextMenu({
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
setIsClosing(false);
|
setIsClosing(false);
|
||||||
setAnimationClass('entering'); // Reset for next opening
|
setAnimationClass('entering'); // Reset for next opening
|
||||||
|
// Reset emoji menu state after context menu animation completes
|
||||||
|
setIsEmojiMenuExpanded(false);
|
||||||
|
setInitialDimensions(null);
|
||||||
|
setExpandUpward(false);
|
||||||
|
setContextMenuHeight(null);
|
||||||
}, 200); // Match the animation duration from _animations.scss
|
}, 200); // Match the animation duration from _animations.scss
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,10 +224,43 @@ export function MessageContextMenu({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleExpandClick() {
|
function handleExpandClick() {
|
||||||
if (onExpandEmojiMenu) {
|
if (!reactionBarRef.current || !wrapperRef.current) return;
|
||||||
onExpandEmojiMenu(message);
|
|
||||||
|
// 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 handleEmojiMenuClose() {
|
||||||
|
setIsEmojiMenuExpanded(false);
|
||||||
|
setInitialDimensions(null);
|
||||||
|
setExpandUpward(false);
|
||||||
|
setContextMenuHeight(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEmojiSelect(emoji: string) {
|
||||||
|
if (onReactionClick) {
|
||||||
|
onReactionClick(message.id, emoji);
|
||||||
}
|
}
|
||||||
handleClose();
|
handleEmojiMenuClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
return isOpen && (
|
return isOpen && (
|
||||||
@@ -234,32 +276,57 @@ export function MessageContextMenu({
|
|||||||
onClick={(e) => e.stopPropagation()}>
|
onClick={(e) => e.stopPropagation()}>
|
||||||
|
|
||||||
{/* Reaction Bar */}
|
{/* Reaction Bar */}
|
||||||
<div
|
<div
|
||||||
ref={reactionBarRef}
|
ref={reactionBarRef}
|
||||||
className={`context-menu-reaction-bar ${reactionBarPosition}`}>
|
className={`context-menu-reaction-bar ${reactionBarPosition} ${isEmojiMenuExpanded ? "expanded" : ""} ${expandUpward ? "expand-upward" : ""}`}
|
||||||
{QUICK_REACTIONS.map((emoji, index) => (
|
style={isEmojiMenuExpanded && !expandUpward ? {
|
||||||
<button
|
position: 'fixed',
|
||||||
key={index}
|
top: `${(-(contextMenuHeight || 0) + 5)}px`,
|
||||||
className="reaction-emoji-button"
|
width: '320px',
|
||||||
onClick={async () => await handleReactionClick(emoji)}
|
height: '400px',
|
||||||
title={emoji}
|
zIndex: 1001
|
||||||
>
|
} : initialDimensions && !isEmojiMenuExpanded ? {
|
||||||
{emoji}
|
width: `${initialDimensions.width}px`,
|
||||||
</button>
|
height: `${initialDimensions.height}px`
|
||||||
))}
|
} : {}}>
|
||||||
<button
|
{!isEmojiMenuExpanded ? (
|
||||||
className="reaction-expand-button"
|
<div className="reaction-bar-content">
|
||||||
onClick={handleExpandClick}
|
{QUICK_REACTIONS.map((emoji, index) => (
|
||||||
title="More emojis"
|
<button
|
||||||
>
|
key={index}
|
||||||
<span className="material-symbols">add</span>
|
className="reaction-emoji-button"
|
||||||
</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={handleEmojiMenuClose}
|
||||||
|
onEmojiSelect={handleEmojiSelect}
|
||||||
|
mode="integrated"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Context Menu */}
|
{/* Context Menu */}
|
||||||
<div
|
<div
|
||||||
ref={contextMenuRef}
|
ref={contextMenuRef}
|
||||||
className="context-menu">
|
className={`context-menu ${isEmojiMenuExpanded ? "faded" : ""}`}>
|
||||||
{actions.map((action, i) => (
|
{actions.map((action, i) => (
|
||||||
action.show && (
|
action.show && (
|
||||||
<div
|
<div
|
||||||
@@ -273,6 +340,7 @@ export function MessageContextMenu({
|
|||||||
)
|
)
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user