feat: dynamic PiP collision avoidance, reaction pill redesign, channel dedup fix
PiP positioning: - Replace hardcoded layout constants with dynamic DOM measurement system - Obstacle elements declare themselves via data-pip-obstacle="left|bottom" - getPipBounds() queries actual element rects at runtime via getBoundingClientRect - MutationObserver + ResizeObserver re-clamp PiP when obstacles appear/resize - PiP reappears after close when navigating away from voice channel Reactions: - Align reaction pills with Aether Drift prototype - Own-reaction accent: purple → mint (bg, border, count color) - Default pills: subtle white overlay bg + border-soft border - Count text: 12px/semibold/txt-secondary per prototype spec Bug fix: - Deduplicate channel insertion in createChannel store action - Prevents double-add race between REST response and WS broadcast
This commit is contained in:
@@ -248,14 +248,14 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => toggleReaction(emoji)}
|
||||
className={`flex items-center gap-1.5 px-1.5 py-0.5 rounded-[8px] text-[14px] font-medium border transition-colors ${
|
||||
className={`flex items-center gap-1 px-2 py-0.5 rounded-md text-[13px] border transition-all duration-[120ms] ease-out cursor-pointer ${
|
||||
me
|
||||
? 'bg-accent-primary/15 border-accent-primary text-accent-primary'
|
||||
: 'bg-surface-channel border-transparent text-txt-tertiary hover:border-txt-tertiary/30'
|
||||
? 'bg-accent-mint/10 border-accent-mint/25 hover:bg-accent-mint/15'
|
||||
: 'bg-white/[0.04] border-border-soft hover:bg-white/[0.08] hover:border-white/[0.12]'
|
||||
}`}
|
||||
>
|
||||
<span>{emoji}</span>
|
||||
<span className={me ? 'text-accent-primary' : 'text-txt-message'}>{count}</span>
|
||||
<span className={`text-xs font-semibold ${me ? 'text-accent-mint' : 'text-txt-secondary'}`}>{count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef, useCallback, useMemo } from 'react';
|
||||
import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
import { isDmChannel, useServerStore } from '../../stores/serverStore';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
@@ -30,6 +30,18 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
const members = useServerStore((s) => s.members);
|
||||
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Auto-focus textarea on channel navigation
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, [channelId]);
|
||||
|
||||
// Auto-focus textarea when replying
|
||||
useEffect(() => {
|
||||
if (replyTo) {
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [replyTo]);
|
||||
|
||||
// Filter members for the mention popover
|
||||
const filteredMembers = useMemo(() => {
|
||||
if (!mentionState) return [];
|
||||
@@ -216,7 +228,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-3 pb-3 flex-shrink-0 md:absolute md:bottom-3 md:left-3 md:right-3 md:z-[110] md:px-0 md:pb-0 md:glass-bubble md:rounded-[14px]">
|
||||
<div data-pip-obstacle="bottom" className="px-3 pb-3 flex-shrink-0 md:absolute md:bottom-3 md:left-3 md:right-3 md:z-[110] md:px-0 md:pb-0 md:glass-bubble md:rounded-[14px]">
|
||||
{replyTo && (
|
||||
<div className="bg-interactive-hover rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-border-hard/50">
|
||||
<div className="flex items-center gap-1 text-[14px] text-txt-message truncate">
|
||||
|
||||
@@ -97,7 +97,7 @@ export function ChannelSidebar() {
|
||||
|
||||
// Floating bottom panel — shared between DM view and server view
|
||||
const floatingPanel = user ? (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-[105] p-2 md:right-auto md:w-[296px] md:bottom-[10px] md:left-[10px] md:p-0">
|
||||
<div data-pip-obstacle="bottom" className="fixed bottom-0 left-0 right-0 z-[105] p-2 md:right-auto md:w-[296px] md:bottom-[10px] md:left-[10px] md:p-0">
|
||||
<div className="glass-bubble rounded-[14px]">
|
||||
{/* Voice controls (expands when connected) */}
|
||||
{(currentVoiceChannelId || activeDmCall) && <VoiceControls />}
|
||||
|
||||
@@ -157,7 +157,7 @@ export function ServerSidebar() {
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="w-[72px] bg-surface-base flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 no-scrollbar select-none md:fixed md:inset-y-0 md:left-0 md:z-[100] md:glass-strip">
|
||||
<nav data-pip-obstacle="left" className="w-[72px] bg-surface-base flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 no-scrollbar select-none md:fixed md:inset-y-0 md:left-0 md:z-[100] md:glass-strip">
|
||||
<SidebarItem
|
||||
id="@me"
|
||||
name="Direct Messages"
|
||||
|
||||
@@ -12,6 +12,47 @@ const PIP_HEIGHT = 180;
|
||||
const PIP_MARGIN = 16;
|
||||
const DRAG_THRESHOLD = 5;
|
||||
|
||||
/**
|
||||
* Computes PiP boundary box by measuring actual DOM obstacles.
|
||||
* Obstacle elements declare themselves with data-pip-obstacle="left"|"bottom".
|
||||
* PiP queries them at boundary-check time — no hardcoded layout values.
|
||||
*
|
||||
* On mobile (<768px) there are no fixed side/bottom UI obstacles.
|
||||
*/
|
||||
function getPipBounds(pipX: number): {
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
} {
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
let minX = PIP_MARGIN;
|
||||
const maxX = vw - PIP_WIDTH - PIP_MARGIN;
|
||||
const minY = PIP_MARGIN;
|
||||
let maxY = vh - PIP_HEIGHT - PIP_MARGIN;
|
||||
|
||||
if (vw < 768) return { minX, maxX, minY, maxY };
|
||||
|
||||
const obstacles = document.querySelectorAll<HTMLElement>('[data-pip-obstacle]');
|
||||
for (const el of obstacles) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) continue;
|
||||
|
||||
const direction = el.dataset.pipObstacle;
|
||||
if (direction === 'left') {
|
||||
minX = Math.max(minX, rect.right + PIP_MARGIN);
|
||||
} else if (direction === 'bottom') {
|
||||
const pipRight = pipX + PIP_WIDTH;
|
||||
if (pipX < rect.right && pipRight > rect.left) {
|
||||
maxY = Math.min(maxY, rect.top - PIP_HEIGHT - PIP_MARGIN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { minX, maxX, minY, maxY };
|
||||
}
|
||||
|
||||
interface SelectedStream {
|
||||
participant: ParticipantInfo;
|
||||
track: MediaStreamTrack;
|
||||
@@ -93,10 +134,21 @@ export function PictureInPicture() {
|
||||
}
|
||||
}, [currentVoiceChannelId, activeDmCall, setPipCollapsed]);
|
||||
|
||||
// Visibility
|
||||
// Visibility — split into wouldShow (ignores collapsed) and shouldShow (full check)
|
||||
const isInServerVoice = currentVoiceChannelId !== null && currentChannelId !== currentVoiceChannelId;
|
||||
const isInDmCall = activeDmCall !== null && currentChannelId !== activeDmCall.dmChannelId;
|
||||
const shouldShow = (isInServerVoice || isInDmCall) && !voiceFullscreen && !pipCollapsed;
|
||||
const wouldShow = (isInServerVoice || isInDmCall) && !voiceFullscreen;
|
||||
const shouldShow = wouldShow && !pipCollapsed;
|
||||
|
||||
// Reset pipCollapsed when wouldShow transitions false → true
|
||||
// (user navigated away from voice channel view → PiP reappears)
|
||||
const prevWouldShow = useRef(wouldShow);
|
||||
useEffect(() => {
|
||||
if (wouldShow && !prevWouldShow.current) {
|
||||
setPipCollapsed(false);
|
||||
}
|
||||
prevWouldShow.current = wouldShow;
|
||||
}, [wouldShow, setPipCollapsed]);
|
||||
|
||||
// Stream selection
|
||||
const selectedStream = useMemo(
|
||||
@@ -146,34 +198,61 @@ export function PictureInPicture() {
|
||||
// Initialize position to bottom-right
|
||||
useEffect(() => {
|
||||
if (shouldShow && position.x === -1) {
|
||||
setPosition({
|
||||
x: window.innerWidth - PIP_WIDTH - PIP_MARGIN,
|
||||
y: window.innerHeight - PIP_HEIGHT - PIP_MARGIN,
|
||||
});
|
||||
const initX = window.innerWidth - PIP_WIDTH - PIP_MARGIN;
|
||||
const { maxY } = getPipBounds(initX);
|
||||
setPosition({ x: initX, y: maxY });
|
||||
}
|
||||
}, [shouldShow, position.x]);
|
||||
|
||||
// Window resize: keep PiP in bounds
|
||||
// Re-clamp PiP when viewport resizes, obstacles appear/disappear, or obstacles resize
|
||||
useEffect(() => {
|
||||
if (!shouldShow) return;
|
||||
const handleResize = () => {
|
||||
setPosition(prev => ({
|
||||
x: Math.max(PIP_MARGIN, Math.min(window.innerWidth - PIP_WIDTH - PIP_MARGIN, prev.x)),
|
||||
y: Math.max(PIP_MARGIN, Math.min(window.innerHeight - PIP_HEIGHT - PIP_MARGIN, prev.y)),
|
||||
}));
|
||||
|
||||
const reclamp = () => {
|
||||
setPosition(prev => {
|
||||
if (prev.x === -1) return prev;
|
||||
const { minX, maxX } = getPipBounds(prev.x);
|
||||
const clampedX = Math.max(minX, Math.min(maxX, prev.x));
|
||||
const { minY, maxY } = getPipBounds(clampedX);
|
||||
return { x: clampedX, y: Math.max(minY, Math.min(maxY, prev.y)) };
|
||||
});
|
||||
};
|
||||
|
||||
// Observe obstacle elements for size changes (e.g., voice panel expanding)
|
||||
let knownObstacles = new Set(document.querySelectorAll<HTMLElement>('[data-pip-obstacle]'));
|
||||
const resizeObserver = new ResizeObserver(reclamp);
|
||||
knownObstacles.forEach(el => resizeObserver.observe(el));
|
||||
|
||||
// Detect obstacle elements being added/removed from the DOM
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
const current = new Set(document.querySelectorAll<HTMLElement>('[data-pip-obstacle]'));
|
||||
if (current.size !== knownObstacles.size || ![...current].every(el => knownObstacles.has(el))) {
|
||||
resizeObserver.disconnect();
|
||||
current.forEach(el => resizeObserver.observe(el));
|
||||
knownObstacles = current;
|
||||
reclamp();
|
||||
}
|
||||
});
|
||||
mutationObserver.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
window.addEventListener('resize', reclamp);
|
||||
return () => {
|
||||
window.removeEventListener('resize', reclamp);
|
||||
mutationObserver.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [shouldShow]);
|
||||
|
||||
// Snap to nearest horizontal edge
|
||||
const snapToEdge = useCallback((currentX: number, currentY: number) => {
|
||||
const centerX = currentX + PIP_WIDTH / 2;
|
||||
const screenMidX = window.innerWidth / 2;
|
||||
const { minX } = getPipBounds(currentX);
|
||||
const targetX = centerX < screenMidX
|
||||
? PIP_MARGIN
|
||||
? minX
|
||||
: window.innerWidth - PIP_WIDTH - PIP_MARGIN;
|
||||
const clampedY = Math.max(PIP_MARGIN, Math.min(window.innerHeight - PIP_HEIGHT - PIP_MARGIN, currentY));
|
||||
const { minY, maxY } = getPipBounds(targetX);
|
||||
const clampedY = Math.max(minY, Math.min(maxY, currentY));
|
||||
setPosition({ x: targetX, y: clampedY });
|
||||
}, []);
|
||||
|
||||
@@ -194,8 +273,11 @@ export function PictureInPicture() {
|
||||
if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) {
|
||||
hasMoved.current = true;
|
||||
}
|
||||
const newX = Math.max(PIP_MARGIN, Math.min(window.innerWidth - PIP_WIDTH - PIP_MARGIN, e.clientX - dragOffset.current.x));
|
||||
const newY = Math.max(PIP_MARGIN, Math.min(window.innerHeight - PIP_HEIGHT - PIP_MARGIN, e.clientY - dragOffset.current.y));
|
||||
const rawX = e.clientX - dragOffset.current.x;
|
||||
const bounds = getPipBounds(rawX);
|
||||
const newX = Math.max(bounds.minX, Math.min(bounds.maxX, rawX));
|
||||
const { minY, maxY } = getPipBounds(newX);
|
||||
const newY = Math.max(minY, Math.min(maxY, e.clientY - dragOffset.current.y));
|
||||
setPosition({ x: newX, y: newY });
|
||||
}, [isDragging]);
|
||||
|
||||
|
||||
@@ -172,9 +172,10 @@ export const useServerStore = create<ServerState>((set, get) => ({
|
||||
|
||||
createChannel: async (serverId: string, name: string, type: 'text' | 'voice' | 'video', topic?: string) => {
|
||||
const channel = await api.channels.create(serverId, { name, type, topic });
|
||||
set((state) => ({
|
||||
channels: [...state.channels, channel].sort((a, b) => a.position - b.position),
|
||||
}));
|
||||
set((state) => {
|
||||
if (state.channels.some(c => c.id === channel.id)) return state;
|
||||
return { channels: [...state.channels, channel].sort((a, b) => a.position - b.position) };
|
||||
});
|
||||
return channel;
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user