Commit Graph
1489 Commits
Author SHA1 Message Date
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
Jannis Braun fb9fe326c4 feat(mobile): Wave 4 — RegisterPage responsive + TransferIndicator on settings + MessageInput sheets
- RegisterPage: scroll envelope, iOS-zoom-safe inputs, ≥44px tap targets, mobile-friendly invite chip + swatch row
- TransferIndicator: mounted via MobileScreenHeader.rightActions across every settings/instance screen and inline in MobileChatScreen; touch-close listener; viewport-safe panel width
- MessageInput popovers (emoji/GIF/mention) now route through new InputPopover wrapper — desktop popover, mobile bottom-sheet
- EmojiPicker mobile branch: dynamicWidth + scoped CSS (.emoji-picker-wrapper--mobile) so the <em-emoji-picker> custom element fills the sheet; bigger touch targets (perLine 8, button 40, emoji 28); desktop unchanged
- MessageInput trigger row: mobile-first sizing with md: desktop overrides (40×40 buttons + gap on mobile, 34×34 unchanged on desktop)
- Spec updates: docs/systems/auth.md (RegisterPage responsive contract), docs/systems/uploads.md (TransferIndicator mobile surfaces)
2026-05-07 22:58:57 +02:00
Jannis Braun 8544f83225 refactor: drop unwired ConnectInstanceModal
Commit 7309f44 removed the friend-add trigger because the server now
handles all routing/peering/lookup; the commit message reserved the
modal for 'Connections settings and space-join flows' but neither flow
ever wired it back in. grep across packages/web/src finds only
self-references — pure dead code.

Removing 162 lines of UI plus the stale vi.mock in FriendsPage.test.tsx.
If a per-instance password-re-entry consent UX is ever needed, the
useInstanceConnect hook is the actual abstraction and the modal can be
rebuilt cleanly with current primitives (Modal mobileStyle="fullscreen",
ContextMenuRenderer's bottom-sheet, etc.).
2026-05-05 23:24:19 +02:00
Jannis Braun 438e7fb113 fix(storage): StoragePanel rows wrap on narrow viewports
Upload Limit, Stale Uploads, and Media Retention rows now use
flex-wrap with gap-x-3 gap-y-2; input + unit pairs that must stay
together are wrapped in a nested flex group so they migrate as a unit.
Desktop spacing identical (gap-x-3 ≡ original gap-3 when content fits
on one line).
2026-05-05 23:24:10 +02:00
Jannis Braun 899dc8b601 feat(settings/mobile): Voice & Video adaption + chat header username normalization
MobileChatScreen DM header now applies parseFederatedUsername and
useCanonicalUserView, matching MobileDmsScreen so federated users render
as 'realname' rather than 'realname@domain'.

Mobile Voice & Audio renamed to Voice & Video across MobileSettingsScreen
and MobileYouScreen — parity with desktop's VoicePanel title.

AudioInputSection and VideoSection picker click-outside now listens for
touchstart alongside mousedown so a single tap dismisses on iOS Safari
(which doesn't synthesize mousedown reliably from touch).

AudioOutputSection feature-detects HTMLMediaElement.setSinkId at module
load and returns null on unsupported platforms (notably iOS Safari).
Split into outer gate + inner body to keep Rules of Hooks intact;
VoicePanel's space-y-5 collapses cleanly with no visible hole.

VideoSection 'Stop preview' CTA gets responsive sizing (mobile:
always-visible 44 px tap target; desktop: original hover-reveal pill via
md: prefixes). The preview <video> gains autoPlay so iOS Safari starts
the stream when srcObject is assigned — manual videoEl.play() after an
awaited getUserMedia loses the user-gesture context on iOS and was
silently rejected by .catch(()=>{}), causing the black-preview bug.

voice.md updated for the iOS hide-when-unsupported policy and the
touch-close contract on device pickers.
2026-05-05 23:24:03 +02:00
Jannis Braun fc9a07523f fix(mobile): SpaceInviteCard Join lands on Spaces tab; skip auto-channel-redirect
AppLayout's auto-channel-redirect (turning /channels/<spaceId> into
/channels/<spaceId>/<firstChannelId>) is desktop-correct but on mobile
catapulted users past the channel sidebar straight into a chat — Join
from a SpaceInviteCard or Spaces-tab tap both inherited this. Guard
the effect with isMobile so /channels/<spaceId> settles at the channel
sidebar overview on mobile.

SpaceInviteCard's join handler now switches to the Spaces tab and seeds
spaceStore.currentSpaceId on mobile before navigating — clears the chat
stack the invite was tapped from and lands the user at the joined space's
channel sidebar instead of stuck behind the originating DM.

MobileSpacesScreen syncs its local selectedSpaceId from currentSpaceId
when external code (the Join handler) seeds the store — covers any future
programmatic space switch too.
2026-05-05 23:23:46 +02:00
Jannis Braun e2d938e6c0 feat(mobile): admin reach + post-mount routing + RegistrationPanel modals → Modal
MobileInstancePanel gains Registration and Federation entries (parity with
desktop's six sub-tabs). MobileShell registers the matching screenMap
wrappers; the Federation entry surfaces the live approval-count badge via
a new uiStore slot and a wrapper that forwards FederationPanel's
onApprovalCountChange.

MobileShell's deep-link reconstruction now reacts to post-mount pathname
changes (was [] / mount-only) with an idempotency guard that skips the
push when the topmost stack entry already represents the new URL —
prevents the pushMobileScreen → history.pushState → location-effect
double-push.

RegistrationPanel's four portaled modals (CreateInvite, EditInvite,
ReinstateInvite, Redemptions) now render through the shared <Modal>
with mobileStyle="fullscreen", portaled to document.body to escape the
parent settings dialog's backdrop-filter containing block. Desktop
appearance preserved (same maxWidth, same sticky action bar).
2026-05-05 23:23:37 +02:00
Jannis Braun 69cd2dc537 chore: ignore .playwright-mcp/ session artifacts 2026-05-05 20:55:10 +02:00