Commit Graph
1696 Commits
Author SHA1 Message Date
Jannis Braun 9279ac78e5 fix(ui): AvatarStack — radial overlap layout for 3-10 members (replaces 2x2 grid)
The old 2x2 grid for 3+ members produced a cramped, misaligned look
(circles cut off inside their own border, no overlap, just four small
disks in a grid). The 2-member overlap aesthetic — equal-size tiles
that overlap diagonally — was the visual gold standard but only
existed for that one count.

Extend the same "huddle of overlapping faces" aesthetic to all
member counts:

  • 3 members → equilateral triangle of 62%-size tiles (top, bl, br)
  • 4 members → diamond of 58%-size tiles (top, right, bottom, left)
  • 5-10 members → diamond with `+N` overflow occupying the bottom
    slot (z-index boosted above neighbors so the digit is never clipped)

Tiles are positioned radially around the box center on a circle of
radius (S − T) / 2 so the farthest edges of each tile graze the
bounding rect — no clipping, no wasted whitespace. Z-index descends
clockwise from the top slot so each tile tucks slightly under its
clockwise neighbor, mirroring the 2-member z-stack.

The 0/1/2-member cases and the icon-override branch are unchanged.
Props interface unchanged — no call-site updates needed.

Tests updated to assert the new layout markers (`triangle`, `diamond`)
and to verify the geometry (top tile in triangle has smallest `top`;
overflow tile in diamond has largest `top`). 11/11 AvatarStack tests
pass; 360/360 web tests pass; typecheck clean.

design-system.md spec updated with the new layout table + geometry
section.
2026-05-10 21:57:38 +02:00
Jannis Braun 9f9a7c556b fix(ui): mount GroupDmSettings on desktop too — Open Group Settings button now opens the modal 2026-05-10 21:50:58 +02:00
Jannis Braun dbb9b2c34b wip(mobile): in-progress mobile + voice polish
Snapshot of in-progress work parked here so group-DM-polish can land
cleanly on main. Touches MainContent + mobile-ui.md which overlap with
group-DM-polish; rebase onto post-merge main and resolve conflicts on
those two files manually.

Files: MainContent, MessageInput, MobileVoiceFullScreen (+test), StreamTile,
VoiceUser, useLiveKit, useVisualViewportInset, AudioManager, voiceStore,
voice utils, mobile-ui.md, voice.md, mobile-parity handoff doc.
2026-05-10 21:26:40 +02:00
Jannis Braun 24240f24f4 fix(janitor): protect dm_channels.icon files from cleanup (owner + receiver)
getProfileReferencedFilenames() didn't include dm_channels.icon, so the
storage janitor deleted group DM icons within ~1 hour:

- Owner instance: PATCH /api/dm/:id leaves an attachments row with
  messageId=null and dmMessageId=null. After 1h, getUnlinkedAttachments
  flags it and cleanupStorage phase 2 deletes the file because the
  filename isn't in profileReferenced.

- Receiver instance: downloadProfileAsset writes the icon directly to
  uploadDir with no attachments row. cleanupStorage phase 1 treats it
  as orphaned and deletes it.

