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:
Jannis Braun
2026-03-02 02:09:52 +01:00
parent f79575ffa7
commit ddeb72101e
6 changed files with 124 additions and 29 deletions
+4 -4
View File
@@ -248,14 +248,14 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
<button <button
key={emoji} key={emoji}
onClick={() => toggleReaction(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 me
? 'bg-accent-primary/15 border-accent-primary text-accent-primary' ? 'bg-accent-mint/10 border-accent-mint/25 hover:bg-accent-mint/15'
: 'bg-surface-channel border-transparent text-txt-tertiary hover:border-txt-tertiary/30' : 'bg-white/[0.04] border-border-soft hover:bg-white/[0.08] hover:border-white/[0.12]'
}`} }`}
> >
<span>{emoji}</span> <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> </button>
))} ))}
</div> </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 { useChatStore } from '../../stores/chatStore';
import { isDmChannel, useServerStore } from '../../stores/serverStore'; import { isDmChannel, useServerStore } from '../../stores/serverStore';
import { wsSend } from '../../hooks/useWebSocket'; import { wsSend } from '../../hooks/useWebSocket';
@@ -30,6 +30,18 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
const members = useServerStore((s) => s.members); const members = useServerStore((s) => s.members);
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>(); 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 // Filter members for the mention popover
const filteredMembers = useMemo(() => { const filteredMembers = useMemo(() => {
if (!mentionState) return []; if (!mentionState) return [];
@@ -216,7 +228,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
}; };
return ( 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 && ( {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="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"> <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 // Floating bottom panel — shared between DM view and server view
const floatingPanel = user ? ( 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]"> <div className="glass-bubble rounded-[14px]">
{/* Voice controls (expands when connected) */} {/* Voice controls (expands when connected) */}
{(currentVoiceChannelId || activeDmCall) && <VoiceControls />} {(currentVoiceChannelId || activeDmCall) && <VoiceControls />}
@@ -157,7 +157,7 @@ export function ServerSidebar() {
}; };
return ( 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 <SidebarItem
id="@me" id="@me"
name="Direct Messages" name="Direct Messages"
@@ -12,6 +12,47 @@ const PIP_HEIGHT = 180;
const PIP_MARGIN = 16; const PIP_MARGIN = 16;
const DRAG_THRESHOLD = 5; 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 { interface SelectedStream {
participant: ParticipantInfo; participant: ParticipantInfo;
track: MediaStreamTrack; track: MediaStreamTrack;
@@ -93,10 +134,21 @@ export function PictureInPicture() {
} }
}, [currentVoiceChannelId, activeDmCall, setPipCollapsed]); }, [currentVoiceChannelId, activeDmCall, setPipCollapsed]);
// Visibility // Visibility — split into wouldShow (ignores collapsed) and shouldShow (full check)
const isInServerVoice = currentVoiceChannelId !== null && currentChannelId !== currentVoiceChannelId; const isInServerVoice = currentVoiceChannelId !== null && currentChannelId !== currentVoiceChannelId;
const isInDmCall = activeDmCall !== null && currentChannelId !== activeDmCall.dmChannelId; 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 // Stream selection
const selectedStream = useMemo( const selectedStream = useMemo(
@@ -146,34 +198,61 @@ export function PictureInPicture() {
// Initialize position to bottom-right // Initialize position to bottom-right
useEffect(() => { useEffect(() => {
if (shouldShow && position.x === -1) { if (shouldShow && position.x === -1) {
setPosition({ const initX = window.innerWidth - PIP_WIDTH - PIP_MARGIN;
x: window.innerWidth - PIP_WIDTH - PIP_MARGIN, const { maxY } = getPipBounds(initX);
y: window.innerHeight - PIP_HEIGHT - PIP_MARGIN, setPosition({ x: initX, y: maxY });
});
} }
}, [shouldShow, position.x]); }, [shouldShow, position.x]);
// Window resize: keep PiP in bounds // Re-clamp PiP when viewport resizes, obstacles appear/disappear, or obstacles resize
useEffect(() => { useEffect(() => {
if (!shouldShow) return; if (!shouldShow) return;
const handleResize = () => {
setPosition(prev => ({ const reclamp = () => {
x: Math.max(PIP_MARGIN, Math.min(window.innerWidth - PIP_WIDTH - PIP_MARGIN, prev.x)), setPosition(prev => {
y: Math.max(PIP_MARGIN, Math.min(window.innerHeight - PIP_HEIGHT - PIP_MARGIN, prev.y)), 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]); }, [shouldShow]);
// Snap to nearest horizontal edge // Snap to nearest horizontal edge
const snapToEdge = useCallback((currentX: number, currentY: number) => { const snapToEdge = useCallback((currentX: number, currentY: number) => {
const centerX = currentX + PIP_WIDTH / 2; const centerX = currentX + PIP_WIDTH / 2;
const screenMidX = window.innerWidth / 2; const screenMidX = window.innerWidth / 2;
const { minX } = getPipBounds(currentX);
const targetX = centerX < screenMidX const targetX = centerX < screenMidX
? PIP_MARGIN ? minX
: window.innerWidth - PIP_WIDTH - PIP_MARGIN; : 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 }); setPosition({ x: targetX, y: clampedY });
}, []); }, []);
@@ -194,8 +273,11 @@ export function PictureInPicture() {
if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) { if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) {
hasMoved.current = true; hasMoved.current = true;
} }
const newX = Math.max(PIP_MARGIN, Math.min(window.innerWidth - PIP_WIDTH - PIP_MARGIN, e.clientX - dragOffset.current.x)); const rawX = 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 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 }); setPosition({ x: newX, y: newY });
}, [isDragging]); }, [isDragging]);
+4 -3
View File
@@ -172,9 +172,10 @@ export const useServerStore = create<ServerState>((set, get) => ({
createChannel: async (serverId: string, name: string, type: 'text' | 'voice' | 'video', topic?: string) => { createChannel: async (serverId: string, name: string, type: 'text' | 'voice' | 'video', topic?: string) => {
const channel = await api.channels.create(serverId, { name, type, topic }); const channel = await api.channels.create(serverId, { name, type, topic });
set((state) => ({ set((state) => {
channels: [...state.channels, channel].sort((a, b) => a.position - b.position), 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; return channel;
}, },