Adds uploadWithProgress() using XMLHttpRequest for real-time upload
progress events. MessageInput now shows per-file progress bars with
percentage overlay during upload. Failed uploads show toast warnings
with the specific error instead of silently failing. Upload timeout
raised to 10 minutes for large files.
The 500 MB cap was arbitrary. Self-hosted admins should decide based
on their own disk space. Global multipart ceiling raised to 5 GB,
validation cap to 5120 MB.
Replaces the layout-shifting error banner at the bottom of the panel
with non-intrusive toast notifications. Upload limit save, cleanup
success, and all error feedback now use the existing toast system.
Cleanup preview results stay inline as expected contextual responses.
Adds cleanupOldMedia() to the storage janitor and a new endpoint
POST /api/admin/storage/cleanup-media { maxAgeDays, dryRun }.
Deletes chat attachments older than the specified threshold while
preserving profile images.
Replace the give-up-after-3-retries path with a persistent background
scheduler: 10 retries every 30s (5 min), then 12 retries every 5 min
(1 hr). Active timers are keyed by origin so rapid password changes
cancel and replace the previous loop. All timers are cleared on logout
via clearPasswordSyncTimers() called from instanceStore.reset().
Add `pendingSyncOrigins: string[]` to InstanceState so components re-render
when pending password-sync flags change. Hydrate from localStorage on
autoConnectAll, clear on reset, and remove the localStorage-only read path
from `hasPendingPasswordSync`.
Add installers/ to .gitignore and remove from tracking — binaries
should not be in git.
Also includes:
- Re-push Electron activities after WebSocket reconnect (sleep/wake)
- Merge activity map instead of replacing to preserve local state
- Nori mascot design plan
When the app is focused and the native uiohook hook is active, both the
IPC bridge and the web fallback capture the same keypress, dispatching
the toggle action twice (~0-20ms apart). This double-toggles mute/deafen
back to the original state.
Add a 100ms dedup window in dispatchKeybindAction to suppress the second
dispatch. Safe for human input (key repeat starts at ~500ms).
- Add .catch handler to scheduleNext to prevent unhandled promise
rejections from killing the scheduling loop
- Replace `as unknown as Animation` cast with Cancellable interface
for type-safe cleanup handles
The svgRef was created but never passed to the sub-component SVG
elements, meaning it would always be null when the animation hook
tries to use it. Now passed via SvgProps interface.
Implement the <Mascot> component that renders Nori in four emotional
states (idle, sleeping, excited, lonely) with distinct SVG geometry,
color palettes, and facial expressions. Includes useMascotAnimation
hook stub for Task 2 and full test coverage (15 tests).
When the Electron main process can't start uiohook (macOS Accessibility
denied, Linux missing input group), the IPC path silently produces no
events. Previously this left zero keybind listeners active. Now the web
capture-phase fallback is always set up as a safety net, ensuring
keybinds work in-app even when the OS-level hook fails.
When running in Electron but the desktop app hasn't been rebuilt with
keybind support (no syncKeybinds/onKeybindAction in preload), the hook
now falls through to the web capture-phase listener instead of silently
doing nothing. This ensures keybinds work immediately on the web-served
UI regardless of the desktop app version.
The previous hash (charCodeAt(0)*256 + charCodeAt(1)) only used the
first 2 characters of KeyboardEvent.code strings, causing all Key*
codes (KeyA, KeyB, ..., KeyZ) to collide to the same numeric value.
This made Ctrl+D and Ctrl+M appear as identical bindings (false
conflict) and broke keybind matching in the web fallback.
djb2 hashes the full string, producing unique values for all codes.
Move mute/deafen/camera/screen-share/disconnect logic out of VoiceControlBar into a shared voiceActions.ts utility so the same handlers can be called from both UI buttons and the upcoming keybind dispatcher.
Replace one-off inline tab bar in InstancePanel with a reusable
SettingsTabBar that reads from SettingsSectionsContext. Any panel
using tab mode can drop it in with zero configuration.
Polish: proper underline indicator, consistent gap spacing,
negative top margin to sit flush with content area padding.
Renders tab pills (General, Streaming, Storage, Users) at the
top of the content area, matching Discord's Family Center pattern.
Works alongside the sidebar sub-links — both navigate the same tabs.
- Add <h2> title headings to General, Streaming, Storage, Users panels
- Reset password now shows confirmation dialog before executing
- Temp password displays inline below the user row (not at top of list)
- Temp password has copy button and dismiss action
The continuous scroll was wrong for Instance — its sub-panels are
fundamentally different page types (user table, storage tool, bitrate
matrix, toggles). Revert to conditional rendering.
The hook now supports two modes:
- Scroll mode (default): smooth scroll + IntersectionObserver spy
- Tab mode (onNavigate callback): sidebar sub-links switch tabs
Instance uses tab mode. The scroll-spy infrastructure remains for
future panels that benefit from it (e.g. Account sections).
Also reverts GeneralPanel to sticky save bar since it's on its own
page again with no overlap risk.
- Section headings: text-lg with descriptions, dividers spaced my-10
- GeneralPanel: convert to auto-save (toggles save immediately,
text fields save on blur) — no more hidden save bar
- StreamingPanel: restore sticky save bar (complex multi-field form
needs explicit save, and it's the only sticky bar now)
- Memoize context value in SettingsSectionsProvider
- Use ctxRef pattern in hook to avoid depending on ctx object
in useLayoutEffect deps (ctx object changes on every render,
causing effect → setState → re-render → effect loop)
Remove `as const` + spread pattern that created a new array on
every render, triggering useLayoutEffect re-registration. Use
typed SettingsSection[] constant instead.