Fix: include dm_channels.icon (non-null, not soft-deleted, not http://)
in the profile-referenced set. Soft-deleted DMs are excluded so their
files still get reaped by cleanupSoftDeletedDmChannels. Absolute URLs
are skipped because they live on a remote instance.

Also mirror the avatar precedent at the PATCH endpoint by deleting the
new icon's standalone attachment row — the file is now protected via
dm_channels.icon, matching users.ts:473.
2026-05-10 21:18:58 +02:00
Jannis Braun 0acdbbc4eb docs(design-system): AvatarStack reusable component 2026-05-10 21:03:05 +02:00
Jannis Braun 941a365224 docs(mobile-ui): MobileGroupDmInfo + members-button rule for group DMs 2026-05-10 21:02:32 +02:00
Jannis Braun a694522931 docs(api): three new group DM endpoints 2026-05-10 21:01:32 +02:00
Jannis Braun 62eb2c8be1 docs(federation): group_metadata_update event + bootstrap payload extension 2026-05-10 21:00:54 +02:00
Jannis Braun 1d24fe2182 docs(dm-system): group metadata update + owner-routing helper + bootstrap extension 2026-05-10 20:59:23 +02:00
Jannis Braun f499176c01 docs(database): dm_channels.name + icon + metadata_updated_at 2026-05-10 20:57:05 +02:00
Jannis Braun 94a1d28a4c feat(mobile): MobileDmsScreen uses AvatarStack + dm.name + group globe 2026-05-10 20:51:09 +02:00
Jannis Braun 058cf36d5a feat(mobile): MobileChatScreen members button shows for group DMs 2026-05-10 20:50:05 +02:00
Jannis Braun c0a61dc0fc feat(mobile): MobileGroupDmInfo pushed screen with inline edit 2026-05-10 20:49:42 +02:00
Jannis Braun a6c37e9e49 chore(ui): remove dead Pinned Messages buttons (DM + space chat headers) 2026-05-10 20:42:42 +02:00
Jannis Braun 86a668032a feat(ui): DmListItem uses AvatarStack + dm.name + group globe 2026-05-10 20:40:35 +02:00
Jannis Braun 1b99f65d07 feat(ui): WelcomeHeader polish — AvatarStack, Owner label, Open Group Settings, Leave hover fix 2026-05-10 20:40:31 +02:00
Jannis Braun f583d9186d feat(ui): group DM chat-header polish — avatar stack, globe, cog, name + count click targets 2026-05-10 20:36:27 +02:00
Jannis Braun f04477c812 test(ui): GroupDmSettings — mock ImageCropModal to harden cancel + save-with-icon tests 2026-05-10 20:33:06 +02:00
Jannis Braun 5e739b170e feat(ui): GroupDmSettings modal with Overview + Members tabs 2026-05-10 20:26:25 +02:00
Jannis Braun b34caf5f2d fix(ui): DmMemberRow anchors profile popout to row bounds (matches MemberSidebar) 2026-05-10 20:18:05 +02:00
Jannis Braun c21bc8078b feat(ui): right-side DmRosterPanel for group DMs 2026-05-10 20:10:16 +02:00
Jannis Braun 68c4133abd feat(ui): shared DmMemberRow component (used by roster panel, settings modal, mobile info) 2026-05-10 19:59:18 +02:00
Jannis Braun c0151996c4 feat(client): chat timeline renders name_changed + icon_changed system messages 2026-05-10 19:47:18 +02:00
Jannis Braun 91aa06b48b feat(client): sidebar previews for name_changed + icon_changed 2026-05-10 19:43:31 +02:00
Jannis Braun d508fecfb0 feat(client): dm_channel_updated WS handler + updateDmMetadata store action 2026-05-10 19:42:05 +02:00
Jannis Braun e77fae0402 feat(client): api.dm.updateMetadata + kickMember + transferOwnership 2026-05-10 19:41:24 +02:00
Jannis Braun 22c3fd6d50 feat(client): getOwnerInstanceForDm — route owner-only DM ops to current owner instance 2026-05-10 19:39:13 +02:00
Jannis Braun 063ed2dd64 refactor(federation): extract normalizeIconForWire helper 2026-05-10 19:31:06 +02:00
Jannis Braun 04d7910077 feat(federation): bootstrap carries group name + icon + metadataUpdatedAt 2026-05-10 19:23:47 +02:00
Jannis Braun 7c8de29eff test(federation): lock kick authority behavior in member_remove receiver 2026-05-10 19:14:48 +02:00
Jannis Braun 78d2ab496d refactor(federation): drop dead export on downloadProfileAsset 2026-05-10 19:10:56 +02:00
Jannis Braun 240df49d1d feat(federation): processGroupMetadataUpdateEvent with receiver hardening 2026-05-10 19:04:00 +02:00
Jannis Braun 7f8d650cab refactor(server): extract transferGroupDmOwnership helper + wrap in transaction 2026-05-10 18:56:01 +02:00
Jannis Braun 35c720429e feat(server): POST /api/dm/:id/transfer — manual ownership transfer 2026-05-10 18:46:41 +02:00
Jannis Braun ce11fd12b5 refactor(server): extract evictUserFromDmVoiceRoom helper + drop redundant optional chains 2026-05-10 18:42:25 +02:00
Jannis Braun 78035bfc64 feat(server): DELETE /api/dm/:id/members/:targetUserId — owner kick
Refactors the leave-DM destructive core into a shared `removeDmMember`
helper and adds a kick endpoint that reuses it. Both endpoints write the
`member_removed` system message, delete the dm_members + read_states
rows, broadcast `dm_member_removed`, and queue a federation
`member_remove` event with the appropriate `reason` ('leave' | 'kick').

Branching invariants preserved by the helper:
- Ownership transfer fires only on self-leave when the leaver was the
  owner. Kicks cannot orphan a group (the owner is still present), so
  the transfer block is skipped.
- Soft-delete on last-member-empty fires only on self-leave. Kicks are
  guaranteed to leave the owner behind, so the channel can never be
  empty after a kick.

Endpoint validation:
- 1-on-1 DM → 400 'Cannot kick from a 1-on-1 DM'
- Caller not the owner → 403 'Only the group owner can remove members'
- Self-target → 400 'Owners cannot kick themselves; use leave instead'
- Target not a member → 404
- Channel missing or soft-deleted → 404

The kicked user is also evicted from the DM voice room (mirroring the
self-leave path) and receives `dm_channel_closed` so the client closes
the channel locally.
2026-05-10 18:35:29 +02:00
Jannis Braun a06776fd86 feat(server): PATCH /api/dm/:id — group name + icon update 2026-05-10 18:23:48 +02:00
Jannis Braun 096f12daf2 fix(federation): queueGroupMetadataRelay tighter http(s) check + caller contract note 2026-05-10 18:13:12 +02:00
Jannis Braun 4d97dd1253 feat(federation): queueGroupMetadataRelay helper + outbox-worker reconstruction 2026-05-10 18:07:32 +02:00
Jannis Braun 2afe230de0 feat(shared): group_metadata_update event + extended FederationGroupPayload (with safe defaults at producers) 2026-05-10 18:00:07 +02:00
Jannis Braun f2542fd849 feat(db): dm_channels.name/icon/metadata_updated_at 2026-05-10 17:58:00 +02:00
Jannis Braun e4717ef733 feat(shared): group DM name + icon validation constants 2026-05-10 17:56:49 +02:00
Jannis Braun f01c5aba8c feat(ui): reusable AvatarStack with +N badge and single-member group affordance 2026-05-10 17:49:54 +02:00
Jannis Braun 724ba31da0 test(server): hermetic env defaults — tests no longer require local .env
config.ts requires JWT_SECRET at module-load time. Since `.env` is gitignored
it doesn't propagate to git worktrees, fresh clones, or CI without secrets —
producing 200+ confusing cascade failures (vitest module-mock errors that
look unrelated to the real cause).

Add a vitest setupFile that sets JWT_SECRET to a fixed test-only value via
`??=`, so a real .env still wins where one exists. Tests are now hermetic:
clone the repo, `pnpm install`, `pnpm --filter server test` → 477/477 pass.
2026-05-10 17:42:22 +02:00
Jannis Braun 6fb38d391c feat(mobile): chat polish — floating composer + iOS keyboard handling + bottom-sheet drag-close + file-chip overflow
Multi-pass mobile chat polish landed across this session.

- MessageInput is now a floating glass-bubble (`position: absolute`) on both desktop and mobile — last messages scroll *behind* the translucent bubble. MobileChatScreen wraps MessageList + MessageInput in a `relative` parent so absolute positioning resolves. Removed the prior in-flow mobile branch that clipped message-list bottom against an invisible barrier.
- iOS PWA keyboard handling: new `useVisualViewportInset` hook subscribes to `visualViewport.resize/scroll` AND polls `vv.height` for ~600ms after focusin (iOS PWA standalone often fails to dispatch resize for keyboard transitions). MobileShell sizes its container to `vv.height` when keyboard is open — `bottom: 0` on the composer naturally lands flush with the keyboard top, regardless of how reliably resize events fire. Composer uses 6px gap above home indicator (keyboard closed) and 0px gap above keyboard (keyboard open). Added `interactive-widget=resizes-content` viewport meta as the cleaner native equivalent for Chrome/Android.
- MessageList bottom padding is dynamic via `--composer-clearance` CSS variable. MessageInput writes `composerHeight + bottomOffset + 12px` to its parent via ResizeObserver — re-fires on textarea autosize, reply banner, attachment tile growth, parent resize. Last message always has 12px breathing room above the bubble regardless of composer state.
- AttachmentRenderer generic file chip: `max-w-full sm:max-w-[400px]` on outer + `min-w-0` + `flex-shrink-0` on icon + `flex-1` on text + `flex-wrap` on badge row. Long filenames now ellipsize cleanly on narrow viewports instead of pushing the chip off-screen.
- New `useDragToClose` hook: shared bottom-sheet drag-down-to-dismiss gesture. Spread on handle/header only (body scrolling unaffected). 6px deadzone, 100px or 0.5px/ms velocity threshold, 200ms `cubic-bezier(0.22, 1, 0.36, 1)` close-out animation, rAF-staged transform for a stable from-value. `hasInteracted` latch prevents the open keyframe from re-firing mid-close (the bounce-up-then-vanish bug). Wired into InputPopover (emoji/GIF), MobileVoiceJoinSheet, MobileFolderSheet.

Specs: docs/systems/mobile-ui.md (Floating Composer + Drag-to-Close sections), docs/systems/message-list.md (--composer-clearance), docs/systems/design-system.md (glass-bubble row references).
2026-05-08 10:23:32 +02:00
Jannis Braun cdb4b5f41f feat(mobile): loading skeleton parity + mascot-flash settle gate
Brings mobile to skeleton parity with desktop. Two mobile-specific renders, plus a fix for the empty-state mascot flashing on every space switch.

- MobileSpacesScreen: shimmer skeleton for the channel list while `useSpaceStore.loadingSpaceId === selectedSpaceId` (uncategorized rows + category header + categorized rows). Mirrors desktop ChannelSidebar's `showChannelSkeleton`. Gated through `useDelayedLoading` so cached/fast loads don't flash the placeholder.
- MobileMembersScreen: shimmer skeleton for the member list (two role-section headers + circular avatars + name bars). Mirrors desktop MemberSidebar's `showMemberSkeleton`.
- Empty-state "No channels yet." mascot was firing on EVERY space switch for ~50–200 ms — `state.channels` only ever holds the most-recently loaded space's channels, so `spaceChannels` filters to `[]` immediately on selection change while `loadSpaceDetail` is still in flight. New `loadedSpaceIds: Set<string>` on useSpaceStore, populated only on `loadSpaceDetail` success and pruned on space removal events. The mascot is now gated on `loadedSpaceIds.has(selectedSpaceId)` so it only fires when the space is *settled* with zero channels.

Reuses the existing `.skeleton` / `.skeleton-bar` / `.skeleton-circle` CSS primitives + `useDelayedLoading` hook (no new shared `<Skeleton />` primitive — sites need row-specific geometry, parameterizing would either over-abstract or duplicate the inline approach).

Specs: docs/systems/mobile-ui.md (Loading Skeletons inventory + per-screen skeleton bullets + settle-gate rationale); docs/systems/spaces.md (Client Load State subsection covering loadingSpaceId vs loadedSpaceIds).
2026-05-08 10:22:59 +02:00
Jannis Braun 71b383ce7f fix(mobile): voice join flow — correct screen key + remove desktop PiP from mobile branch
Two compounding root causes produced a "PiP-style grey view" briefly visible after Join Voice on mobile:

1. MobileSpacesScreen.handleVoiceJoin pushed 'voice' onto the mobile stack. MobileShell.screenMap only has 'voice-full' — the renderer returned null for 'voice' while the root screen sat under `visibility: hidden` (stack length > 0). Corrected to 'voice-full' with an explanatory comment.
2. AppLayout mounted the desktop <PictureInPicture /> in the mobile branch too. Its visibility check `(isInServerVoice || isInDmCall) && !voiceFullscreen` is true the moment currentVoiceChannelId is set, so the desktop PiP rendered as the only visible UI on top of the hidden mobile shell. Removed from the mobile branch; kept on desktop. Added a comment to prevent regression.

Mobile owns its voice overlay chrome exclusively via MobileVoiceMiniBar + MobileVoiceFullScreen.

Spec: docs/systems/mobile-ui.md gains a "Voice Join Flow (Mobile)" subsection and a leading paragraph in "Voice Overlay" stating that PiP is desktop-only.
2026-05-07 23:44:34 +02:00
Jannis Braun 74ccf20308 feat(mobile): Wave 5 — voice-join camera preview + Electron settings entries + toast positioning
Closes the mobile parity push.

- MobileVoiceJoinSheet: full pre-join camera preview. Dormant-by-default (never auto-fires getUserMedia), explicit user gesture to arm, hard-bound disarm on sheet close (any path). Camera picker popup portaled to document.body so a long device list stays scrollable above the aspect-video preview tile (max-height min(50vh,320px), iOS scroll momentum). iOS Safari-safe: autoPlay playsInline muted + post-await play().
- MobileSettingsScreen: Keybinds + Desktop sections gated on isElectron(); they reuse the existing KeybindsPanel/DesktopPanel which are already mobile-fit. screenMap entries added in MobileShell. (Verify on Electron desktop build at narrow viewport.)
- ToastContainer: real overlap was hiding the voice-fullscreen control bar. Mobile branch now reads isMobile/mobileStack/currentVoiceChannelId and computes the bottom offset across five mobile states (voice-full / pushed+voice / pushed / root+voice / root), all with safe-area-inset-bottom; left-3 right-3 + items-center keeps toasts in the safe-tap zone. Desktop bottom-6 right-6 unchanged.

Specs: docs/systems/mobile-ui.md (Toast Positioning section, screenMap rows, Electron-entry rationale, z-index row); docs/systems/voice.md (Mobile pre-join preview subsection covering lifecycle + camera picker portal).
2026-05-07 23:44:06 +02:00
Jannis Braun d9c9f18b20 fix(mobile): Spaces tab remembers last-selected space across DM/Friends/Settings detours
Adds sticky `lastSelectedSpaceId` to useSpaceStore. Updated on every
setCurrentSpace(non-null) and on loadSpaceDetail success; NOT cleared by
setCurrentSpace(null), so the @me URL effect in AppLayout no longer wipes
the memory. Cleared only when the remembered space is actually removed
(deleteSpace / leaveSpace / removeSpace / removeInstanceSpaces / reset).
Ephemeral — URL drives initial state on reload.

MobileBottomNav.handleTab('spaces') and MobileSpacesScreen's initial
useState now resolve via `currentSpaceId ?? lastSelectedSpaceId`. The
prior Object.keys(lastChannelPerSpace)[0] fallback was wrong twice over:
first-inserted ordering locked the tab to whichever space the user
opened first in their session, and any approach reading currentSpaceId
alone breaks after a /channels/@me detour.

Also fixes a pre-existing render loop in MobileSpacesScreen's
currentSpaceId sync effect — `selectedSpaceId` removed from deps,
functional setState makes the update idempotent.

Spec: docs/systems/mobile-ui.md updates Tab Tap Behavior, MobileScreenHeader,
and LocalStorage Persistence sections.
2026-05-07 23:00:01 +02:00
Jannis Braun f733e39b4a fix(mobile): suppress iOS Safari auto-zoom on every input + contenteditable
iOS Safari auto-zooms (and shifts the viewport right) on focus when the
computed font-size is <16px. Single @media (max-width: 767px) block in
globals.css covers all four input tiers, every relevant bare <input>
type, <textarea>, <select>, and contenteditable surfaces. `!important`
is required because Tailwind utilities (text-[15px] on the chat composer,
text-sm on form fields) emit after @layer components and would otherwise
override the input-tier rules.

Replaces ad-hoc per-input `text-base md:text-sm` overrides with a single
global truth. New inputs/contenteditables get the fix for free.

Spec: docs/systems/design-system.md gains an "iOS Auto-Zoom Suppression"
section under Input Tiers.
2026-05-07 22:59:41 +02:00