feat: draggable PiP call window
Drag the PiP window anywhere on screen to move it out of the way. Click (without dragging) still navigates back to the call room. 5px movement threshold distinguishes drag from click. Mouse and touch both supported. Cursor shows grab/grabbing cues.
This commit is contained in:
@@ -59,41 +59,4 @@ A full custom theme engine layered on top of Cinny's vanilla-extract theming:
|
||||
- Implemented via `CallControl.setMicrophone()` public method on the widget bridge
|
||||
- **Noise suppression toggle**: Settings > General > Calls — passes `noiseSuppression` URL parameter to the embedded Element Call widget
|
||||
- **DM calls**: Phone button in DM room header starts a 1-on-1 call via `useCallStart(true)`. `Room.tsx` switches to CallView layout when the DM has an active call embed. Standard voice rooms were already supported; this extends call support to all direct messages.
|
||||
- **Picture-in-picture (PiP)**: When navigating away from a call room while in an active call, the call embed shrinks to a 280×158px floating window in the bottom-right corner (above the message composer). Clicking it navigates back to the call room. Implemented via `useEffect` that imperatively overrides inline styles on `callEmbedRef.current` — a wrapper div cannot be used because `useCallEmbedPlacementSync` writes `top/left/width/height` directly onto that element.
|
||||
|
||||
### GIF Picker
|
||||
|
||||
- **Giphy integration**: GIF button appears in the message composer next to Send when `gifApiKey` is present in `config.json`
|
||||
- Uses Giphy JS/React SDK (`@giphy/react-components`, `@giphy/js-fetch-api`, `styled-components`)
|
||||
- Selected GIF is fetched as a blob, uploaded via `mx.uploadContent`, and sent as `m.image` — appears inline in the timeline like any image
|
||||
- `FocusTrap` wraps the picker: clicking outside or pressing Escape closes it
|
||||
- **Terminal theme**: When LotusGuild TDS is active the picker switches to: dark navy background (`#060c14`), orange dim border, 4px angular radius, `// GIF_SEARCH` header in JetBrains Mono, Giphy SearchBar input overridden (dark bg, orange border/focus ring, JetBrains Mono font), custom orange scrollbar
|
||||
- API key set in root `config.json` → `gifApiKey` (the root file is what vite copies to dist — `public/config.json` is not used)
|
||||
|
||||
### Settings Additions
|
||||
|
||||
**Settings > General > Calls** (new section):
|
||||
- Camera on join: toggle whether camera starts enabled
|
||||
- Noise suppression: toggle noise suppression in calls (default: on)
|
||||
- Push to Talk: enable PTT mode and configure the keybind
|
||||
|
||||
### Editor
|
||||
|
||||
- Editor toolbar visibility is user-configurable (toggle added to message composer settings)
|
||||
|
||||
### Technical
|
||||
|
||||
- All upstream Cinny URLs replaced (cinny.in → lotusguild.org, Cinny Matrix room → Lotus Guild room)
|
||||
- Version string is dynamically read from `package.json` at build time — no hardcoded version strings
|
||||
- `useCallEmbed.ts`: noise suppression setting wired through `createCallEmbed` → `CallEmbed.getWidget()` → Element Call URL params
|
||||
- `CallControl.ts`: `setMicrophone(enabled: boolean)` public method for PTT mic control; `onControlMutation()` patched to detect screenshare start and auto-revert spotlight to grid
|
||||
- `callPreferences` atom: never persists `video: true` to localStorage; camera state always resets to OFF on page load
|
||||
- `CallEmbedProvider.tsx`: `pipMode` logic and `useEffect` for imperative style overrides; `navigateRoom` hook for PiP click-to-return
|
||||
- `GifPicker.tsx`: `useSetting(settingsAtom, 'lotusTerminal')` drives conditional terminal styling; injected `<style>` scoped to `[data-gif-terminal]` overrides Giphy SDK styled-components without polluting global styles
|
||||
- `useClientConfig.ts`: `gifApiKey?: string` added to `ClientConfig` type
|
||||
|
||||
---
|
||||
|
||||
## Upstream
|
||||
|
||||
Based on [Cinny](https://github.com/cinnyapp/cinny) — a Matrix client focusing on simple, elegant and secure interface.
|
||||
- **Picture-in-picture (PiP)**: When navigating away from a call room while in an active call, the call embed shrinks to a 280x158px floating window in the bottom-right corner. The PiP window is **draggable** — drag it anywhere on screen to move it out of the way. Clicking (without dragging) navigates back to the call room. Drag vs click distinguished by a 5px movement threshold; touch drag supported. Imperative style overrides on `callEmbedRef.current` via `useEffect` — a wrapper div cannot be used because `useCallEmbedPlacementSync` writes `top/left/width/height` directly onto that element.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { ReactNode, useCallback, useRef } from 'react';
|
||||
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { config } from 'folds';
|
||||
import {
|
||||
@@ -13,6 +13,7 @@ import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
||||
import { CallEmbed } from '../plugins/call';
|
||||
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||
import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
||||
|
||||
function CallUtils({ embed }: { embed: CallEmbed }) {
|
||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
@@ -40,10 +41,150 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
const selectedRoom = useSelectedRoom();
|
||||
const chat = useAtomValue(callChatAtom);
|
||||
const screenSize = useScreenSizeContext();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
const chatOnlyView = chat && screenSize !== ScreenSize.Desktop;
|
||||
const inCallRoom = callEmbed && selectedRoom === callEmbed.roomId;
|
||||
const callActive = callEmbed && joined;
|
||||
const callVisible = inCallRoom && callActive && !chatOnlyView;
|
||||
const pipMode = callActive && !inCallRoom;
|
||||
|
||||
const callVisible = callEmbed && selectedRoom === callEmbed.roomId && joined && !chatOnlyView;
|
||||
// Tracks whether the current pointer-down turned into a drag vs a tap/click.
|
||||
const pipDragRef = useRef<{
|
||||
startX: number;
|
||||
startY: number;
|
||||
origLeft: number;
|
||||
origTop: number;
|
||||
dragged: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// useCallEmbedPlacementSync writes top/left/width/height as inline styles directly on this
|
||||
// element. Override those when in PiP mode; let them be restored when returning to the call room.
|
||||
useEffect(() => {
|
||||
const el = callEmbedRef.current;
|
||||
if (!el) return;
|
||||
if (pipMode) {
|
||||
el.style.top = 'auto';
|
||||
el.style.left = 'auto';
|
||||
el.style.bottom = '72px';
|
||||
el.style.right = '16px';
|
||||
el.style.width = '280px';
|
||||
el.style.height = '158px';
|
||||
el.style.borderRadius = '12px';
|
||||
el.style.overflow = 'hidden';
|
||||
el.style.zIndex = '99';
|
||||
el.style.boxShadow = '0 8px 32px rgba(0,0,0,0.55)';
|
||||
el.style.border = '1px solid rgba(255,255,255,0.1)';
|
||||
el.style.visibility = 'visible';
|
||||
} else {
|
||||
// Clear all PiP overrides so useCallEmbedPlacementSync can take back control.
|
||||
el.style.top = '';
|
||||
el.style.left = '';
|
||||
el.style.bottom = '';
|
||||
el.style.right = '';
|
||||
el.style.borderRadius = '';
|
||||
el.style.overflow = '';
|
||||
el.style.zIndex = '';
|
||||
el.style.boxShadow = '';
|
||||
el.style.border = '';
|
||||
el.style.visibility = callVisible ? '' : 'hidden';
|
||||
}
|
||||
}, [pipMode, callVisible]);
|
||||
|
||||
const handlePipMouseDown = (e: React.MouseEvent) => {
|
||||
const el = callEmbedRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
pipDragRef.current = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origLeft: rect.left,
|
||||
origTop: rect.top,
|
||||
dragged: false,
|
||||
};
|
||||
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
if (!pipDragRef.current || !el) return;
|
||||
const dx = ev.clientX - pipDragRef.current.startX;
|
||||
const dy = ev.clientY - pipDragRef.current.startY;
|
||||
if (!pipDragRef.current.dragged && Math.abs(dx) + Math.abs(dy) > 5) {
|
||||
pipDragRef.current.dragged = true;
|
||||
document.body.style.cursor = 'grabbing';
|
||||
document.body.style.userSelect = 'none';
|
||||
}
|
||||
if (pipDragRef.current.dragged) {
|
||||
const newLeft = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx));
|
||||
const newTop = Math.max(0, Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy));
|
||||
el.style.left = `${newLeft}px`;
|
||||
el.style.top = `${newTop}px`;
|
||||
el.style.right = 'auto';
|
||||
el.style.bottom = 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
// Defer clearing the dragged flag so the onClick handler fires first.
|
||||
setTimeout(() => {
|
||||
if (pipDragRef.current) pipDragRef.current.dragged = false;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
const handlePipTouchStart = (e: React.TouchEvent) => {
|
||||
const el = callEmbedRef.current;
|
||||
if (!el || e.touches.length !== 1) return;
|
||||
const touch = e.touches[0];
|
||||
const rect = el.getBoundingClientRect();
|
||||
pipDragRef.current = {
|
||||
startX: touch.clientX,
|
||||
startY: touch.clientY,
|
||||
origLeft: rect.left,
|
||||
origTop: rect.top,
|
||||
dragged: false,
|
||||
};
|
||||
|
||||
const onTouchMove = (ev: TouchEvent) => {
|
||||
if (!pipDragRef.current || !el || ev.touches.length !== 1) return;
|
||||
ev.preventDefault();
|
||||
const t = ev.touches[0];
|
||||
const dx = t.clientX - pipDragRef.current.startX;
|
||||
const dy = t.clientY - pipDragRef.current.startY;
|
||||
if (!pipDragRef.current.dragged && Math.abs(dx) + Math.abs(dy) > 5) {
|
||||
pipDragRef.current.dragged = true;
|
||||
}
|
||||
if (pipDragRef.current.dragged) {
|
||||
const newLeft = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx));
|
||||
const newTop = Math.max(0, Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy));
|
||||
el.style.left = `${newLeft}px`;
|
||||
el.style.top = `${newTop}px`;
|
||||
el.style.right = 'auto';
|
||||
el.style.bottom = 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
document.removeEventListener('touchmove', onTouchMove);
|
||||
document.removeEventListener('touchend', onTouchEnd);
|
||||
setTimeout(() => {
|
||||
if (pipDragRef.current) pipDragRef.current.dragged = false;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', onTouchEnd);
|
||||
};
|
||||
|
||||
const handlePipClick = (roomId: string) => {
|
||||
if (pipDragRef.current?.dragged) return;
|
||||
navigateRoom(roomId);
|
||||
};
|
||||
|
||||
return (
|
||||
<CallEmbedContextProvider value={callEmbed}>
|
||||
@@ -51,6 +192,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
<CallEmbedRefContextProvider value={callEmbedRef}>{children}</CallEmbedRefContextProvider>
|
||||
<div
|
||||
data-call-embed-container
|
||||
ref={callEmbedRef}
|
||||
style={{
|
||||
visibility: callVisible ? undefined : 'hidden',
|
||||
position: 'fixed',
|
||||
@@ -59,8 +201,45 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
width: '100%',
|
||||
height: '50%',
|
||||
}}
|
||||
ref={callEmbedRef}
|
||||
/>
|
||||
>
|
||||
{pipMode && callEmbed && (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Return to call"
|
||||
onMouseDown={handlePipMouseDown}
|
||||
onTouchStart={handlePipTouchStart}
|
||||
onClick={() => handlePipClick(callEmbed.roomId)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handlePipClick(callEmbed.roomId)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 1,
|
||||
background: 'transparent',
|
||||
cursor: 'grab',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-end',
|
||||
padding: '6px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.65)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
borderRadius: '6px',
|
||||
padding: '3px 8px',
|
||||
color: '#fff',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
↗ Return to call
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CallEmbedContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user