mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
fix(client): resolve 94 verified defects across voice, identity, transport and UI (#1332)
* fix(client): gate every mic re-enable path on the user's mute state
Six separate paths republished the microphone without consulting whether
the user had muted themselves: the audio-device fallback, selecting the
"Default" input, un-deafening, retryMicPermission, a stale PTT ownership
latch, and auto-reconnect's restoreLocalVoiceState. Each one produced a
hot mic while every remote UI still showed the user as muted.
These were six findings but one missing guard. Adds isMicPolicyGated()
(localMuted || localDeafened || localServerMuted || pttGated) and routes
the device-switch cycle, applyMicMuteState's unmute branch and
retryMicPermission through it, which also covers setDeafened(false) --
a call site no finding named.
Also extracts reconnectSuperseded() so all five supersession checkpoints
in the auto-reconnect loop carry the state-type check that only the
give-up path had, and clears the PTT gate on stopPtt and on ptt-error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): stop camera/screenshare publishing after the user turns it off
enableCamera and enableScreenshare set the store flag before awaiting
getUserMedia/getDisplayMedia, so clicking off during the OS picker left
the track publishing to the SFU while the UI showed it off, with no stop
affordance. Adds one shared generation guard: disable bumps, enable
captures before the await and discards the track if it changed.
Also in this area:
- a server refusal of voice_screenshare (or a non-VIDEO_LIMIT refusal of
voice_camera) never rolled back the published track; the dispatcher now
correlates the error by envelope id rather than blanket-rolling-back.
- a full-ready resync left every loaded channel with a permanent hole in
its history, because that tier never replays chat_message frames.
Loaded windows are now invalidated on a resync (pending and failed rows
carry through) and the active channel refetched.
- CHANNEL_FULL while joining left voiceStatus stuck; DM mirror rows kept
phantom entries and stale unread counts across a resync; addMessage and
setAroundMessages dropped offline/failed optimistic rows.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): preserve a mid-setup key-holder promotion, and route the
audio graph through the noise suppressor
setupKeyExchange unconditionally wrote the server's key-holder value
captured at join, clobbering a handleParticipantLeft promotion that
landed during its pre-publish awaits. The joiner then waited for an offer
only it could send, timed out, and was ejected from voice. The write now
preserves an existing promotion; it sits after the existing
session-generation check, and clearState bumps that generation and resets
the flag synchronously, so stale state cannot survive a teardown.
Enhanced Noise Suppression silently disabled the input-volume slider and
the VAD gate: livekit-client's setProcessor() does its own internal
replaceTrack(processedTrack) after awaiting addModule and a fetch, so it
landed after ours and wired the sender straight to the raw mic. The
pipeline now sources from the processed track and re-runs after
attaching, so our replaceTrack wins.
Also scopes the voice identity keypair by host AND user id so two
accounts sharing one OS profile stop sharing an identity keypair, guards
peer-key and TOFU writes against a clearState during their IPC awaits,
and seeds VideoGrid tiles from the persisted per-user volume.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): drop the previous server's bearer token on a host switch
api.setConfig spread the new config over the old, so switching hosts
carried the previous server's session token forward and the login request
to the next server went out holding a live credential for the first one.
The token is now dropped in the shared setConfig when host changes
without an accompanying token, covering login, register and auto-connect
at once.
Also fixes a packaged-build-only failure: the CSP omitted blob: from
img-src, so avatar upload validation (which measures the image via
URL.createObjectURL) always failed in release and never in dev.
Smaller connection and IPC fixes: ws_disconnect now bumps the connection
generation instead of nulling the sender slot, so an in-flight handshake
cannot install after a disconnect; a dead LiveKit proxy listener
deregisters itself instead of being reused forever; httpProxy no longer
caches an origin the Rust side may have torn down; logPersistence stopped
looping on its own flush-failure logs; ConnectPage subscribes to
transientError instead of reading it once; cert-mismatch accept/reject
only act when the event host matches the live session.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): guard the quick-switcher against a double-open
openQuickSwitch assigned its instance only after awaiting the profile
load, so a second click during that window mounted a second overlay and
orphaned the first. Every close affordance destroys only the tracked
instance, leaving a body-mounted position:fixed backdrop that blocks all
input until the app is reloaded. Adds the same `opening` flag the sibling
overlay controllers already use; audited every other opener in these
files and found no second instance of the race.
Also: loadOlderMessages and loadMessages now discard a response whose
window was replaced mid-fetch by a same-channel jump; the ArrowUp
edit-last-message scan skips unsent rows, matching the visual affordance;
unpinning from the pinned panel writes the store row; the pinned panel
forwards the channel it captured at open time rather than reading the
active one at click time; the reaction picker closes on channel teardown;
a non-voice channel switch dismisses the video grid; and destroy() closes
the settings overlay.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): repair the status-picker stylesheet and a dozen UI defects
The .status-picker rules targeted a root element the component never
toggles, leaving the popup's own chrome unstyled and the root
display:none. Repointed at .status-picker-dropdown and dropped the dead
rules.
Component and store fixes, all test-first: the upload preview bar never
became visible so upload errors were invisible; replying while editing
left the edit text in the textarea; MessageList's load-older latch keyed
off a raw count so a live tail append refired the fetch; drag-reorder
renumbered channels into a 0..n-1 range instead of reusing the group's
own position slots; DM avatars bypassed the authenticated fetch path;
the member-list moderation gate read a mount-time role snapshot; mention
autocomplete offered usernames the mention grammar cannot express;
notifications titled DMs as "#channel"; the update-notifier catch
dereferenced a null banner; and the channel context menu leaked its node
on teardown.
Also resets authStore in member-list.test.ts's shared reset helper: one
test was leaving role="admin" set for every test after it, unnoticed
because no gate read authStore for role until now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): adopt the legacy identity key instead of re-minting one
Scoping the identity keypair by host and user id changed the keyring
account name, so every existing install would have found nothing at the
new account and generated a fresh identity key. Every peer who had
already pinned the old one would then see a TOFU mismatch, which raises
the re-pin modal telling the user to verify the safety number
out-of-band -- a MITM alarm fired at the whole alpha population at once,
which teaches people to click through the one warning meant to matter.
When the scoped account is empty, the legacy host-only account is now
adopted: saved under the scoped name, then the legacy account deleted.
Save happens before delete so a partial failure leaves the legacy key in
place for the next launch rather than stranding the user with neither.
A corrupt legacy blob falls through to fresh generation without throwing.
A second account on the same host still mints its own distinct keypair,
which was the point of the scoping fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): surface server errors that had no dedicated handler
The WebSocket error handler bannered only RATE_LIMITED and FORBIDDEN, so
every other code that reached the fallthrough was dropped in silence --
a rejected chat_edit reported nothing at all while the optimistic
"Message edited" toast still fired. Every specific branch above already
returns, so the fallthrough sees only genuinely unhandled codes; it now
banners all of them.
Also:
- reattachToPresent cleared the detached flag eagerly, so a failed tail
refetch let a live broadcast splice onto the stale around-window with a
silent gap. The flag now survives until setMessages lands the tail.
- a mixed-case host and its lowercase-normalized URL form resolved to
different cert-store pin keys; tofu::cert_store_key and ws.ts's
normalizeHostForCertCompare both lowercase now. attachments.ts already
did the right thing and is unchanged.
- clearAuth left the channels store populated for the next login.
- capabilities/default.json was missing
core:window:allow-request-user-attention.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): tear down video tiles, focus and the lightbox on leave
Four defects an earlier pass could not finish because each spanned two
files:
- closeVideoGrid only hid the grid, so remote video tiles survived a
channel leave and reappeared on the next join. VideoGrid grew a
clearStreams(), called from the real-leave branch of checkVideoMode
(not the reconnect branch).
- the grid kept its focused-tile state across a close; setFocusedTile now
accepts null and closeVideoGrid clears it.
- the per-user volume preference key had no host component, so volumes
set on one server applied to a different user with the same id on
another. Scoped via setAudioVolumeHost, mirroring channel-mutes.
- the media lightbox stayed mounted after MainPage.destroy().
Also repairs tests/unit/audio-elements.test.ts, which was missing an
afterEach import and failing to compile.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): close eight defects a review found in this batch
Three of these are incomplete fixes from earlier commits on this branch --
the diagnosis landed, the cure stopped short.
- main.ts kept a hand-copied normalizeHostForCertCompare that never got the
.toLowerCase() its ws.ts original and tofu::cert_store_key both have. Since
the Rust side always emits the lowercased host and a profile stores it
verbatim, any uppercase in the hostname broke all three guards -- worst of
them the mismatch modal's onReject, which then skipped disconnect/clearAuth
and left the user connected to the server whose certificate they had just
refused. ws.ts now exports the one implementation and the copy is gone.
- the status-picker stylesheet repair repointed the root and deleted the old
.status-option rules without adding replacements under the names the
component emits, so the trigger dot -- a bare div whose only style is an
inline background -- stayed 0x0, invisible and unclickable. The picker still
could not be opened.
- ungateMic's re-open branch was unreachable in the one scenario its comment
described: a PTT release routes through setMuted(true), so localMuted is
always true there. It now takes the pttOwnsMute latch read *before* each
call site resets it; reading the module flag from inside would always see
false and move the bug rather than fix it.
The rest:
- dispatcher.ts statically imported @lib/screenShare, which has value imports
from livekit-client -- dragging ~1.3 MB into the entry chunk that the file's
own comment says is deliberately kept out of it. Now lazy, like every other
voice call site here.
- replay detection compared payload.timestamp (server clock) against
Date.now() (client clock). A self-hosted server without NTP made every live
message after a reconnect look like a replay, silently killing notifications
for the whole drift window. Both sides are now in server time via an
observed skew estimate; latency biases it toward treat-as-live, which is the
side that costs a duplicate rather than a dropped notification.
- identity.ts and livekitE2EE.ts each derived the keyring scope with `?? 0`.
A missing user id would have adopted-and-deleted the real legacy key into a
bogus host:0 account, then minted a second keypair under host:<realId> --
published key and signing key permanently disagreeing, which is a false MITM
warning for every peer. Unreachable today, irreversible if reached.
- per-user volumes were scoped by host with a legacy fallback that only fired
when currentHost was null, which MainPage never leaves it as -- so every
saved volume silently read as the default on upgrade. Reads now fall through
to the unscoped key once and persist under the scoped one.
- a post-resync invalidate ran unconditionally while its refetch was guarded,
so a missing getMessages left every window dropped with nothing to reload it.
A ninth finding -- that the DM reconcile could strand activeChannelId -- was
checked and rejected: the block 40 lines above already clears it whenever the
id is absent from both channels and dm_channels.
Two test-suite notes: livekit-session's announce-signing test was joining
voice with no authenticated user, which production does not permit, so it now
sets one (below PEER_ID, leaving key-holder election unchanged) and clears it
after. status-picker-userbar reads app.css from disk rather than `?raw`, which
vitest stubs to an empty string for stylesheets.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): repair the e2e cert test and four defects found verifying it
The e2e suite caught one behavioural divergence from this branch, and
hand-verifying the hunt's flagged-but-unchecked items turned up four more
defects.
E2E:
- cert-tofu's "disconnect on mismatch returns to the connect page" emitted the
mismatch for myserver.example:8443 while the session was authenticated
against localhost:8443, so it asserted the pre-fix behaviour: a certificate
rotating on ANY unrelated saved profile logs you out of the server you are
using. That is the bug 8917c28 deliberately fixed. The test now emits for the
live host, and a new sibling pins the guard itself -- a mismatch for another
host must leave the session alone. Verified by defeating the guard: only the
new test goes red, which is why the old one never noticed the change.
Defects found verifying the ledger's open items:
- logging out fired delete_credential fire-and-forget and then navigated to the
connect page, whose auto-login immediately read the same account back. Since
B4-3 moved the credential commands to #[tauri::command(async)] they no longer
serialize on the IPC thread, so a read that wins that race signs the user
straight back into the server they just left. Two fixes, because the race and
the intent are separate problems: a CREDENTIAL_LOCK mutex restores the
one-operation-at-a-time property that also keeps secret_store::set's
read-modify-write atomic, and the connect page now skips auto-login once
after a logout that removed the credential -- mirroring the quick-switch
sessionStorage idiom already in that file. A server_shutdown logout keeps its
credential and deliberately does not set the flag, so restart auto-login
still works. e2e-pinned: with the suppression defeated, the user is visibly
back in the app after clicking Log Out.
- a post-resync history refetch that REJECTED left the active channel's window
already invalidated but never marked errored, so MessageList fell into its
"no messages yet" welcome branch -- rendering a failed reload as a genuinely
empty channel, with no Retry, until the user navigated away and back. Now
calls setChannelLoadError, reusing MessageController's existing plumbing.
- an invite deep link arriving during the connected overlay's 800ms ready
countdown hit a gate that assumed isAuthenticated implies the router is on
"main". It is not: clearAuth() ran without the teardown that only the
authStore subscriber performs (and only while on "main"), so the overlay's
timer then mounted MainPage over a nulled-out auth state, and the invite was
dropped. Gated on the real invariant and the in-flight session is now torn
down explicitly.
- channel mutes carried the same dead legacy-preference fallback that per-user
volumes had -- guarded on currentHost === null, which MainPage never leaves
it as -- so every saved mute was silently discarded on upgrade. Mutes are a
list, where an empty saved value is real data, so this needed a presence
probe rather than the volume fix's sentinel.
Also extends the e2e Tauri mock with storedSettings/storedCredential seeds so
auto-login paths are exercisable at all.
Verified clean: 4800 vitest, 293 Playwright, 97 cargo, tsc, tsc -p e2e, eslint,
prettier, clippy -D warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): stop Tab escaping every modal, and a duplicate row after a resync
Two bugs left open by the previous round.
The "flaky" a11y focus-trap test was not flaky -- it was a real accessibility
defect surfacing nondeterministically. FOCUSABLE_SELECTOR is structural and
says nothing about visibility, but this codebase hides controls with inline
`style.display = "none"` (MemberPickerModal's group-name field and confirm
button both start hidden). So focusDialog() picked a display:none input as the
dialog's first focusable and called .focus() on it -- which browsers silently
refuse -- and focus never entered the dialog at all. trapFocus() then computed
first/last as those same hidden elements, so neither Tab branch ever matched
document.activeElement, preventDefault() never fired, and Tab fell through to
the browser's native order and walked straight out of the dialog. Whether the
test noticed depended on how much async sidebar content happened to be
focusable at that moment, which is what made it look intermittent.
Fixed in the shared helper rather than in the one modal that exposed it: about
forty call sites hide controls the same way, so every factory modal had the
same hole. trapFocus and focusDialog now filter out inline-hidden elements.
Reproduced first at 3/10 failures under --repeat-each; 10/10 after, and 20/20
at --workers=4. Note the check reads inline styles only -- an element hidden by
a CSS class would still slip through, which no current call site does.
Second: a message the server persisted but whose chat_send_ok ack was lost to
the same disconnect that forced a resync was displayed twice. The optimistic
row keeps id 0 until confirmSend stamps it, so setMessages' id-based carry-over
could never collide it with the real row, while addMessage had solved exactly
this for the live path by matching on content. Extracted that predicate as
isUnreconciledEcho and used it in both, so the two cannot drift apart.
The dangerous direction here is over-merging, not under-merging: collapsing two
genuinely distinct sends of the same text loses a real message. Three things
bound it -- only rows still awaiting reconciliation qualify (pending, or failed
for OFFLINE specifically, since a SLOW_MODE rejection is never broadcast and
eating that row would kill a live retry draft), author and content must both
match, and each snapshot row is consumed at most once, so N identical pending
sends pair off against N identical real rows instead of collapsing onto one.
Both directions are tested.
Verified: 4804 vitest, 293 Playwright with zero flaky, tsc, tsc -p e2e, eslint,
prettier.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
"core:window:allow-outer-size",
|
||||
"core:window:allow-available-monitors",
|
||||
"core:window:allow-center",
|
||||
"core:window:allow-request-user-attention",
|
||||
"notification:default",
|
||||
"notification:allow-notify",
|
||||
"notification:allow-request-permission",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use serde::Serialize;
|
||||
use std::sync::Mutex;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::secret_store::{self, Backend};
|
||||
@@ -53,6 +54,42 @@ fn require_non_empty(value: &str, field: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-command serialization
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// B4-3 moved every command below to `#[tauri::command(async)]` so the
|
||||
// blocking keyring/DPAPI I/O runs off Tauri's IPC main thread instead of
|
||||
// freezing the UI on it. Before that, Tauri ran all (sync) commands one at a
|
||||
// time on that thread, so two overlapping invocations were always fully
|
||||
// serialized in arrival order. `async` dispatches each invocation onto the
|
||||
// async runtime's thread pool instead, so two overlapping calls can now
|
||||
// genuinely run concurrently and interleave their OS credential-store
|
||||
// operations.
|
||||
//
|
||||
// That is reachable, not hypothetical: `identity.ts`'s legacy-key migration
|
||||
// does a save-then-delete pair for two different accounts, and logging out
|
||||
// fires a fire-and-forget `delete_credential` for a host whose connect-page
|
||||
// auto-login can immediately issue `load_credential` for the very same host.
|
||||
// Nothing upstream awaits the delete before the read can start.
|
||||
//
|
||||
// This mutex restores the "only one credential-store operation in flight at
|
||||
// a time" property that made ordering safe pre-`async`, without giving back
|
||||
// the perf win: it guards the whole command body (not just the raw OS call),
|
||||
// so the fallback file's read-modify-write in `secret_store::set_with` is
|
||||
// still atomic with respect to a concurrent read or delete for the same or a
|
||||
// different account.
|
||||
static CREDENTIAL_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Run `f` with every other credential-store command excluded. Poisoning is
|
||||
/// recovered from (the guarded value is `()`, so there is nothing to
|
||||
/// distrust) rather than propagated, so a panic inside one command cannot
|
||||
/// permanently wedge every credential operation for the rest of the process.
|
||||
fn with_credential_lock<T>(f: impl FnOnce() -> T) -> T {
|
||||
let _guard = CREDENTIAL_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
f()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri commands
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -68,7 +105,7 @@ fn require_non_empty(value: &str, field: &str) -> Result<(), String> {
|
||||
/// On macOS it is stored in the system Keychain. The write is read back before
|
||||
/// this returns — see [`crate::secret_store`] for what happens when it does not
|
||||
/// come back.
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn save_credential(
|
||||
app: AppHandle,
|
||||
host: String,
|
||||
@@ -76,37 +113,41 @@ pub fn save_credential(
|
||||
token: String,
|
||||
password: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
require_non_empty(&host, "host")?;
|
||||
require_non_empty(&token, "token")?;
|
||||
require_non_empty(&username, "username")?;
|
||||
with_credential_lock(|| {
|
||||
require_non_empty(&host, "host")?;
|
||||
require_non_empty(&token, "token")?;
|
||||
require_non_empty(&username, "username")?;
|
||||
|
||||
let mut payload = serde_json::json!({
|
||||
"username": username,
|
||||
"token": token,
|
||||
});
|
||||
if let Some(ref pw) = password {
|
||||
payload["password"] = serde_json::Value::String(pw.clone());
|
||||
}
|
||||
let mut payload = serde_json::json!({
|
||||
"username": username,
|
||||
"token": token,
|
||||
});
|
||||
if let Some(ref pw) = password {
|
||||
payload["password"] = serde_json::Value::String(pw.clone());
|
||||
}
|
||||
|
||||
secret_store::set(&app, &login_account(&host), &payload.to_string())
|
||||
.map_err(|e| format!("save_credential failed: {e}"))?;
|
||||
Ok(())
|
||||
secret_store::set(&app, &login_account(&host), &payload.to_string())
|
||||
.map_err(|e| format!("save_credential failed: {e}"))?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Load a credential from the system credential store.
|
||||
///
|
||||
/// Returns `None` when no credential exists for the given host.
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn load_credential(app: AppHandle, host: String) -> Result<Option<CredentialData>, String> {
|
||||
require_non_empty(&host, "host")?;
|
||||
with_credential_lock(|| {
|
||||
require_non_empty(&host, "host")?;
|
||||
|
||||
let Some(json_str) = secret_store::get(&app, &login_account(&host))
|
||||
.map_err(|e| format!("load_credential failed: {e}"))?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(json_str) = secret_store::get(&app, &login_account(&host))
|
||||
.map_err(|e| format!("load_credential failed: {e}"))?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
parse_credential_blob(&json_str).map(Some)
|
||||
parse_credential_blob(&json_str).map(Some)
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the stored credential JSON blob.
|
||||
@@ -142,11 +183,13 @@ fn parse_credential_blob(json_str: &str) -> Result<CredentialData, String> {
|
||||
/// Delete a credential from the system credential store.
|
||||
///
|
||||
/// Deleting a non-existent credential is not treated as an error.
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn delete_credential(app: AppHandle, host: String) -> Result<(), String> {
|
||||
require_non_empty(&host, "host")?;
|
||||
secret_store::delete(&app, &login_account(&host))
|
||||
.map_err(|e| format!("delete_credential failed: {e}"))
|
||||
with_credential_lock(|| {
|
||||
require_non_empty(&host, "host")?;
|
||||
secret_store::delete(&app, &login_account(&host))
|
||||
.map_err(|e| format!("delete_credential failed: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -165,34 +208,40 @@ pub fn delete_credential(app: AppHandle, host: String) -> Result<(), String> {
|
||||
/// file (DPAPI on Windows, sealed per-install key elsewhere); if that is also
|
||||
/// unavailable this returns an error rather than reporting a success that would
|
||||
/// leave peers rejecting the user's voice announce after a restart.
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn save_identity_key(app: AppHandle, host: String, key: String) -> Result<(), String> {
|
||||
require_non_empty(&host, "host")?;
|
||||
require_non_empty(&key, "key")?;
|
||||
with_credential_lock(|| {
|
||||
require_non_empty(&host, "host")?;
|
||||
require_non_empty(&key, "key")?;
|
||||
|
||||
secret_store::set(&app, &identity_account(&host), &key)
|
||||
.map_err(|e| format!("save_identity_key failed: {e}"))?;
|
||||
Ok(())
|
||||
secret_store::set(&app, &identity_account(&host), &key)
|
||||
.map_err(|e| format!("save_identity_key failed: {e}"))?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Load the identity private key for `host`.
|
||||
///
|
||||
/// Returns `None` when no identity key exists for the given host.
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn load_identity_key(app: AppHandle, host: String) -> Result<Option<String>, String> {
|
||||
require_non_empty(&host, "host")?;
|
||||
secret_store::get(&app, &identity_account(&host))
|
||||
.map_err(|e| format!("load_identity_key failed: {e}"))
|
||||
with_credential_lock(|| {
|
||||
require_non_empty(&host, "host")?;
|
||||
secret_store::get(&app, &identity_account(&host))
|
||||
.map_err(|e| format!("load_identity_key failed: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete the identity private key for `host`.
|
||||
///
|
||||
/// Deleting a non-existent key is not treated as an error.
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn delete_identity_key(app: AppHandle, host: String) -> Result<(), String> {
|
||||
require_non_empty(&host, "host")?;
|
||||
secret_store::delete(&app, &identity_account(&host))
|
||||
.map_err(|e| format!("delete_identity_key failed: {e}"))
|
||||
with_credential_lock(|| {
|
||||
require_non_empty(&host, "host")?;
|
||||
secret_store::delete(&app, &identity_account(&host))
|
||||
.map_err(|e| format!("delete_identity_key failed: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -217,44 +266,46 @@ pub struct CredentialStoreProbe {
|
||||
/// announce: it distinguishes "the credential store is fine" from "writes are
|
||||
/// accepted and dropped" without touching any real credential. The probe
|
||||
/// account is removed again whatever the outcome.
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn probe_credential_store(app: AppHandle) -> CredentialStoreProbe {
|
||||
// Underscores are not legal in DNS hostnames, so this cannot collide with a
|
||||
// real `{host}` or `identity:{host}` account.
|
||||
const PROBE_ACCOUNT: &str = "__diagnostic_probe__";
|
||||
const PROBE_SECRET: &str = "owncord-credential-store-probe";
|
||||
with_credential_lock(|| {
|
||||
// Underscores are not legal in DNS hostnames, so this cannot collide
|
||||
// with a real `{host}` or `identity:{host}` account.
|
||||
const PROBE_ACCOUNT: &str = "__diagnostic_probe__";
|
||||
const PROBE_SECRET: &str = "owncord-credential-store-probe";
|
||||
|
||||
let result = secret_store::set(&app, PROBE_ACCOUNT, PROBE_SECRET).and_then(|backend| {
|
||||
match secret_store::get(&app, PROBE_ACCOUNT)? {
|
||||
Some(ref got) if got == PROBE_SECRET => Ok(backend),
|
||||
Some(_) => Err("read back a different value than was written".into()),
|
||||
None => Err("the store reported a successful write but returned no entry".into()),
|
||||
let result = secret_store::set(&app, PROBE_ACCOUNT, PROBE_SECRET).and_then(|backend| {
|
||||
match secret_store::get(&app, PROBE_ACCOUNT)? {
|
||||
Some(ref got) if got == PROBE_SECRET => Ok(backend),
|
||||
Some(_) => Err("read back a different value than was written".into()),
|
||||
None => Err("the store reported a successful write but returned no entry".into()),
|
||||
}
|
||||
});
|
||||
|
||||
// Always clean up, including when the probe failed part-way through.
|
||||
if let Err(e) = secret_store::delete(&app, PROBE_ACCOUNT) {
|
||||
log::warn!("failed to remove credential store probe entry: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
// Always clean up, including when the probe failed part-way through.
|
||||
if let Err(e) = secret_store::delete(&app, PROBE_ACCOUNT) {
|
||||
log::warn!("failed to remove credential store probe entry: {e}");
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(backend) => {
|
||||
log::info!("credential store probe succeeded (backend: {backend:?})");
|
||||
CredentialStoreProbe {
|
||||
ok: true,
|
||||
backend: Some(backend),
|
||||
error: None,
|
||||
match result {
|
||||
Ok(backend) => {
|
||||
log::info!("credential store probe succeeded (backend: {backend:?})");
|
||||
CredentialStoreProbe {
|
||||
ok: true,
|
||||
backend: Some(backend),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("credential store probe failed: {e}");
|
||||
CredentialStoreProbe {
|
||||
ok: false,
|
||||
backend: None,
|
||||
error: Some(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("credential store probe failed: {e}");
|
||||
CredentialStoreProbe {
|
||||
ok: false,
|
||||
backend: None,
|
||||
error: Some(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -357,4 +408,50 @@ mod tests {
|
||||
assert!(!json.contains("password"));
|
||||
assert!(!json.contains("pw"));
|
||||
}
|
||||
|
||||
/// B4-3 follow-up: all 7 commands moved to `#[tauri::command(async)]`,
|
||||
/// which runs each invocation on the async runtime's thread pool instead
|
||||
/// of Tauri's single IPC main thread. Two overlapping invocations (e.g.
|
||||
/// `identity.ts`'s save-then-delete legacy-key migration, or a logout's
|
||||
/// `delete_credential` racing a connect-page auto-login's
|
||||
/// `load_credential` for the same host) can now genuinely run
|
||||
/// concurrently. `with_credential_lock` must serialize them: this proves
|
||||
/// no two holders of the lock ever run their critical section at the
|
||||
/// same time, regardless of which OS thread the runtime schedules them
|
||||
/// on.
|
||||
#[test]
|
||||
fn credential_lock_serializes_overlapping_commands() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
let concurrent = Arc::new(AtomicUsize::new(0));
|
||||
let max_concurrent = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let handles: Vec<_> = (0..8)
|
||||
.map(|_| {
|
||||
let concurrent = Arc::clone(&concurrent);
|
||||
let max_concurrent = Arc::clone(&max_concurrent);
|
||||
thread::spawn(move || {
|
||||
with_credential_lock(|| {
|
||||
let now = concurrent.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
max_concurrent.fetch_max(now, Ordering::SeqCst);
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
concurrent.fetch_sub(1, Ordering::SeqCst);
|
||||
});
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
for h in handles {
|
||||
h.join().unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
max_concurrent.load(Ordering::SeqCst),
|
||||
1,
|
||||
"two credential-store commands ran their critical section concurrently"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ use log::{debug, error, info, warn};
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use rustls::pki_types::ServerName;
|
||||
use tauri::Runtime;
|
||||
use tauri::{Manager, Runtime};
|
||||
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::Mutex;
|
||||
@@ -65,6 +65,21 @@ impl LiveKitProxyState {
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the running-proxy state, but only if it still points at `port`.
|
||||
/// Mirrors HttpProxyState::remove_if_port_matches; used by run_proxy_loop's
|
||||
/// accept-error exit path so a dead listener doesn't keep being handed
|
||||
/// back by start_livekit_proxy's reuse branch, and doesn't race a newer
|
||||
/// proxy that may have already replaced it.
|
||||
async fn clear_if_port_matches(&self, port: u16) {
|
||||
let mut inner = self.inner.lock().await;
|
||||
if inner.port == Some(port) {
|
||||
inner.port = None;
|
||||
inner.remote_host.clear();
|
||||
inner.pinned_fingerprint.clear();
|
||||
inner.shutdown_tx = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -224,7 +239,14 @@ pub async fn start_livekit_proxy<R: Runtime>(
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let host = remote_host.clone();
|
||||
let loop_handle = tokio::spawn(run_proxy_loop(listener, host, fingerprint.clone(), shutdown_rx));
|
||||
let loop_handle = tokio::spawn(run_proxy_loop(
|
||||
app.clone(),
|
||||
listener,
|
||||
host,
|
||||
port,
|
||||
fingerprint.clone(),
|
||||
shutdown_rx,
|
||||
));
|
||||
// Watch the loop so a panic is logged instead of vanishing silently.
|
||||
tokio::spawn(async move {
|
||||
match loop_handle.await {
|
||||
@@ -266,9 +288,11 @@ pub async fn stop_livekit_proxy(
|
||||
/// Maximum consecutive accept errors before the proxy loop exits.
|
||||
const MAX_CONSECUTIVE_ACCEPT_ERRORS: u32 = 5;
|
||||
|
||||
async fn run_proxy_loop(
|
||||
async fn run_proxy_loop<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
listener: TcpListener,
|
||||
remote_host: String,
|
||||
port: u16,
|
||||
pinned_fingerprint: String,
|
||||
mut shutdown_rx: tokio::sync::oneshot::Receiver<()>,
|
||||
) {
|
||||
@@ -300,6 +324,20 @@ async fn run_proxy_loop(
|
||||
"[livekit_proxy] {} consecutive accept errors, stopping proxy loop",
|
||||
MAX_CONSECUTIVE_ACCEPT_ERRORS
|
||||
);
|
||||
// Deregister the dead proxy BEFORE the break drops
|
||||
// `listener`, so a future start_livekit_proxy
|
||||
// rebinds a fresh port instead of handing back
|
||||
// this closed one forever (the reuse branch keys
|
||||
// only on host+pin, not liveness). Mirrors
|
||||
// http_proxy.rs's identical fix.
|
||||
if let Some(state) = app.try_state::<LiveKitProxyState>() {
|
||||
state.clear_if_port_matches(port).await;
|
||||
} else {
|
||||
warn!(
|
||||
"[livekit_proxy] state unmanaged; cannot deregister dead proxy for {}",
|
||||
remote_host
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -677,4 +715,42 @@ mod tests {
|
||||
"a silent peer must produce an error, not a usable TLS stream"
|
||||
);
|
||||
}
|
||||
|
||||
// ── LiveKitProxyState::clear_if_port_matches ────────────────────────────
|
||||
//
|
||||
// B4_conn_ipc-7: run_proxy_loop's accept-error exit path drops the
|
||||
// listener without deregistering it, so ProxyInner.port stays set and
|
||||
// start_livekit_proxy's reuse branch (unchanged host+pin) hands the dead
|
||||
// port back forever. Mirrors http_proxy.rs's
|
||||
// remove_if_port_matches_removes_only_matching_entry test.
|
||||
|
||||
#[tokio::test]
|
||||
async fn clear_if_port_matches_clears_only_a_matching_entry() {
|
||||
let state = LiveKitProxyState::new();
|
||||
{
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let mut inner = state.inner.lock().await;
|
||||
inner.port = Some(4242);
|
||||
inner.remote_host = "example.com:8443".to_string();
|
||||
inner.pinned_fingerprint = "aa:bb".to_string();
|
||||
inner.shutdown_tx = Some(tx);
|
||||
}
|
||||
|
||||
// A stale loop reporting a port that no longer matches the live
|
||||
// listener must leave the current entry alone.
|
||||
state.clear_if_port_matches(9999).await;
|
||||
assert_eq!(
|
||||
state.inner.lock().await.port,
|
||||
Some(4242),
|
||||
"mismatched port must not clear a newer proxy's state"
|
||||
);
|
||||
|
||||
// A loop reporting its own still-current port must clear it so the
|
||||
// next start_livekit_proxy rebinds instead of reusing the dead listener.
|
||||
state.clear_if_port_matches(4242).await;
|
||||
let inner = state.inner.lock().await;
|
||||
assert_eq!(inner.port, None, "matching port must deregister the dead proxy");
|
||||
assert!(inner.remote_host.is_empty());
|
||||
assert!(inner.pinned_fingerprint.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,8 +283,13 @@ impl rustls::client::danger::ServerCertVerifier for HostScopedVerifier {
|
||||
/// Cert-store key for a host. Strips a default `:443` so the ws proxy (which
|
||||
/// keys off `wss://host` with no explicit 443) and the http/livekit proxies
|
||||
/// (which see `host:443`) resolve the SAME pin. Non-default ports are kept.
|
||||
/// Case-folded (DNS names are case-insensitive): the host reaches this from
|
||||
/// several places (a profile-entered host verbatim, a `wss://` URL, a URL
|
||||
/// parsed on the TS side, which lowercases) — without folding case here, two
|
||||
/// callers with the same server in different case would pin/read different
|
||||
/// entries, opening a second, unpinned proxy tunnel.
|
||||
pub(crate) fn cert_store_key(host: &str) -> String {
|
||||
host.strip_suffix(":443").unwrap_or(host).to_string()
|
||||
host.strip_suffix(":443").unwrap_or(host).to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// Extract the host (with any non-default port) from a `wss://` URL.
|
||||
@@ -390,6 +395,20 @@ mod tests {
|
||||
assert_eq!(cert_store_key("example.com:8443"), "example.com:8443");
|
||||
}
|
||||
|
||||
// DNS names are case-insensitive, but a raw host string (a profile-entered
|
||||
// host, or one taken verbatim from a wss:// URL) is not normalized before
|
||||
// reaching here. Two call sites can derive the SAME host in different
|
||||
// case (e.g. login uses the host as typed, an attachment fetch resolves
|
||||
// it through URL parsing, which lowercases) — without folding case here,
|
||||
// they pin/read two different cert-store entries for the same server,
|
||||
// opening a second, unpinned proxy tunnel.
|
||||
#[test]
|
||||
fn cert_store_key_folds_case() {
|
||||
assert_eq!(cert_store_key("Example.COM"), "example.com");
|
||||
assert_eq!(cert_store_key("MyServer.LAN:8443"), "myserver.lan:8443");
|
||||
assert_eq!(cert_store_key("Example.COM:443"), "example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_host_variants() {
|
||||
assert_eq!(extract_host("wss://example.com/chat"), "example.com");
|
||||
|
||||
@@ -335,8 +335,13 @@ pub async fn ws_send(
|
||||
/// Disconnect the proxy WebSocket.
|
||||
#[tauri::command]
|
||||
pub async fn ws_disconnect(state: tauri::State<'_, WsState>) -> Result<(), String> {
|
||||
let mut tx_lock = state.tx.lock().await;
|
||||
*tx_lock = None; // dropping the sender closes the channel → write task ends
|
||||
// begin_connection() both clears the sender slot (dropping it closes the
|
||||
// channel so the write task ends) AND bumps the generation counter, so a
|
||||
// handshake still pending from before this disconnect fails install_sender
|
||||
// instead of installing itself afterward — reusing the same invalidation
|
||||
// path a superseding connect() already has. The returned generation is
|
||||
// unused: nothing will ever install under it.
|
||||
state.begin_connection().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -589,4 +594,30 @@ mod tests {
|
||||
assert_eq!(got, None, "rx.recv() must yield None so the write task exits");
|
||||
}
|
||||
|
||||
// B4_conn_ipc-9: ws_disconnect must invalidate an in-flight ws_connect
|
||||
// attempt, not just null the sender slot. A handshake can pend for up to
|
||||
// CONNECT_TIMEOUT (10s) past a disconnect (JS calls connect fire-and-
|
||||
// forget — logout during "connecting" is a real interleaving), and
|
||||
// install_sender checks generation alone, so a manual `*tx_lock = None`
|
||||
// leaves a "cancelled" connection free to install itself afterward and
|
||||
// spawn its worker tasks against a socket JS believes closed.
|
||||
#[tokio::test]
|
||||
async fn disconnect_invalidates_an_in_flight_connect_attempt() {
|
||||
let state = WsState::new();
|
||||
// A's handshake is in flight: generation claimed, sender not yet
|
||||
// installed (mirrors the pending window before install_sender runs).
|
||||
let gen_a = state.begin_connection().await;
|
||||
|
||||
// ws_disconnect fires while A is still mid-handshake — this is
|
||||
// ws_disconnect's real body (state.begin_connection().await).
|
||||
state.begin_connection().await;
|
||||
|
||||
// A's handshake finally completes and tries to install its sender.
|
||||
// It must be rejected: JS already believes the connection is closed.
|
||||
let (tx_a, _rx_a) = mpsc::channel::<String>(4);
|
||||
assert!(
|
||||
!state.install_sender(gen_a, tx_a).await,
|
||||
"a handshake pending during disconnect must not be able to install after it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
],
|
||||
"withGlobalTauri": false,
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://ipc.localhost https: wss: http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:*; img-src 'self' https: data:; media-src 'self' blob:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-src https://www.youtube.com https://youtube.com; worker-src 'self' blob:"
|
||||
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://ipc.localhost https: wss: http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:*; img-src 'self' blob: https: data:; media-src 'self' blob:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-src https://www.youtube.com https://youtube.com; worker-src 'self' blob:"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
@@ -505,6 +505,11 @@ function renderVoiceChannelItem(
|
||||
// Don't trigger if the right-click menu is open
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
// Watching a stream needs a live LiveKit room -- join first, same
|
||||
// as the hover/focus preview's placeholder click below.
|
||||
if (voiceStore.getState().currentChannelId !== channel.id) {
|
||||
onVoiceJoin(channel.id);
|
||||
}
|
||||
const tileId = user.screenshare
|
||||
? user.userId + SCREENSHARE_TILE_ID_OFFSET
|
||||
: user.userId;
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
import { createElement, appendChildren, setText } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import type { UserStatus } from "@lib/types";
|
||||
import { isSafeUrl } from "./message-list/attachments";
|
||||
import { isRenderableAvatar } from "@lib/avatar";
|
||||
import { fetchImageAsDataUrl, resolveServerUrl } from "./message-list/attachments";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -146,22 +147,31 @@ export function createDmProfileSidebar(
|
||||
wrapper.style.position = "relative";
|
||||
wrapper.style.flexShrink = "0";
|
||||
|
||||
if (user.avatar !== null && user.avatar.length > 0 && isSafeUrl(user.avatar)) {
|
||||
wrapper.style.background = "transparent";
|
||||
const img = createElement("img", {
|
||||
src: user.avatar,
|
||||
alt: user.username,
|
||||
class: "dps-avatar-img",
|
||||
// The letter draws immediately; the picture (if any) is fetched through
|
||||
// the same cert-pinned, bearer-token path attachments use and swapped in
|
||||
// once the bytes arrive. `<img src>` cannot carry the auth header an
|
||||
// `/api/v1/files/{id}` avatar needs, so the URL is never assigned raw.
|
||||
wrapper.style.background = "var(--accent, #5865f2)";
|
||||
const initial = user.username.charAt(0).toUpperCase() || "?";
|
||||
const letter = createElement("span", {}, initial);
|
||||
wrapper.appendChild(letter);
|
||||
|
||||
if (isRenderableAvatar(user.avatar)) {
|
||||
const resolved = resolveServerUrl(user.avatar);
|
||||
void fetchImageAsDataUrl(resolved).then((dataUrl) => {
|
||||
if (dataUrl === null || !wrapper.isConnected) return;
|
||||
const img = createElement("img", {
|
||||
src: dataUrl,
|
||||
alt: user.username,
|
||||
class: "dps-avatar-img",
|
||||
});
|
||||
img.style.width = "80px";
|
||||
img.style.height = "80px";
|
||||
img.style.borderRadius = "50%";
|
||||
letter.remove();
|
||||
wrapper.style.background = "transparent";
|
||||
wrapper.insertBefore(img, wrapper.firstChild);
|
||||
});
|
||||
img.style.width = "80px";
|
||||
img.style.height = "80px";
|
||||
img.style.borderRadius = "50%";
|
||||
wrapper.appendChild(img);
|
||||
} else {
|
||||
wrapper.style.background = "var(--accent, #5865f2)";
|
||||
const initial = user.username.charAt(0).toUpperCase() || "?";
|
||||
const text = createElement("span", {}, initial);
|
||||
wrapper.appendChild(text);
|
||||
}
|
||||
|
||||
// Status dot overlay
|
||||
|
||||
@@ -16,7 +16,8 @@ import { createElement, setText, appendChildren } from "@lib/dom";
|
||||
import { createIcon } from "@lib/icons";
|
||||
import { showContextMenu } from "@lib/context-menu";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import { isSafeUrl } from "./message-list/attachments";
|
||||
import { isRenderableAvatar } from "@lib/avatar";
|
||||
import { fetchImageAsDataUrl, resolveServerUrl } from "./message-list/attachments";
|
||||
|
||||
/** One member of a group DM, as far as the sidebar needs to draw them. */
|
||||
export interface DmParticipant {
|
||||
@@ -74,17 +75,26 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
offline: "var(--text-micro)",
|
||||
};
|
||||
|
||||
/** Fill one avatar circle: the picture if it is safe to load, else the letter. */
|
||||
/**
|
||||
* Fill one avatar circle: the letter immediately, the picture swapped in once
|
||||
* fetched. `<img src>` cannot carry the bearer token an authenticated
|
||||
* `/api/v1/files/{id}` avatar needs, so the URL is always fetched through the
|
||||
* same cert-pinned path attachments and custom emoji use rather than assigned
|
||||
* directly.
|
||||
*/
|
||||
function paintAvatar(el: HTMLElement, avatar: string | null, label: string): void {
|
||||
if (avatar !== null && isSafeUrl(avatar)) {
|
||||
const img = createElement("img", { src: avatar, alt: label });
|
||||
setText(el, label.charAt(0).toUpperCase());
|
||||
if (!isRenderableAvatar(avatar)) return;
|
||||
const resolved = resolveServerUrl(avatar);
|
||||
void fetchImageAsDataUrl(resolved).then((dataUrl) => {
|
||||
if (dataUrl === null || !el.isConnected) return;
|
||||
const img = createElement("img", { src: dataUrl, alt: label });
|
||||
img.style.width = "100%";
|
||||
img.style.height = "100%";
|
||||
img.style.borderRadius = "50%";
|
||||
el.textContent = "";
|
||||
el.appendChild(img);
|
||||
return;
|
||||
}
|
||||
setText(el, label.charAt(0).toUpperCase());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -318,6 +328,18 @@ export function createDmSidebar(options: DmSidebarOptions): MountableComponent {
|
||||
|
||||
const items = sorted.map((convo) => renderDmItem(convo, options, ac.signal));
|
||||
|
||||
searchInput.addEventListener(
|
||||
"input",
|
||||
() => {
|
||||
const q = searchInput.value.trim().toLowerCase();
|
||||
items.forEach((el, i) => {
|
||||
const match = q === "" || sorted[i]!.username.toLowerCase().includes(q);
|
||||
el.style.display = match ? "" : "none";
|
||||
});
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
appendChildren(root, header, sectionLabel, ...items);
|
||||
container.appendChild(root);
|
||||
}
|
||||
|
||||
@@ -272,7 +272,11 @@ function createMemberItem(
|
||||
|
||||
// Moderation actions are permission-gated per item (a role name told us
|
||||
// nothing about what its bits allow); block/unblock is open to everyone.
|
||||
const gates = moderationGates(opts.currentUserRole);
|
||||
// The role name is read live from authStore, not the opts snapshot
|
||||
// taken once at mount -- dispatcher.ts keeps authStore.user.role
|
||||
// current on every self MEMBER_UPDATE precisely so gates like this one
|
||||
// see a promotion/demotion without waiting for the sidebar to rebuild.
|
||||
const gates = moderationGates(authStore.getState().user?.role ?? opts.currentUserRole);
|
||||
const showAdminActions = gates.canKick || gates.canBan || gates.canManageRoles;
|
||||
|
||||
closeActiveMenu();
|
||||
|
||||
@@ -60,6 +60,10 @@ export function filterMentionSuggestions(query: string): MentionSuggestion[] {
|
||||
const substring: MentionSuggestion[] = [];
|
||||
|
||||
for (const member of membersStore.getState().members.values()) {
|
||||
// Skip usernames the mention grammar cannot express (a space, an "@",
|
||||
// etc. truncate the token on insert) -- picking one would insert a dead
|
||||
// token that resolves to no mention and notifies nobody.
|
||||
if (!/^[\p{L}\p{N}_.-]{1,64}$/u.test(member.username)) continue;
|
||||
const lower = member.username.toLowerCase();
|
||||
if (q !== "" && !lower.includes(q)) continue;
|
||||
const entry: MentionSuggestion = {
|
||||
|
||||
@@ -418,10 +418,21 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
},
|
||||
message,
|
||||
);
|
||||
// app.css only shows the preview bar via .visible -- without this an
|
||||
// error with no attachments already queued renders into a display:none
|
||||
// container and is never seen.
|
||||
attachmentPreviewBar.classList.add("visible");
|
||||
attachmentPreviewBar.appendChild(errEl);
|
||||
const t = setTimeout(() => {
|
||||
activeTimers.delete(t);
|
||||
errEl.remove();
|
||||
if (
|
||||
attachmentPreviewBar !== null &&
|
||||
pendingAttachments.length === 0 &&
|
||||
attachmentPreviewBar.childElementCount === 0
|
||||
) {
|
||||
attachmentPreviewBar.classList.remove("visible");
|
||||
}
|
||||
}, 4000);
|
||||
activeTimers.add(t);
|
||||
}
|
||||
@@ -522,6 +533,14 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
async function handlePasteFile(file: File): Promise<void> {
|
||||
if (options.onUploadFile === undefined || attachmentPreviewBar === null) return;
|
||||
|
||||
// Attachments queued during an edit are neither sent (the edit branch
|
||||
// never reads pendingAttachments) nor cleared -- they'd silently ride
|
||||
// along with the next ordinary message. Refuse at the single entry point.
|
||||
if (state.editing !== null) {
|
||||
showUploadError("Can't attach files while editing a message");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
showUploadError(`File too large: ${file.name} exceeds 100 MB limit`);
|
||||
@@ -615,7 +634,9 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
}
|
||||
|
||||
function setReplyTo(messageId: number, username: string): void {
|
||||
if (state.editing !== null) hideEditBar();
|
||||
// cancelEdit also clears the textarea -- without it the stale edit text
|
||||
// survives into reply mode and Enter reposts it as a duplicate.
|
||||
if (state.editing !== null) cancelEdit();
|
||||
state = { replyTo: { messageId, username }, editing: null };
|
||||
showReplyBar(username);
|
||||
textarea?.focus();
|
||||
|
||||
@@ -158,7 +158,11 @@ function buildVirtualItems(
|
||||
// A message directly under the NEW line starts a fresh block: rendering it
|
||||
// as a grouped continuation of a message from before the line hides both
|
||||
// its author and the fact that the line is there.
|
||||
const isGrouped = !isFirstUnread && prevMsg !== null && shouldGroup(prevMsg, msg);
|
||||
const isGrouped =
|
||||
!isFirstUnread &&
|
||||
prevMsg !== null &&
|
||||
isSameDay(prevMsg.timestamp, msg.timestamp) &&
|
||||
shouldGroup(prevMsg, msg);
|
||||
items.push({ kind: "message", message: msg, isGrouped });
|
||||
lastTimestamp = msg.timestamp;
|
||||
prevMsg = msg;
|
||||
@@ -710,14 +714,22 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let loadingOlder = false;
|
||||
let prevMessageCount = 0;
|
||||
// The oldest loaded message's id, not the count: a live tail append also
|
||||
// changes the count while a history fetch is still in flight, and
|
||||
// resetting the latch on that lets the next scroll refire loadOlderMessages
|
||||
// with the same unchanged cursor -- the same page then lands twice. Only a
|
||||
// prepend moves messages[0]. Seeded from the current state (not left at a
|
||||
// placeholder) so the first change observed after construction is compared
|
||||
// against reality, not an arbitrary initial value.
|
||||
let prevOldestId: number | null = getChannelMessages(options.channelId)[0]?.id ?? null;
|
||||
|
||||
const unsubLoadingReset = messagesStore.subscribeSelector(
|
||||
(s) => s.messagesByChannel,
|
||||
() => {
|
||||
const msgs = getChannelMessages(options.channelId);
|
||||
if (msgs.length !== prevMessageCount) {
|
||||
prevMessageCount = msgs.length;
|
||||
const oldestId = msgs.length > 0 ? msgs[0]!.id : null;
|
||||
if (oldestId !== prevOldestId) {
|
||||
prevOldestId = oldestId;
|
||||
loadingOlder = false;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -90,6 +90,10 @@ export function createUpdateNotifier(options: UpdateNotifierOptions): MountableC
|
||||
// App will relaunch — this code won't execute after relaunch()
|
||||
} catch (err) {
|
||||
log.error("Update install failed", { error: String(err) });
|
||||
// The component may have been destroyed while the download was in
|
||||
// flight (page swap / logout) -- the banner it wanted to repaint is
|
||||
// already gone, so there is nothing left to do.
|
||||
if (banner === null) return;
|
||||
while (banner.firstChild) banner.removeChild(banner.firstChild);
|
||||
const errorText = createElement(
|
||||
"span",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createIcon } from "@lib/icons";
|
||||
import {
|
||||
getScreenshareAudioMuted,
|
||||
getScreenshareAudioVolume,
|
||||
getUserVolume,
|
||||
muteScreenshareAudio,
|
||||
setScreenshareAudioVolume,
|
||||
setUserVolume,
|
||||
@@ -26,8 +27,11 @@ export interface TileConfig {
|
||||
export interface VideoGridComponent extends MountableComponent {
|
||||
addStream(userId: number, username: string, stream: MediaStream, config?: TileConfig): void;
|
||||
removeStream(userId: number): void;
|
||||
/** Remove every tile — used on a real voice leave so stale remote tiles
|
||||
* from the previous session don't survive into the next join. */
|
||||
clearStreams(): void;
|
||||
hasStreams(): boolean;
|
||||
setFocusedTile(tileId: number): void;
|
||||
setFocusedTile(tileId: number | null): void;
|
||||
getFocusedTileId(): number | null;
|
||||
}
|
||||
|
||||
@@ -224,7 +228,7 @@ export function createVideoGrid(): VideoGridComponent {
|
||||
}
|
||||
}
|
||||
|
||||
function setFocusedTile(tileId: number): void {
|
||||
function setFocusedTile(tileId: number | null): void {
|
||||
focusedTileId = tileId;
|
||||
rebuildFocusLayout();
|
||||
}
|
||||
@@ -306,13 +310,18 @@ export function createVideoGrid(): VideoGridComponent {
|
||||
|
||||
// Add audio control overlay for remote tiles
|
||||
if (config !== undefined && !config.isSelf) {
|
||||
// Screenshare audio state survives tile rebuilds — initialize from it.
|
||||
// Screenshare sliders are 0-100 (HTMLAudioElement.volume caps at 1.0);
|
||||
// mic sliders keep 0-200 (LiveKit setVolume supports boost up to 2.0).
|
||||
let muted = config.isScreenshare ? getScreenshareAudioMuted(config.audioUserId) : false;
|
||||
let currentVolume = config.isScreenshare
|
||||
// Mic and screenshare audio state both survive tile rebuilds —
|
||||
// initialize from the same persisted values the sidebar volume menu
|
||||
// reads, instead of hardcoding "unmuted at 100%" (B3-5). Screenshare
|
||||
// sliders are 0-100 (HTMLAudioElement.volume caps at 1.0); mic sliders
|
||||
// keep 0-200 (LiveKit setVolume supports boost up to 2.0).
|
||||
const savedVolume = config.isScreenshare
|
||||
? Math.round(getScreenshareAudioVolume(config.audioUserId) * 100)
|
||||
: 100;
|
||||
: getUserVolume(config.audioUserId);
|
||||
let currentVolume = savedVolume;
|
||||
let muted = config.isScreenshare
|
||||
? getScreenshareAudioMuted(config.audioUserId)
|
||||
: savedVolume === 0;
|
||||
|
||||
const overlay = createElement("div", { class: "video-tile-overlay" });
|
||||
|
||||
@@ -421,6 +430,15 @@ export function createVideoGrid(): VideoGridComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove every tile (trackCleanup + srcObject=null via removeStream).
|
||||
* Deleting the current key mid-iteration is well-defined for Map — no
|
||||
* entries are skipped — so this needs no snapshot copy of the keys. */
|
||||
function clearStreams(): void {
|
||||
for (const userId of cells.keys()) {
|
||||
removeStream(userId);
|
||||
}
|
||||
}
|
||||
|
||||
function hasStreams(): boolean {
|
||||
return cells.size > 0;
|
||||
}
|
||||
@@ -470,6 +488,7 @@ export function createVideoGrid(): VideoGridComponent {
|
||||
destroy,
|
||||
addStream,
|
||||
removeStream,
|
||||
clearStreams,
|
||||
hasStreams,
|
||||
setFocusedTile,
|
||||
getFocusedTileId: getFocusedTileIdFn,
|
||||
|
||||
@@ -185,7 +185,7 @@ export function attachChannelContextMenu(
|
||||
// Tie this bridge listener's own lifetime to menuAc so it does not
|
||||
// outlive the menu it belongs to — closeMenu (which aborts menuAc)
|
||||
// already fires far more often than the sidebar's own teardown.
|
||||
signal.addEventListener("abort", () => menuAc.abort(), { signal: menuAc.signal });
|
||||
signal.addEventListener("abort", closeMenu, { signal: menuAc.signal });
|
||||
// Defer so this click event doesn't immediately close it
|
||||
setTimeout(() => {
|
||||
if (menuAc.signal.aborted) return;
|
||||
|
||||
@@ -143,17 +143,23 @@ export function ensureGlobalDragListeners(owner: AbortSignal): void {
|
||||
...withoutDrag.slice(insertIdx),
|
||||
];
|
||||
|
||||
// Build reorder data and update store immediately
|
||||
// Build reorder data and update store immediately. Reassign the
|
||||
// group's own existing position slots, not a 0..n-1 range: the
|
||||
// server's position space is global, so a category can sit at
|
||||
// non-contiguous positions (interleaved with other categories), and
|
||||
// renumbering from 0 would stomp another category's slots.
|
||||
const slots = drag.channels.map((c) => c.position).sort((a, b) => a - b);
|
||||
const reorders: ChannelReorderData[] = [];
|
||||
for (let i = 0; i < reorderedIds.length; i++) {
|
||||
const id = reorderedIds[i];
|
||||
if (id === undefined) {
|
||||
const newPosition = slots[i];
|
||||
if (id === undefined || newPosition === undefined) {
|
||||
continue;
|
||||
}
|
||||
const ch = drag.channels.find((c) => c.id === id);
|
||||
if (ch !== undefined && ch.position !== i) {
|
||||
reorders.push({ channelId: id, newPosition: i });
|
||||
updateChannelPosition(id, i);
|
||||
if (ch !== undefined && ch.position !== newPosition) {
|
||||
reorders.push({ channelId: id, newPosition });
|
||||
updateChannelPosition(id, newPosition);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,11 +128,19 @@ export function showUserVolumeMenu(
|
||||
);
|
||||
}, 0);
|
||||
|
||||
// Also clean up if the parent component is destroyed
|
||||
signal.addEventListener("abort", () => {
|
||||
menu.remove();
|
||||
dismissAc.abort();
|
||||
});
|
||||
// Also clean up if the parent component is destroyed. Tied to dismissAc's
|
||||
// own signal (mirrors context-menu.ts's menuAc pattern) so this bridge
|
||||
// listener is torn down with the menu itself — otherwise it never runs
|
||||
// (the parent signal is long-lived) and every right-click permanently
|
||||
// accumulates one closure retaining a detached .user-vol-menu subtree.
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
menu.remove();
|
||||
dismissAc.abort();
|
||||
},
|
||||
{ signal: dismissAc.signal },
|
||||
);
|
||||
}
|
||||
|
||||
/** Builds the moderation rows. close() runs after any action so the menu does
|
||||
|
||||
@@ -346,6 +346,13 @@ export function renderInlineImage(url: string): HTMLDivElement {
|
||||
// properly remove document-level listeners from the previous instance.
|
||||
let activeLightboxClose: (() => void) | null = null;
|
||||
|
||||
/** Close the active lightbox, if any. Called on page teardown (logout, page
|
||||
* swap) so an open overlay doesn't survive onto the next page with live
|
||||
* document listeners and a revoked blob URL. */
|
||||
export function closeActiveLightbox(): void {
|
||||
activeLightboxClose?.();
|
||||
}
|
||||
|
||||
/** Open a full-screen lightbox overlay with zoom and pan. */
|
||||
export function openImageLightbox(src: string, alt: string): void {
|
||||
// Close any existing lightbox (including its document listeners)
|
||||
|
||||
@@ -12,6 +12,26 @@
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
/**
|
||||
* Elements the app hides via inline `style.display = "none"` (the codebase's
|
||||
* standard show/hide idiom — e.g. a group-name field revealed only once a
|
||||
* second member is picked) still match FOCUSABLE_SELECTOR: the selector is
|
||||
* structural, not a visibility check. A browser silently refuses to move
|
||||
* focus onto a display:none element, so treating one as the dialog's "first"
|
||||
* or "last" focusable leaves .focus() a no-op and the Tab trap comparing
|
||||
* against an edge focus never actually reached — Tab then falls through to
|
||||
* the browser's native order and can walk out of the dialog entirely.
|
||||
*/
|
||||
function isFocusable(el: HTMLElement): boolean {
|
||||
return el.style.display !== "none" && el.style.visibility !== "hidden";
|
||||
}
|
||||
|
||||
function queryFocusable(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||||
isFocusable,
|
||||
);
|
||||
}
|
||||
|
||||
export interface DialogSemanticsOptions {
|
||||
/** Accessible name for the dialog (aria-label). */
|
||||
readonly label?: string;
|
||||
@@ -45,7 +65,7 @@ export function trapFocus(container: HTMLElement, signal: AbortSignal): void {
|
||||
"keydown",
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key !== "Tab") return;
|
||||
const focusable = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
|
||||
const focusable = queryFocusable(container);
|
||||
if (focusable.length === 0) {
|
||||
// Nothing tabbable inside — keep focus on the container itself.
|
||||
e.preventDefault();
|
||||
@@ -143,7 +163,7 @@ export function enableRovingNavigation(
|
||||
*/
|
||||
export function focusDialog(container: HTMLElement): () => void {
|
||||
const previous = document.activeElement;
|
||||
const firstFocusable = container.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
|
||||
const firstFocusable = queryFocusable(container)[0];
|
||||
(firstFocusable ?? container).focus();
|
||||
return () => {
|
||||
if (previous instanceof HTMLElement && previous.isConnected) {
|
||||
|
||||
@@ -211,7 +211,20 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
|
||||
log.error("setConfig rejected invalid host", { host: newConfig.host });
|
||||
throw new Error("Invalid host format");
|
||||
}
|
||||
config = { ...config, ...newConfig };
|
||||
// Switching to a different host without an accompanying new token must
|
||||
// not carry the previous host's bearer token forward — otherwise the
|
||||
// login/register request to the new host rides a still-live session
|
||||
// token for the old one. Callers that only rotate the token (post-auth)
|
||||
// never pass `host`, so this never touches a same-host token refresh.
|
||||
if (
|
||||
newConfig.host !== undefined &&
|
||||
newConfig.host !== config.host &&
|
||||
newConfig.token === undefined
|
||||
) {
|
||||
config = { ...config, ...newConfig, token: undefined };
|
||||
} else {
|
||||
config = { ...config, ...newConfig };
|
||||
}
|
||||
},
|
||||
|
||||
/** Get current config (for debugging). Token is redacted. */
|
||||
|
||||
@@ -17,9 +17,49 @@ import { voiceStore } from "@stores/voice.store";
|
||||
|
||||
const log = createLogger("audioElements");
|
||||
|
||||
/** Get saved per-user volume (0-200 range, default 100). Applied via LiveKit's GainNode-backed setVolume(). */
|
||||
/**
|
||||
* Server host the per-user volume prefs below belong to. Mirrors
|
||||
* channel-mutes.ts's currentHost — the client is multi-server (one webview
|
||||
* origin means one localStorage) and userId is only unique per server, so
|
||||
* without a host component a volume set for user 7 on one server would
|
||||
* silence user 7 on every other server too. `setAudioVolumeHost` is always
|
||||
* called with a real host before any volume is read (see MainPage.ts), so
|
||||
* the `null` startup default is not what protects a pre-scoping install's
|
||||
* saved volumes — `getSavedUserVolume` does that below by reading through to
|
||||
* the original unscoped key on a miss at the scoped one.
|
||||
*/
|
||||
let currentHost: string | null = null;
|
||||
|
||||
/** Point per-user volume reads/writes at a specific server. Call on connect
|
||||
* and on server switch — mirroring channel-mutes.ts's setChannelMutesHost. */
|
||||
export function setAudioVolumeHost(host: string | null): void {
|
||||
currentHost = host;
|
||||
}
|
||||
|
||||
function userVolumeKey(userId: number): string {
|
||||
return currentHost === null ? `userVolume_${userId}` : `userVolume_${userId}:${currentHost}`;
|
||||
}
|
||||
|
||||
// setUserVolume always clamps to 0-200, so -1 is safe as a "nothing saved" sentinel.
|
||||
const VOLUME_NOT_SET = -1;
|
||||
|
||||
/** Get saved per-user volume (0-200 range, default 100). Applied via LiveKit's
|
||||
* GainNode-backed setVolume(). On a miss at the host-scoped key, reads
|
||||
* through to the pre-scoping unscoped key once and persists the result
|
||||
* under the scoped key so the read-through isn't repeated. */
|
||||
function getSavedUserVolume(userId: number): number {
|
||||
return loadPref<number>(`userVolume_${userId}`, 100);
|
||||
const scopedKey = userVolumeKey(userId);
|
||||
if (currentHost === null) return loadPref<number>(scopedKey, 100);
|
||||
|
||||
const scoped = loadPref<number>(scopedKey, VOLUME_NOT_SET);
|
||||
if (scoped !== VOLUME_NOT_SET) return scoped;
|
||||
|
||||
const legacy = loadPref<number>(`userVolume_${userId}`, VOLUME_NOT_SET);
|
||||
if (legacy !== VOLUME_NOT_SET) {
|
||||
savePref(scopedKey, legacy);
|
||||
return legacy;
|
||||
}
|
||||
return loadPref<number>(scopedKey, 100);
|
||||
}
|
||||
|
||||
export class AudioElements {
|
||||
@@ -185,7 +225,7 @@ export class AudioElements {
|
||||
|
||||
setUserVolume(userId: number, volume: number): void {
|
||||
const clamped = Math.max(0, Math.min(200, volume));
|
||||
savePref(`userVolume_${userId}`, clamped);
|
||||
savePref(userVolumeKey(userId), clamped);
|
||||
if (this.room !== null) {
|
||||
for (const participant of this.room.remoteParticipants.values()) {
|
||||
if (parseUserId(participant.identity) === userId) {
|
||||
|
||||
@@ -79,6 +79,10 @@ export class AudioPipeline {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- LocalTrack.setProcessor uses wide generic, but AudioProcessorOptions is guaranteed at runtime with webAudioMix
|
||||
await micPub.track.setProcessor(processor as any);
|
||||
log.info("RNNoise processor attached to mic track");
|
||||
// Rebuild so the gain/VAD chain sources from the processor's output and
|
||||
// its own sender.replaceTrack runs last, winning over setProcessor's
|
||||
// internal replaceTrack to the raw processed track (B3-1).
|
||||
this.setupAudioPipeline();
|
||||
}
|
||||
|
||||
/** Remove RNNoise processor from the local mic track. Safe to call if none attached. */
|
||||
@@ -89,6 +93,9 @@ export class AudioPipeline {
|
||||
if (micPub.track.getProcessor() === undefined) return;
|
||||
await micPub.track.stopProcessor();
|
||||
log.info("RNNoise processor removed from mic track");
|
||||
// Rebuild so the sender ends back on the gain/VAD chain over the raw mic,
|
||||
// not whatever track stopProcessor's own internals left wired (B3-1).
|
||||
this.setupAudioPipeline();
|
||||
}
|
||||
|
||||
// --- Pipeline setup/teardown ---
|
||||
@@ -101,7 +108,15 @@ export class AudioPipeline {
|
||||
if (micPub?.track === undefined) return;
|
||||
|
||||
try {
|
||||
const mediaTrack = micPub.track.mediaStreamTrack;
|
||||
// Source from the NS processor's output when one is attached, not the
|
||||
// raw mic track — livekit-client's LocalTrack.setProcessor() does its
|
||||
// own (internal, unawaited) sender.replaceTrack(processedTrack) once
|
||||
// the worklet loads, and that call lands AFTER this one (it awaits
|
||||
// addModule+fetch first). Sourcing from mediaStreamTrack unconditionally
|
||||
// meant that call always won, silently rewiring the sender straight to
|
||||
// the raw mic and bypassing this pipeline's gain/VAD entirely (B3-1).
|
||||
const mediaTrack =
|
||||
micPub.track.getProcessor()?.processedTrack ?? micPub.track.mediaStreamTrack;
|
||||
const ctx = new AudioContext({ sampleRate: 48000 });
|
||||
void ctx.resume(); // Ensure not suspended (WebView2 autoplay policy)
|
||||
|
||||
@@ -168,7 +183,11 @@ export class AudioPipeline {
|
||||
if (this.room !== null) {
|
||||
const micPub = this.room.localParticipant.getTrackPublication(Track.Source.Microphone);
|
||||
if (micPub?.track?.sender !== undefined) {
|
||||
const originalTrack = micPub.track.mediaStreamTrack;
|
||||
// Restore to the NS processor's output when one is still attached, not
|
||||
// the raw mic — otherwise tearing down just the gain/VAD wrapper (e.g.
|
||||
// muting) would also silently bypass an active noise suppressor (B3-1).
|
||||
const originalTrack =
|
||||
micPub.track.getProcessor()?.processedTrack ?? micPub.track.mediaStreamTrack;
|
||||
void micPub.track.sender
|
||||
.replaceTrack(originalTrack)
|
||||
.then(() => {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* and `notificationSounds` live in localStorage next to it.
|
||||
*/
|
||||
|
||||
import { loadPref, savePref } from "./preferences";
|
||||
import { loadPref, savePref, STORAGE_PREFIX } from "./preferences";
|
||||
|
||||
/** localStorage key (under the shared settings prefix). */
|
||||
const MUTED_KEY = "mutedChannels";
|
||||
@@ -28,9 +28,11 @@ const MUTED_KEY = "mutedChannels";
|
||||
* profiles keyed by host, all sharing one Tauri webview origin and therefore
|
||||
* one localStorage), and channel ids are per-server SQLite autoincrement
|
||||
* integers — without a host component in the key, muting channel 7 on one
|
||||
* server silently mutes channel 7 on every other server too. `null` (the
|
||||
* startup default, before any host is known) falls back to the original
|
||||
* unscoped key so a pre-scoping install's mutes are not orphaned.
|
||||
* server silently mutes channel 7 on every other server too. `setChannelMutesHost`
|
||||
* is always called with a real host before any mute is read (see MainPage.ts),
|
||||
* so the `null` startup default is not what protects a pre-scoping install's
|
||||
* saved mutes — `readMuted` does that below by reading through to the
|
||||
* original unscoped key on a miss at the scoped one.
|
||||
*/
|
||||
let currentHost: string | null = null;
|
||||
|
||||
@@ -60,9 +62,7 @@ export function setChannelMutesHost(host: string | null): void {
|
||||
invalidateMuteCache();
|
||||
}
|
||||
|
||||
function readMuted(): ReadonlySet<number> {
|
||||
if (cache !== null) return cache;
|
||||
const raw = loadPref<unknown[]>(mutedKey(), []);
|
||||
function parseMutedIds(raw: unknown): Set<number> {
|
||||
const ids = new Set<number>();
|
||||
if (Array.isArray(raw)) {
|
||||
for (const v of raw) {
|
||||
@@ -71,10 +71,40 @@ function readMuted(): ReadonlySet<number> {
|
||||
if (typeof v === "number" && Number.isInteger(v) && v > 0) ids.add(v);
|
||||
}
|
||||
}
|
||||
cache = ids;
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Whether a raw localStorage entry exists at all under `key` (prefixed) —
|
||||
* as opposed to `loadPref`'s fallback, which can't distinguish "absent" from
|
||||
* "present but happens to equal the fallback". An empty saved mute list is
|
||||
* real data (the user unmuted everything) and must not be treated as a miss. */
|
||||
function keyExists(key: string): boolean {
|
||||
return localStorage.getItem(STORAGE_PREFIX + key) !== null;
|
||||
}
|
||||
|
||||
function readMuted(): ReadonlySet<number> {
|
||||
if (cache !== null) return cache;
|
||||
|
||||
const scopedKey = mutedKey();
|
||||
if (currentHost === null || keyExists(scopedKey)) {
|
||||
cache = parseMutedIds(loadPref<unknown[]>(scopedKey, []));
|
||||
return cache;
|
||||
}
|
||||
|
||||
// Miss at the scoped key: read through to the pre-scoping legacy key once
|
||||
// and persist the result under the scoped key so the read-through isn't
|
||||
// repeated. A different host with its OWN explicit (even empty) mute list
|
||||
// is not touched by this — it never reaches this branch.
|
||||
if (keyExists(MUTED_KEY)) {
|
||||
const legacy = parseMutedIds(loadPref<unknown[]>(MUTED_KEY, []));
|
||||
writeMuted(legacy);
|
||||
return legacy;
|
||||
}
|
||||
|
||||
cache = new Set();
|
||||
return cache;
|
||||
}
|
||||
|
||||
function writeMuted(ids: ReadonlySet<number>): void {
|
||||
cache = ids;
|
||||
savePref(mutedKey(), [...ids]);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Monitors navigator.mediaDevices.ondevicechange for hot-swap (unplug/plug).
|
||||
|
||||
import { Room } from "livekit-client";
|
||||
import { voiceStore } from "@stores/voice.store";
|
||||
import { loadPref, savePref } from "@components/settings/helpers";
|
||||
import { createLogger } from "@lib/logger";
|
||||
import type { AudioPipeline } from "@lib/audioPipeline";
|
||||
@@ -14,6 +15,25 @@ const log = createLogger("deviceManager");
|
||||
/** Debounce interval for device change events (ms). */
|
||||
const DEVICE_CHANGE_DEBOUNCE_MS = 500;
|
||||
|
||||
/** True when a mute/deafen/server-mute/push-to-talk gate means the mic must
|
||||
* stay off regardless of a caller's own request to (re-)enable it.
|
||||
* livekit-client's setMicrophoneEnabled(true) is a bare track.unmute() when
|
||||
* a muted-but-published track survives a toggle (only ScreenShare actually
|
||||
* unpublishes) — no LocalTrackPublished/TrackUnmuted event fires for
|
||||
* anything downstream to catch and correct, so every re-enable path has to
|
||||
* check this itself instead of relying on one. Exported so LiveKitSession's
|
||||
* own re-enable paths (setDeafened's unmute branch, retryMicPermission)
|
||||
* share the same gate instead of each re-deriving it. */
|
||||
export function isMicPolicyGated(): boolean {
|
||||
const s = voiceStore.getState();
|
||||
return (
|
||||
s.localMuted === true ||
|
||||
s.localDeafened === true ||
|
||||
s.localServerMuted === true ||
|
||||
s.pttGated === true
|
||||
);
|
||||
}
|
||||
|
||||
export class DeviceManager {
|
||||
private room: Room | null = null;
|
||||
private audioPipeline: AudioPipeline | null = null;
|
||||
@@ -43,6 +63,21 @@ export class DeviceManager {
|
||||
this.onToast = cb;
|
||||
}
|
||||
|
||||
/** Toggle the mic off/on to force a fresh capture after a device change,
|
||||
* skipping the re-enable when a mute/deafen/server-mute/PTT gate is
|
||||
* active. Shared by handleDeviceChange's device-removed fallback and
|
||||
* switchInputDevice('') — both drive the exact same false/true cycle, and
|
||||
* both were unconditionally republishing a gated mic before this guard. */
|
||||
private async cycleMicForDeviceSwitch(room: Room): Promise<void> {
|
||||
await room.localParticipant.setMicrophoneEnabled(false);
|
||||
if (this.room !== room) return;
|
||||
if (isMicPolicyGated()) {
|
||||
log.debug("Skipping mic re-enable after device switch — muted/deafened/gated");
|
||||
return;
|
||||
}
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
}
|
||||
|
||||
// --- Device change detection (hot-swap) ---
|
||||
|
||||
private startDeviceChangeListener(): void {
|
||||
@@ -92,9 +127,7 @@ export class DeviceManager {
|
||||
savePref("audioInputDevice", "");
|
||||
// Switch to default device
|
||||
try {
|
||||
await room.localParticipant.setMicrophoneEnabled(false);
|
||||
if (this.room !== room) return;
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
await this.cycleMicForDeviceSwitch(room);
|
||||
if (this.room !== room) return;
|
||||
try {
|
||||
this.audioPipeline?.setupAudioPipeline();
|
||||
@@ -134,9 +167,7 @@ export class DeviceManager {
|
||||
if (deviceId) {
|
||||
await room.switchActiveDevice("audioinput", deviceId);
|
||||
} else {
|
||||
await room.localParticipant.setMicrophoneEnabled(false);
|
||||
if (this.room !== room) return;
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
await this.cycleMicForDeviceSwitch(room);
|
||||
}
|
||||
if (this.room !== room) return;
|
||||
// Rebuild audio pipeline (source track changed after device switch)
|
||||
|
||||
@@ -27,6 +27,9 @@ import {
|
||||
confirmSend,
|
||||
markSendFailed,
|
||||
messagesStore,
|
||||
setMessages,
|
||||
invalidateLoadedMessageWindows,
|
||||
setChannelLoadError,
|
||||
} from "@stores/messages.store";
|
||||
import {
|
||||
setMembers,
|
||||
@@ -71,6 +74,11 @@ import { markChannelRead } from "./read-state";
|
||||
import { createLogger } from "./logger";
|
||||
import { showToast } from "./toast";
|
||||
import { ServerMessageType as S } from "./protocolTypes";
|
||||
// SidebarDmHelpers is page-level, but addDmToChannelsStore is the only
|
||||
// place the DM->channelsStore mirror row is synthesized (selectDmConversation
|
||||
// on open); the dm_channel_close fallback below needs the same synthesis for
|
||||
// a DM it is activating that was never opened this session.
|
||||
import { addDmToChannelsStore } from "@pages/main-page/SidebarDmHelpers";
|
||||
|
||||
const log = createLogger("dispatcher");
|
||||
|
||||
@@ -130,15 +138,46 @@ export function wireConnectionStatus(ws: Pick<WsClient, "onStateChange">): () =>
|
||||
* Returns a cleanup function that removes all listeners.
|
||||
*
|
||||
* `api` is optional so tests can wire the dispatcher without a client; when
|
||||
* present it is used to refresh DM block state (GET /blocks) on ready.
|
||||
* present it is used to refresh DM block state (GET /blocks) on ready, and to
|
||||
* refetch the active channel's history after a full-ready resync.
|
||||
*/
|
||||
export function wireDispatcher(
|
||||
ws: WsClient,
|
||||
api?: Pick<ApiClient, "listBlocks"> &
|
||||
Partial<Pick<ApiClient, "updateProfile" | "getConfig" | "listEmoji">>,
|
||||
Partial<Pick<ApiClient, "updateProfile" | "getConfig" | "listEmoji" | "getMessages">>,
|
||||
): DispatcherCleanup {
|
||||
const unsubs: Array<() => void> = [];
|
||||
|
||||
// A second (or later) auth_ok/ready in this call's lifetime is always a
|
||||
// reconnect: wireDispatcher is called once per login (main.ts's
|
||||
// wirePostAuth), and every automatic reconnect fires its events through
|
||||
// these same long-lived listeners. Closure-scoped so a fresh login (a new
|
||||
// wireDispatcher call) always starts clean.
|
||||
let hasAuthenticatedBefore = false;
|
||||
let hasReceivedReadyBefore = false;
|
||||
// Set from the second-or-later auth_ok — the reconnect handshake time, in
|
||||
// THIS CLIENT's clock. A chat_message replay frame the transport delivers
|
||||
// after it is timestamped *before* it; a genuinely live message is
|
||||
// timestamped after. But payload.timestamp is the SERVER's created_at, in
|
||||
// the SERVER's clock — comparing it to this anchor directly mixes clock
|
||||
// domains, so the comparison below shifts the anchor into server time
|
||||
// using serverClockSkewMs first (see its declaration below).
|
||||
let lastReconnectHandshakeAt: number | null = null;
|
||||
// Running estimate of (this client's clock) minus (the server's clock),
|
||||
// sampled from the most recently accepted live chat_message (Date.now() at
|
||||
// receipt minus that frame's own server timestamp). A self-hosted server
|
||||
// routinely runs without NTP or with a skewed TZ/clock, and comparing its
|
||||
// timestamps against lastReconnectHandshakeAt without this correction means
|
||||
// a lagging server clock makes every genuinely live message look like a
|
||||
// replay for the whole drift window after every reconnect — and with
|
||||
// persistent skew that never recovers. Network latency between the
|
||||
// server's send and this receipt biases the estimate positive, which nudges
|
||||
// the boundary computed below slightly EARLY relative to the server's true
|
||||
// clock; that is the safe direction — a missed replay suppression is at
|
||||
// worst a duplicate notification, while a false replay classification
|
||||
// silently drops one.
|
||||
let serverClockSkewMs = 0;
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────
|
||||
|
||||
// Let the transport declare the open channel in the auth frame itself, so a
|
||||
@@ -151,6 +190,10 @@ export function wireDispatcher(
|
||||
|
||||
unsubs.push(
|
||||
ws.on(S.AUTH_OK, (payload) => {
|
||||
if (hasAuthenticatedBefore) {
|
||||
lastReconnectHandshakeAt = Date.now();
|
||||
}
|
||||
hasAuthenticatedBefore = true;
|
||||
setAuth(authStore.getState().token ?? "", payload.user, payload.server_name, payload.motd);
|
||||
|
||||
// The resume path can land with no ChannelTopic subscription: the hub
|
||||
@@ -248,6 +291,42 @@ export function wireDispatcher(
|
||||
}
|
||||
}
|
||||
|
||||
// A second (or later) `ready` in this dispatcher's lifetime only ever
|
||||
// arrives from a full-ready resync (Server/ws/serve.go: a fresh connect
|
||||
// and a full resync are the only paths that send `ready` at all — a
|
||||
// successful seq-based replay reconnect does not), and that tier never
|
||||
// replays missed chat_message frames. Every channel this session had
|
||||
// already loaded would otherwise keep a permanent, silent hole in its
|
||||
// history — invalidate them and refetch the one actually on screen.
|
||||
if (hasReceivedReadyBefore) {
|
||||
const activeAfterReady = channelsStore.select((s) => s.activeChannelId);
|
||||
const getMessages = api?.getMessages;
|
||||
// Only invalidate when the refetch below can actually happen — api is
|
||||
// a Partial<...>, so getMessages may be absent, and there may be no
|
||||
// resolvable active channel to refetch. Dropping every loaded window
|
||||
// with nothing able to reload it would leave a mounted MessageList
|
||||
// showing only carried-through pending rows until the user navigates
|
||||
// away and back.
|
||||
if (activeAfterReady !== null && getMessages !== undefined) {
|
||||
invalidateLoadedMessageWindows();
|
||||
getMessages(activeAfterReady, { limit: 50 })
|
||||
.then((resp) => setMessages(activeAfterReady, resp.messages, resp.has_more))
|
||||
.catch((err) => {
|
||||
log.warn("Failed to reload message history after resync", { error: String(err) });
|
||||
// The invalidate above already dropped this channel's window,
|
||||
// so a silent catch would leave a mounted MessageList showing
|
||||
// its "no messages yet" welcome state — indistinguishable from
|
||||
// a genuinely empty channel. Route through the same
|
||||
// historyLoadState the normal load path uses so the region
|
||||
// shows the inline error + Retry instead (MessageController's
|
||||
// loadMessages, wired to the Retry button, re-fetches because
|
||||
// invalidate also cleared "loaded").
|
||||
setChannelLoadError(activeAfterReady);
|
||||
});
|
||||
}
|
||||
}
|
||||
hasReceivedReadyBefore = true;
|
||||
|
||||
// Populate DM channels from the ready payload. The server always sends
|
||||
// the field, so an empty array is an authoritative "no open DMs" (all
|
||||
// closed/left on another device) and must clear ghosts from dmStore —
|
||||
@@ -255,6 +334,37 @@ export function wireDispatcher(
|
||||
const dmPayloads = payload.dm_channels ?? [];
|
||||
setDmChannels(dmPayloads.map(mapDmPayload));
|
||||
|
||||
// The channels-store mirror row for a DM (synthesized on open by
|
||||
// addDmToChannelsStore) is deliberately carried across setChannels'
|
||||
// rebuild above, because the ready payload never includes DM rows at
|
||||
// all — but that means a DM closed elsewhere while this client was
|
||||
// offline keeps a phantom row here (closeDmLocally fixes this exact
|
||||
// shape for the live dm_channel_close path; this is its ready-time
|
||||
// equivalent), and a DM read elsewhere keeps a stale unread/mention
|
||||
// count (incrementUnread/incrementMention bump the mirror in parallel
|
||||
// with dmStore once it exists, but only dmStore is restated above).
|
||||
// Reconcile every dm-typed row against the just-restated payload.
|
||||
channelsStore.setState((prev) => {
|
||||
const dmById = new Map(dmPayloads.map((d) => [d.channel_id, d]));
|
||||
const nextChannels = new Map(prev.channels);
|
||||
let changed = false;
|
||||
for (const [id, ch] of prev.channels) {
|
||||
if (ch.type !== "dm") continue;
|
||||
const dm = dmById.get(id);
|
||||
if (dm === undefined) {
|
||||
nextChannels.delete(id);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
const mentionCount = dm.mention_count ?? 0;
|
||||
if (ch.unreadCount !== dm.unread_count || ch.mentionCount !== mentionCount) {
|
||||
nextChannels.set(id, { ...ch, unreadCount: dm.unread_count, mentionCount });
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? { ...prev, channels: nextChannels } : prev;
|
||||
});
|
||||
|
||||
// The server's read_states go stale while a channel stays focused
|
||||
// (channel_focus is sent once per mount, mark_read only from the context
|
||||
// menu), so a full-ready resync restates non-zero unread/mention counts
|
||||
@@ -330,6 +440,13 @@ export function wireDispatcher(
|
||||
closeDmLocally(payload.channel_id, () => {
|
||||
const remaining = dmStore.getState().channels;
|
||||
if (remaining.length > 0) {
|
||||
// Synthesize the channelsStore mirror row before activating: it is
|
||||
// only ever created by addDmToChannelsStore (on open, via
|
||||
// selectDmConversation), so a DM present in dmStore from `ready`
|
||||
// but never opened this session has none — without this,
|
||||
// activating it lands on an id ChannelController can't resolve and
|
||||
// blanks the chat area with no way to recover.
|
||||
addDmToChannelsStore(remaining[0]!);
|
||||
setActiveChannel(remaining[0]!.channelId);
|
||||
return;
|
||||
}
|
||||
@@ -401,8 +518,23 @@ export function wireDispatcher(
|
||||
}
|
||||
}
|
||||
|
||||
// Fire desktop notification, taskbar flash, and sound
|
||||
notifyIncomingMessage(payload);
|
||||
// Fire desktop notification, taskbar flash, and sound — but not for a
|
||||
// reconnect's replayed burst. ws.isReplaying() cannot gate this the way
|
||||
// it gates the unread counter above: ws.ts clears it as soon as auth_ok
|
||||
// is processed, before the replay burst itself even arrives. A replay
|
||||
// frame's timestamp instead predates the reconnect handshake that
|
||||
// preceded it, unlike a genuinely new live message — compared in
|
||||
// server-clock terms (see serverClockSkewMs above) so a lagging or
|
||||
// skewed server clock cannot make a live message look like a replay.
|
||||
const isReplayFrame =
|
||||
lastReconnectHandshakeAt !== null &&
|
||||
Date.parse(payload.timestamp) < lastReconnectHandshakeAt - serverClockSkewMs;
|
||||
if (!isReplayFrame) {
|
||||
notifyIncomingMessage(payload);
|
||||
// Refresh the skew estimate from this accepted-as-live frame so it
|
||||
// stays current for the next reconnect.
|
||||
serverClockSkewMs = Date.now() - Date.parse(payload.timestamp);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -838,6 +970,18 @@ export function wireDispatcher(
|
||||
// with an explanation buried in the log.
|
||||
if (payload.code === "CHANNEL_FULL") {
|
||||
showToast(payload.message || "That voice channel is full", "error");
|
||||
// The sidebar/widget optimistically writes currentChannelId before
|
||||
// the server answers (VoiceCallbacks.onVoiceJoin). A first-time join
|
||||
// refusal earns no voice_leave (there was no previous channel to
|
||||
// leave), so nothing else clears that optimistic state — the sidebar
|
||||
// is left keyed on a channel with no LiveKit session. A channel
|
||||
// *switch* refusal doesn't need this: the server always leaves the
|
||||
// old channel first, whose self voice_leave already reset
|
||||
// voiceStatus to idle before this error arrives, so the guard is a
|
||||
// no-op there.
|
||||
if (voiceStore.getState().voiceStatus === "joining") {
|
||||
leaveVoiceChannel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (payload.code === "VIDEO_LIMIT") {
|
||||
@@ -848,8 +992,32 @@ export function wireDispatcher(
|
||||
void livekitSession().then(({ disableCamera }) => disableCamera());
|
||||
return;
|
||||
}
|
||||
if (payload.code === "RATE_LIMITED" || payload.code === "FORBIDDEN") {
|
||||
setTransientError(payload.message || "Server error");
|
||||
// Every remaining code has no dedicated handler above (not a pending
|
||||
// send/reaction rollback, not a capacity refusal) — this is the one
|
||||
// place every remaining server error lands (a rejected fire-and-forget
|
||||
// chat_edit, for one), so it must not be silently dropped just because
|
||||
// it isn't RATE_LIMITED/FORBIDDEN. Set synchronously, independent of
|
||||
// the video-rollback lookup below: both paths produce this exact same
|
||||
// message, so there is nothing left to gate on that lookup resolving.
|
||||
setTransientError(payload.message || "Server error");
|
||||
|
||||
// A server refusal of a voice_camera/voice_screenshare enable other
|
||||
// than VIDEO_LIMIT (FORBIDDEN, RATE_LIMITED, INTERNAL, ...): roll back
|
||||
// the already-published track, or it keeps streaming to every peer
|
||||
// while the store says it's off. Correlated by envelope id — exactly
|
||||
// like pendingSends/pendingReactions above — so an unrelated
|
||||
// FORBIDDEN/RATE_LIMITED on some other action never touches video
|
||||
// state. screenShare.ts pulls in livekit-client at module scope, so —
|
||||
// like livekitSession() above — it's loaded lazily here too, at its
|
||||
// one call site in this file.
|
||||
if (id !== undefined) {
|
||||
void import("@lib/screenShare").then(({ rollbackPendingVideo }) => {
|
||||
const kind = rollbackPendingVideo(id);
|
||||
if (kind === undefined) return;
|
||||
void livekitSession().then(({ disableCamera, disableScreenshare }) =>
|
||||
kind === "camera" ? disableCamera() : disableScreenshare(),
|
||||
);
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -15,26 +15,28 @@ import { createLogger } from "./logger";
|
||||
|
||||
const log = createLogger("http-proxy");
|
||||
|
||||
/** host → resolved loopback origin (e.g. "http://127.0.0.1:49812"). */
|
||||
const origins = new Map<string, string>();
|
||||
/** host → in-flight start so concurrent callers don't race the tunnel. */
|
||||
const pending = new Map<string, Promise<string>>();
|
||||
|
||||
/**
|
||||
* Ensure a tunnel exists for `host` and return its loopback origin
|
||||
* (no trailing slash). Idempotent and concurrency-safe per host.
|
||||
* (no trailing slash). Concurrency-safe per host.
|
||||
*
|
||||
* Always invokes start_http_proxy — never caches the resolved origin here.
|
||||
* Only the Rust side knows whether its listener is still alive: after 5
|
||||
* consecutive accept errors run_proxy_loop deregisters itself so the next
|
||||
* start_http_proxy rebinds a fresh port (http_proxy.rs). A JS-side cache
|
||||
* would keep pointing every REST call at that dead tunnel until app restart.
|
||||
* The Rust reuse branch dedups an unchanged host cheaply, so the repeat
|
||||
* invoke is inexpensive — mirroring livekitSession.ts's ensureLiveKitProxy.
|
||||
*/
|
||||
export async function ensureHttpProxy(host: string): Promise<string> {
|
||||
const cached = origins.get(host);
|
||||
if (cached) return cached;
|
||||
|
||||
const inFlight = pending.get(host);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
const start = (async () => {
|
||||
const port = await invoke<number>("start_http_proxy", { remoteHost: host });
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
origins.set(host, origin);
|
||||
log.debug("tunnel ready", { host, origin });
|
||||
return origin;
|
||||
})();
|
||||
@@ -47,9 +49,8 @@ export async function ensureHttpProxy(host: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop the tunnel for `host` and drop its cached origin (best-effort). */
|
||||
/** Stop the tunnel for `host` (best-effort). */
|
||||
export async function stopHttpProxy(host: string): Promise<void> {
|
||||
origins.delete(host);
|
||||
pending.delete(host);
|
||||
try {
|
||||
await invoke("stop_http_proxy", { remoteHost: host });
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
* layer (F3). Mirrors credentials.ts: dynamically imports Tauri `invoke` and
|
||||
* no-ops in non-Tauri environments (tests, browser).
|
||||
*
|
||||
* Two backing stores, both keyed by connection host:
|
||||
* - OS keyring (save/load/delete_identity_key, account `identity:{host}`):
|
||||
* the client's own long-term identity PRIVATE key (base64 JWK blob).
|
||||
* Two backing stores:
|
||||
* - OS keyring (save/load/delete_identity_key, account `identity:{host}:{uid}`):
|
||||
* the client's own long-term identity PRIVATE key (base64 JWK blob),
|
||||
* scoped by host AND user id (see `identityKeyPairCache` below — two
|
||||
* accounts must never share one identity keypair).
|
||||
* - identity_pins.json (store/get_identity_pin, key `{host}:{userId}`):
|
||||
* peers' pinned identity PUBLIC keys (base64), for TOFU verification.
|
||||
*/
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
generateIdentityKeyPair,
|
||||
importIdentityKeyPair,
|
||||
} from "./e2eeCrypto";
|
||||
import { authStore } from "@stores/auth.store";
|
||||
|
||||
const log = createLogger("identity");
|
||||
|
||||
@@ -173,7 +176,17 @@ export async function getIdentityPin(host: string, userId: string): Promise<Iden
|
||||
// ── High-level lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One identity keypair per host, shared by every caller in this process.
|
||||
* One identity keypair per host+user, shared by every caller in this process.
|
||||
*
|
||||
* Scoped by BOTH host and user id (B3-3), not host alone: two different
|
||||
* accounts signed into the same host — including two people sharing one OS
|
||||
* profile/keyring, or one client used to log into several accounts on the
|
||||
* same server without a restart — must never share a voice-E2EE identity
|
||||
* keypair. Sharing one would make their announces verify against each
|
||||
* other's TOFU pin, silently defeating the identity model's distinctness
|
||||
* guarantee. (Pre-existing installs mint a fresh per-account keypair the
|
||||
* first time they run this scoping — a one-time re-verify for their peers,
|
||||
* traded for closing the cross-account sharing hole.)
|
||||
*
|
||||
* The keypair has two independent consumers: the ready hook publishes its
|
||||
* public half (`ensureIdentityKeyPublished`) and the voice session signs
|
||||
@@ -191,45 +204,118 @@ export async function getIdentityPin(host: string, userId: string): Promise<Iden
|
||||
*/
|
||||
const identityKeyPairCache = new Map<string, Promise<CryptoKeyPair>>();
|
||||
|
||||
/** Composite keyring/memo key scoping the identity keypair by host AND user
|
||||
* id. The keyring commands only take a single opaque `host` string, so the
|
||||
* scope is folded into that one field rather than requiring a Rust-side
|
||||
* change. */
|
||||
function identityScopeKey(host: string, userId: number): string {
|
||||
return `${host}:${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load this host's identity keypair from the keyring, generating and saving a
|
||||
* fresh one on first login (or when the stored blob is corrupt). In non-Tauri
|
||||
* environments the keypair is in-memory only (not persisted).
|
||||
* Load this host+user's identity keypair from the keyring, generating and
|
||||
* saving a fresh one on first login (or when the stored blob is corrupt). In
|
||||
* non-Tauri environments the keypair is in-memory only (not persisted).
|
||||
*
|
||||
* Stable for the lifetime of the process: repeat callers get the same keypair
|
||||
* even when the keyring is unavailable (see `identityKeyPairCache`).
|
||||
*/
|
||||
export function getOrCreateIdentityKeyPair(host: string): Promise<CryptoKeyPair> {
|
||||
let pending = identityKeyPairCache.get(host);
|
||||
export function getOrCreateIdentityKeyPair(host: string, userId: number): Promise<CryptoKeyPair> {
|
||||
const scope = identityScopeKey(host, userId);
|
||||
let pending = identityKeyPairCache.get(scope);
|
||||
if (pending === undefined) {
|
||||
// A rejected load must not be cached, or the host is poisoned for the
|
||||
// A rejected load must not be cached, or the scope is poisoned for the
|
||||
// rest of the session; drop it so the next caller can retry.
|
||||
pending = loadOrGenerateIdentityKeyPair(host).catch((err: unknown) => {
|
||||
identityKeyPairCache.delete(host);
|
||||
pending = loadOrGenerateIdentityKeyPair(host, userId).catch((err: unknown) => {
|
||||
identityKeyPairCache.delete(scope);
|
||||
throw err;
|
||||
});
|
||||
identityKeyPairCache.set(host, pending);
|
||||
identityKeyPairCache.set(scope, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Test-only: drop the per-host keypair memo so each case starts clean. */
|
||||
/** Test-only: drop the per-host+user keypair memo so each case starts clean. */
|
||||
export function resetIdentityKeyPairCache(): void {
|
||||
identityKeyPairCache.clear();
|
||||
}
|
||||
|
||||
async function loadOrGenerateIdentityKeyPair(host: string): Promise<CryptoKeyPair> {
|
||||
const stored = await loadIdentityKey(host);
|
||||
/**
|
||||
* One-time migration for pre-B3-3 installs (see `identityKeyPairCache` above):
|
||||
* before that fix, the identity keypair lived under the host-only keyring
|
||||
* account (`identity:{host}`, passed here as plain `host`) instead of the
|
||||
* scoped `identity:{host}:{uid}`. Without this, every existing install finds
|
||||
* nothing at the new scoped account and mints a fresh identity keypair, and
|
||||
* every peer who already pinned the old key sees a TOFU mismatch — a MITM
|
||||
* warning firing for the whole alpha population at once, training users to
|
||||
* click through the one warning meant to matter.
|
||||
*
|
||||
* Only called when the scoped account is empty, so a genuine first login (or
|
||||
* a second account on a host whose legacy key the first already adopted)
|
||||
* still gets its own fresh keypair — that distinctness is the point of B3-3.
|
||||
* On a host that really did have two accounts sharing one key, whichever logs
|
||||
* in first adopts it and the other mints fresh: the legacy account records no
|
||||
* user id, so there is nothing to match on. That leaves the old shared-key
|
||||
* behaviour in place for exactly one account instead of two, and it resolves
|
||||
* itself once both have signed in once.
|
||||
* The scoped save happens before the legacy delete, so a failed save can't
|
||||
* leave the user with neither key; the legacy account just stays put for the
|
||||
* next launch to retry.
|
||||
*
|
||||
* Delete this once the alpha population has rolled onto the scoped account.
|
||||
*/
|
||||
async function migrateLegacyIdentityKey(
|
||||
host: string,
|
||||
scope: string,
|
||||
): Promise<CryptoKeyPair | null> {
|
||||
const legacyBlob = await loadIdentityKey(host);
|
||||
if (!legacyBlob) {
|
||||
return null;
|
||||
}
|
||||
let keyPair: CryptoKeyPair;
|
||||
try {
|
||||
keyPair = await importIdentityKeyPair(legacyBlob);
|
||||
} catch (err) {
|
||||
log.error("Legacy identity key is corrupt — generating fresh instead of migrating", {
|
||||
host,
|
||||
error: String(err),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (await saveIdentityKey(scope, legacyBlob)) {
|
||||
await deleteIdentityKey(host);
|
||||
} else {
|
||||
log.error(
|
||||
"Failed to migrate legacy identity key to the scoped account — leaving the legacy " +
|
||||
"key in place so the next launch can retry",
|
||||
{ host },
|
||||
);
|
||||
}
|
||||
return keyPair;
|
||||
}
|
||||
|
||||
async function loadOrGenerateIdentityKeyPair(host: string, userId: number): Promise<CryptoKeyPair> {
|
||||
const scope = identityScopeKey(host, userId);
|
||||
const stored = await loadIdentityKey(scope);
|
||||
if (stored) {
|
||||
try {
|
||||
return await importIdentityKeyPair(stored);
|
||||
} catch (err) {
|
||||
log.error("Stored identity key is corrupt — regenerating", { host, error: String(err) });
|
||||
log.error("Stored identity key is corrupt — regenerating", {
|
||||
host,
|
||||
userId,
|
||||
error: String(err),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const migrated = await migrateLegacyIdentityKey(host, scope);
|
||||
if (migrated) {
|
||||
return migrated;
|
||||
}
|
||||
}
|
||||
const keyPair = await generateIdentityKeyPair();
|
||||
const blob = await exportIdentityKeyPair(keyPair.privateKey);
|
||||
if (await saveIdentityKey(host, blob)) {
|
||||
if (await saveIdentityKey(scope, blob)) {
|
||||
// Outer half of a two-layer check. `save_identity_key` already reads its own
|
||||
// write back and falls through to the DPAPI file if the OS credential store
|
||||
// does not return it (see src-tauri/src/secret_store.rs and
|
||||
@@ -243,7 +329,7 @@ async function loadOrGenerateIdentityKeyPair(host: string): Promise<CryptoKeyPai
|
||||
// nothing left to abort.
|
||||
let persisted: boolean;
|
||||
try {
|
||||
persisted = (await loadIdentityKey(host)) === blob;
|
||||
persisted = (await loadIdentityKey(scope)) === blob;
|
||||
} catch {
|
||||
persisted = false;
|
||||
}
|
||||
@@ -251,7 +337,7 @@ async function loadOrGenerateIdentityKeyPair(host: string): Promise<CryptoKeyPai
|
||||
log.error(
|
||||
"Identity key did not persist — the credential store accepted the write but did not return it. " +
|
||||
"This session works, but peers will see a new identity (and prompt to re-verify) every restart.",
|
||||
{ host },
|
||||
{ host, userId },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -281,11 +367,27 @@ export async function publishIdentityKey(
|
||||
|
||||
/**
|
||||
* Login/ready hook: ensure the server holds this client's identity public key.
|
||||
* Loads (or generates) the host keypair and publishes it via the REST profile
|
||||
* update when the server's stored copy is absent or stale — idempotent, so it
|
||||
* runs at most once per key. The server's PATCH /users/me requires a username,
|
||||
* so `username` is sent alongside the key. Fire-and-forget: errors are logged
|
||||
* and swallowed (returns false) so the connect/voice flow is never blocked.
|
||||
* Loads (or generates) the host+user keypair and publishes it via the REST
|
||||
* profile update when the server's stored copy is absent or stale —
|
||||
* idempotent, so it runs at most once per key. The server's PATCH /users/me
|
||||
* requires a username, so `username` is sent alongside the key. Fire-and-forget:
|
||||
* errors are logged and swallowed (returns false) so the connect/voice flow is
|
||||
* never blocked.
|
||||
*
|
||||
* The user id is read from `authStore` rather than taken as a parameter: this
|
||||
* is called from the ready hook, by which point auth state is populated, and
|
||||
* keeping the signature unchanged avoids threading the id through every call
|
||||
* site just to scope the keyring lookup (B3-3).
|
||||
*
|
||||
* If auth state is NOT yet populated (no user id), this returns false
|
||||
* without touching the keyring at all — it must never substitute a
|
||||
* placeholder scope like `?? 0`. `getOrCreateIdentityKeyPair` is host+user
|
||||
* scoped, and minting (or migrating) a keypair under a bogus `host:0`
|
||||
* would adopt-and-DELETE the real legacy key into that wrong account (see
|
||||
* `identityKeyPairCache` / `migrateLegacyIdentityKey` above); the next,
|
||||
* correctly-authenticated call then mints a second, different keypair under
|
||||
* `host:<realId>`, so the published key and the announce signing key
|
||||
* permanently disagree — a false MITM warning for every peer.
|
||||
*/
|
||||
export async function ensureIdentityKeyPublished(
|
||||
host: string,
|
||||
@@ -294,7 +396,12 @@ export async function ensureIdentityKeyPublished(
|
||||
updateProfile: (data: { username: string; identity_public_key: string }) => Promise<unknown>,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const keyPair = await getOrCreateIdentityKeyPair(host);
|
||||
const userId = authStore.getState().user?.id;
|
||||
if (userId === undefined) {
|
||||
log.warn("No authenticated user id yet — not publishing identity key", { host });
|
||||
return false;
|
||||
}
|
||||
const keyPair = await getOrCreateIdentityKeyPair(host, userId);
|
||||
return await publishIdentityKey(
|
||||
(data) => updateProfile({ username, ...data }),
|
||||
serverCopy,
|
||||
|
||||
@@ -176,8 +176,16 @@ export class E2EEManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use server-authoritative is_key_holder from voice_token payload.
|
||||
this._isKeyHolder = isKeyHolder;
|
||||
// Use server-authoritative is_key_holder from voice_token payload — OR'd
|
||||
// with whatever this._isKeyHolder already is. The server value was
|
||||
// captured when we started joining and cannot see a handleParticipantLeft
|
||||
// promotion that landed during the awaits above: the generation check
|
||||
// just above proves no clearState() ran since myGeneration was captured,
|
||||
// so the only other writer of this field for THIS generation is that
|
||||
// promotion — unconditionally overwriting it with the stale server value
|
||||
// strands the newly-elected holder waiting for an offer nobody (least of
|
||||
// all itself) will ever send, timing out and ejecting it from voice.
|
||||
this._isKeyHolder = isKeyHolder || this._isKeyHolder;
|
||||
|
||||
if (this._isKeyHolder) {
|
||||
// Generate the room key BEFORE draining queued announces, so the
|
||||
@@ -343,13 +351,28 @@ export class E2EEManager {
|
||||
|
||||
/** Load (once per session) this client's long-term identity keypair from the
|
||||
* OS keyring so we can sign ephemeral announces. Returns null when there is
|
||||
* no server host (identity is host-scoped) — the announce then goes out
|
||||
* unsigned and peers treat us as a legacy/unverified client. */
|
||||
* no server host (identity is host-scoped) OR no authenticated user id yet
|
||||
* (identity is host+user scoped, B3-3) — the announce then goes out
|
||||
* unsigned and peers treat us as a legacy/unverified client. A missing user
|
||||
* id must never fall back to a placeholder scope like `?? 0`:
|
||||
* `getOrCreateIdentityKeyPair` would mint (or migrate-and-DELETE the real
|
||||
* legacy key into) a bogus `host:0` keyring account, and a later
|
||||
* authenticated call would then mint a second, different keypair under
|
||||
* `host:<realId>` — so the published key and the announce signing key
|
||||
* permanently disagree and every peer's verifyPeerAnnounce reports a false
|
||||
* MITM "mismatch" (see identity.ts's `identityKeyPairCache` doc). */
|
||||
private async ensureIdentityKeyPair(): Promise<CryptoKeyPair | null> {
|
||||
if (this._identityKeyPair) return this._identityKeyPair;
|
||||
const host = this.deps.getServerHost();
|
||||
if (host === null) return null;
|
||||
this._identityKeyPair = await getOrCreateIdentityKeyPair(host);
|
||||
const myUserId = authStore.getState().user?.id;
|
||||
if (myUserId === undefined) {
|
||||
log.warn(
|
||||
"E2EE: no authenticated user id yet — announcing unsigned instead of scoping under a placeholder id",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
this._identityKeyPair = await getOrCreateIdentityKeyPair(host, myUserId);
|
||||
return this._identityKeyPair;
|
||||
}
|
||||
|
||||
@@ -397,7 +420,8 @@ export class E2EEManager {
|
||||
private async verifyPeerAnnounce(
|
||||
userId: number,
|
||||
publicKeyBase64: string,
|
||||
signatureBase64?: string,
|
||||
signatureBase64: string | undefined,
|
||||
myGeneration: number,
|
||||
): Promise<boolean> {
|
||||
const publishedIdentity =
|
||||
membersStore.getState().members.get(userId)?.identityPublicKey ?? null;
|
||||
@@ -416,7 +440,11 @@ export class E2EEManager {
|
||||
// the server delivered. Reject the announce and surface the distinct
|
||||
// "unknown" state; the peer stays blocked for E2EE until the store recovers.
|
||||
if (lookup.status === "unavailable") {
|
||||
setPeerVerification({ userId, status: "unknown", safetyNumber: null });
|
||||
this.setPeerVerificationIfCurrent(myGeneration, {
|
||||
userId,
|
||||
status: "unknown",
|
||||
safetyNumber: null,
|
||||
});
|
||||
log.error("E2EE: identity pin store unreadable — rejecting announce (fail closed)", {
|
||||
userId,
|
||||
});
|
||||
@@ -428,7 +456,11 @@ export class E2EEManager {
|
||||
// Pinned peer whose delivered key is absent or differs from the pin —
|
||||
// possible server MITM. Block until the user re-pins.
|
||||
if (pin !== null && publishedIdentity !== pin) {
|
||||
setPeerVerification({ userId, status: "mismatch", safetyNumber: null });
|
||||
this.setPeerVerificationIfCurrent(myGeneration, {
|
||||
userId,
|
||||
status: "mismatch",
|
||||
safetyNumber: null,
|
||||
});
|
||||
log.error("E2EE: pinned peer identity key missing/changed — blocking (identity-tofu)", {
|
||||
userId,
|
||||
});
|
||||
@@ -439,7 +471,11 @@ export class E2EEManager {
|
||||
// but mark unverified (pin-pending). This is the only case the compatibility
|
||||
// posture keeps open.
|
||||
if (!publishedIdentity) {
|
||||
setPeerVerification({ userId, status: "unverified", safetyNumber: null });
|
||||
this.setPeerVerificationIfCurrent(myGeneration, {
|
||||
userId,
|
||||
status: "unverified",
|
||||
safetyNumber: null,
|
||||
});
|
||||
log.warn("E2EE: peer has no identity key — accepting as unverified (legacy)", { userId });
|
||||
return true;
|
||||
}
|
||||
@@ -454,7 +490,11 @@ export class E2EEManager {
|
||||
: false;
|
||||
if (!ok) {
|
||||
// Fail closed: peer has an identity key but no valid signature (MITM).
|
||||
setPeerVerification({ userId, status: "mismatch", safetyNumber: null });
|
||||
this.setPeerVerificationIfCurrent(myGeneration, {
|
||||
userId,
|
||||
status: "mismatch",
|
||||
safetyNumber: null,
|
||||
});
|
||||
log.error("E2EE: peer announce signature invalid — rejecting (MITM?)", { userId });
|
||||
return false;
|
||||
}
|
||||
@@ -479,14 +519,32 @@ export class E2EEManager {
|
||||
}
|
||||
}
|
||||
if (pinWriteFailed) {
|
||||
setPeerVerification({ userId, status: "unverified", safetyNumber: null });
|
||||
this.setPeerVerificationIfCurrent(myGeneration, {
|
||||
userId,
|
||||
status: "unverified",
|
||||
safetyNumber: null,
|
||||
});
|
||||
return true; // still accept the announce — the write failure alone shouldn't block the call
|
||||
}
|
||||
const safetyNumber = await computeKeyFingerprint(identityKey);
|
||||
setPeerVerification({ userId, status: "verified", safetyNumber });
|
||||
this.setPeerVerificationIfCurrent(myGeneration, { userId, status: "verified", safetyNumber });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** setPeerVerification, but a no-op if a clearState() teardown happened
|
||||
* since myGeneration was captured. verifyPeerAnnounce awaits a Tauri IPC
|
||||
* (identity pin lookup) internally and writes verification state on every
|
||||
* branch, so a Disconnect mid-await must not let the resumed continuation
|
||||
* resurrect voice-store state for a session that no longer exists
|
||||
* (finding B3-7). */
|
||||
private setPeerVerificationIfCurrent(
|
||||
myGeneration: number,
|
||||
verification: Parameters<typeof setPeerVerification>[0],
|
||||
): void {
|
||||
if (this._sessionGeneration !== myGeneration) return;
|
||||
setPeerVerification(verification);
|
||||
}
|
||||
|
||||
/**
|
||||
* F3 TOFU re-pin recovery (finding #4). Pin the EXACT identity key
|
||||
* `verifiedKey` — the bytes whose fingerprint the caller displayed and the
|
||||
@@ -566,15 +624,27 @@ export class E2EEManager {
|
||||
log.info("E2EE: queued announce (keypair not ready)", { userId });
|
||||
return;
|
||||
}
|
||||
// Captured before verifyPeerAnnounce's awaits (a Tauri IPC pin lookup) so
|
||||
// a clearState() that lands during them — e.g. Disconnect mid-verify —
|
||||
// can be detected before this continuation writes into a session a newer
|
||||
// (or no) attempt now owns (finding B3-7).
|
||||
const myGeneration = this._sessionGeneration;
|
||||
try {
|
||||
// ── F3 TOFU verification gate ──────────────────────────────────────
|
||||
// Resolve the peer's identity key and verify the announce signature
|
||||
// BEFORE storing the ECDH key or wrapping the room key. A malicious
|
||||
// server that swaps user_id↔ephemeral-key or forges keys fails here.
|
||||
if (!(await this.verifyPeerAnnounce(userId, publicKeyBase64, signatureBase64))) {
|
||||
if (
|
||||
!(await this.verifyPeerAnnounce(userId, publicKeyBase64, signatureBase64, myGeneration))
|
||||
) {
|
||||
return; // rejected/blocked — do not store or wrap
|
||||
}
|
||||
|
||||
if (this._sessionGeneration !== myGeneration) {
|
||||
log.info("E2EE: discarding stale announce (session torn down during verify)", { userId });
|
||||
return;
|
||||
}
|
||||
|
||||
// Deduplicate: if the key is identical, skip the import but still
|
||||
// re-send the room key offer (the peer may be re-requesting after a
|
||||
// missed offer or reconnect).
|
||||
|
||||
@@ -19,7 +19,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { AudioPipeline } from "@lib/audioPipeline";
|
||||
import { AudioElements } from "@lib/audioElements";
|
||||
import { E2EEManager } from "@lib/livekitE2EE";
|
||||
import { DeviceManager } from "@lib/deviceManager";
|
||||
import { DeviceManager, isMicPolicyGated } from "@lib/deviceManager";
|
||||
import {
|
||||
type VideoTrackDeps,
|
||||
type CameraTrackState,
|
||||
@@ -439,6 +439,24 @@ export class LiveKitSession {
|
||||
this._deviceManager.setOnToast(this.onErrorCallback);
|
||||
}
|
||||
|
||||
/** True when an in-flight reconnect attempt for `channelId` has been
|
||||
* superseded and must stop touching shared state: the signal was
|
||||
* aborted, OR a newer connectAndSetup() already claimed `_state` (whether
|
||||
* by moving to "idle"/"connected" for a DIFFERENT channel, or — the
|
||||
* airtight case — by reaching "connected" for the SAME channel, since
|
||||
* connectAndSetup()'s entry-point leaveVoice(false) never runs while
|
||||
* `_room` reads null during "reconnecting" and so never aborts our
|
||||
* signal). State is always "reconnecting" during this loop's own
|
||||
* legitimate run (it only transitions to "connected" at the end of a
|
||||
* successful attempt), so the type check can never false-positive on a
|
||||
* still-current attempt. Checked at every checkpoint in the loop, in the
|
||||
* loop's own state-restore branch, and in the post-loop give-up path. */
|
||||
private reconnectSuperseded(signal: AbortSignal, channelId: number): boolean {
|
||||
return (
|
||||
signal.aborted || this._state.type !== "reconnecting" || this._currentChannelId !== channelId
|
||||
);
|
||||
}
|
||||
|
||||
/** Attempt to auto-reconnect after unexpected disconnect using stored token.
|
||||
* The signal is aborted by leaveVoice() to cancel the loop when the user
|
||||
* voluntarily leaves voice during the reconnect delay. */
|
||||
@@ -457,7 +475,7 @@ export class LiveKitSession {
|
||||
// oxlint-disable-next-line no-await-in-loop -- intentional sequential polling with backoff delay
|
||||
await new Promise((r) => setTimeout(r, LiveKitSession.RECONNECT_DELAY_MS));
|
||||
// If user manually left or joined a different channel during the delay, abort.
|
||||
if (signal.aborted || this._currentChannelId !== channelId) {
|
||||
if (this.reconnectSuperseded(signal, channelId)) {
|
||||
log.info("Auto-reconnect aborted — user left or channel changed");
|
||||
return;
|
||||
}
|
||||
@@ -493,7 +511,7 @@ export class LiveKitSession {
|
||||
this._deviceManager.setRoom(newRoom);
|
||||
this._deviceManager.setAudioPipeline(this._audioPipeline);
|
||||
|
||||
if (signal.aborted || this._currentChannelId !== channelId) {
|
||||
if (this.reconnectSuperseded(signal, channelId)) {
|
||||
log.info("Auto-reconnect aborted after room creation");
|
||||
await cleanupAbortedReconnect();
|
||||
return;
|
||||
@@ -502,7 +520,7 @@ export class LiveKitSession {
|
||||
// oxlint-disable-next-line no-await-in-loop -- sequential reconnect: resolve URL then connect
|
||||
const resolvedUrl = await this.resolveLiveKitUrl(url, directUrl);
|
||||
|
||||
if (signal.aborted || this._currentChannelId !== channelId) {
|
||||
if (this.reconnectSuperseded(signal, channelId)) {
|
||||
log.info("Auto-reconnect aborted before room connect");
|
||||
await cleanupAbortedReconnect();
|
||||
return;
|
||||
@@ -519,7 +537,7 @@ export class LiveKitSession {
|
||||
// oxlint-disable-next-line no-await-in-loop -- sequential reconnect: must connect before restoring state
|
||||
await newRoom.connect(resolvedUrl, token);
|
||||
|
||||
if (signal.aborted || this._currentChannelId !== channelId) {
|
||||
if (this.reconnectSuperseded(signal, channelId)) {
|
||||
log.info("Auto-reconnect aborted after room connect");
|
||||
await cleanupAbortedReconnect();
|
||||
return;
|
||||
@@ -612,11 +630,7 @@ export class LiveKitSession {
|
||||
// `_room` getter is null while "reconnecting", so its entry-point
|
||||
// leaveVoice(false) never runs), leaving both `signal.aborted` false and
|
||||
// `_currentChannelId` equal to ours once that join reaches "connected".
|
||||
if (
|
||||
signal.aborted ||
|
||||
this._state.type !== "reconnecting" ||
|
||||
this._currentChannelId !== channelId
|
||||
) {
|
||||
if (this.reconnectSuperseded(signal, channelId)) {
|
||||
log.info("Auto-reconnect give-up skipped — superseded");
|
||||
return;
|
||||
}
|
||||
@@ -798,7 +812,17 @@ export class LiveKitSession {
|
||||
// user muted themselves", and ptt.ts refuses to open the mic on a PTT
|
||||
// press while it is set — writing it here would close the mic for the
|
||||
// whole session instead of only until the first press.
|
||||
const pttArmed = mode === "join" && isPttPollingLive() && loadPref<number>("pttVk", 0) !== 0;
|
||||
// On reconnect, don't recompute pttArmed from scratch — that always
|
||||
// yields false (mode !== "join") and ignores whatever pttGated the store
|
||||
// is still carrying from before the disconnect. If the user joined with
|
||||
// PTT armed and never pressed the key before the connection dropped, the
|
||||
// gate is still supposed to be closed; reading it back here (instead of
|
||||
// silently reopening the mic) is what keeps that promise across a
|
||||
// reconnect.
|
||||
const pttArmed =
|
||||
mode === "join"
|
||||
? isPttPollingLive() && loadPref<number>("pttVk", 0) !== 0
|
||||
: state.pttGated === true;
|
||||
if (mode === "join") {
|
||||
setPttGated(pttArmed);
|
||||
}
|
||||
@@ -1298,15 +1322,16 @@ export class LiveKitSession {
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
setListenOnly(false);
|
||||
// BUG-103: Honor deafened state — keep mic muted if user is deafened.
|
||||
// Also honor a moderator's server-mute the same way: a listen-only join
|
||||
// publishes no audio track, so the server-side mute has nothing to act
|
||||
// on and persists silently — republishing here must not hand the whole
|
||||
// channel a fresh, unmuted track. (The setMuted() guard does not cover
|
||||
// this direct setMicrophoneEnabled call.)
|
||||
const { localDeafened, localServerMuted } = voiceStore.getState();
|
||||
if (localDeafened || localServerMuted) {
|
||||
// Also honor a moderator's server-mute, a genuine self-mute, and an
|
||||
// unpressed push-to-talk key the same way: a listen-only join publishes
|
||||
// no audio track, so none of these have anything to act on and persist
|
||||
// silently — republishing here must not hand the whole channel a
|
||||
// fresh, unmuted track. Shares applyMicMuteState's own gate rather
|
||||
// than re-deriving a narrower one (the setMuted() guard does not cover
|
||||
// this direct setMicrophoneEnabled call).
|
||||
if (isMicPolicyGated()) {
|
||||
await this.applyMicMuteState(true);
|
||||
log.info("Microphone acquired but muted (user is deafened or server-muted)");
|
||||
log.info("Microphone acquired but muted (mute/deafen/server-mute/PTT gate active)");
|
||||
} else {
|
||||
setLocalMuted(false);
|
||||
log.info("Microphone permission granted — exited listen-only mode");
|
||||
@@ -1424,6 +1449,14 @@ export class LiveKitSession {
|
||||
await room.localParticipant.setMicrophoneEnabled(false);
|
||||
log.debug("Mic fully unpublished (muted)");
|
||||
} else {
|
||||
// A push-to-talk gate (or, defensively, a moderator's server-mute) is
|
||||
// not this call's to lift — setMuted/setDeafened only guard their own
|
||||
// flag before calling here, so this is the one place every re-enable
|
||||
// path (present and future) shares the full policy check.
|
||||
if (isMicPolicyGated()) {
|
||||
log.debug("Skipping mic re-publish — still gated (mute/deafen/server-mute/PTT)");
|
||||
return;
|
||||
}
|
||||
// Re-enable mic — this re-publishes the track to the SFU
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
// Rebuild the audio pipeline on the fresh track
|
||||
|
||||
@@ -107,6 +107,12 @@ async function rotateOldFiles(): Promise<void> {
|
||||
/** Handle a log entry by serializing it and buffering for disk write. */
|
||||
function onLogEntry(entry: LogEntry): void {
|
||||
if (!initialized) return;
|
||||
// Break the self-sustaining loop: a persistently failing flush logs
|
||||
// through this module's own logger (flush failed / rotation failed),
|
||||
// which would otherwise re-enter here and re-arm scheduleFlush every 2s
|
||||
// forever. The entry still reaches console/the in-memory ring buffer —
|
||||
// it just never gets queued for its own persistence.
|
||||
if (entry.component === "logPersistence") return;
|
||||
buffer.push(JSON.stringify(entry));
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { notificationAllowed } from "./channel-mutes";
|
||||
import { loadUserStatus } from "./userStatus";
|
||||
import { authStore } from "@stores/auth.store";
|
||||
import { channelsStore } from "@stores/channels.store";
|
||||
import { dmStore, dmDisplayName } from "@stores/dm.store";
|
||||
import type { ChatMessagePayload } from "./types";
|
||||
import { mentionsCurrentUser } from "./mentions";
|
||||
import { createLogger } from "./logger";
|
||||
@@ -19,11 +20,21 @@ function isWindowFocused(): boolean {
|
||||
return document.hasFocus();
|
||||
}
|
||||
|
||||
/** Get the channel name for a given channel ID. */
|
||||
function getChannelName(channelId: number): string {
|
||||
const channels = channelsStore.getState().channels;
|
||||
const channel = channels.get(channelId);
|
||||
return channel?.name ?? `Channel ${channelId}`;
|
||||
/**
|
||||
* The name to show for a given channel/DM id, and whether it is a DM (a DM
|
||||
* gets no "#" prefix -- it is not a channel).
|
||||
*
|
||||
* DM ids are absent from channelsStore until the conversation is opened
|
||||
* (dispatcher.ts), so they must be checked first or the fallback below always
|
||||
* wins and a DM notification reads "Channel <id>". dmDisplayName is the one
|
||||
* place every DM-labelling surface (sidebar, header, quick switcher, and
|
||||
* this) agrees on what a conversation is called.
|
||||
*/
|
||||
function resolveNotificationChannel(channelId: number): { name: string; isDm: boolean } {
|
||||
const dm = dmStore.getState().channels.find((c) => c.channelId === channelId);
|
||||
if (dm !== undefined) return { name: dmDisplayName(dm), isDm: true };
|
||||
const channel = channelsStore.getState().channels.get(channelId);
|
||||
return { name: channel?.name ?? `Channel ${channelId}`, isDm: false };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,7 +84,8 @@ export function notifyIncomingMessage(payload: ChatMessagePayload): void {
|
||||
// flash stays: it's a passive hint, not a notification.
|
||||
const dnd = loadUserStatus() === "dnd";
|
||||
|
||||
const channelName = getChannelName(payload.channel_id);
|
||||
const { name: channelName, isDm } = resolveNotificationChannel(payload.channel_id);
|
||||
const channelLabel = isDm ? channelName : `#${channelName}`;
|
||||
|
||||
// oxlint-disable-next-line consistent-function-scoping -- co-located with its sole caller for readability
|
||||
function sanitizeNotif(s: string, maxLen: number): string {
|
||||
@@ -84,8 +96,8 @@ export function notifyIncomingMessage(payload: ChatMessagePayload): void {
|
||||
|
||||
const title = sanitizeNotif(
|
||||
mentioned
|
||||
? `${payload.user.username} mentioned you in #${channelName}`
|
||||
: `${payload.user.username} in #${channelName}`,
|
||||
? `${payload.user.username} mentioned you in ${channelLabel}`
|
||||
: `${payload.user.username} in ${channelLabel}`,
|
||||
80,
|
||||
);
|
||||
const body = sanitizeNotif(payload.content, 100);
|
||||
|
||||
@@ -12,15 +12,54 @@ const log = createLogger("ptt");
|
||||
|
||||
let listening = false;
|
||||
let pttUnsubscribe: (() => void) | null = null;
|
||||
/** Unsubscribes from voiceStore so a non-PTT unmute (widget button,
|
||||
* retryMicPermission, ...) can clear a stale `pttOwnsMute` latch. See its
|
||||
* registration in initPtt for why. */
|
||||
let pttStoreUnsubscribe: (() => void) | null = null;
|
||||
/** Unsubscribes the 'ptt-error' listener registered in initPtt. */
|
||||
let pttErrorUnsubscribe: (() => void) | null = null;
|
||||
|
||||
/** True when the mute currently in effect is the one a PTT release applied,
|
||||
* rather than one the user asked for. livekitSession.setMuted() writes
|
||||
* localMuted for every caller, so that flag alone cannot tell "the user
|
||||
* muted themselves" (which a press must never lift — v006) from "the last
|
||||
* release muted the mic" (which it must). Reset on init/stop so a mute that
|
||||
* outlived the previous PTT binding is treated as the user's. */
|
||||
* outlived the previous PTT binding is treated as the user's.
|
||||
*
|
||||
* This alone is not enough: the only writes are PTT's own (press/release),
|
||||
* so a non-PTT unmute (the widget's mic button, retryMicPermission) never
|
||||
* clears it. If the user then re-mutes, the stale `true` survives and the
|
||||
* next PTT press wrongly treats their genuine self-mute as PTT's own to
|
||||
* lift. The voiceStore subscription registered in initPtt closes that gap
|
||||
* by clearing the latch on any observed unmute, not just PTT's. */
|
||||
let pttOwnsMute = false;
|
||||
|
||||
/** Clear the PTT gate and, if the mute in effect is the one PTT's own last
|
||||
* release applied (not one the user asked for) and nothing else
|
||||
* independently wants the mic closed, re-open it. Used whenever the poller
|
||||
* can no longer produce a future press/release edge to lift that mute —
|
||||
* clearing the key binding (stopPtt) or the polling thread dying
|
||||
* (ptt-error) — so a PTT-applied mute is never stranded gated with no
|
||||
* recovery path.
|
||||
*
|
||||
* `mutedByPtt` must be the caller's `pttOwnsMute` latch read BEFORE it
|
||||
* resets the latch to false: both call sites zero it ahead of calling this
|
||||
* (a mute must not outlive its PTT binding), so by the time this body runs
|
||||
* the module-level flag itself is already false and can't be consulted
|
||||
* here — the pre-reset value has to be threaded through instead. */
|
||||
function ungateMic(mutedByPtt: boolean): void {
|
||||
if (voiceStore.getState().pttGated !== true) return;
|
||||
setPttGated(false);
|
||||
const { localMuted, localDeafened } = voiceStore.getState();
|
||||
if (localDeafened) return;
|
||||
// A mute the user asked for is never PTT's to lift (v006) — only lift it
|
||||
// when it's the one PTT's own release applied.
|
||||
if (localMuted && !mutedByPtt) return;
|
||||
void import("./livekitSession")
|
||||
.then(({ setMuted }) => setMuted(false))
|
||||
.catch((e) => log.warn("Failed to re-open mic after clearing PTT gate", e));
|
||||
}
|
||||
|
||||
// Well-known virtual key code names for display
|
||||
const VK_NAMES: ReadonlyMap<number, string> = new Map([
|
||||
[0x01, "Mouse Left"],
|
||||
@@ -108,12 +147,35 @@ export async function initPtt(): Promise<void> {
|
||||
log.warn("PTT key polling unsupported on this platform — mic will not be gated at join");
|
||||
}
|
||||
|
||||
// Clean up previous listener if any
|
||||
// Clean up previous listeners if any
|
||||
pttUnsubscribe?.();
|
||||
pttUnsubscribe = null;
|
||||
pttStoreUnsubscribe?.();
|
||||
pttStoreUnsubscribe = null;
|
||||
pttErrorUnsubscribe?.();
|
||||
pttErrorUnsubscribe = null;
|
||||
// A mute left over from a previous binding is no longer PTT's to lift.
|
||||
pttOwnsMute = false;
|
||||
|
||||
// See pttOwnsMute's doc comment: a non-PTT unmute must clear the latch
|
||||
// too, or a later genuine self-mute is mistaken for one PTT itself
|
||||
// applied and a subsequent press republishes the mic over it.
|
||||
pttStoreUnsubscribe = voiceStore.subscribe((s) => {
|
||||
if (!s.localMuted) pttOwnsMute = false;
|
||||
});
|
||||
|
||||
// Surface a backend polling-thread panic: no further ptt-state events can
|
||||
// ever arrive afterward, so a mute the last release applied would
|
||||
// otherwise be stranded with no way to lift it.
|
||||
pttErrorUnsubscribe = await listen<string>("ptt-error", (event) => {
|
||||
log.warn("PTT polling thread stopped unexpectedly", { error: event.payload });
|
||||
setPttPollingLive(false);
|
||||
// Capture before resetting — see ungateMic's doc comment.
|
||||
const mutedByPtt = pttOwnsMute;
|
||||
pttOwnsMute = false;
|
||||
ungateMic(mutedByPtt);
|
||||
});
|
||||
|
||||
// Listen for press/release events
|
||||
const unsub = await listen<boolean>("ptt-state", (event) => {
|
||||
// Only toggle mute when in a voice channel
|
||||
@@ -175,10 +237,20 @@ export async function stopPtt(): Promise<void> {
|
||||
try {
|
||||
pttUnsubscribe?.();
|
||||
pttUnsubscribe = null;
|
||||
pttStoreUnsubscribe?.();
|
||||
pttStoreUnsubscribe = null;
|
||||
pttErrorUnsubscribe?.();
|
||||
pttErrorUnsubscribe = null;
|
||||
// Capture before resetting — see ungateMic's doc comment.
|
||||
const mutedByPtt = pttOwnsMute;
|
||||
pttOwnsMute = false;
|
||||
// No further ptt-state events once the loop is torn down; clear the flag
|
||||
// before the await so a concurrent join cannot observe a stale `true`.
|
||||
setPttPollingLive(false);
|
||||
// With the key idle there is no press/release edge left to lift a mute
|
||||
// PTT's last release applied — rearm now, or it stays stranded for the
|
||||
// rest of the voice session (see ungateMic's doc comment).
|
||||
ungateMic(mutedByPtt);
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
await invoke("ptt_stop");
|
||||
listening = false;
|
||||
|
||||
@@ -145,12 +145,60 @@ export interface VideoTrackDeps {
|
||||
readonly reapplyAudioPipeline: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared race guard for the enable/disable pairs below
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A camera/screenshare disable() bumps this so a concurrent enable() that
|
||||
* captured an earlier value before its async device-acquisition gap
|
||||
* (getUserMedia / getDisplayMedia — a seconds-long window on the first
|
||||
* permission prompt) can tell it was superseded once that gap resolves, and
|
||||
* discard the track it just created instead of publishing over a stop. The
|
||||
* camera and screenshare races are the same shape, so both state types
|
||||
* carry this and share the two helpers below rather than each hand-rolling
|
||||
* a counter. */
|
||||
interface GenerationGuarded {
|
||||
generation?: number;
|
||||
}
|
||||
|
||||
function bumpGeneration(state: GenerationGuarded): void {
|
||||
state.generation = (state.generation ?? 0) + 1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server-refusal rollback correlation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Correlates an in-flight camera/screenshare *enable* request's envelope id
|
||||
* with its kind, so dispatcher.ts's ERROR handler can roll back the
|
||||
* already-published track on a server refusal (FORBIDDEN, RATE_LIMITED,
|
||||
* INTERNAL, ...) it could not have pre-blocked. A new enable of the same
|
||||
* kind supersedes the previous entry — only the latest in-flight request
|
||||
* for that kind can still be refused. */
|
||||
const pendingVideoEnables = new Map<string, "camera" | "screen">();
|
||||
|
||||
function registerPendingVideoEnable(id: string, kind: "camera" | "screen"): void {
|
||||
for (const [existingId, existingKind] of pendingVideoEnables) {
|
||||
if (existingKind === kind) pendingVideoEnables.delete(existingId);
|
||||
}
|
||||
pendingVideoEnables.set(id, kind);
|
||||
}
|
||||
|
||||
/** Consume a pending video-enable correlation on a server refusal. Returns
|
||||
* the kind that must be rolled back, or undefined when `id` doesn't match
|
||||
* an in-flight enable (unrelated error, or already resolved). */
|
||||
export function rollbackPendingVideo(id: string): "camera" | "screen" | undefined {
|
||||
const kind = pendingVideoEnables.get(id);
|
||||
if (kind !== undefined) pendingVideoEnables.delete(id);
|
||||
return kind;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Camera track state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Mutable state for the manually published camera track. */
|
||||
export interface CameraTrackState {
|
||||
export interface CameraTrackState extends GenerationGuarded {
|
||||
manualCameraTrack: LocalVideoTrack | null;
|
||||
}
|
||||
|
||||
@@ -176,6 +224,7 @@ export async function enableCamera(state: CameraTrackState, deps: VideoTrackDeps
|
||||
}
|
||||
setLocalCamera(true);
|
||||
const quality = getStreamQuality();
|
||||
const generation = state.generation ?? 0;
|
||||
try {
|
||||
const savedVideoDevice = loadPref<string>("videoInputDevice", "");
|
||||
stopManualCameraTrack(state, room);
|
||||
@@ -183,6 +232,13 @@ export async function enableCamera(state: CameraTrackState, deps: VideoTrackDeps
|
||||
...CAMERA_PRESETS[quality],
|
||||
...(savedVideoDevice ? { deviceId: savedVideoDevice } : {}),
|
||||
});
|
||||
if ((state.generation ?? 0) !== generation) {
|
||||
// A disableCamera ran to completion while getUserMedia was pending —
|
||||
// it already reset localCamera and sent voice_camera(false).
|
||||
// Publishing now would resurrect a track the user just turned off.
|
||||
videoTrack.stop();
|
||||
return;
|
||||
}
|
||||
state.manualCameraTrack = videoTrack;
|
||||
await room.localParticipant.publishTrack(videoTrack, {
|
||||
source: Track.Source.Camera,
|
||||
@@ -192,7 +248,8 @@ export async function enableCamera(state: CameraTrackState, deps: VideoTrackDeps
|
||||
maxFramerate: quality === "low" ? 15 : 30,
|
||||
},
|
||||
});
|
||||
ws.send({ type: "voice_camera", payload: { enabled: true } });
|
||||
const sendId = ws.send({ type: "voice_camera", payload: { enabled: true } });
|
||||
registerPendingVideoEnable(sendId, "camera");
|
||||
deps.reapplyAudioPipeline();
|
||||
log.info("Camera enabled", { quality, maxBitrate: CAMERA_PUBLISH_BITRATES[quality] });
|
||||
} catch (err) {
|
||||
@@ -214,6 +271,9 @@ export async function enableCamera(state: CameraTrackState, deps: VideoTrackDeps
|
||||
}
|
||||
|
||||
export async function disableCamera(state: CameraTrackState, deps: VideoTrackDeps): Promise<void> {
|
||||
// Bump first: a concurrent enableCamera that is still awaiting device
|
||||
// acquisition captured the pre-bump value and will detect it changed.
|
||||
bumpGeneration(state);
|
||||
const room = deps.getRoom();
|
||||
try {
|
||||
stopManualCameraTrack(state, room);
|
||||
@@ -233,7 +293,7 @@ export async function disableCamera(state: CameraTrackState, deps: VideoTrackDep
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Mutable state for the manually published screenshare tracks. */
|
||||
export interface ScreenTrackState {
|
||||
export interface ScreenTrackState extends GenerationGuarded {
|
||||
manualScreenTracks: LocalTrack[];
|
||||
}
|
||||
|
||||
@@ -267,9 +327,17 @@ export async function enableScreenshare(
|
||||
const fps = getScreenShareFps();
|
||||
const effectiveFps = getEffectiveScreenShareFps(quality, fps);
|
||||
const maxBitrate = getScreenShareMaxBitrate(quality, fps);
|
||||
const generation = state.generation ?? 0;
|
||||
try {
|
||||
stopManualScreenTracks(state, room);
|
||||
const screenTracks = await createLocalScreenTracks(getScreenShareCaptureOptions(quality, fps));
|
||||
if ((state.generation ?? 0) !== generation) {
|
||||
// A disableScreenshare ran to completion while the OS picker was still
|
||||
// up — it already reset localScreenshare and sent voice_screenshare
|
||||
// (false). Publishing now would resurrect a share the user just ended.
|
||||
for (const t of screenTracks) t.stop();
|
||||
return;
|
||||
}
|
||||
state.manualScreenTracks = screenTracks;
|
||||
for (const track of screenTracks) {
|
||||
const isVideo = track.kind === Track.Kind.Video;
|
||||
@@ -299,15 +367,19 @@ export async function enableScreenshare(
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
ws.send({ type: "voice_screenshare", payload: { enabled: true } });
|
||||
const sendId = ws.send({ type: "voice_screenshare", payload: { enabled: true } });
|
||||
registerPendingVideoEnable(sendId, "screen");
|
||||
deps.reapplyAudioPipeline();
|
||||
log.info("Screenshare enabled", { quality, fps: effectiveFps, maxBitrate });
|
||||
} catch (err) {
|
||||
// BUG-100: Stop created tracks to release screen capture if publish failed.
|
||||
for (const t of state.manualScreenTracks) {
|
||||
t.stop();
|
||||
}
|
||||
state.manualScreenTracks = [];
|
||||
// BUG-100 (+ partial-publish-failure hardening): release every created
|
||||
// track, not just stop() it — a track already published before a later
|
||||
// one in the batch fails (all quality presets request audio alongside
|
||||
// video) needs unpublishing too, or it stays orphaned in the room:
|
||||
// track.stop() is programmatic and never fires the DOM "ended" event, so
|
||||
// LiveKit's ended-driven auto-unpublish never runs. stopManualScreenTracks
|
||||
// does both and no-ops when nothing was created.
|
||||
stopManualScreenTracks(state, room);
|
||||
setLocalScreenshare(false);
|
||||
log.error("Failed to enable screenshare", err);
|
||||
if (err instanceof DOMException && err.name === "NotAllowedError") {
|
||||
@@ -322,6 +394,9 @@ export async function disableScreenshare(
|
||||
state: ScreenTrackState,
|
||||
deps: VideoTrackDeps,
|
||||
): Promise<void> {
|
||||
// Bump first: a concurrent enableScreenshare still awaiting the OS picker
|
||||
// captured the pre-bump value and will detect it changed.
|
||||
bumpGeneration(state);
|
||||
const room = deps.getRoom();
|
||||
try {
|
||||
stopManualScreenTracks(state, room);
|
||||
|
||||
@@ -118,12 +118,13 @@ function uuid(): string {
|
||||
}
|
||||
|
||||
/** Normalize a host for comparison against the Rust proxies' cert-tofu event
|
||||
* host, mirroring `tofu::cert_store_key`'s trailing-":443" strip
|
||||
* (src-tauri/src/tofu.rs). Profile/config hosts are stored verbatim (e.g.
|
||||
* "example.com:443"), but the proxies always emit the normalized form, so an
|
||||
* un-normalized comparison here would silently miss the match. */
|
||||
function normalizeHostForCertCompare(host: string): string {
|
||||
return host.replace(/:443$/, "");
|
||||
* host, mirroring `tofu::cert_store_key`'s trailing-":443" strip and
|
||||
* lowercasing (src-tauri/src/tofu.rs). Profile/config hosts are stored
|
||||
* verbatim (e.g. "Example.COM:443"), but the proxies always emit the
|
||||
* normalized (stripped, lowercased) form, so an un-normalized comparison
|
||||
* here would silently miss the match. */
|
||||
export function normalizeHostForCertCompare(host: string): string {
|
||||
return host.replace(/:443$/, "").toLowerCase();
|
||||
}
|
||||
|
||||
export function createWsClient() {
|
||||
|
||||
+126
-13
@@ -9,7 +9,7 @@ import "@styles/theme-neon-glow.css";
|
||||
import { installGlobalErrorHandlers, safeMount } from "@lib/safe-render";
|
||||
import { createRouter } from "@lib/router";
|
||||
import { createApiClient } from "@lib/api";
|
||||
import { createWsClient } from "@lib/ws";
|
||||
import { createWsClient, normalizeHostForCertCompare } from "@lib/ws";
|
||||
import { wireDispatcher, wireConnectionStatus } from "@lib/dispatcher";
|
||||
import { authStore, clearAuth } from "@stores/auth.store";
|
||||
import { setTransientError } from "@stores/ui.store";
|
||||
@@ -38,6 +38,7 @@ import { createProfileManager, createTauriBackend } from "@lib/profiles";
|
||||
import type { CertTofuEvent } from "@lib/ws";
|
||||
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
// Gate the log level before anything logs: debug entries are serialized and
|
||||
// persisted to disk, so in production the level must filter real work, not
|
||||
@@ -66,7 +67,10 @@ document.addEventListener("contextmenu", (e) => {
|
||||
// F5 and Ctrl+R are blocked to prevent accidental page reloads which cause
|
||||
// ghost voice state (user appears in channel with no LiveKit connection).
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "F5" || (e.ctrlKey && e.key === "r")) {
|
||||
// KeyboardEvent.key carries the shifted/CapsLock-cased character, so
|
||||
// Ctrl+Shift+R (and Ctrl+R with CapsLock on) would fall through this guard
|
||||
// as "R" without the lowercase compare.
|
||||
if (e.key === "F5" || (e.ctrlKey && e.key.toLowerCase() === "r")) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
@@ -114,7 +118,12 @@ const router = createRouter("connect");
|
||||
// accepted; the bearer token never rides an unpinned TLS connection.
|
||||
const api = createApiClient({ host: "" }, () => {
|
||||
log.warn("Session expired (401), clearing auth");
|
||||
setTransientError("Your session expired — sign in again.");
|
||||
// A 401 on a request made before any session existed (e.g. a failed login
|
||||
// attempt) is not a session "expiring" — the login form's own catch block
|
||||
// already surfaces that failure. Only warn about a session that was live.
|
||||
if (authStore.getState().isAuthenticated) {
|
||||
setTransientError("Your session expired — sign in again.");
|
||||
}
|
||||
clearAuth();
|
||||
});
|
||||
const ws = createWsClient();
|
||||
@@ -163,9 +172,16 @@ ws.onCertFirstUse((evt: CertTofuEvent) => {
|
||||
try {
|
||||
await ws.acceptCertFingerprint(evt.host, evt.fingerprint);
|
||||
// Refresh server health so the now-trusted host becomes reachable,
|
||||
// and resume a pending connect if one was in flight.
|
||||
// and resume a pending connect if one was in flight — but only when
|
||||
// it was pending for THIS host. Accepting a first-use cert for one
|
||||
// profile must not force-reconnect (or churn) a session already
|
||||
// live for a different host.
|
||||
rerunConnectHealth?.();
|
||||
if (lastConnectHost && lastConnectToken) {
|
||||
if (
|
||||
lastConnectHost &&
|
||||
lastConnectToken &&
|
||||
evt.host === normalizeHostForCertCompare(lastConnectHost)
|
||||
) {
|
||||
ws.connect({ host: lastConnectHost, token: lastConnectToken });
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -194,7 +210,11 @@ ws.onCertMismatch((evt: CertTofuEvent) => {
|
||||
void (async () => {
|
||||
try {
|
||||
await ws.acceptCertFingerprint(evt.host, evt.fingerprint);
|
||||
if (lastConnectHost && lastConnectToken) {
|
||||
if (
|
||||
lastConnectHost &&
|
||||
lastConnectToken &&
|
||||
evt.host === normalizeHostForCertCompare(lastConnectHost)
|
||||
) {
|
||||
reconnectAfterCertAccept(ws, router, lastConnectHost, lastConnectToken);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -205,9 +225,14 @@ ws.onCertMismatch((evt: CertTofuEvent) => {
|
||||
onReject: () => {
|
||||
modal.destroy?.();
|
||||
certModalActive = false;
|
||||
ws.disconnect();
|
||||
clearAuth();
|
||||
router.navigate("connect");
|
||||
// Only tear down the live session when the mismatch is FOR that
|
||||
// session's host — a rotated cert on an unrelated saved profile must
|
||||
// not disconnect and log out an unrelated authenticated session.
|
||||
if (evt.host === normalizeHostForCertCompare(lastConnectHost)) {
|
||||
ws.disconnect();
|
||||
clearAuth();
|
||||
router.navigate("connect");
|
||||
}
|
||||
},
|
||||
});
|
||||
modal.mount(document.body);
|
||||
@@ -217,6 +242,19 @@ ws.onCertMismatch((evt: CertTofuEvent) => {
|
||||
// are received during the connect page's health checks, before any WS connect.
|
||||
void ws.startCertListener();
|
||||
|
||||
// Route the tray's Status submenu (Online/Idle/Do Not Disturb/Offline) into
|
||||
// the same presence_update wire message the in-app StatusPicker sends
|
||||
// (UserBar.ts/MainPage.ts's applyPresence) — the Rust side only emitted
|
||||
// "status-change" with nothing in the webview listening for it. ws.send is a
|
||||
// safe no-op (logged) when there is no live session, so no auth guard is
|
||||
// needed here.
|
||||
void listen<string>("status-change", (e) => {
|
||||
const status = e.payload;
|
||||
if (status === "online" || status === "idle" || status === "dnd" || status === "offline") {
|
||||
ws.send({ type: "presence_update", payload: { status } });
|
||||
}
|
||||
});
|
||||
|
||||
// Current page component reference for cleanup
|
||||
let currentPage: { destroy?(): void } | null = null;
|
||||
|
||||
@@ -402,6 +440,17 @@ async function renderPage(pageId: "connect" | "main"): Promise<void> {
|
||||
return [{ name: "Local Server", host: "localhost:8443" }];
|
||||
}
|
||||
|
||||
// Persist a profile mutation, surfacing a failure instead of letting it
|
||||
// silently revert on next launch: profiles.ts awaits invoke("save_settings"),
|
||||
// which rejects when the store write fails (read-only file, disk full),
|
||||
// and `void`-ing that rejection at each call site (as this used to) left
|
||||
// the in-memory store as the only record of the change.
|
||||
function persistProfiles(): void {
|
||||
void profileManager.saveProfiles().catch(() => {
|
||||
setTransientError("Could not save server profiles");
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-save a profile for a host after successful login (if not already saved)
|
||||
function ensureProfileExists(host: string, username: string, rememberPassword: boolean): void {
|
||||
const existing = profileManager.getAll().find((p) => p.host === host);
|
||||
@@ -420,7 +469,7 @@ async function renderPage(pageId: "connect" | "main"): Promise<void> {
|
||||
});
|
||||
profileManager.setLastConnected(created.id);
|
||||
}
|
||||
void profileManager.saveProfiles();
|
||||
persistProfiles();
|
||||
}
|
||||
|
||||
const connectPage = createConnectPage(
|
||||
@@ -483,23 +532,37 @@ async function renderPage(pageId: "connect" | "main"): Promise<void> {
|
||||
rememberPassword: false,
|
||||
color: "#5865F2",
|
||||
});
|
||||
void profileManager.saveProfiles();
|
||||
persistProfiles();
|
||||
connectPage.refreshProfiles(getProfileList());
|
||||
// Check health for the new profile
|
||||
runHealthChecks(connectPage, getProfileList());
|
||||
},
|
||||
onDeleteProfile(profileId) {
|
||||
profileManager.removeProfile(profileId);
|
||||
void profileManager.saveProfiles();
|
||||
persistProfiles();
|
||||
connectPage.refreshProfiles(getProfileList());
|
||||
},
|
||||
onToggleAutoLogin(profileId, enabled) {
|
||||
profileManager.setAutoLogin(enabled ? profileId : null);
|
||||
void profileManager.saveProfiles();
|
||||
persistProfiles();
|
||||
connectPage.refreshProfiles(getProfileList());
|
||||
},
|
||||
onAutoLoginCancel() {
|
||||
autoLoginCancelled = true;
|
||||
// Every read of this flag below runs before the overlay carrying
|
||||
// this Cancel button is ever painted, so by the time a click
|
||||
// reaches here the session is already in flight (wirePostAuth has
|
||||
// called ws.connect and registered listeners). Tear it down the
|
||||
// same way the logout path does.
|
||||
sessionCleanup?.();
|
||||
sessionCleanup = null;
|
||||
dispatcherCleanup?.();
|
||||
dispatcherCleanup = null;
|
||||
connectedOverlay?.destroy();
|
||||
connectedOverlay = null;
|
||||
ws.disconnect();
|
||||
lastConnectHost = "";
|
||||
lastConnectToken = "";
|
||||
},
|
||||
},
|
||||
getProfileList(),
|
||||
@@ -556,6 +619,19 @@ async function renderPage(pageId: "connect" | "main"): Promise<void> {
|
||||
return; // Skip auto-login when switching servers
|
||||
}
|
||||
|
||||
// A logout that deleted this host's credential is on its way out as a
|
||||
// fire-and-forget delete_credential. Auto-login below would read the
|
||||
// same account back concurrently, and the credential commands run off
|
||||
// the IPC thread now (`#[tauri::command(async)]`), so a read that wins
|
||||
// that race signs the user straight back into the server they just left.
|
||||
// Suppressing the attempt removes the race instead of relying on the
|
||||
// delete being dispatched early enough to win it — and an auto-login
|
||||
// immediately after an explicit logout is wrong regardless of timing.
|
||||
if (sessionStorage.getItem("owncord:skip-auto-login") !== null) {
|
||||
sessionStorage.removeItem("owncord:skip-auto-login");
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-login: if a profile has autoConnect enabled, try to reconnect
|
||||
// using the stored token (password is no longer returned from the
|
||||
// credential store over IPC for security).
|
||||
@@ -643,6 +719,11 @@ authStore.subscribeSelector(
|
||||
const host = api.getConfig().host;
|
||||
if (host && authStore.getState().logoutReason !== "server_shutdown") {
|
||||
void deleteCredential(host);
|
||||
// Same condition on purpose: whenever the credential is being removed,
|
||||
// the connect page must not turn around and auto-login with it. A
|
||||
// server_shutdown keeps the credential precisely so auto-login still
|
||||
// works on restart, so it deliberately does not set this.
|
||||
sessionStorage.setItem("owncord:skip-auto-login", "1");
|
||||
}
|
||||
router.navigate("connect");
|
||||
}
|
||||
@@ -672,6 +753,38 @@ void initWindowState();
|
||||
// form — it can't complete a join by itself.
|
||||
function handleInviteDeepLink(code: string, host?: string): void {
|
||||
pendingInviteLink = { code, host };
|
||||
if (router.getCurrentPage() === "main") {
|
||||
// Let the logout path do the teardown instead of navigating behind a
|
||||
// live session: clearAuth() fires while the router is still on "main",
|
||||
// so the authStore subscriber above runs its full teardown (voice leave,
|
||||
// dispatcher/session cleanup, ws.disconnect) and navigates to "connect"
|
||||
// itself — whose render branch consumes pendingInviteLink below.
|
||||
clearAuth();
|
||||
return;
|
||||
}
|
||||
if (lastConnectHost !== "") {
|
||||
// wirePostAuth already ran — a login/auto-login/register is connecting,
|
||||
// or reached auth_ok (isAuthenticated flipped true) but the connected
|
||||
// overlay's ready countdown hasn't called router.navigate("main") yet, so
|
||||
// the branch above never triggered. The authStore subscriber only tears
|
||||
// down once the router IS "main", so it won't fire for this window
|
||||
// either: left alone, the overlay's timer fires router.navigate("main")
|
||||
// regardless, mounting MainPage on top of whatever this handler does to
|
||||
// authStore below. Tear the in-flight session down directly, the same
|
||||
// way onAutoLoginCancel does, before applying the invite below.
|
||||
sessionCleanup?.();
|
||||
sessionCleanup = null;
|
||||
dispatcherCleanup?.();
|
||||
dispatcherCleanup = null;
|
||||
connectedOverlay?.destroy();
|
||||
connectedOverlay = null;
|
||||
ws.disconnect();
|
||||
lastConnectHost = "";
|
||||
lastConnectToken = "";
|
||||
if (authStore.getState().isAuthenticated) {
|
||||
clearAuth();
|
||||
}
|
||||
}
|
||||
router.navigate("connect");
|
||||
// If the connect page was already mounted, navigate() may not re-render it —
|
||||
// apply directly. Otherwise the connect render branch consumes the pending link.
|
||||
|
||||
@@ -211,6 +211,7 @@ export function createConnectPage(
|
||||
> | null = null;
|
||||
let settingsOverlayLoading = false;
|
||||
let unsubSettingsOpen: (() => void) | null = null;
|
||||
let unsubTransientError: (() => void) | null = null;
|
||||
|
||||
// The settings overlay (whose tabs pull in the LiveKit stack) is created
|
||||
// lazily on first open so it stays out of the startup path. Once created it
|
||||
@@ -254,6 +255,20 @@ export function createConnectPage(
|
||||
);
|
||||
if (uiStore.getState().settingsOpen) ensureSettingsOverlay();
|
||||
|
||||
// Surface a transient error for as long as this page is mounted — not
|
||||
// just one already pending at mount time. A WS auth_error, a cert-
|
||||
// mismatch reject, or a background credential-save warning can all set
|
||||
// this while the connect page is already up; a one-time getState() read
|
||||
// here would silently drop them.
|
||||
unsubTransientError = uiStore.subscribeSelector(
|
||||
(s) => s.transientError,
|
||||
(msg) => {
|
||||
if (msg) {
|
||||
loginForm.showError(msg);
|
||||
setTransientError(null);
|
||||
}
|
||||
},
|
||||
);
|
||||
// Show any pending auth error (e.g. "already connected from another client")
|
||||
const pendingError = uiStore.getState().transientError;
|
||||
if (pendingError) {
|
||||
@@ -270,9 +285,17 @@ export function createConnectPage(
|
||||
abortController.abort();
|
||||
unsubSettingsOpen?.();
|
||||
unsubSettingsOpen = null;
|
||||
unsubTransientError?.();
|
||||
unsubTransientError = null;
|
||||
settingsOverlay?.destroy?.();
|
||||
settingsOverlay = null;
|
||||
|
||||
// Any transient error set while this page was mounted (shown or not)
|
||||
// must not resurface at the next mount — which only happens after a
|
||||
// later logout, where it would misleadingly read as a fresh login
|
||||
// failure rather than whatever set it during this session.
|
||||
setTransientError(null);
|
||||
|
||||
if (container && root) {
|
||||
container.removeChild(root);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "@lib/livekitSession";
|
||||
import { setServerHost } from "@components/message-list/renderers";
|
||||
import { clearAttachmentCaches } from "@components/message-list/attachments";
|
||||
import { closeActiveLightbox } from "@components/message-list/media";
|
||||
import {
|
||||
setReactionUsersFetcher,
|
||||
clearReactionUsersCache,
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
import { setMarkReadSender } from "@lib/read-state";
|
||||
import { setChannelMutesHost } from "@lib/channel-mutes";
|
||||
import { setNsfwGateHost } from "@lib/nsfw-gate";
|
||||
import { setAudioVolumeHost } from "@lib/audioElements";
|
||||
import { createQuickSwitcherManager } from "./main-page/OverlayManagers";
|
||||
import { attachGlobalKeybinds } from "./main-page/GlobalKeybinds";
|
||||
import { createVoiceWidgetCallbacks } from "./main-page/VoiceCallbacks";
|
||||
@@ -102,6 +104,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
// cannot leave the previous server's scope armed for the next connection.
|
||||
setChannelMutesHost(apiConfig.host ?? null);
|
||||
setNsfwGateHost(apiConfig.host ?? null);
|
||||
setAudioVolumeHost(apiConfig.host ?? null);
|
||||
|
||||
// "Mark as Read" affordances need the socket but are reached from deep inside
|
||||
// the sidebar; register the sender once instead of threading ws through.
|
||||
@@ -232,7 +235,11 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
if (active === null || active.type !== "dm") return;
|
||||
|
||||
const dmChannel = dmStore.getState().channels.find((c) => c.channelId === active.id);
|
||||
if (dmChannel === undefined) return;
|
||||
// A group has no single "recipient" — dm.store.ts documents .recipient as
|
||||
// just the first of .participants for a group, with group-correct code
|
||||
// expected to read .participants instead. A 1:1 profile panel built from
|
||||
// it would present one arbitrary member's identity as the conversation.
|
||||
if (dmChannel === undefined || dmChannel.isGroup) return;
|
||||
|
||||
const recipient = dmChannel.recipient;
|
||||
const status =
|
||||
@@ -271,6 +278,13 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
function startCall(): void {
|
||||
const active = getActiveChannel();
|
||||
if (active === null || active.type !== "dm") return;
|
||||
// onVoiceJoin silently refuses to join when the socket is down
|
||||
// (VoiceCallbacks.ts's socketLive() guard) — without this check the ring
|
||||
// and "Calling…" toast fire anyway, promising a call nobody can hear.
|
||||
if (uiStore.getState().connectionStatus !== "connected") {
|
||||
showToast("Not connected", "error");
|
||||
return;
|
||||
}
|
||||
createSidebarVoiceCallbacks(ws).onVoiceJoin(active.id);
|
||||
ws.send({ type: "call_ring", payload: { channel_id: active.id } });
|
||||
showToast("Calling…", "info");
|
||||
@@ -526,7 +540,20 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
},
|
||||
});
|
||||
callBanner = createIncomingCallBanner({
|
||||
onAccept: () => ringCtrl?.accept(),
|
||||
onAccept: () => {
|
||||
// ringCtrl.accept() unconditionally consumes the ring
|
||||
// (stopRinging) before onVoiceJoin ever runs, and onVoiceJoin itself
|
||||
// silently refuses to join while the socket is down (VoiceCallbacks
|
||||
// .ts's socketLive() guard) — so accepting while reconnecting would
|
||||
// otherwise discard the ring for good with no join and no retry.
|
||||
// Guarded here, the banner's only caller of accept(), so the ring
|
||||
// survives for the user to accept again once reconnected.
|
||||
if (uiStore.getState().connectionStatus !== "connected") {
|
||||
showToast("Can't answer while reconnecting", "error");
|
||||
return;
|
||||
}
|
||||
ringCtrl?.accept();
|
||||
},
|
||||
onDecline: () => ringCtrl?.decline(),
|
||||
});
|
||||
callBanner.mount(root);
|
||||
@@ -680,7 +707,11 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
try {
|
||||
const active = getActiveChannel();
|
||||
if (active !== null) {
|
||||
if (active.type === "text") {
|
||||
// Voice is the only channel type that should keep the grid up;
|
||||
// text, dm and announcement all mount a chat surface and must
|
||||
// dismiss it, not just "text" (a dm/announcement switch used to
|
||||
// leave the grid covering an unrelated channel's chat).
|
||||
if (active.type !== "voice") {
|
||||
videoModeCtrl?.showChat();
|
||||
}
|
||||
// Close DM profile sidebar when switching channels
|
||||
@@ -723,6 +754,12 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
function destroy(): void {
|
||||
log.info("MainPage destroying");
|
||||
try {
|
||||
// closeSettings() is otherwise only ever called from the overlay's own
|
||||
// onClose — a non-user-initiated unmount (401, ban, server shutdown)
|
||||
// left `settingsOpen` stale, and the next page to mount an (initially
|
||||
// hidden) SettingsOverlay off that flag — ConnectPage, after logout —
|
||||
// would show it over the login screen.
|
||||
closeSettings();
|
||||
teardownToast();
|
||||
// Full voice cleanup — tears down room, callbacks, ws ref, serverHost.
|
||||
// Prevents stale module-level state persisting across logout/reconnect cycles.
|
||||
@@ -735,6 +772,11 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
// clip viewed this session stays pinned (as a blob: URL or a cached
|
||||
// data: URI) past logout.
|
||||
clearAttachmentCaches();
|
||||
// The lightbox is a module-level overlay appended straight to
|
||||
// document.body — renderPage only clears #app, so a forced logout with
|
||||
// it open would otherwise leave it floating over the login screen with
|
||||
// live document listeners and a since-revoked blob URL (B6-15).
|
||||
closeActiveLightbox();
|
||||
autoIdle?.destroy();
|
||||
autoIdle = null;
|
||||
channelCtrl?.destroyChannel();
|
||||
|
||||
@@ -123,6 +123,12 @@ export function createChannelController(opts: ChannelControllerOptions): Channel
|
||||
|
||||
function destroyChannel(): void {
|
||||
pendingDeleteManager.cleanup();
|
||||
// The reaction picker is a body-mounted overlay keyed to a message in
|
||||
// this channel — every other teardown path already routes through here,
|
||||
// so this is the one choke point to close it before the channel it was
|
||||
// opened against goes away. destroy() is idempotent (closePicker
|
||||
// null-checks), so it is safe even when no picker is open.
|
||||
reactionCtrl.destroy();
|
||||
|
||||
for (const unsub of composerGatingUnsubs) unsub();
|
||||
composerGatingUnsubs = [];
|
||||
@@ -498,7 +504,11 @@ export function createChannelController(opts: ChannelControllerOptions): Channel
|
||||
// Find the last message sent by the current user (array is chronological)
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
const m = msgs[i]!;
|
||||
if (m.user.id === myId && !m.deleted) {
|
||||
// Mirrors renderers.ts's gate on the visual Edit affordance: an
|
||||
// optimistic row (pending/failed) carries id 0 until the server
|
||||
// acks it, so editing it would send chat_edit for a message that
|
||||
// does not exist yet.
|
||||
if (m.user.id === myId && !m.deleted && m.status === "sent") {
|
||||
messageInput?.startEdit(m.id, m.content);
|
||||
break;
|
||||
}
|
||||
@@ -574,6 +584,18 @@ export function createChannelController(opts: ChannelControllerOptions): Channel
|
||||
updateChatHeaderForDm(chatHeaderRefs, null);
|
||||
if (chatHeaderName !== null) {
|
||||
setText(chatHeaderName, channelName);
|
||||
// Keep the header name live across channel_update events (a rename),
|
||||
// same as the topic subscription right below — otherwise it is set
|
||||
// once from the mount-time snapshot and disagrees with the sidebar
|
||||
// row (which does re-render off the live store) until the channel is
|
||||
// remounted.
|
||||
const nameEl = chatHeaderName;
|
||||
composerGatingUnsubs.push(
|
||||
channelsStore.subscribeSelector(
|
||||
(s) => s.channels.get(channelId)?.name ?? channelName,
|
||||
(name) => setText(nameEl, name),
|
||||
),
|
||||
);
|
||||
}
|
||||
// Show the channel topic and keep it live across channel_update events.
|
||||
const topicEl = chatHeaderRefs.topicEl;
|
||||
|
||||
@@ -84,9 +84,11 @@ export function createChatArea(opts: ChatAreaOptions): ChatAreaResult {
|
||||
api,
|
||||
getRoot,
|
||||
getCurrentChannelId: () => getChannelCtrl()?.currentChannelId ?? null,
|
||||
onJumpToMessage: (msgId: number) => {
|
||||
const channelId = getChannelCtrl()?.currentChannelId;
|
||||
if (channelId == null) return;
|
||||
// The panel forwards the channel it was opened for (captured at open
|
||||
// time), not whatever is active now — the active channel can change
|
||||
// while the panel is sitting open, and re-deriving it live here would
|
||||
// silently jump in the wrong channel.
|
||||
onJumpToMessage: (channelId: number, msgId: number) => {
|
||||
void jumper.jumpTo(channelId, msgId);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -82,7 +82,12 @@ export function createMessageController(opts: MessageControllerOptions): Message
|
||||
setChannelLoading(channelId);
|
||||
try {
|
||||
const resp = await api.getMessages(channelId, { limit: PAGE_SIZE }, signal);
|
||||
if (!signal.aborted) {
|
||||
// Re-check "loaded" after the await: a same-channel jump can install an
|
||||
// around-window (setAroundMessages) while this mount-time tail fetch is
|
||||
// still in flight — nothing aborts this fetch's signal in that case.
|
||||
// Both landing marks the channel loaded, so a tail response that lost
|
||||
// the race is discarded instead of clobbering the jump's window.
|
||||
if (!signal.aborted && !isChannelLoaded(channelId)) {
|
||||
log.info("Messages loaded", {
|
||||
channelId,
|
||||
count: resp.messages.length,
|
||||
@@ -120,7 +125,16 @@ export function createMessageController(opts: MessageControllerOptions): Message
|
||||
signal,
|
||||
);
|
||||
if (!signal.aborted) {
|
||||
prependMessages(channelId, resp.messages, resp.has_more);
|
||||
// The window can be replaced wholesale while this fetch is in flight
|
||||
// (e.g. a same-channel jump swaps in an around-window via
|
||||
// setAroundMessages) — nothing aborts this fetch's controller in that
|
||||
// case. Splicing this now-stale page onto a window it was never
|
||||
// fetched for would duplicate/misorder rows, so bail if the row this
|
||||
// page continues from is no longer the window's oldest.
|
||||
const current = getChannelMessages(channelId);
|
||||
if (current.length > 0 && current[0]!.id === oldest.id) {
|
||||
prependMessages(channelId, resp.messages, resp.has_more);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!signal.aborted) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { PinnedMessage } from "@components/PinnedMessages";
|
||||
import { createSearchOverlay } from "@components/SearchOverlay";
|
||||
import { showToast } from "@lib/toast";
|
||||
import { setActiveChannel } from "@stores/channels.store";
|
||||
import { setMessagePinned } from "@stores/messages.store";
|
||||
|
||||
const log = createLogger("overlays");
|
||||
|
||||
@@ -226,11 +227,14 @@ export function createPinnedPanelController(opts: {
|
||||
|
||||
readonly getCurrentChannelId: () => number | null;
|
||||
/**
|
||||
* Jump to a pinned message. Fire-and-forget: the jumper fetches the
|
||||
* around-window when the message is not loaded and reports its own failures,
|
||||
* so the panel simply closes and gets out of the way.
|
||||
* Jump to a pinned message, in the channel the panel was opened for (the
|
||||
* panel does not re-derive "current channel" live — a channel switch while
|
||||
* it is open must not silently retarget the jump). Fire-and-forget: the
|
||||
* jumper fetches the around-window when the message is not loaded and
|
||||
* reports its own failures, so the panel simply closes and gets out of the
|
||||
* way.
|
||||
*/
|
||||
readonly onJumpToMessage?: (messageId: number) => void;
|
||||
readonly onJumpToMessage?: (channelId: number, messageId: number) => void;
|
||||
}): PinnedPanelController {
|
||||
let instance: MountableComponent | null = null;
|
||||
// Same guard as InviteManagerController.open: `instance` is only assigned
|
||||
@@ -262,13 +266,17 @@ export function createPinnedPanelController(opts: {
|
||||
channelId,
|
||||
pinnedMessages: pins,
|
||||
onJumpToMessage: (msgId: number) => {
|
||||
opts.onJumpToMessage?.(msgId);
|
||||
opts.onJumpToMessage?.(channelId, msgId);
|
||||
close();
|
||||
},
|
||||
onUnpin: (msgId: number) => {
|
||||
void opts.api
|
||||
.unpinMessage(channelId, msgId)
|
||||
.then(() => {
|
||||
// The server has no pin/unpin broadcast — this store write is
|
||||
// the row's only local authority for `pinned`. Without it the
|
||||
// row still says "Unpin" after this panel closes.
|
||||
setMessagePinned(channelId, msgId, false);
|
||||
close();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from "./SidebarDmHelpers";
|
||||
import { createMemberPickerModal } from "./MemberPickerModal";
|
||||
import { createPromptModal } from "@lib/modalFactory";
|
||||
import type { ModalInstance } from "@lib/modalFactory";
|
||||
import { toggleChannelMute } from "@lib/channel-mutes";
|
||||
import { createSidebarDmSection } from "./SidebarDmSection";
|
||||
import { uiStore, setSidebarMode, loadCollapsedCategories } from "@stores/ui.store";
|
||||
@@ -103,6 +104,17 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
|
||||
|
||||
// Quick-switch overlay instance
|
||||
let quickSwitchInstance: MountableComponent | null = null;
|
||||
// Set for the duration of the profile-load round trip. `quickSwitchInstance`
|
||||
// is only assigned after that await, so the synchronous
|
||||
// `quickSwitchInstance !== null` guard alone lets a double-click during the
|
||||
// load mount two overlays — the second assignment orphans the first, which
|
||||
// is then unreachable by its own close affordances. Same pattern as
|
||||
// InviteManagerController / PinnedPanelController in OverlayManagers.ts.
|
||||
let openingQuickSwitch = false;
|
||||
|
||||
// Track the rename-group prompt so page teardown removes it — every other
|
||||
// modal in this file assigns `activeModal` for the same reason.
|
||||
let activePrompt: ModalInstance | null = null;
|
||||
|
||||
// Re-render hook for the DM sidebar, set while DM mode is mounted. Mute state
|
||||
// lives in localStorage rather than a store, so toggling it has no subscriber
|
||||
@@ -462,7 +474,7 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
|
||||
function renameGroup(channelId: number): void {
|
||||
const dm = dmStore.getState().channels.find((c) => c.channelId === channelId);
|
||||
if (dm === undefined || !dm.isGroup) return;
|
||||
createPromptModal({
|
||||
const prompt = createPromptModal({
|
||||
title: "Rename Group",
|
||||
label: "Leave it empty to go back to listing the members.",
|
||||
initialValue: dm.name,
|
||||
@@ -477,7 +489,11 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
|
||||
getToast()?.show(msg, "error");
|
||||
});
|
||||
},
|
||||
onClose: () => {
|
||||
activePrompt = null;
|
||||
},
|
||||
});
|
||||
activePrompt = prompt;
|
||||
}
|
||||
|
||||
function buildDmSidebar(): MountableComponent {
|
||||
@@ -678,49 +694,54 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function openQuickSwitch(): void {
|
||||
if (quickSwitchInstance !== null) return;
|
||||
if (quickSwitchInstance !== null || openingQuickSwitch) return;
|
||||
openingQuickSwitch = true;
|
||||
|
||||
const currentHost = api.getConfig().host ?? "";
|
||||
|
||||
// Load profiles asynchronously, then show overlay
|
||||
void (async () => {
|
||||
let profiles: readonly QuickSwitchProfile[];
|
||||
|
||||
try {
|
||||
if (profileManager === null) {
|
||||
profileManager = createProfileManager(createTauriBackend());
|
||||
let profiles: readonly QuickSwitchProfile[];
|
||||
|
||||
try {
|
||||
if (profileManager === null) {
|
||||
profileManager = createProfileManager(createTauriBackend());
|
||||
}
|
||||
await profileManager.loadProfiles();
|
||||
profiles = profileManager.getAll().map((p) => ({
|
||||
name: p.name,
|
||||
host: p.host,
|
||||
}));
|
||||
} catch {
|
||||
// If profiles fail to load (e.g., outside Tauri), show empty list
|
||||
profiles = [];
|
||||
}
|
||||
await profileManager.loadProfiles();
|
||||
profiles = profileManager.getAll().map((p) => ({
|
||||
name: p.name,
|
||||
host: p.host,
|
||||
}));
|
||||
} catch {
|
||||
// If profiles fail to load (e.g., outside Tauri), show empty list
|
||||
profiles = [];
|
||||
|
||||
// Ensure we haven't been cleaned up while awaiting
|
||||
if (sidebarWrapper.parentElement === null) return;
|
||||
|
||||
quickSwitchInstance = createQuickSwitchOverlay({
|
||||
profiles,
|
||||
currentHost,
|
||||
onSwitch: (host, _name) => {
|
||||
closeQuickSwitch();
|
||||
// Store target for ConnectPage to auto-select after navigation
|
||||
sessionStorage.setItem("owncord:quick-switch-target", host);
|
||||
// Trigger normal logout flow (clears auth -> ws disconnect -> navigate to connect)
|
||||
clearAuth();
|
||||
},
|
||||
onAddServer: () => {
|
||||
closeQuickSwitch();
|
||||
// Navigate to ConnectPage so the user can add a new server
|
||||
clearAuth();
|
||||
},
|
||||
onClose: closeQuickSwitch,
|
||||
});
|
||||
quickSwitchInstance.mount(document.body);
|
||||
} finally {
|
||||
openingQuickSwitch = false;
|
||||
}
|
||||
|
||||
// Ensure we haven't been cleaned up while awaiting
|
||||
if (sidebarWrapper.parentElement === null) return;
|
||||
|
||||
quickSwitchInstance = createQuickSwitchOverlay({
|
||||
profiles,
|
||||
currentHost,
|
||||
onSwitch: (host, _name) => {
|
||||
closeQuickSwitch();
|
||||
// Store target for ConnectPage to auto-select after navigation
|
||||
sessionStorage.setItem("owncord:quick-switch-target", host);
|
||||
// Trigger normal logout flow (clears auth -> ws disconnect -> navigate to connect)
|
||||
clearAuth();
|
||||
},
|
||||
onAddServer: () => {
|
||||
closeQuickSwitch();
|
||||
// Navigate to ConnectPage so the user can add a new server
|
||||
clearAuth();
|
||||
},
|
||||
onClose: closeQuickSwitch,
|
||||
});
|
||||
quickSwitchInstance.mount(document.body);
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -752,6 +773,13 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
|
||||
}
|
||||
});
|
||||
|
||||
unsubscribers.push(() => {
|
||||
if (activePrompt !== null) {
|
||||
activePrompt.destroy();
|
||||
activePrompt = null;
|
||||
}
|
||||
});
|
||||
|
||||
unsubscribers.push(() => {
|
||||
if (activeSidebarContent !== null) {
|
||||
activeSidebarContent.destroy?.();
|
||||
|
||||
@@ -78,6 +78,10 @@ export function createVideoModeController(opts: VideoModeControllerOptions): Vid
|
||||
if (!videoMode) return;
|
||||
videoMode = false;
|
||||
focusedTileId = null;
|
||||
// Clear the grid's own focus state too — otherwise it stays pinned to
|
||||
// whatever tile was focused and the next auto-open (B5-15) reopens
|
||||
// straight into a stale focus layout.
|
||||
videoGrid.setFocusedTile(null);
|
||||
localTileAdded = false;
|
||||
localScreenshareTileAdded = false;
|
||||
slots.messagesSlot.style.display = "";
|
||||
@@ -105,6 +109,12 @@ export function createVideoModeController(opts: VideoModeControllerOptions): Vid
|
||||
// localCamera/localScreenshare go false, and this early return skips
|
||||
// the reset below — showChat() here would strand userDismissedVideo
|
||||
// set and suppress auto-open for the next session.
|
||||
//
|
||||
// This is a real leave (not a reconnect — currentChannelId stays set
|
||||
// during auto-reconnect), so clear any remote tiles left over from the
|
||||
// ended session too (B1-8) — otherwise they persist as dead
|
||||
// MediaStreams and keep hasStreams() true for the next join.
|
||||
videoGrid.clearStreams();
|
||||
closeVideoGrid();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createStore } from "@lib/store";
|
||||
import type { UserWithRole } from "@lib/types";
|
||||
import { resetVoiceStore, voiceStore } from "@stores/voice.store";
|
||||
import { resetMessagesStore } from "@stores/messages.store";
|
||||
import { resetChannelsStore } from "@stores/channels.store";
|
||||
import { cleanupNotificationAudio } from "@lib/notifications";
|
||||
import { clearNsfwAcknowledgements } from "@lib/nsfw-gate";
|
||||
import { createLogger } from "@lib/logger";
|
||||
@@ -68,7 +69,9 @@ export function setAuth(token: string, user: UserWithRole, serverName: string, m
|
||||
* id that also exists on the next-signed-into server (channel ids are only
|
||||
* unique per-server) would short-circuit its refetch and render the
|
||||
* previous session's messages, and same-account relogin would leave a
|
||||
* permanent hole for messages posted while logged out. */
|
||||
* permanent hole for messages posted while logged out. Also clears
|
||||
* channelsStore: setChannels' DM-row carry otherwise re-inserts the
|
||||
* previous server's DM channel rows into the next server's channel map. */
|
||||
export function clearAuth(reason: LogoutReason = "user"): void {
|
||||
// livekitSession (and the ~1.3 MB livekit-client SDK behind it) is loaded
|
||||
// lazily so it stays out of the startup path. Only import it when there is
|
||||
@@ -87,6 +90,7 @@ export function clearAuth(reason: LogoutReason = "user"): void {
|
||||
}
|
||||
resetVoiceStore();
|
||||
resetMessagesStore();
|
||||
resetChannelsStore();
|
||||
// NSFW acknowledgements are per-viewer consent, not per-device: without this
|
||||
// the next account signed into the same server inherits the previous user's
|
||||
// acks and the age gate silently never appears for them. Host-scoping the
|
||||
|
||||
@@ -128,6 +128,18 @@ export function setRoles(roles: readonly ReadyRole[]): void {
|
||||
channelsStore.setState((prev) => ({ ...prev, roles }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the entire store to its initial (empty) state — e.g. on logout.
|
||||
* Also clears unreadOnOpen: without this, setChannels' DM-row carry (above)
|
||||
* re-inserts the previous server's DM channel rows into the next server's
|
||||
* channel map on the next login, and a stale read-position snapshot from the
|
||||
* old server would otherwise leak into the new one.
|
||||
*/
|
||||
export function resetChannelsStore(): void {
|
||||
unreadOnOpen.clear();
|
||||
channelsStore.setState(() => INITIAL_STATE);
|
||||
}
|
||||
|
||||
/** Look up a role ID by name (case-insensitive). Returns undefined if not found. */
|
||||
export function getRoleIdByName(name: string): number | undefined {
|
||||
const roles = channelsStore.getState().roles;
|
||||
|
||||
@@ -172,6 +172,35 @@ export const messagesStore = createStore<MessagesState>(INITIAL_STATE);
|
||||
// Actions
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whether `optimistic` is an unreconciled local row for the same send that
|
||||
* `candidate` (a fresh server-sourced "sent" row) represents — i.e. the
|
||||
* server did persist the send but the local row never learned that, because
|
||||
* its chat_send_ok ack was lost. Shared by addMessage's live-broadcast path
|
||||
* and setMessages' resync merge so the two never grow divergent notions of
|
||||
* "same message".
|
||||
*
|
||||
* Bounded to avoid collapsing two genuinely distinct sends that happen to
|
||||
* share text: only rows still actually awaiting reconciliation qualify —
|
||||
* "pending", or "failed" for a reason (OFFLINE) that means the send may
|
||||
* still have gone through despite the local failure. A server-rejected send
|
||||
* (SLOW_MODE/FORBIDDEN/...) is never broadcast or replayed, so no echo can
|
||||
* legitimately arrive for it; matching those would silently eat a row the
|
||||
* user still needs to retry. Beyond that, callers must consume each
|
||||
* candidate at most once (findIndex + a seen-set) so N identical pending
|
||||
* sends match N identical real messages one-to-one instead of collapsing
|
||||
* onto a single row.
|
||||
*/
|
||||
function isUnreconciledEcho(optimistic: Message, candidate: Message): boolean {
|
||||
return (
|
||||
(optimistic.status === "pending" ||
|
||||
(optimistic.status === "failed" && optimistic.errorCode === "OFFLINE")) &&
|
||||
optimistic.correlationId !== null &&
|
||||
optimistic.user.id === candidate.user.id &&
|
||||
optimistic.content === candidate.content
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a new message from a chat_message WS event, reconciling with any
|
||||
* optimistic row it corresponds to.
|
||||
@@ -181,8 +210,10 @@ export const messagesStore = createStore<MessagesState>(INITIAL_STATE);
|
||||
* 1. If a row with the same real id exists, replace it in place — this turns
|
||||
* an optimistic "sent" row into the full server message (attachments,
|
||||
* sanitized content, server timestamp) and is idempotent against replay.
|
||||
* 2. Otherwise, defensively reconcile the oldest still-pending row from the
|
||||
* same author (covers a broadcast that raced ahead of its ack).
|
||||
* 2. Otherwise, defensively reconcile the oldest still-pending (or
|
||||
* OFFLINE-failed) row from the same author (covers a broadcast that
|
||||
* raced ahead of its ack, or one the offline sweep gave up on before
|
||||
* learning the server had already stored it).
|
||||
* 3. Otherwise, append as a new message.
|
||||
*/
|
||||
export function addMessage(payload: ChatMessagePayload): void {
|
||||
@@ -200,18 +231,17 @@ export function addMessage(payload: ChatMessagePayload): void {
|
||||
return { ...prev, messagesByChannel: updated };
|
||||
}
|
||||
|
||||
// 2. Defensive: reconcile the oldest pending optimistic row from this author
|
||||
// (a broadcast that arrived before its chat_send_ok ack). Content must
|
||||
// match too — our own echo always carries identical content, while a
|
||||
// same-author message from another session of this account does not,
|
||||
// and consuming the pending row for it would orphan the real send.
|
||||
const pendingIdx = existing.findIndex(
|
||||
(m) =>
|
||||
m.status === "pending" &&
|
||||
m.correlationId !== null &&
|
||||
m.user.id === message.user.id &&
|
||||
m.content === message.content,
|
||||
);
|
||||
// 2. Defensive: reconcile the oldest pending (or transport-failed) optimistic
|
||||
// row from this author (a broadcast that arrived before its chat_send_ok
|
||||
// ack, or arrived after the dispatcher's offline sweep gave up on a send
|
||||
// that had actually gone through). Scoped to OFFLINE — a server-rejected
|
||||
// send (SLOW_MODE/FORBIDDEN/...) is never broadcast, so no echo can ever
|
||||
// arrive for it, and eating that row here would silently drop the retry
|
||||
// the user still needs. Content must match too — our own echo always
|
||||
// carries identical content, while a same-author message from another
|
||||
// session of this account does not, and consuming the pending row for it
|
||||
// would orphan the real send.
|
||||
const pendingIdx = existing.findIndex((m) => isUnreconciledEcho(m, message));
|
||||
if (pendingIdx !== -1) {
|
||||
const replaced = existing.map((m, i) => (i === pendingIdx ? message : m));
|
||||
const updated = new Map(prev.messagesByChannel);
|
||||
@@ -375,9 +405,23 @@ export function setMessages(
|
||||
const previous = prev.messagesByChannel.get(channelId) ?? [];
|
||||
const snapshotIds = new Set(trimmed.map((m) => m.id));
|
||||
const maxSnapshotId = trimmed.reduce((max, m) => Math.max(max, m.id), 0);
|
||||
const carried = previous.filter(
|
||||
(m) => !snapshotIds.has(m.id) && (m.status !== "sent" || m.id > maxSnapshotId),
|
||||
);
|
||||
// A pending/OFFLINE-failed row whose chat_send_ok ack was lost to the same
|
||||
// disconnect that forced this resync would otherwise survive forever
|
||||
// (its id stays 0, so it can never collide with the real id above) while
|
||||
// the fresh snapshot already carries its persisted echo — drop it rather
|
||||
// than show both. Each snapshot row is consumed by at most one carried
|
||||
// row so two genuinely distinct sends with identical text each keep a row.
|
||||
const consumedEchoes = new Set<number>();
|
||||
const carried = previous.filter((m) => {
|
||||
if (snapshotIds.has(m.id)) return false;
|
||||
if (m.status === "sent") return m.id > maxSnapshotId;
|
||||
const echoIdx = trimmed.findIndex(
|
||||
(s, i) => !consumedEchoes.has(i) && isUnreconciledEcho(m, s),
|
||||
);
|
||||
if (echoIdx === -1) return true;
|
||||
consumedEchoes.add(echoIdx);
|
||||
return false;
|
||||
});
|
||||
let merged = carried.length > 0 ? [...trimmed, ...carried] : trimmed;
|
||||
const mergeTrimmed = merged.length > MAX_MESSAGES_PER_CHANNEL;
|
||||
if (mergeTrimmed) {
|
||||
@@ -422,6 +466,11 @@ export function setMessages(
|
||||
* `hasMoreAfter` marks the window as detached from the live tail: the list
|
||||
* offers "Jump to Present" and live broadcasts stop being appended until
|
||||
* reattachToPresent (or a fresh setMessages) lands.
|
||||
*
|
||||
* Carries unreconciled (pending/failed) rows across the replacement exactly
|
||||
* like setMessages does — they are the only copy of the user's composed
|
||||
* text, and a jump elsewhere must not silently destroy an in-flight send or
|
||||
* orphan its Retry draft.
|
||||
*/
|
||||
export function setAroundMessages(
|
||||
channelId: number,
|
||||
@@ -439,8 +488,10 @@ export function setAroundMessages(
|
||||
? converted.slice(0, MAX_MESSAGES_PER_CHANNEL)
|
||||
: converted;
|
||||
messagesStore.setState((prev) => {
|
||||
const previous = prev.messagesByChannel.get(channelId) ?? [];
|
||||
const carried = previous.filter((m) => m.status !== "sent");
|
||||
const updatedMessages = new Map(prev.messagesByChannel);
|
||||
updatedMessages.set(channelId, trimmed);
|
||||
updatedMessages.set(channelId, carried.length > 0 ? [...trimmed, ...carried] : trimmed);
|
||||
|
||||
const updatedLoaded = new Set(prev.loadedChannels);
|
||||
updatedLoaded.add(channelId);
|
||||
@@ -472,18 +523,60 @@ export function setAroundMessages(
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a channel's detached window so the next history fetch reloads the live
|
||||
* tail. Clears the loaded flag too — otherwise MessageController short-circuits
|
||||
* on "already loaded" and the stale window stays on screen.
|
||||
* Invalidate every channel's loaded window after a full-ready resync (see
|
||||
* dispatcher.ts's `ready` handler). That tier never replays missed
|
||||
* chat_message frames — only a fresh connect and a full resync send `ready`
|
||||
* at all, and a successful seq-based replay reconnect doesn't — so a channel
|
||||
* loaded before the drop would otherwise keep a permanent hole in its
|
||||
* history for the rest of the session.
|
||||
*
|
||||
* Carries pending/failed optimistic rows exactly like setMessages' merge —
|
||||
* they are the only copy of an unsent message — but drops "sent" rows so the
|
||||
* next fetch rebuilds a contiguous window instead of leaving stale rows
|
||||
* above a gap the fetch has no way to detect.
|
||||
*/
|
||||
export function invalidateLoadedMessageWindows(): void {
|
||||
messagesStore.setState((prev) => {
|
||||
if (prev.loadedChannels.size === 0) return prev;
|
||||
const updatedMessages = new Map(prev.messagesByChannel);
|
||||
for (const channelId of prev.loadedChannels) {
|
||||
const existing = updatedMessages.get(channelId);
|
||||
if (existing === undefined) continue;
|
||||
const carried = existing.filter((m) => m.status !== "sent");
|
||||
if (carried.length > 0) {
|
||||
updatedMessages.set(channelId, carried);
|
||||
} else {
|
||||
updatedMessages.delete(channelId);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
messagesByChannel: updatedMessages,
|
||||
loadedChannels: new Set(),
|
||||
hasMore: new Map(),
|
||||
detachedChannels: new Set(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a channel's loaded flag so the next history fetch reloads the live
|
||||
* tail — otherwise MessageController short-circuits on "already loaded" and
|
||||
* the stale window stays on screen.
|
||||
*
|
||||
* Deliberately does NOT clear detachedChannels itself: that flag is what
|
||||
* keeps the "Jump to Present" pill visible and blocks addMessage from
|
||||
* appending a live broadcast onto the stale around-window. setMessages
|
||||
* clears it on success, once the tail has actually landed — if that refetch
|
||||
* fails instead, the channel must stay detached so a live broadcast can't
|
||||
* splice onto history with a silent gap.
|
||||
*/
|
||||
export function reattachToPresent(channelId: number): void {
|
||||
messagesStore.setState((prev) => {
|
||||
if (!prev.detachedChannels.has(channelId)) return prev;
|
||||
const updatedDetached = new Set(prev.detachedChannels);
|
||||
updatedDetached.delete(channelId);
|
||||
const updatedLoaded = new Set(prev.loadedChannels);
|
||||
updatedLoaded.delete(channelId);
|
||||
return { ...prev, detachedChannels: updatedDetached, loadedChannels: updatedLoaded };
|
||||
return { ...prev, loadedChannels: updatedLoaded };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -710,8 +710,17 @@
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* Status picker popup */
|
||||
/* Status picker: the root is the inline anchor (trigger dot lives here and
|
||||
must always be visible); the popup chrome + its display:none/open toggle
|
||||
belong on the dropdown child, which is what StatusPicker.ts actually
|
||||
toggles (status-picker-dropdown--open). This block used to be a verbatim
|
||||
holdover from the pre-component popup and hid the whole picker. */
|
||||
.status-picker {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.status-picker-dropdown {
|
||||
position: absolute;
|
||||
bottom: 60px;
|
||||
left: 8px;
|
||||
@@ -724,10 +733,24 @@
|
||||
display: none;
|
||||
min-width: 160px;
|
||||
}
|
||||
.status-picker.open {
|
||||
.status-picker-dropdown--open {
|
||||
display: block;
|
||||
}
|
||||
.status-option {
|
||||
/* Trigger dot: only inline style is `background` (StatusPicker.ts), so the
|
||||
size has to live here or the dot collapses to 0x0 and becomes both
|
||||
invisible and unclickable. */
|
||||
.status-picker-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: var(--radius-circle);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-picker-dot:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.status-picker-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -736,20 +759,31 @@
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-normal);
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
.status-option:hover {
|
||||
.status-picker-option:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.status-option .so-dot {
|
||||
/* Rows are tabindex="0" menuitems, not buttons, so they need their own
|
||||
focus ring instead of relying on native button focus styling. */
|
||||
.status-picker-option:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.status-picker-option-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: var(--radius-circle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-picker-option-label {
|
||||
flex: 1;
|
||||
}
|
||||
.status-picker-option-check {
|
||||
display: flex;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Chat Area ── */
|
||||
.chat-area {
|
||||
|
||||
@@ -38,6 +38,9 @@ const FIRST_USE: CertTofuPayload = {
|
||||
status: "first_use",
|
||||
};
|
||||
|
||||
// Deliberately NOT the host the session below authenticates against — used to
|
||||
// prove a rotated cert on some other saved profile leaves the live session
|
||||
// alone.
|
||||
const MISMATCH: CertTofuPayload = {
|
||||
host: "myserver.example:8443",
|
||||
fingerprint: "99:88:77:66:55:44:33:22",
|
||||
@@ -45,6 +48,11 @@ const MISMATCH: CertTofuPayload = {
|
||||
status: "mismatch",
|
||||
};
|
||||
|
||||
// The host helpers.ts's submitLogin authenticates against, so main.ts records
|
||||
// it as the live session's host. Only a mismatch for THIS host may tear the
|
||||
// session down.
|
||||
const MISMATCH_LIVE_HOST: CertTofuPayload = { ...MISMATCH, host: "localhost:8443" };
|
||||
|
||||
test.describe("Cert TOFU — first use", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockTauriConnect(page);
|
||||
@@ -114,7 +122,7 @@ test.describe("Cert TOFU — mismatch", () => {
|
||||
});
|
||||
|
||||
test("disconnect on mismatch returns to the connect page", async ({ page }) => {
|
||||
await emitCertTofu(page, MISMATCH);
|
||||
await emitCertTofu(page, MISMATCH_LIVE_HOST);
|
||||
await expect(page.locator("h3", { hasText: "Certificate Warning" })).toBeVisible();
|
||||
|
||||
await page.locator(".modal-footer button", { hasText: "Disconnect" }).click();
|
||||
@@ -122,4 +130,21 @@ test.describe("Cert TOFU — mismatch", () => {
|
||||
await expect(page.locator("h3", { hasText: "Certificate Warning" })).toBeHidden();
|
||||
await expect(page.locator(".connect-page")).toBeVisible();
|
||||
});
|
||||
|
||||
// The client is multi-server: a cert rotating on some other saved profile
|
||||
// must not disconnect and log out the session the user is actually in. The
|
||||
// modal is still shown (main.ts notifies unconditionally so the connect
|
||||
// page's own ceremony sees every event) — only the teardown is scoped.
|
||||
test("disconnect on a mismatch for another host leaves the live session alone", async ({
|
||||
page,
|
||||
}) => {
|
||||
await emitCertTofu(page, MISMATCH);
|
||||
await expect(page.locator("h3", { hasText: "Certificate Warning" })).toBeVisible();
|
||||
|
||||
await page.locator(".modal-footer button", { hasText: "Disconnect" }).click();
|
||||
|
||||
await expect(page.locator("h3", { hasText: "Certificate Warning" })).toBeHidden();
|
||||
await expect(page.locator("[data-testid='app-layout']")).toBeVisible();
|
||||
await expect(page.locator(".connect-page")).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -439,6 +439,14 @@ export function buildTauriMockScript(opts: {
|
||||
/** Make get_identity_pin REJECT — models a transient keyring failure, the
|
||||
* DC-08 fail-closed path. */
|
||||
identityPinError?: boolean;
|
||||
/** Seeds the `get_settings` payload — pass `{"owncord:profiles": {schemaVersion, profiles}}`
|
||||
* to give the connect page saved server profiles (e.g. one with
|
||||
* autoConnect) instead of the default empty store. */
|
||||
storedSettings?: Record<string, unknown>;
|
||||
/** Seeds `load_credential`. Default null = nothing stored. `delete_credential`
|
||||
* stays a no-op, so a test can assert behaviour that must hold even when the
|
||||
* credential is still readable. */
|
||||
storedCredential?: { username: string; token: string } | null;
|
||||
}): string {
|
||||
const readyPayload = buildReadyPayload(opts.readyOverrides);
|
||||
|
||||
@@ -652,10 +660,11 @@ export function buildTauriMockScript(opts: {
|
||||
if (cmd === "stop_livekit_proxy") return;
|
||||
|
||||
// ---- Credentials ----
|
||||
if (cmd === "save_credential" || cmd === "delete_credential" || cmd === "load_credential") return null;
|
||||
if (cmd === "save_credential" || cmd === "delete_credential") return null;
|
||||
if (cmd === "load_credential") return ${JSON.stringify(opts.storedCredential ?? null)};
|
||||
|
||||
// ---- Settings ----
|
||||
if (cmd === "get_settings") return {};
|
||||
if (cmd === "get_settings") return ${JSON.stringify(opts.storedSettings ?? {})};
|
||||
if (cmd === "save_settings") return;
|
||||
|
||||
// ---- Certs ----
|
||||
@@ -731,6 +740,45 @@ export async function mockTauriFullSession(page: Page): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full session where the connect page ALSO has a saved auto-connect profile
|
||||
* for the same host, and `load_credential` still returns a usable credential.
|
||||
* `delete_credential` remains a no-op, so a logout test built on this asserts
|
||||
* the client refuses to auto-login on its own account rather than relying on
|
||||
* the credential delete having already won a race.
|
||||
*/
|
||||
export async function mockTauriFullSessionWithAutoConnect(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
buildTauriMockScript({
|
||||
httpRoutes: [
|
||||
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
|
||||
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
|
||||
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES },
|
||||
{ pattern: "/pins", status: 200, body: MOCK_PINNED_MESSAGES },
|
||||
],
|
||||
simulateWsFlow: true,
|
||||
storedCredential: { username: "testuser", token: "stored-token" },
|
||||
storedSettings: {
|
||||
"owncord:profiles": {
|
||||
schemaVersion: 1,
|
||||
profiles: [
|
||||
{
|
||||
id: "p1",
|
||||
name: "Local",
|
||||
host: "localhost:8443",
|
||||
username: "testuser",
|
||||
autoConnect: true,
|
||||
rememberPassword: true,
|
||||
color: "#5865f2",
|
||||
lastConnected: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function mockTauriFullSessionWithMessages(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
buildTauriMockScript({
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
* Covers: settings → Log Out → returns to connect page.
|
||||
*/
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { mockTauriFullSession, navigateToMainPage } from "./helpers";
|
||||
import {
|
||||
mockTauriFullSession,
|
||||
mockTauriFullSessionWithAutoConnect,
|
||||
navigateToMainPage,
|
||||
} from "./helpers";
|
||||
|
||||
test.describe("Logout Flow", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -44,3 +48,36 @@ test.describe("Logout Flow", () => {
|
||||
await expect(page.locator(".app")).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
// Logging out deletes the host's credential fire-and-forget, then navigates to
|
||||
// the connect page — whose auto-login would read that same credential back.
|
||||
// The credential commands run off the IPC thread, so a read that wins the race
|
||||
// signs the user straight back into the server they just left. This fixture
|
||||
// keeps load_credential returning a usable credential (delete_credential is a
|
||||
// no-op) so the assertion holds on the client's own refusal to auto-login,
|
||||
// not on the delete having already landed.
|
||||
test.describe("Logout Flow — auto-connect profile", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockTauriFullSessionWithAutoConnect(page);
|
||||
await page.goto("/");
|
||||
// No submitLogin here: with a seeded auto-connect profile the client signs
|
||||
// itself in, which is exactly the path under test. Reaching the app layout
|
||||
// without touching the form also proves the fixture really does auto-login,
|
||||
// so the assertion below cannot pass merely because the seeds were inert.
|
||||
await expect(page.locator("[data-testid='app-layout']")).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("logging out does not immediately auto-login back in", async ({ page }) => {
|
||||
const settingsBtn = page.locator("button[aria-label='Settings']");
|
||||
await settingsBtn.click();
|
||||
await expect(page.locator(".settings-overlay.open")).toBeVisible({ timeout: 3000 });
|
||||
|
||||
await page.locator(".settings-nav-item.danger", { hasText: "Log Out" }).click();
|
||||
|
||||
await expect(page.locator(".connect-form, .login-form")).toBeVisible({ timeout: 5000 });
|
||||
// Give the auto-login path (and its connecting overlay) time to fire.
|
||||
await page.waitForTimeout(1500);
|
||||
await expect(page.locator("[data-testid='app-layout']")).not.toBeVisible();
|
||||
await expect(page.locator(".connect-form, .login-form")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,6 +136,35 @@ describe("trapFocus", () => {
|
||||
|
||||
expect(e.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("skips display:none controls when wrapping — a hidden control earlier in DOM order is not treated as the edge", () => {
|
||||
// Mirrors the member-picker modal: fields hidden via inline style.display
|
||||
// sit ahead of the only visible control in DOM order. Tabbing from that
|
||||
// visible control must wrap to itself, not escape to whatever the
|
||||
// browser's native tab order finds outside the dialog.
|
||||
const ac = new AbortController();
|
||||
const dialog = document.createElement("div");
|
||||
applyDialogSemantics(dialog);
|
||||
const hiddenA = document.createElement("input");
|
||||
hiddenA.style.display = "none";
|
||||
const hiddenB = document.createElement("input");
|
||||
hiddenB.style.display = "none";
|
||||
const visible = document.createElement("button");
|
||||
visible.textContent = "only visible control";
|
||||
dialog.append(hiddenA, hiddenB, visible);
|
||||
container.appendChild(dialog);
|
||||
trapFocus(dialog, ac.signal);
|
||||
|
||||
visible.focus();
|
||||
const forward = tab(visible);
|
||||
expect(forward.defaultPrevented).toBe(true);
|
||||
expect(document.activeElement).toBe(visible);
|
||||
|
||||
const backward = tab(visible, true);
|
||||
expect(backward.defaultPrevented).toBe(true);
|
||||
expect(document.activeElement).toBe(visible);
|
||||
ac.abort();
|
||||
});
|
||||
});
|
||||
|
||||
describe("focusDialog", () => {
|
||||
@@ -181,4 +210,23 @@ describe("focusDialog", () => {
|
||||
expect(() => restore()).not.toThrow();
|
||||
expect(document.activeElement).not.toBe(outside);
|
||||
});
|
||||
|
||||
it("skips a display:none control that is earlier in DOM order than the first visible one", () => {
|
||||
// A hidden field (e.g. a group-name input revealed only after a
|
||||
// selection) sits first in DOM order. Browsers refuse to focus a
|
||||
// display:none element, so calling .focus() on it silently fails and
|
||||
// focus never lands in the dialog at all. The first *visible* focusable
|
||||
// must be chosen instead.
|
||||
const dialog = document.createElement("div");
|
||||
applyDialogSemantics(dialog);
|
||||
const hidden = document.createElement("input");
|
||||
hidden.style.display = "none";
|
||||
const visible = document.createElement("button");
|
||||
dialog.append(hidden, visible);
|
||||
container.appendChild(dialog);
|
||||
|
||||
focusDialog(dialog);
|
||||
|
||||
expect(document.activeElement).toBe(visible);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -317,6 +317,34 @@ describe("API Client", () => {
|
||||
expect(headers["Authorization"]).toBeUndefined();
|
||||
expect(headers["Content-Type"]).toBe("application/json");
|
||||
});
|
||||
|
||||
// B4_conn_ipc-2: a host switch must not carry the previous host's bearer
|
||||
// token forward — otherwise a login/register request to a new server
|
||||
// rides a still-live session token for the old one.
|
||||
it("setConfig drops the previous token when switching to a different host without a new token", async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({}));
|
||||
// `api` (beforeEach) already holds token "test-token" for "localhost:8443".
|
||||
api.setConfig({ host: "evil.example.com:8443" });
|
||||
await api.getMe();
|
||||
const headers = fetchCallOpts().headers as Record<string, string>;
|
||||
expect(headers["Authorization"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("setConfig keeps the token when the host is unchanged", async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({}));
|
||||
api.setConfig({ host: "localhost:8443" });
|
||||
await api.getMe();
|
||||
const headers = fetchCallOpts().headers as Record<string, string>;
|
||||
expect(headers["Authorization"]).toBe("Bearer test-token");
|
||||
});
|
||||
|
||||
it("setConfig keeps a token provided alongside a host change", async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({}));
|
||||
api.setConfig({ host: "new.example.com:8443", token: "fresh-token" });
|
||||
await api.getMe();
|
||||
const headers = fetchCallOpts().headers as Record<string, string>;
|
||||
expect(headers["Authorization"]).toBe("Bearer fresh-token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("user endpoints", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const { mockLoadPref, mockSavePref } = vi.hoisted(() => ({
|
||||
mockLoadPref: vi.fn((_key: string, defaultVal: unknown) => defaultVal),
|
||||
@@ -49,7 +49,7 @@ vi.mock("@lib/livekitSession", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { AudioElements } from "../../src/lib/audioElements";
|
||||
import { AudioElements, setAudioVolumeHost } from "../../src/lib/audioElements";
|
||||
|
||||
function createMockTrack(kind: string, sid: string) {
|
||||
const audioEl = document.createElement("audio");
|
||||
@@ -180,6 +180,64 @@ describe("AudioElements", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("per-user volume — host scoping (B3-6)", () => {
|
||||
afterEach(() => {
|
||||
// currentHost is module-level state that outlives a single test.
|
||||
setAudioVolumeHost(null);
|
||||
});
|
||||
|
||||
it("falls back to the legacy unscoped key when no host is set", () => {
|
||||
elements.setUserVolume(42, 150);
|
||||
expect(mockSavePref).toHaveBeenCalledWith("userVolume_42", 150);
|
||||
});
|
||||
|
||||
it("scopes the saved-volume key to the current host", () => {
|
||||
setAudioVolumeHost("a.example.com");
|
||||
elements.setUserVolume(42, 150);
|
||||
expect(mockSavePref).toHaveBeenCalledWith("userVolume_42:a.example.com", 150);
|
||||
});
|
||||
|
||||
it("reads the volume back under the scoped key, not the unscoped one", () => {
|
||||
setAudioVolumeHost("a.example.com");
|
||||
elements.getUserVolume(42);
|
||||
expect(mockLoadPref).toHaveBeenCalledWith("userVolume_42:a.example.com", 100);
|
||||
});
|
||||
|
||||
it("does not leak a volume set on one host onto another", () => {
|
||||
// Regression for B3-6: a mute (volume 0) set for user 7 on server A
|
||||
// must not silence user 7 on server B.
|
||||
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
|
||||
if (key === "userVolume_7:a.example.com") return 0;
|
||||
return defaultVal;
|
||||
});
|
||||
|
||||
setAudioVolumeHost("a.example.com");
|
||||
expect(elements.getUserVolume(7)).toBe(0);
|
||||
|
||||
setAudioVolumeHost("b.example.com");
|
||||
expect(elements.getUserVolume(7)).toBe(100);
|
||||
});
|
||||
|
||||
it("migrates a pre-scoping legacy volume through to the scoped key without leaking to a different host", () => {
|
||||
// A save made before host-scoping existed lives at the bare
|
||||
// `userVolume_7` key. It must still apply once a host is set, and the
|
||||
// migration must write under THAT host's scoped key specifically —
|
||||
// not clobber a different host's own explicit choice.
|
||||
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
|
||||
if (key === "userVolume_7") return 30;
|
||||
if (key === "userVolume_7:b.example.com") return 80;
|
||||
return defaultVal;
|
||||
});
|
||||
|
||||
setAudioVolumeHost("a.example.com");
|
||||
expect(elements.getUserVolume(7)).toBe(30);
|
||||
expect(mockSavePref).toHaveBeenCalledWith("userVolume_7:a.example.com", 30);
|
||||
|
||||
setAudioVolumeHost("b.example.com");
|
||||
expect(elements.getUserVolume(7)).toBe(80);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setOutputVolume", () => {
|
||||
it("saves clamped volume to preferences", () => {
|
||||
elements.setOutputVolume(80);
|
||||
|
||||
@@ -555,6 +555,118 @@ describe("AudioPipeline", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- B3-1: pipeline must source from (and restore to) the NS processor's
|
||||
// output when one is attached, or the gain/VAD chain gets silently bypassed
|
||||
// by the processor's own replaceTrack ---
|
||||
|
||||
describe("AudioPipeline sourcing when an NS processor is attached (B3-1)", () => {
|
||||
afterEach(() => {
|
||||
pipeline.teardownAudioPipeline();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function stubAudioContext(): { mockSender: any } {
|
||||
const mockSender = { replaceTrack: vi.fn().mockResolvedValue(undefined) };
|
||||
const mockAudioCtx = {
|
||||
resume: vi.fn().mockResolvedValue(undefined),
|
||||
createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }),
|
||||
createAnalyser: vi.fn().mockReturnValue({
|
||||
fftSize: 0,
|
||||
smoothingTimeConstant: 0,
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getFloatTimeDomainData: vi.fn(),
|
||||
}),
|
||||
createGain: vi.fn().mockReturnValue({
|
||||
gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() },
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}),
|
||||
createMediaStreamDestination: vi.fn().mockReturnValue({
|
||||
stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "adjusted" }]) },
|
||||
disconnect: vi.fn(),
|
||||
}),
|
||||
currentTime: 0,
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
state: "running",
|
||||
audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no worklet")) },
|
||||
};
|
||||
vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx));
|
||||
vi.stubGlobal(
|
||||
"MediaStream",
|
||||
vi.fn().mockImplementation((tracks: unknown) => ({ tracks })),
|
||||
);
|
||||
return { mockSender };
|
||||
}
|
||||
|
||||
it("setupAudioPipeline sources from the processor's processedTrack, not the raw mic track", () => {
|
||||
const { mockSender } = stubAudioContext();
|
||||
const mockRoom = {
|
||||
localParticipant: {
|
||||
getTrackPublication: vi.fn().mockReturnValue({
|
||||
track: {
|
||||
mediaStreamTrack: { id: "raw-track" },
|
||||
sender: mockSender,
|
||||
getProcessor: vi.fn().mockReturnValue({ processedTrack: { id: "processed-track" } }),
|
||||
},
|
||||
}),
|
||||
},
|
||||
} as any;
|
||||
pipeline.setRoom(mockRoom);
|
||||
pipeline.setupAudioPipeline();
|
||||
|
||||
expect(MediaStream).toHaveBeenCalledWith([{ id: "processed-track" }]);
|
||||
});
|
||||
|
||||
it("teardownAudioPipeline restores the sender to the processor's processedTrack, not the raw mic track, when NS is still attached", () => {
|
||||
const { mockSender } = stubAudioContext();
|
||||
const mockRoom = {
|
||||
localParticipant: {
|
||||
getTrackPublication: vi.fn().mockReturnValue({
|
||||
track: {
|
||||
mediaStreamTrack: { id: "raw-track" },
|
||||
sender: mockSender,
|
||||
getProcessor: vi.fn().mockReturnValue({ processedTrack: { id: "processed-track" } }),
|
||||
},
|
||||
}),
|
||||
},
|
||||
} as any;
|
||||
pipeline.setRoom(mockRoom);
|
||||
pipeline.setupAudioPipeline();
|
||||
mockSender.replaceTrack.mockClear();
|
||||
pipeline.teardownAudioPipeline();
|
||||
|
||||
expect(mockSender.replaceTrack).toHaveBeenCalledWith({ id: "processed-track" });
|
||||
});
|
||||
|
||||
it("applyNoiseSuppressor rebuilds the pipeline after attaching, so the sender ends on the gain/VAD chain instead of the processor's raw output winning", async () => {
|
||||
const { mockSender } = stubAudioContext();
|
||||
const setProcessor = vi.fn().mockResolvedValue(undefined);
|
||||
const mockRoom = {
|
||||
localParticipant: {
|
||||
getTrackPublication: vi.fn().mockReturnValue({
|
||||
track: {
|
||||
mediaStreamTrack: { id: "raw-track" },
|
||||
sender: mockSender,
|
||||
getProcessor: vi.fn().mockReturnValue(undefined), // no processor yet
|
||||
setProcessor,
|
||||
},
|
||||
}),
|
||||
},
|
||||
} as any;
|
||||
pipeline.setRoom(mockRoom);
|
||||
|
||||
await pipeline.applyNoiseSuppressor();
|
||||
|
||||
expect(setProcessor).toHaveBeenCalled();
|
||||
// The rebuilt pipeline's own replaceTrack (dest/adjusted track) must be
|
||||
// the LAST sender.replaceTrack call, so it wins over setProcessor's own
|
||||
// (unawaited, internal) replaceTrack to the raw processed track.
|
||||
const calls = mockSender.replaceTrack.mock.calls;
|
||||
expect(calls.at(-1)?.[0]).toEqual({ id: "adjusted" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("setupAudioPipeline AudioContext configuration", () => {
|
||||
let mockAudioCtx: any;
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
} from "../../src/stores/voice.store";
|
||||
import { leaveVoice } from "@lib/livekitSession";
|
||||
import { setMessages, isChannelLoaded, getChannelMessages } from "../../src/stores/messages.store";
|
||||
import { channelsStore, setChannels } from "../../src/stores/channels.store";
|
||||
import type { ReadyChannel } from "../../src/lib/types";
|
||||
import { acknowledgeNsfw, isNsfwAcknowledged } from "../../src/lib/nsfw-gate";
|
||||
import type { UserWithRole, MessageResponse, MessageUser } from "../../src/lib/types";
|
||||
|
||||
@@ -417,4 +419,59 @@ describe("auth store", () => {
|
||||
expect(getChannelMessages(1)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: clearAuth() must also drop channelsStore, or setChannels'
|
||||
// DM-row carry (a DM channel row is deliberately preserved across a normal
|
||||
// `ready` rebuild, since ready never restates DM rows) re-inserts the
|
||||
// PREVIOUS server's DM channel ids into the NEXT server's channel map on
|
||||
// the next login — a stale phantom channel signed into an unrelated server.
|
||||
describe("clearAuth channels cleanup", () => {
|
||||
const readyChannels: ReadyChannel[] = [
|
||||
{ id: 1, name: "general", type: "text", category: "Text", position: 0, unread_count: 3 },
|
||||
];
|
||||
|
||||
it("clears channels, activeChannelId, and roles on logout", () => {
|
||||
setChannels(readyChannels);
|
||||
expect(channelsStore.getState().channels.size).toBe(1);
|
||||
|
||||
clearAuth();
|
||||
|
||||
expect(channelsStore.getState().channels.size).toBe(0);
|
||||
expect(channelsStore.getState().activeChannelId).toBeNull();
|
||||
});
|
||||
|
||||
it("does not carry the previous server's DM channel row into the next session", () => {
|
||||
setChannels(readyChannels);
|
||||
// Synthesize a DM row the way addDmToChannelsStore does — setChannels'
|
||||
// carry loop (channels.store.ts) re-inserts any "dm"-typed row across
|
||||
// every future setChannels call unless the store is reset first.
|
||||
channelsStore.setState((prev) => {
|
||||
const next = new Map(prev.channels);
|
||||
next.set(999, {
|
||||
id: 999,
|
||||
name: "alice",
|
||||
type: "dm",
|
||||
category: null,
|
||||
topic: "",
|
||||
position: 0,
|
||||
unreadCount: 0,
|
||||
mentionCount: 0,
|
||||
lastMessageId: null,
|
||||
canSend: true,
|
||||
slowMode: 0,
|
||||
nsfw: false,
|
||||
voiceMaxUsers: 0,
|
||||
voiceMaxVideo: 0,
|
||||
});
|
||||
return { ...prev, channels: next };
|
||||
});
|
||||
expect(channelsStore.getState().channels.has(999)).toBe(true);
|
||||
|
||||
clearAuth();
|
||||
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
|
||||
setChannels([]); // the next server's `ready` — no DMs of its own yet
|
||||
|
||||
expect(channelsStore.getState().channels.has(999)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,6 +77,16 @@ describe("Tauri default capability — HTTP scope", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("grants core:window:allow-request-user-attention (the Flash Taskbar notification setting needs it)", () => {
|
||||
// core:window:default's implicit permission set is getters only — no
|
||||
// request-user-attention — so without this explicit grant, every
|
||||
// win.requestUserAttention() call is ACL-rejected and the default-on
|
||||
// "Flash Taskbar" setting silently does nothing.
|
||||
expect(find("core:window:allow-request-user-attention")).toBe(
|
||||
"core:window:allow-request-user-attention",
|
||||
);
|
||||
});
|
||||
|
||||
it("filesystem grants stay under $APPDATA/$APPLOG", () => {
|
||||
const fsPaths = permissions.flatMap((p) =>
|
||||
typeof p !== "string" && p.identifier.startsWith("fs:")
|
||||
|
||||
@@ -29,6 +29,7 @@ const {
|
||||
content?: string;
|
||||
user?: { id: number; username: string };
|
||||
deleted?: boolean;
|
||||
status?: string;
|
||||
}> => [],
|
||||
),
|
||||
mockSetReplyTo: vi.fn(),
|
||||
@@ -241,7 +242,13 @@ vi.mock("@stores/blocks.store", () => ({
|
||||
import { createChannelController } from "../../src/pages/main-page/ChannelController";
|
||||
import type { ChannelControllerOptions } from "../../src/pages/main-page/ChannelController";
|
||||
import { setConnectionStatus } from "@stores/ui.store";
|
||||
import { channelsStore, setChannels, setActiveChannel, setRoles } from "@stores/channels.store";
|
||||
import {
|
||||
channelsStore,
|
||||
setChannels,
|
||||
setActiveChannel,
|
||||
setRoles,
|
||||
updateChannel,
|
||||
} from "@stores/channels.store";
|
||||
import { acknowledgeNsfw } from "@lib/nsfw-gate";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -905,10 +912,34 @@ describe("createChannelController", () => {
|
||||
describe("edit-last-message event", () => {
|
||||
it("finds the last non-deleted message by current user and starts edit", () => {
|
||||
mockGetChannelMessages.mockReturnValue([
|
||||
{ id: 1, content: "first", user: { id: 1, username: "me" }, deleted: false },
|
||||
{ id: 2, content: "other user", user: { id: 2, username: "them" }, deleted: false },
|
||||
{ id: 3, content: "my deleted", user: { id: 1, username: "me" }, deleted: true },
|
||||
{ id: 4, content: "my latest", user: { id: 1, username: "me" }, deleted: false },
|
||||
{
|
||||
id: 1,
|
||||
content: "first",
|
||||
user: { id: 1, username: "me" },
|
||||
deleted: false,
|
||||
status: "sent",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
content: "other user",
|
||||
user: { id: 2, username: "them" },
|
||||
deleted: false,
|
||||
status: "sent",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
content: "my deleted",
|
||||
user: { id: 1, username: "me" },
|
||||
deleted: true,
|
||||
status: "sent",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
content: "my latest",
|
||||
user: { id: 1, username: "me" },
|
||||
deleted: false,
|
||||
status: "sent",
|
||||
},
|
||||
]);
|
||||
const opts = makeOpts();
|
||||
const ctrl = createChannelController(opts);
|
||||
@@ -922,8 +953,20 @@ describe("createChannelController", () => {
|
||||
|
||||
it("skips deleted messages and finds earlier non-deleted message", () => {
|
||||
mockGetChannelMessages.mockReturnValue([
|
||||
{ id: 1, content: "earliest", user: { id: 1, username: "me" }, deleted: false },
|
||||
{ id: 2, content: "deleted", user: { id: 1, username: "me" }, deleted: true },
|
||||
{
|
||||
id: 1,
|
||||
content: "earliest",
|
||||
user: { id: 1, username: "me" },
|
||||
deleted: false,
|
||||
status: "sent",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
content: "deleted",
|
||||
user: { id: 1, username: "me" },
|
||||
deleted: true,
|
||||
status: "sent",
|
||||
},
|
||||
]);
|
||||
const opts = makeOpts();
|
||||
const ctrl = createChannelController(opts);
|
||||
@@ -937,7 +980,63 @@ describe("createChannelController", () => {
|
||||
|
||||
it("does nothing when no own messages exist", () => {
|
||||
mockGetChannelMessages.mockReturnValue([
|
||||
{ id: 1, content: "other", user: { id: 2, username: "them" }, deleted: false },
|
||||
{
|
||||
id: 1,
|
||||
content: "other",
|
||||
user: { id: 2, username: "them" },
|
||||
deleted: false,
|
||||
status: "sent",
|
||||
},
|
||||
]);
|
||||
const opts = makeOpts();
|
||||
const ctrl = createChannelController(opts);
|
||||
ctrl.mountChannel(42, "general");
|
||||
vi.clearAllMocks();
|
||||
|
||||
opts.slots.inputSlot.dispatchEvent(new Event("edit-last-message"));
|
||||
|
||||
expect(mockStartEdit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips an unconfirmed/failed optimistic row (id 0) and edits the last actually-sent message", () => {
|
||||
mockGetChannelMessages.mockReturnValue([
|
||||
{
|
||||
id: 5,
|
||||
content: "sent earlier",
|
||||
user: { id: 1, username: "me" },
|
||||
deleted: false,
|
||||
status: "sent",
|
||||
},
|
||||
{
|
||||
id: 0,
|
||||
content: "still sending",
|
||||
user: { id: 1, username: "me" },
|
||||
deleted: false,
|
||||
status: "pending",
|
||||
},
|
||||
]);
|
||||
const opts = makeOpts();
|
||||
const ctrl = createChannelController(opts);
|
||||
ctrl.mountChannel(42, "general");
|
||||
vi.clearAllMocks();
|
||||
|
||||
opts.slots.inputSlot.dispatchEvent(new Event("edit-last-message"));
|
||||
|
||||
// The visual Edit affordance only appears on status === "sent" rows
|
||||
// (renderers.ts); the keyboard shortcut must honor the same gate rather
|
||||
// than editing an optimistic row that has no real message id yet.
|
||||
expect(mockStartEdit).toHaveBeenCalledWith(5, "sent earlier");
|
||||
});
|
||||
|
||||
it("does nothing when every own message is still pending/failed", () => {
|
||||
mockGetChannelMessages.mockReturnValue([
|
||||
{
|
||||
id: 0,
|
||||
content: "still sending",
|
||||
user: { id: 1, username: "me" },
|
||||
deleted: false,
|
||||
status: "failed",
|
||||
},
|
||||
]);
|
||||
const opts = makeOpts();
|
||||
const ctrl = createChannelController(opts);
|
||||
@@ -1165,6 +1264,36 @@ describe("createChannelController", () => {
|
||||
expect(opts.chatHeaderName!.textContent).toBe("random");
|
||||
expect(mockUpdateChatHeaderForDm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the header name live when the channel is renamed mid-session (mirrors the topic subscription)", () => {
|
||||
setChannels([
|
||||
{
|
||||
id: 42,
|
||||
name: "general",
|
||||
type: "text",
|
||||
category: null,
|
||||
position: 0,
|
||||
can_send: true,
|
||||
nsfw: false,
|
||||
},
|
||||
]);
|
||||
const chatHeaderRefs = {
|
||||
hashEl: document.createElement("span"),
|
||||
nameEl: document.createElement("span"),
|
||||
topicEl: document.createElement("span"),
|
||||
callBtn: document.createElement("button"),
|
||||
};
|
||||
const opts = makeOpts({ chatHeaderRefs });
|
||||
const ctrl = createChannelController(opts);
|
||||
|
||||
ctrl.mountChannel(42, "general", "text");
|
||||
expect(opts.chatHeaderName!.textContent).toBe("general");
|
||||
|
||||
updateChannel({ id: 42, name: "renamed" });
|
||||
channelsStore.flush();
|
||||
|
||||
expect(opts.chatHeaderName!.textContent).toBe("renamed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("slow mode", () => {
|
||||
@@ -1398,6 +1527,16 @@ describe("createChannelController", () => {
|
||||
opts.slots.inputSlot.dispatchEvent(new Event("edit-last-message"));
|
||||
expect(mockStartEdit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("destroyChannel closes any open reaction picker — it survives every other teardown path otherwise", () => {
|
||||
const opts = makeOpts();
|
||||
const ctrl = createChannelController(opts);
|
||||
ctrl.mountChannel(42, "general");
|
||||
|
||||
ctrl.destroyChannel();
|
||||
|
||||
expect(opts.reactionCtrl.destroy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
// ─── NSFW age gate ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -100,6 +100,21 @@ describe("channel context menu — mute", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("channel context menu — teardown", () => {
|
||||
// The menu is appended to document.body, not under the sidebar's own root,
|
||||
// so the sidebar's own AbortController tearing down must also remove it --
|
||||
// otherwise it survives as an undismissable orphan (e.g. onto the connect
|
||||
// page after logout).
|
||||
it("removes the open menu from document.body when the owning signal aborts", () => {
|
||||
openMenu(channel());
|
||||
expect(document.querySelector(".channel-ctx-menu")).not.toBeNull();
|
||||
|
||||
ac.abort();
|
||||
|
||||
expect(document.querySelector(".channel-ctx-menu")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationsTab — muted channel list", () => {
|
||||
it("says nothing is muted when nothing is", () => {
|
||||
const tab = buildNotificationsTab(ac.signal);
|
||||
|
||||
@@ -123,6 +123,24 @@ describe("channel mutes — host scoping", () => {
|
||||
muteChannel(9);
|
||||
expect(JSON.parse(localStorage.getItem(KEY)!)).toEqual([9]);
|
||||
});
|
||||
|
||||
it("migrates a pre-scoping legacy mute through to the scoped key without leaking to a different host", () => {
|
||||
// A mute saved before host-scoping existed lives at the bare
|
||||
// `mutedChannels` key. It must still apply once a host is set, and the
|
||||
// migration must write under THAT host's scoped key specifically — not
|
||||
// clobber a different host's own explicit mute list, even an empty one.
|
||||
localStorage.setItem(KEY, JSON.stringify([7]));
|
||||
localStorage.setItem(`${STORAGE_PREFIX}mutedChannels:b.example.com`, JSON.stringify([]));
|
||||
|
||||
setChannelMutesHost("a.example.com");
|
||||
expect(isChannelMuted(7)).toBe(true);
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem(`${STORAGE_PREFIX}mutedChannels:a.example.com`)!),
|
||||
).toEqual([7]);
|
||||
|
||||
setChannelMutesHost("b.example.com");
|
||||
expect(isChannelMuted(7)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("channel mutes — notification gating", () => {
|
||||
|
||||
@@ -630,6 +630,34 @@ describe("ChannelSidebar", () => {
|
||||
expect(onWatchStream).toHaveBeenCalledWith(30 + 1_000_000);
|
||||
});
|
||||
|
||||
it("clicking a peer's stream row also joins the channel when not already in it", () => {
|
||||
const onWatchStream = vi.fn();
|
||||
sidebar.destroy?.();
|
||||
sidebar = createChannelSidebar({ onVoiceJoin, onVoiceLeave, onWatchStream });
|
||||
|
||||
setChannels(testChannels);
|
||||
updateVoiceState({
|
||||
channel_id: 3,
|
||||
user_id: 30,
|
||||
username: "Streamer",
|
||||
muted: false,
|
||||
deafened: false,
|
||||
speaking: false,
|
||||
camera: false,
|
||||
screenshare: true,
|
||||
});
|
||||
sidebar.mount(container);
|
||||
|
||||
const voiceUserItem = container.querySelector(".voice-user-item") as HTMLElement;
|
||||
expect(voiceUserItem).not.toBeNull();
|
||||
voiceUserItem.click();
|
||||
|
||||
// Watching a peer's stream needs a live LiveKit room -- without joining
|
||||
// first, showVideoGrid has no cells to focus and the user is stranded on
|
||||
// an empty grid. currentChannelId defaults to null (not channel 3 here).
|
||||
expect(onVoiceJoin).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
// ── Empty state ──
|
||||
|
||||
it("shows empty state when no channels exist", () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
incrementMention,
|
||||
clearUnread,
|
||||
getUnreadOnOpen,
|
||||
resetChannelsStore,
|
||||
} from "../../src/stores/channels.store";
|
||||
import type { ReadyChannel, ChannelCreatePayload, ChannelUpdatePayload } from "../../src/lib/types";
|
||||
|
||||
@@ -1004,4 +1005,38 @@ describe("channels store", () => {
|
||||
expect(before?.nsfw).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// v107-follow-up: channelsStore was never reset on logout, so setChannels'
|
||||
// DM-row carry (line ~117) re-inserted the previous server's DM channels
|
||||
// into the next server's map, and a stale unreadOnOpen snapshot leaked
|
||||
// across accounts/servers too.
|
||||
describe("resetChannelsStore", () => {
|
||||
it("resets channels, activeChannelId, and roles to initial state", () => {
|
||||
setChannels(readyChannels);
|
||||
setRoles([{ id: 1, name: "admin", color: "#ff0000", permissions: 0 }]);
|
||||
setActiveChannel(1);
|
||||
expect(channelsStore.getState().channels.size).toBeGreaterThan(0);
|
||||
expect(channelsStore.getState().activeChannelId).toBe(1);
|
||||
expect(channelsStore.getState().roles.length).toBeGreaterThan(0);
|
||||
|
||||
resetChannelsStore();
|
||||
|
||||
const state = channelsStore.getState();
|
||||
expect(state.channels.size).toBe(0);
|
||||
expect(state.activeChannelId).toBeNull();
|
||||
expect(state.roles).toEqual([]);
|
||||
});
|
||||
|
||||
it("clears the unreadOnOpen snapshot so a stale read position can't leak into the next server", () => {
|
||||
setChannels(readyChannels);
|
||||
// setActiveChannel(1) snapshots unreadOnOpen[1] = channel 1's pre-open
|
||||
// unread count (3, from readyChannels).
|
||||
setActiveChannel(1);
|
||||
expect(getUnreadOnOpen(1)).toBe(3);
|
||||
|
||||
resetChannelsStore();
|
||||
|
||||
expect(getUnreadOnOpen(1)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -380,24 +380,17 @@ describe("createChatArea", () => {
|
||||
|
||||
// --- Jump wiring: both overlays route into the one jumper ---
|
||||
|
||||
it("pinned onJumpToMessage jumps within the current channel", () => {
|
||||
it("pinned onJumpToMessage forwards the channel the panel passes, not whatever is active now", () => {
|
||||
// The panel captured its own channel (99) when it was opened, which can
|
||||
// differ from whatever channel is active by the time the click lands
|
||||
// (e.g. the user switched channels while the panel stayed open).
|
||||
const channelCtrl = { currentChannelId: 7, messageList: null } as any;
|
||||
createChatArea(makeOptions({ getChannelCtrl: () => channelCtrl }));
|
||||
|
||||
const call = vi.mocked(createPinnedPanelController).mock.calls[0]![0];
|
||||
call.onJumpToMessage!(42);
|
||||
call.onJumpToMessage!(99, 42);
|
||||
|
||||
// The pinned panel only ever lists the current channel's pins, so the
|
||||
// channel id comes from the controller rather than the entry.
|
||||
expect(mockJumpTo).toHaveBeenCalledWith(7, 42);
|
||||
});
|
||||
|
||||
it("pinned onJumpToMessage is a no-op with no channel mounted", () => {
|
||||
createChatArea(makeOptions({ getChannelCtrl: () => null }));
|
||||
|
||||
const call = vi.mocked(createPinnedPanelController).mock.calls[0]![0];
|
||||
expect(() => call.onJumpToMessage!(42)).not.toThrow();
|
||||
expect(mockJumpTo).not.toHaveBeenCalled();
|
||||
expect(mockJumpTo).toHaveBeenCalledWith(99, 42);
|
||||
});
|
||||
|
||||
it("search onJumpToMessage jumps to the result's own channel", () => {
|
||||
|
||||
@@ -486,6 +486,44 @@ describe("ConnectPage", () => {
|
||||
page.destroy?.();
|
||||
});
|
||||
|
||||
// B4_conn_ipc-6: a WS auth_error, cert-mismatch reject, or credential-save
|
||||
// warning can set transientError AFTER this page is already mounted — a
|
||||
// one-time getState() read at mount would silently drop it.
|
||||
it("shows a transient error set after mount, not just one already pending at mount time", () => {
|
||||
const page = createConnectPage(makeCallbacks(), testProfiles);
|
||||
page.mount(container);
|
||||
|
||||
// No error yet at mount time.
|
||||
expect(container.querySelector(".error-banner")!.classList.contains("visible")).toBe(false);
|
||||
|
||||
setTransientError("Your session expired — sign in again.");
|
||||
uiStore.flush();
|
||||
|
||||
const errorBanner = container.querySelector(".error-banner")!;
|
||||
expect(errorBanner.classList.contains("visible")).toBe(true);
|
||||
expect(errorBanner.textContent).toBe("Your session expired — sign in again.");
|
||||
expect(uiStore.getState().transientError).toBeNull();
|
||||
|
||||
page.destroy?.();
|
||||
});
|
||||
|
||||
// B4_conn_ipc-15: a transient error set while this page was mounted (e.g. a
|
||||
// background credential-save failure fired after a successful login moved
|
||||
// on to MainPage) must not survive to resurface at the NEXT mount — which
|
||||
// only happens after a later logout, where it would misleadingly read as a
|
||||
// fresh login failure.
|
||||
it("clears a transient error on destroy so it cannot resurface as a bogus login error later", () => {
|
||||
const page = createConnectPage(makeCallbacks(), testProfiles);
|
||||
page.mount(container);
|
||||
|
||||
// Set while mounted but not yet observed (no flush before destroy) —
|
||||
// mirrors a background failure landing just as the page is torn down.
|
||||
setTransientError("Could not save credentials — auto-login won't work");
|
||||
page.destroy?.();
|
||||
|
||||
expect(uiStore.getState().transientError).toBeNull();
|
||||
});
|
||||
|
||||
// --- TOTP overlay interactions ---
|
||||
|
||||
it("TOTP submit calls onTotpSubmit with 6-digit code", async () => {
|
||||
|
||||
@@ -29,7 +29,53 @@ vi.mock("livekit-client", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { DeviceManager } from "../../src/lib/deviceManager";
|
||||
const mockVoiceState = vi.hoisted(() => ({
|
||||
localMuted: false,
|
||||
localDeafened: false,
|
||||
localServerMuted: false,
|
||||
pttGated: false,
|
||||
}));
|
||||
|
||||
vi.mock("@stores/voice.store", () => ({
|
||||
voiceStore: {
|
||||
getState: () => mockVoiceState,
|
||||
},
|
||||
}));
|
||||
|
||||
import { DeviceManager, isMicPolicyGated } from "../../src/lib/deviceManager";
|
||||
|
||||
describe("isMicPolicyGated", () => {
|
||||
beforeEach(() => {
|
||||
mockVoiceState.localMuted = false;
|
||||
mockVoiceState.localDeafened = false;
|
||||
mockVoiceState.localServerMuted = false;
|
||||
mockVoiceState.pttGated = false;
|
||||
});
|
||||
|
||||
it("is false when nothing gates the mic", () => {
|
||||
expect(isMicPolicyGated()).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when localMuted", () => {
|
||||
mockVoiceState.localMuted = true;
|
||||
expect(isMicPolicyGated()).toBe(true);
|
||||
});
|
||||
|
||||
it("is true when localDeafened", () => {
|
||||
mockVoiceState.localDeafened = true;
|
||||
expect(isMicPolicyGated()).toBe(true);
|
||||
});
|
||||
|
||||
it("is true when localServerMuted", () => {
|
||||
mockVoiceState.localServerMuted = true;
|
||||
expect(isMicPolicyGated()).toBe(true);
|
||||
});
|
||||
|
||||
it("is true when pttGated", () => {
|
||||
mockVoiceState.pttGated = true;
|
||||
expect(isMicPolicyGated()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeviceManager", () => {
|
||||
let dm: DeviceManager;
|
||||
@@ -38,6 +84,10 @@ describe("DeviceManager", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
mockVoiceState.localMuted = false;
|
||||
mockVoiceState.localDeafened = false;
|
||||
mockVoiceState.localServerMuted = false;
|
||||
mockVoiceState.pttGated = false;
|
||||
dm = new DeviceManager();
|
||||
mockRoom = {
|
||||
localParticipant: {
|
||||
@@ -186,6 +236,26 @@ describe("DeviceManager", () => {
|
||||
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
// B1_voice_mic-2: switchInputDevice('') must not silently undo a mute,
|
||||
// deafen, server-mute, or PTT gate — livekit's setMicrophoneEnabled(true)
|
||||
// is a bare unmute when the muted-but-published track survives the
|
||||
// toggle, so nothing downstream ever observes the re-publish.
|
||||
it("does not re-enable the mic on default-device switch while the user is muted", async () => {
|
||||
mockVoiceState.localMuted = true;
|
||||
dm.setRoom(mockRoom);
|
||||
await dm.switchInputDevice("");
|
||||
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false);
|
||||
expect(mockRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("does not re-enable the mic on default-device switch while push-to-talk is gating it", async () => {
|
||||
mockVoiceState.pttGated = true;
|
||||
dm.setRoom(mockRoom);
|
||||
await dm.switchInputDevice("");
|
||||
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false);
|
||||
expect(mockRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("calls setupAudioPipeline on the pipeline after switch", async () => {
|
||||
const pipeline = {
|
||||
setupAudioPipeline: vi.fn(),
|
||||
@@ -331,6 +401,29 @@ describe("DeviceManager", () => {
|
||||
expect(onToast).toHaveBeenCalledWith("Audio device disconnected — switched to default");
|
||||
});
|
||||
|
||||
// B1_voice_mic-1: the device-removed fallback must not silently undo a
|
||||
// mute the user (or a moderator, or push-to-talk) applied.
|
||||
it("does not re-enable the mic on device-removed fallback while the user is muted", async () => {
|
||||
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
|
||||
if (key === "audioInputDevice") return "saved-device-id";
|
||||
if (key === "audioOutputDevice") return "";
|
||||
return defaultVal;
|
||||
});
|
||||
mockGetLocalDevices.mockImplementation((kind: string) => {
|
||||
if (kind === "audioinput") return Promise.resolve([{ deviceId: "other-device" }]);
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
mockVoiceState.localMuted = true;
|
||||
|
||||
dm.setRoom(mockRoom);
|
||||
const handler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1];
|
||||
handler();
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
|
||||
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false);
|
||||
expect(mockRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("does nothing if saved input device still exists", async () => {
|
||||
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
|
||||
if (key === "audioInputDevice") return "device-A";
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { wireDispatcher, wireConnectionStatus } from "../../src/lib/dispatcher";
|
||||
// Vite's `?raw` suffix inlines the file's source text as a string (see
|
||||
// src/vite-env.d.ts's `vite/client` types) — used below for a structural
|
||||
// bundle-hygiene assertion, without pulling in node:fs.
|
||||
import dispatcherSource from "../../src/lib/dispatcher.ts?raw";
|
||||
import { createMockWsClient } from "../helpers/mock-ws";
|
||||
import { authStore, clearAuth } from "../../src/stores/auth.store";
|
||||
import { channelsStore, setRoles, getRoleIdByName } from "../../src/stores/channels.store";
|
||||
@@ -8,6 +12,10 @@ import {
|
||||
addOptimisticMessage,
|
||||
addOptimisticReaction,
|
||||
getChannelMessages,
|
||||
setMessages,
|
||||
markSendFailed,
|
||||
isChannelLoaded,
|
||||
getHistoryLoadState,
|
||||
} from "../../src/stores/messages.store";
|
||||
import { membersStore } from "../../src/stores/members.store";
|
||||
import { voiceStore } from "../../src/stores/voice.store";
|
||||
@@ -29,7 +37,7 @@ import {
|
||||
} from "../../src/components/message-list/reaction-tooltip";
|
||||
import { setMarkReadSender } from "../../src/lib/read-state";
|
||||
import type { WsClient, WsListener, ConnectionState } from "../../src/lib/ws";
|
||||
import type { ServerMessage } from "../../src/lib/types";
|
||||
import type { ServerMessage, MessageResponse } from "../../src/lib/types";
|
||||
|
||||
// Mock notifications and livekitSession to avoid side effects
|
||||
vi.mock("@lib/notifications", () => ({
|
||||
@@ -47,6 +55,13 @@ vi.mock("@lib/livekitSession", () => ({
|
||||
setMuted: vi.fn(),
|
||||
setDeafened: vi.fn(),
|
||||
disableCamera: vi.fn(async () => {}),
|
||||
disableScreenshare: vi.fn(async () => {}),
|
||||
}));
|
||||
// screenShare.ts's rollback correlation is exercised at the unit level in
|
||||
// screen-share-tracks.test.ts; here only the dispatcher's own reaction to it
|
||||
// is under test, so the lookup itself is mocked and controlled per test.
|
||||
vi.mock("@lib/screenShare", () => ({
|
||||
rollbackPendingVideo: vi.fn(() => undefined as "camera" | "screen" | undefined),
|
||||
}));
|
||||
// F3: the ready handler publishes our identity key. Mock the orchestrator so
|
||||
// the wiring is asserted without real keygen/keyring.
|
||||
@@ -62,12 +77,16 @@ vi.mock("@lib/identity", () => ({
|
||||
import { ensureIdentityKeyPublished as _ensureIdentityKeyPublished } from "../../src/lib/identity";
|
||||
const mockEnsurePublished = vi.mocked(_ensureIdentityKeyPublished);
|
||||
|
||||
import { notifyIncomingMessage as mockNotifyIncomingMessage } from "../../src/lib/notifications";
|
||||
|
||||
import {
|
||||
setMuted as mockSetMuted,
|
||||
setDeafened as mockSetDeafened,
|
||||
leaveVoice as mockLeaveVoice,
|
||||
disableCamera as mockDisableCamera,
|
||||
disableScreenshare as mockDisableScreenshare,
|
||||
} from "@lib/livekitSession";
|
||||
import { rollbackPendingVideo as mockRollbackPendingVideo } from "@lib/screenShare";
|
||||
|
||||
// Suppress console output
|
||||
vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
@@ -315,6 +334,154 @@ describe("WS Dispatcher", () => {
|
||||
expect(ch?.unreadCount).toBe(1);
|
||||
});
|
||||
|
||||
describe("chat_message notifications during a reconnect replay burst", () => {
|
||||
// ws.ts clears isReplaying() as soon as auth_ok is processed — before the
|
||||
// replay burst of chat_message frames the server sends right after it
|
||||
// even arrives — so it cannot gate notifications the way it gates the
|
||||
// unread counter above. A second auth_ok in this dispatcher's lifetime is
|
||||
// always a reconnect handshake; its timestamp is the gate instead.
|
||||
beforeEach(() => {
|
||||
vi.mocked(mockNotifyIncomingMessage).mockClear();
|
||||
});
|
||||
|
||||
it("does not notify for a replay frame timestamped before the reconnect handshake", () => {
|
||||
mock.dispatch("auth_ok", {
|
||||
user: { id: 1, username: "alex", avatar: null, role: "admin" },
|
||||
server_name: "TestServer",
|
||||
motd: "",
|
||||
});
|
||||
const handshakeAt = Date.now();
|
||||
// Second auth_ok in the same dispatcher lifetime = a reconnect.
|
||||
mock.dispatch("auth_ok", {
|
||||
user: { id: 1, username: "alex", avatar: null, role: "admin" },
|
||||
server_name: "TestServer",
|
||||
motd: "",
|
||||
});
|
||||
|
||||
mock.dispatch("chat_message", {
|
||||
id: 1,
|
||||
channel_id: 1,
|
||||
user: { id: 2, username: "bob", avatar: null },
|
||||
content: "missed while offline",
|
||||
reply_to: null,
|
||||
attachments: [],
|
||||
timestamp: new Date(handshakeAt - 5000).toISOString(),
|
||||
});
|
||||
|
||||
expect(mockNotifyIncomingMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still notifies for a genuinely live message after reconnecting", () => {
|
||||
mock.dispatch("auth_ok", {
|
||||
user: { id: 1, username: "alex", avatar: null, role: "admin" },
|
||||
server_name: "TestServer",
|
||||
motd: "",
|
||||
});
|
||||
const handshakeAt = Date.now();
|
||||
mock.dispatch("auth_ok", {
|
||||
user: { id: 1, username: "alex", avatar: null, role: "admin" },
|
||||
server_name: "TestServer",
|
||||
motd: "",
|
||||
});
|
||||
|
||||
mock.dispatch("chat_message", {
|
||||
id: 2,
|
||||
channel_id: 1,
|
||||
user: { id: 2, username: "bob", avatar: null },
|
||||
content: "live now",
|
||||
reply_to: null,
|
||||
attachments: [],
|
||||
timestamp: new Date(handshakeAt + 1000).toISOString(),
|
||||
});
|
||||
|
||||
expect(mockNotifyIncomingMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockNotifyIncomingMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: "live now" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not gate messages on the session's very first connect (no prior handshake)", () => {
|
||||
mock.dispatch("auth_ok", {
|
||||
user: { id: 1, username: "alex", avatar: null, role: "admin" },
|
||||
server_name: "TestServer",
|
||||
motd: "",
|
||||
});
|
||||
|
||||
// Old timestamp, but there was no earlier auth_ok — not a reconnect.
|
||||
mock.dispatch("chat_message", {
|
||||
id: 3,
|
||||
channel_id: 1,
|
||||
user: { id: 2, username: "bob", avatar: null },
|
||||
content: "first connect",
|
||||
reply_to: null,
|
||||
attachments: [],
|
||||
timestamp: "2020-01-01T00:00:00Z",
|
||||
});
|
||||
|
||||
expect(mockNotifyIncomingMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// BUG: the anchor (lastReconnectHandshakeAt) is stamped from the CLIENT's
|
||||
// Date.now(), but payload.timestamp is the SERVER's created_at — a raw
|
||||
// comparison mixes clock domains. A self-hosted server routinely runs
|
||||
// without NTP; if its clock lags the client's, every genuinely live
|
||||
// message for the drift window after every reconnect looks like it
|
||||
// predates the handshake and gets silently treated as a replay (no
|
||||
// notification, no taskbar flash) — and with persistent skew this never
|
||||
// recovers.
|
||||
it("does not misclassify a live post-reconnect message as a replay when the server clock lags (observed skew)", () => {
|
||||
const driftMs = 30_000; // server clock reads 30s behind the client's
|
||||
const t0 = Date.now();
|
||||
|
||||
// First connect. A live message here establishes the observed skew
|
||||
// before any reconnect exists to misclassify.
|
||||
mock.dispatch("auth_ok", {
|
||||
user: { id: 1, username: "alex", avatar: null, role: "admin" },
|
||||
server_name: "TestServer",
|
||||
motd: "",
|
||||
});
|
||||
mock.dispatch("chat_message", {
|
||||
id: 1,
|
||||
channel_id: 1,
|
||||
user: { id: 2, username: "bob", avatar: null },
|
||||
content: "before reconnect",
|
||||
reply_to: null,
|
||||
attachments: [],
|
||||
timestamp: new Date(t0 - driftMs).toISOString(),
|
||||
});
|
||||
vi.mocked(mockNotifyIncomingMessage).mockClear();
|
||||
|
||||
// Reconnect 1s later (client clock).
|
||||
vi.setSystemTime(t0 + 1000);
|
||||
const handshakeAt = Date.now();
|
||||
mock.dispatch("auth_ok", {
|
||||
user: { id: 1, username: "alex", avatar: null, role: "admin" },
|
||||
server_name: "TestServer",
|
||||
motd: "",
|
||||
});
|
||||
|
||||
// A genuinely live message arrives 1s after the handshake (client
|
||||
// clock). Its server timestamp — still 30s behind — reads 29s
|
||||
// *before* the handshake in the client's own clock, which a
|
||||
// client-clock-only comparison misclassifies as a replay.
|
||||
vi.setSystemTime(handshakeAt + 1000);
|
||||
mock.dispatch("chat_message", {
|
||||
id: 2,
|
||||
channel_id: 1,
|
||||
user: { id: 2, username: "bob", avatar: null },
|
||||
content: "live after reconnect, server clock still lagging",
|
||||
reply_to: null,
|
||||
attachments: [],
|
||||
timestamp: new Date(Date.now() - driftMs).toISOString(),
|
||||
});
|
||||
|
||||
expect(mockNotifyIncomingMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockNotifyIncomingMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: "live after reconnect, server clock still lagging" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mention counts", () => {
|
||||
function seedChannel(): void {
|
||||
channelsStore.setState((prev) => {
|
||||
@@ -909,6 +1076,375 @@ describe("WS Dispatcher", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ready reconciles the DM channelsStore mirror", () => {
|
||||
function seedDmMirrorRow(unreadCount: number, mentionCount: number): void {
|
||||
channelsStore.setState((prev) => {
|
||||
const ch = new Map(prev.channels);
|
||||
ch.set(50, {
|
||||
id: 50,
|
||||
name: "bob",
|
||||
type: "dm" as const,
|
||||
category: null,
|
||||
position: 0,
|
||||
unreadCount,
|
||||
mentionCount,
|
||||
lastMessageId: null,
|
||||
canSend: true,
|
||||
topic: "",
|
||||
slowMode: 0,
|
||||
nsfw: false,
|
||||
voiceMaxUsers: 0,
|
||||
voiceMaxVideo: 0,
|
||||
});
|
||||
return { ...prev, channels: ch };
|
||||
});
|
||||
}
|
||||
|
||||
// The mirror row is only ever created by addDmToChannelsStore (on open),
|
||||
// and setChannels' carry loop deliberately preserves dm-typed rows across
|
||||
// every ready rebuild — so a DM closed elsewhere while offline keeps a
|
||||
// phantom row here forever unless something prunes it.
|
||||
it("removes a dm-typed mirror row absent from the fresh dm_channels payload", () => {
|
||||
seedDmMirrorRow(3, 1);
|
||||
|
||||
mock.dispatch("ready", {
|
||||
channels: [],
|
||||
members: [],
|
||||
voice_states: [],
|
||||
roles: [],
|
||||
dm_channels: [],
|
||||
});
|
||||
|
||||
expect(channelsStore.getState().channels.has(50)).toBe(false);
|
||||
});
|
||||
|
||||
// incrementUnread/incrementMention bump the mirror in parallel with
|
||||
// dmStore once it exists, but only dmStore is restated by `ready` — so a
|
||||
// DM read on another device keeps a stale count here that survives every
|
||||
// reconnect until this reconciles it too.
|
||||
it("restates a surviving dm-typed mirror row's unread/mention counts from the payload", () => {
|
||||
seedDmMirrorRow(9, 4);
|
||||
|
||||
mock.dispatch("ready", {
|
||||
channels: [],
|
||||
members: [],
|
||||
voice_states: [],
|
||||
roles: [],
|
||||
dm_channels: [
|
||||
{
|
||||
channel_id: 50,
|
||||
recipient: { id: 10, username: "bob", avatar: "", status: "online" },
|
||||
last_message_id: null,
|
||||
last_message: "",
|
||||
last_message_at: "",
|
||||
unread_count: 0,
|
||||
mention_count: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const ch = channelsStore.getState().channels.get(50);
|
||||
expect(ch?.unreadCount).toBe(0);
|
||||
expect(ch?.mentionCount).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves a non-dm channel row's counts alone", () => {
|
||||
channelsStore.setState((prev) => {
|
||||
const ch = new Map(prev.channels);
|
||||
ch.set(1, {
|
||||
id: 1,
|
||||
name: "general",
|
||||
type: "text" as const,
|
||||
category: null,
|
||||
position: 0,
|
||||
unreadCount: 5,
|
||||
mentionCount: 0,
|
||||
lastMessageId: null,
|
||||
canSend: true,
|
||||
topic: "",
|
||||
slowMode: 0,
|
||||
nsfw: false,
|
||||
voiceMaxUsers: 0,
|
||||
voiceMaxVideo: 0,
|
||||
});
|
||||
// Active channel is some other id — channel 1 must be neither
|
||||
// auto-selected (activeChannelId isn't null) nor mark-read'd (it
|
||||
// isn't the active one), both of which legitimately zero a badge on
|
||||
// their own and would otherwise be confused for this reconciliation
|
||||
// reaching into a channel type it must not touch.
|
||||
return { ...prev, channels: ch, activeChannelId: 2 };
|
||||
});
|
||||
|
||||
mock.dispatch("ready", {
|
||||
channels: [
|
||||
{
|
||||
id: 1,
|
||||
name: "general",
|
||||
type: "text",
|
||||
category: null,
|
||||
position: 0,
|
||||
unread_count: 5,
|
||||
mention_count: 0,
|
||||
},
|
||||
],
|
||||
members: [],
|
||||
voice_states: [],
|
||||
roles: [],
|
||||
dm_channels: [],
|
||||
});
|
||||
|
||||
expect(channelsStore.getState().channels.get(1)?.unreadCount).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ready invalidates loaded message windows on a full-ready resync", () => {
|
||||
function storedMessage(id: number, channelId = 1): MessageResponse {
|
||||
return {
|
||||
id,
|
||||
channel_id: channelId,
|
||||
user: { id: 1, username: "alex", avatar: null },
|
||||
content: `msg ${id}`,
|
||||
reply_to: null,
|
||||
attachments: [],
|
||||
reactions: [],
|
||||
pinned: false,
|
||||
edited_at: null,
|
||||
deleted: false,
|
||||
timestamp: "2026-03-15T09:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
it("leaves history alone on the session's very first ready", () => {
|
||||
setMessages(1, [storedMessage(10)], false);
|
||||
channelsStore.setState((prev) => ({ ...prev, activeChannelId: 1 }));
|
||||
|
||||
mock.dispatch("ready", {
|
||||
channels: [],
|
||||
members: [],
|
||||
voice_states: [],
|
||||
roles: [],
|
||||
dm_channels: [],
|
||||
});
|
||||
|
||||
expect(isChannelLoaded(1)).toBe(true);
|
||||
expect(getChannelMessages(1)).toHaveLength(1);
|
||||
});
|
||||
|
||||
// The full-ready tier (this is the only tier that ever sends `ready`
|
||||
// again after the first) never replays chat_message frames, so every
|
||||
// channel loaded before the drop keeps a permanent hole unless its
|
||||
// window is invalidated and the one on screen is refetched.
|
||||
it("invalidates every loaded channel and refetches the active one on a second ready", async () => {
|
||||
cleanup();
|
||||
const listBlocks = vi.fn().mockResolvedValue({ blocked_user_ids: [] });
|
||||
const getMessages = vi.fn().mockResolvedValue({
|
||||
messages: [storedMessage(900)],
|
||||
has_more: false,
|
||||
});
|
||||
cleanup = wireDispatcher(mock.ws, { listBlocks, getMessages });
|
||||
|
||||
channelsStore.setState((prev) => ({ ...prev, activeChannelId: 1 }));
|
||||
setMessages(1, [storedMessage(10)], false);
|
||||
setMessages(2, [storedMessage(20, 2)], false);
|
||||
// Channel 1 must stay present in every ready payload — otherwise the
|
||||
// "channel this session was viewing is gone" branch clears
|
||||
// activeChannelId first, which would make the refetch target null for
|
||||
// reasons unrelated to what this test is pinning.
|
||||
const readyChannels = [
|
||||
{ id: 1, name: "general", type: "text" as const, category: null, position: 0 },
|
||||
];
|
||||
|
||||
// First ready in this dispatcher's lifetime: initial connect.
|
||||
mock.dispatch("ready", {
|
||||
channels: readyChannels,
|
||||
members: [],
|
||||
voice_states: [],
|
||||
roles: [],
|
||||
dm_channels: [],
|
||||
});
|
||||
expect(getMessages).not.toHaveBeenCalled();
|
||||
|
||||
// Second ready: a full-ready resync.
|
||||
mock.dispatch("ready", {
|
||||
channels: readyChannels,
|
||||
members: [],
|
||||
voice_states: [],
|
||||
roles: [],
|
||||
dm_channels: [],
|
||||
});
|
||||
|
||||
// The inactive channel is invalidated but not eagerly refetched.
|
||||
expect(isChannelLoaded(2)).toBe(false);
|
||||
expect(getChannelMessages(2)).toEqual([]);
|
||||
|
||||
// The active channel is refetched from the server.
|
||||
expect(getMessages).toHaveBeenCalledWith(1, { limit: 50 });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(getChannelMessages(1).map((m) => m.id)).toEqual([900]);
|
||||
expect(isChannelLoaded(1)).toBe(true);
|
||||
});
|
||||
|
||||
// BUG: invalidateLoadedMessageWindows() ran unconditionally, but the
|
||||
// refetch below it only runs when there's a resolvable active channel
|
||||
// AND api.getMessages exists (api is a Partial<...>, so it may be
|
||||
// absent). When it can't refetch, every loaded window is dropped with
|
||||
// nothing left to reload it — the mounted MessageList is stuck showing
|
||||
// only carried-through pending rows until the user navigates away and
|
||||
// back. The default wireDispatcher(mock.ws) from the outer beforeEach
|
||||
// has no `api` at all, so there is no getMessages to refetch with here.
|
||||
it("keeps loaded history across a resync when there is no getMessages to refetch it", () => {
|
||||
channelsStore.setState((prev) => ({ ...prev, activeChannelId: 1 }));
|
||||
setMessages(1, [storedMessage(10)], false);
|
||||
|
||||
const readyChannels = [
|
||||
{ id: 1, name: "general", type: "text" as const, category: null, position: 0 },
|
||||
];
|
||||
|
||||
// First ready: initial connect.
|
||||
mock.dispatch("ready", {
|
||||
channels: readyChannels,
|
||||
members: [],
|
||||
voice_states: [],
|
||||
roles: [],
|
||||
dm_channels: [],
|
||||
});
|
||||
|
||||
// Second ready: a full-ready resync, with nothing able to refetch it.
|
||||
mock.dispatch("ready", {
|
||||
channels: readyChannels,
|
||||
members: [],
|
||||
voice_states: [],
|
||||
roles: [],
|
||||
dm_channels: [],
|
||||
});
|
||||
|
||||
expect(isChannelLoaded(1)).toBe(true);
|
||||
expect(getChannelMessages(1)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("carries a failed optimistic row through the resync invalidation", () => {
|
||||
cleanup();
|
||||
const listBlocks = vi.fn().mockResolvedValue({ blocked_user_ids: [] });
|
||||
const getMessages = vi.fn().mockResolvedValue({ messages: [], has_more: false });
|
||||
cleanup = wireDispatcher(mock.ws, { listBlocks, getMessages });
|
||||
|
||||
channelsStore.setState((prev) => ({ ...prev, activeChannelId: 1 }));
|
||||
setMessages(1, [storedMessage(10)], false);
|
||||
addOptimisticMessage({
|
||||
correlationId: "c1",
|
||||
channelId: 1,
|
||||
user: { id: 1, username: "alex", avatar: null },
|
||||
content: "unsent",
|
||||
replyTo: null,
|
||||
timestamp: "2026-03-15T10:00:00Z",
|
||||
});
|
||||
markSendFailed("c1", "SLOW_MODE");
|
||||
|
||||
mock.dispatch("ready", {
|
||||
channels: [],
|
||||
members: [],
|
||||
voice_states: [],
|
||||
roles: [],
|
||||
dm_channels: [],
|
||||
});
|
||||
mock.dispatch("ready", {
|
||||
channels: [],
|
||||
members: [],
|
||||
voice_states: [],
|
||||
roles: [],
|
||||
dm_channels: [],
|
||||
});
|
||||
|
||||
const msgs = getChannelMessages(1);
|
||||
expect(msgs.some((m) => m.correlationId === "c1" && m.status === "failed")).toBe(true);
|
||||
});
|
||||
|
||||
// Guard added by commit 34c89fb: invalidate only runs when there IS a
|
||||
// resolvable active channel to refetch. Without it, a resync landing with
|
||||
// no active channel (e.g. logged in but nothing selected yet) would drop
|
||||
// every loaded window with nothing able to reload it — the same "stuck
|
||||
// empty" bug as the no-getMessages case above, just keyed on activeId
|
||||
// instead of api.getMessages. Pins the guard's other half and that no
|
||||
// downstream step in the ready handler assumes the invalidation ran.
|
||||
it("does not throw and leaves history untouched when no channel is active on resync", () => {
|
||||
cleanup();
|
||||
const listBlocks = vi.fn().mockResolvedValue({ blocked_user_ids: [] });
|
||||
const getMessages = vi.fn().mockResolvedValue({ messages: [], has_more: false });
|
||||
cleanup = wireDispatcher(mock.ws, { listBlocks, getMessages });
|
||||
|
||||
// Channel 1 is loaded but never made active — an empty `channels` list
|
||||
// on both readies means nothing ever auto-selects it.
|
||||
setMessages(1, [storedMessage(10)], false);
|
||||
|
||||
expect(() => {
|
||||
mock.dispatch("ready", {
|
||||
channels: [],
|
||||
members: [],
|
||||
voice_states: [],
|
||||
roles: [],
|
||||
dm_channels: [],
|
||||
});
|
||||
mock.dispatch("ready", {
|
||||
channels: [],
|
||||
members: [],
|
||||
voice_states: [],
|
||||
roles: [],
|
||||
dm_channels: [],
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
expect(getMessages).not.toHaveBeenCalled();
|
||||
expect(isChannelLoaded(1)).toBe(true);
|
||||
expect(getChannelMessages(1)).toHaveLength(1);
|
||||
});
|
||||
|
||||
// NEW residual gap the guard move opens: invalidate and the refetch now
|
||||
// always fire together, but invalidate is synchronous while the refetch
|
||||
// is async — so a rejection lands *after* the active channel's window is
|
||||
// already dropped. The .catch only logs; without also marking the
|
||||
// channel load-errored, the mounted MessageList falls back to its empty
|
||||
// "no messages yet" welcome state (virtualItems.length === 0 and
|
||||
// historyLoadState is still idle) instead of the inline error+Retry
|
||||
// state — silently misrepresenting a failed reload as a genuinely empty
|
||||
// channel, which is exactly the kind of silent history hole B2-1 exists
|
||||
// to prevent.
|
||||
it("marks the active channel load-errored when the resync refetch rejects", async () => {
|
||||
cleanup();
|
||||
const listBlocks = vi.fn().mockResolvedValue({ blocked_user_ids: [] });
|
||||
const getMessages = vi.fn().mockRejectedValue(new Error("network down"));
|
||||
cleanup = wireDispatcher(mock.ws, { listBlocks, getMessages });
|
||||
|
||||
channelsStore.setState((prev) => ({ ...prev, activeChannelId: 1 }));
|
||||
setMessages(1, [storedMessage(10)], false);
|
||||
|
||||
const readyChannels = [
|
||||
{ id: 1, name: "general", type: "text" as const, category: null, position: 0 },
|
||||
];
|
||||
|
||||
mock.dispatch("ready", {
|
||||
channels: readyChannels,
|
||||
members: [],
|
||||
voice_states: [],
|
||||
roles: [],
|
||||
dm_channels: [],
|
||||
});
|
||||
mock.dispatch("ready", {
|
||||
channels: readyChannels,
|
||||
members: [],
|
||||
voice_states: [],
|
||||
roles: [],
|
||||
dm_channels: [],
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(isChannelLoaded(1)).toBe(false);
|
||||
expect(getHistoryLoadState(1)).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
it("fails every pending optimistic send when the connection drops", () => {
|
||||
addOptimisticMessage({
|
||||
correlationId: "corr-drop",
|
||||
@@ -1790,8 +2326,10 @@ describe("WS Dispatcher", () => {
|
||||
expect(error).toBe("Server error");
|
||||
});
|
||||
|
||||
it("wires error with unknown code does not set transient error", () => {
|
||||
// Clear any previous errors
|
||||
it("wires error with an unrecognized code to the generic fallback banner", () => {
|
||||
// The final fallthrough is the one place every unmatched error code
|
||||
// lands (e.g. a rejected fire-and-forget chat_edit) — it must not be
|
||||
// silently dropped just because it isn't RATE_LIMITED/FORBIDDEN.
|
||||
uiStore.setState((prev) => ({ ...prev, transientError: null }));
|
||||
|
||||
mock.dispatch("error", {
|
||||
@@ -1799,7 +2337,19 @@ describe("WS Dispatcher", () => {
|
||||
message: "Something odd",
|
||||
});
|
||||
|
||||
expect(uiStore.getState().transientError).toBeNull();
|
||||
expect(uiStore.getState().transientError).toBe("Something odd");
|
||||
});
|
||||
|
||||
it("wires a BAD_REQUEST error with no pending correlation (e.g. a rejected chat_edit) to a transient error", () => {
|
||||
// chat_edit is fire-and-forget: it never enters pendingSends, so a
|
||||
// rejection's envelope id matches nothing above and used to fall through
|
||||
// this handler silently, leaving the user's edited text destroyed with
|
||||
// no error shown (only RATE_LIMITED/FORBIDDEN were bannered).
|
||||
uiStore.setState((prev) => ({ ...prev, transientError: null }));
|
||||
|
||||
mock.dispatch("error", { code: "BAD_REQUEST", message: "Message too long" }, "edit-id-1");
|
||||
|
||||
expect(uiStore.getState().transientError).toBe("Message too long");
|
||||
});
|
||||
|
||||
it("wires an error carrying a pending send id to mark that row failed (not a toast)", () => {
|
||||
@@ -2415,6 +2965,59 @@ describe("WS Dispatcher", () => {
|
||||
expect(channelsStore.getState().activeChannelId).toBe(60);
|
||||
});
|
||||
|
||||
// The channelsStore mirror row for a DM is only ever synthesized by
|
||||
// addDmToChannelsStore (on open, via selectDmConversation) — a DM present
|
||||
// in dmStore from `ready` but never opened this session has none. Without
|
||||
// synthesizing it here, activating channel 60 lands on an id
|
||||
// ChannelController can't resolve and blanks the chat area permanently.
|
||||
it("synthesizes the channelsStore mirror row when falling back to a DM never opened this session", () => {
|
||||
dmStore.setState(() => ({
|
||||
channels: [
|
||||
{
|
||||
channelId: 50,
|
||||
recipient: { id: 10, username: "bob", avatar: "", status: "online" },
|
||||
participants: [],
|
||||
name: "",
|
||||
isGroup: false,
|
||||
lastMessageId: null,
|
||||
lastMessage: "",
|
||||
lastMessageAt: "",
|
||||
unreadCount: 0,
|
||||
mentionCount: 0,
|
||||
},
|
||||
{
|
||||
channelId: 60,
|
||||
recipient: { id: 11, username: "carl", avatar: "", status: "online" },
|
||||
participants: [{ id: 11, username: "carl", avatar: "", status: "online" }],
|
||||
name: "",
|
||||
isGroup: false,
|
||||
lastMessageId: null,
|
||||
lastMessage: "",
|
||||
lastMessageAt: "",
|
||||
unreadCount: 2,
|
||||
mentionCount: 1,
|
||||
},
|
||||
],
|
||||
}));
|
||||
channelsStore.setState((prev) => ({ ...prev, activeChannelId: 50 }));
|
||||
// Channel 60 has no channelsStore row yet — the bug this test locks.
|
||||
expect(channelsStore.getState().channels.has(60)).toBe(false);
|
||||
|
||||
mock.dispatch("dm_channel_close", { channel_id: 50 });
|
||||
|
||||
expect(channelsStore.getState().activeChannelId).toBe(60);
|
||||
const ch = channelsStore.getState().channels.get(60);
|
||||
// Without the fix, ch is undefined here — activation succeeded but the
|
||||
// row it needs to resolve a name/type from never got synthesized.
|
||||
expect(ch).toBeDefined();
|
||||
expect(ch?.type).toBe("dm");
|
||||
expect(ch?.name).toBe("carl");
|
||||
// setActiveChannel legitimately zeroes a badge on open (existing,
|
||||
// correct behavior) — this only confirms that ran against a row that
|
||||
// now actually exists, not that synthesis skipped the counts.
|
||||
expect(ch?.unreadCount).toBe(0);
|
||||
});
|
||||
|
||||
it("falls back to the first text channel when the closed DM was active and no DMs remain", () => {
|
||||
dmStore.setState(() => ({
|
||||
channels: [
|
||||
@@ -2708,6 +3311,96 @@ describe("WS Dispatcher", () => {
|
||||
mock.dispatch("error", { code: "CHANNEL_FULL", message: "full" });
|
||||
expect(uiStore.getState().transientError).toBeNull();
|
||||
});
|
||||
|
||||
// The sidebar/widget optimistically writes currentChannelId (voiceStatus
|
||||
// "joining") before the server answers. A first-time join has no prior
|
||||
// channel to leave, so no voice_leave ever arrives to clean that up —
|
||||
// without a rollback here, currentChannelId is stuck pointing at a
|
||||
// channel with no LiveKit session, and the sidebar's recovery click tears
|
||||
// down whatever *is* live instead.
|
||||
it("rolls back the optimistic join on a first-time CHANNEL_FULL", () => {
|
||||
voiceStore.setState((prev) => ({ ...prev, currentChannelId: 5, voiceStatus: "joining" }));
|
||||
|
||||
mock.dispatch("error", { code: "CHANNEL_FULL", message: "full" });
|
||||
|
||||
expect(voiceStore.getState().currentChannelId).toBeNull();
|
||||
expect(voiceStore.getState().voiceStatus).toBe("idle");
|
||||
});
|
||||
|
||||
// A channel-*switch* refusal is different: the server leaves the old
|
||||
// channel first, whose self voice_leave already reset voiceStatus to
|
||||
// idle by the time this error lands — so the guard above is a no-op and
|
||||
// must not blow away a genuinely connected session.
|
||||
it("does not touch an already-established voice session on CHANNEL_FULL", () => {
|
||||
voiceStore.setState((prev) => ({ ...prev, currentChannelId: 5, voiceStatus: "connected" }));
|
||||
|
||||
mock.dispatch("error", { code: "CHANNEL_FULL", message: "full" });
|
||||
|
||||
expect(voiceStore.getState().currentChannelId).toBe(5);
|
||||
expect(voiceStore.getState().voiceStatus).toBe("connected");
|
||||
});
|
||||
});
|
||||
|
||||
// A server refusal of voice_camera/voice_screenshare (FORBIDDEN,
|
||||
// RATE_LIMITED, INTERNAL, ...) other than VIDEO_LIMIT is otherwise
|
||||
// unhandled — the already-published track keeps streaming to every peer
|
||||
// while the store says it's off. Correlated by envelope id, exactly like
|
||||
// pendingSends/pendingReactions above, so an unrelated refusal on some
|
||||
// other action never touches video state.
|
||||
describe("voice video-enable refusal rollback", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(mockRollbackPendingVideo).mockReset().mockReturnValue(undefined);
|
||||
vi.mocked(mockDisableCamera).mockClear();
|
||||
vi.mocked(mockDisableScreenshare).mockClear();
|
||||
uiStore.setState((prev) => ({ ...prev, transientError: null }));
|
||||
});
|
||||
|
||||
it("rolls back the camera publish on a correlated refusal", async () => {
|
||||
vi.mocked(mockRollbackPendingVideo).mockReturnValue("camera");
|
||||
|
||||
mock.dispatch("error", { code: "FORBIDDEN", message: "no permission" }, "vid-1");
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mockRollbackPendingVideo).toHaveBeenCalledWith("vid-1");
|
||||
expect(mockDisableCamera).toHaveBeenCalled();
|
||||
expect(mockDisableScreenshare).not.toHaveBeenCalled();
|
||||
expect(uiStore.getState().transientError).toBe("no permission");
|
||||
});
|
||||
|
||||
it("rolls back the screenshare publish on a correlated refusal", async () => {
|
||||
vi.mocked(mockRollbackPendingVideo).mockReturnValue("screen");
|
||||
|
||||
mock.dispatch("error", { code: "RATE_LIMITED", message: "" }, "vid-2");
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mockDisableScreenshare).toHaveBeenCalled();
|
||||
expect(mockDisableCamera).not.toHaveBeenCalled();
|
||||
expect(uiStore.getState().transientError).toBe("Server error");
|
||||
});
|
||||
|
||||
it("leaves an uncorrelated refusal as a plain transient error — no rollback", () => {
|
||||
vi.mocked(mockRollbackPendingVideo).mockReturnValue(undefined);
|
||||
|
||||
mock.dispatch("error", { code: "FORBIDDEN", message: "nope" }, "unrelated-id");
|
||||
|
||||
expect(mockDisableCamera).not.toHaveBeenCalled();
|
||||
expect(mockDisableScreenshare).not.toHaveBeenCalled();
|
||||
expect(uiStore.getState().transientError).toBe("nope");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundle hygiene", () => {
|
||||
// screenShare.ts has VALUE imports from livekit-client (~1.3 MB) at module
|
||||
// scope. dispatcher.ts is imported statically from main.ts, so a top-level
|
||||
// `import ... from "@lib/screenShare"` here drags the whole library into
|
||||
// the startup bundle — every other voice call site in this file already
|
||||
// loads its module lazily (see the livekitSession() helper), and
|
||||
// screenShare.ts's one export used here (rollbackPendingVideo) must follow
|
||||
// the same idiom instead of a static import.
|
||||
it("does not statically import screenShare — dynamic import only", () => {
|
||||
const staticImport = /^\s*import\s[^;]*from\s+["']@lib\/screenShare["']/m;
|
||||
expect(staticImport.test(dispatcherSource)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const fetchImageAsDataUrl = vi.hoisted(() => vi.fn());
|
||||
|
||||
// Only the network fetch is stubbed -- isSafeUrl/resolveServerUrl are
|
||||
// reimplemented (not mocked away) so the raw-src-vs-authenticated-fetch
|
||||
// distinction this suite exercises stays honest. Mirrors tests/unit/avatar.test.ts.
|
||||
vi.mock("@components/message-list/attachments", () => ({
|
||||
fetchImageAsDataUrl,
|
||||
isSafeUrl: (url: string) => url.startsWith("https://") || url.startsWith("http://"),
|
||||
resolveServerUrl: (url: string) => (url.startsWith("http") ? url : `https://server.test${url}`),
|
||||
}));
|
||||
|
||||
import { createDmProfileSidebar } from "../../src/components/DmProfileSidebar";
|
||||
import type { DmProfileData, DmProfileSidebarOptions } from "../../src/components/DmProfileSidebar";
|
||||
|
||||
@@ -24,6 +36,7 @@ describe("DmProfileSidebar", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchImageAsDataUrl.mockReset();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
@@ -123,14 +136,39 @@ describe("DmProfileSidebar", () => {
|
||||
sidebar.destroy?.();
|
||||
});
|
||||
|
||||
it("shows avatar image when avatar URL is provided", () => {
|
||||
it("shows avatar image when avatar URL is provided", async () => {
|
||||
// <img src> cannot carry the bearer token an authenticated file route
|
||||
// needs, so the picture is fetched and swapped in, never assigned raw.
|
||||
fetchImageAsDataUrl.mockResolvedValue("data:image/png;base64,AAA");
|
||||
const user = makeUser({ avatar: "https://example.com/avatar.png" });
|
||||
const sidebar = createDmProfileSidebar(makeOptions({ user }));
|
||||
sidebar.mount(container);
|
||||
|
||||
const img = container.querySelector(".dps-avatar-img") as HTMLImageElement;
|
||||
expect(img).not.toBeNull();
|
||||
expect(img.src).toBe("https://example.com/avatar.png");
|
||||
expect(fetchImageAsDataUrl).toHaveBeenCalledWith("https://example.com/avatar.png");
|
||||
await vi.waitFor(() => {
|
||||
const img = container.querySelector(".dps-avatar-img") as HTMLImageElement;
|
||||
expect(img).not.toBeNull();
|
||||
expect(img.src).toBe("data:image/png;base64,AAA");
|
||||
});
|
||||
|
||||
sidebar.destroy?.();
|
||||
});
|
||||
|
||||
it("fetches a server-relative avatar through the authenticated path and draws the letter until it arrives", async () => {
|
||||
fetchImageAsDataUrl.mockResolvedValue("data:image/png;base64,BBB");
|
||||
const user = makeUser({ username: "Bob", avatar: "/api/v1/files/42" });
|
||||
const sidebar = createDmProfileSidebar(makeOptions({ user }));
|
||||
sidebar.mount(container);
|
||||
|
||||
const avatarEl = container.querySelector('[data-testid="dps-avatar"]') as HTMLDivElement;
|
||||
expect(avatarEl.textContent).toContain("B");
|
||||
expect(avatarEl.querySelector("img")).toBeNull();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchImageAsDataUrl).toHaveBeenCalledWith("https://server.test/api/v1/files/42");
|
||||
const img = avatarEl.querySelector(".dps-avatar-img");
|
||||
expect(img).not.toBeNull();
|
||||
});
|
||||
|
||||
sidebar.destroy?.();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const fetchImageAsDataUrl = vi.hoisted(() => vi.fn());
|
||||
|
||||
// Only the network fetch is stubbed -- isSafeUrl/resolveServerUrl are
|
||||
// reimplemented (not mocked away) so the raw-src-vs-authenticated-fetch
|
||||
// distinction this suite exercises stays honest. Mirrors tests/unit/avatar.test.ts.
|
||||
vi.mock("@components/message-list/attachments", () => ({
|
||||
fetchImageAsDataUrl,
|
||||
isSafeUrl: (url: string) => url.startsWith("https://") || url.startsWith("http://"),
|
||||
resolveServerUrl: (url: string) => (url.startsWith("http") ? url : `https://server.test${url}`),
|
||||
}));
|
||||
|
||||
import { createDmSidebar } from "../../src/components/DmSidebar";
|
||||
import type { DmConversation } from "../../src/components/DmSidebar";
|
||||
|
||||
@@ -18,6 +30,7 @@ describe("DmSidebar", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchImageAsDataUrl.mockReset();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
@@ -41,6 +54,37 @@ describe("DmSidebar", () => {
|
||||
sidebar.destroy?.();
|
||||
});
|
||||
|
||||
it("filters the conversation list as the search box is typed into", () => {
|
||||
const sidebar = createDmSidebar({
|
||||
conversations: [
|
||||
makeConvo({ channelId: 1, username: "Alice" }),
|
||||
makeConvo({ channelId: 2, username: "Bob" }),
|
||||
],
|
||||
onSelectConversation: vi.fn(),
|
||||
onNewDm: vi.fn(),
|
||||
});
|
||||
sidebar.mount(container);
|
||||
|
||||
const searchInput = container.querySelector(".dm-search") as HTMLInputElement;
|
||||
searchInput.value = "ali";
|
||||
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
|
||||
const visibleNames = [...container.querySelectorAll(".dm-item")]
|
||||
.filter((el) => (el as HTMLElement).style.display !== "none")
|
||||
.map((el) => el.querySelector(".dm-name")?.textContent);
|
||||
expect(visibleNames).toEqual(["Alice"]);
|
||||
|
||||
// Clearing the query shows every conversation again.
|
||||
searchInput.value = "";
|
||||
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
const allVisible = [...container.querySelectorAll(".dm-item")].every(
|
||||
(el) => (el as HTMLElement).style.display !== "none",
|
||||
);
|
||||
expect(allVisible).toBe(true);
|
||||
|
||||
sidebar.destroy?.();
|
||||
});
|
||||
|
||||
// The Friends nav item was removed in phase 6: it was a dead entry whose
|
||||
// callback was never wired, and the plan's stated option was to delete it
|
||||
// rather than build a friends list. This pins the deletion.
|
||||
@@ -175,7 +219,12 @@ describe("DmSidebar", () => {
|
||||
sidebar.destroy?.();
|
||||
});
|
||||
|
||||
it("shows avatar image when avatar URL is provided", () => {
|
||||
it("shows avatar image when avatar URL is provided", async () => {
|
||||
// <img src> cannot carry the bearer token an authenticated file route
|
||||
// needs, so the picture is fetched through the same cert-pinned path
|
||||
// attachments use and swapped in once the bytes arrive -- never assigned
|
||||
// as a raw src.
|
||||
fetchImageAsDataUrl.mockResolvedValue("data:image/png;base64,AAA");
|
||||
const sidebar = createDmSidebar({
|
||||
conversations: [makeConvo({ avatar: "http://example.com/img.png" })],
|
||||
onSelectConversation: vi.fn(),
|
||||
@@ -183,9 +232,36 @@ describe("DmSidebar", () => {
|
||||
});
|
||||
sidebar.mount(container);
|
||||
|
||||
const img = container.querySelector(".dm-avatar img") as HTMLImageElement;
|
||||
expect(img).not.toBeNull();
|
||||
expect(img.src).toBe("http://example.com/img.png");
|
||||
expect(fetchImageAsDataUrl).toHaveBeenCalledWith("http://example.com/img.png");
|
||||
await vi.waitFor(() => {
|
||||
const img = container.querySelector(".dm-avatar img") as HTMLImageElement;
|
||||
expect(img).not.toBeNull();
|
||||
expect(img.src).toBe("data:image/png;base64,AAA");
|
||||
});
|
||||
|
||||
sidebar.destroy?.();
|
||||
});
|
||||
|
||||
it("fetches a server-relative avatar through the authenticated path instead of leaving a raw <img src> that 404s", async () => {
|
||||
fetchImageAsDataUrl.mockResolvedValue("data:image/png;base64,BBB");
|
||||
const sidebar = createDmSidebar({
|
||||
conversations: [makeConvo({ username: "Bob", avatar: "/api/v1/files/42" })],
|
||||
onSelectConversation: vi.fn(),
|
||||
onNewDm: vi.fn(),
|
||||
});
|
||||
sidebar.mount(container);
|
||||
|
||||
// The letter fallback draws immediately, not skipped in favor of a
|
||||
// broken <img>.
|
||||
const avatar = container.querySelector(".dm-avatar") as HTMLDivElement;
|
||||
expect(avatar.textContent).toBe("B");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchImageAsDataUrl).toHaveBeenCalledWith("https://server.test/api/v1/files/42");
|
||||
const img = avatar.querySelector("img");
|
||||
expect(img).not.toBeNull();
|
||||
expect(img?.getAttribute("src")).toBe("data:image/png;base64,BBB");
|
||||
});
|
||||
|
||||
sidebar.destroy?.();
|
||||
});
|
||||
|
||||
@@ -357,6 +357,20 @@ describe("reorder index arithmetic", () => {
|
||||
expect(channelsStore.select((s) => s.channels.get(2)?.position)).toBe(0);
|
||||
});
|
||||
|
||||
it("reassigns the group's own position slots, not a 0-based range, for a category with a non-zero global offset", () => {
|
||||
// This category's channels sit at global positions 5, 7, 9 (interleaved
|
||||
// with another category's channels at 0/1/2/etc in the server's single
|
||||
// global position space). A within-category drag must preserve that
|
||||
// range -- renumbering to 0..n-1 would stomp the other category's slots.
|
||||
signIn("owner");
|
||||
const rig = buildRig([makeCh(1, 5), makeCh(2, 7), makeCh(3, 9)]);
|
||||
|
||||
drag(rig, 3, 0, "top"); // ch3 before ch1 → [3, 1, 2]
|
||||
|
||||
const reorders = rig.onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[];
|
||||
expect(positionsOf(reorders)).toEqual({ 3: 5, 1: 7, 2: 9 });
|
||||
});
|
||||
|
||||
it("does not fire when dropped on itself", () => {
|
||||
signIn("owner");
|
||||
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
|
||||
|
||||
@@ -25,12 +25,21 @@ describe("ensureHttpProxy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("caches the origin per host (one start per host)", async () => {
|
||||
// B4_conn_ipc-11: the origin must NOT be cached in JS. Only Rust knows
|
||||
// whether its listener is still alive (it deregisters itself after 5
|
||||
// consecutive accept errors), so a JS-side cache would keep pointing every
|
||||
// REST call at a dead tunnel forever. Every call re-invokes start_http_proxy;
|
||||
// the Rust reuse branch dedups an unchanged, still-live host cheaply.
|
||||
it("does not cache the origin — every call re-invokes so a self-terminated tunnel can be rebound", async () => {
|
||||
invokeMock.mockResolvedValue(40000);
|
||||
const a = await ensureHttpProxy("host-b.example:8443");
|
||||
// Simulates the Rust side rebinding a fresh port after the old tunnel's
|
||||
// accept loop gave up (fd exhaustion, etc.) and deregistered itself.
|
||||
invokeMock.mockResolvedValue(40001);
|
||||
const b = await ensureHttpProxy("host-b.example:8443");
|
||||
expect(a).toBe(b);
|
||||
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||
expect(a).toBe("http://127.0.0.1:40000");
|
||||
expect(b).toBe("http://127.0.0.1:40001");
|
||||
expect(invokeMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("de-duplicates concurrent starts for the same host", async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const { invokeMock, logMock } = vi.hoisted(() => ({
|
||||
invokeMock: vi.fn(),
|
||||
@@ -19,7 +19,33 @@ import {
|
||||
publishIdentityKey,
|
||||
ensureIdentityKeyPublished,
|
||||
} from "@lib/identity";
|
||||
import { generateIdentityKeyPair, exportPublicKey } from "@lib/e2eeCrypto";
|
||||
import { generateIdentityKeyPair, exportPublicKey, exportIdentityKeyPair } from "@lib/e2eeCrypto";
|
||||
import { authStore } from "@stores/auth.store";
|
||||
|
||||
/**
|
||||
* Stateful keyring double for the legacy-migration tests: a Map keyed by the
|
||||
* exact `host` string each command receives (the scoped account
|
||||
* `chat.example:1` and the legacy account `chat.example` are just different
|
||||
* keys in the same map), so save/delete on one account cannot be confused
|
||||
* with another the way a host-agnostic mock would.
|
||||
*/
|
||||
function keyringDouble(seed: Record<string, string> = {}): Map<string, string> {
|
||||
const store = new Map<string, string>(Object.entries(seed));
|
||||
invokeMock.mockImplementation((cmd: string, args?: Record<string, unknown>) => {
|
||||
const h = args?.host as string;
|
||||
if (cmd === "load_identity_key") return Promise.resolve(store.get(h) ?? null);
|
||||
if (cmd === "save_identity_key") {
|
||||
store.set(h, args!.key as string);
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
if (cmd === "delete_identity_key") {
|
||||
store.delete(h);
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
return store;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
invokeMock.mockReset();
|
||||
@@ -135,13 +161,15 @@ describe("getOrCreateIdentityKeyPair", () => {
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
const kp = await getOrCreateIdentityKeyPair("chat.example");
|
||||
const kp = await getOrCreateIdentityKeyPair("chat.example", 1);
|
||||
expect(kp.privateKey).toBeDefined();
|
||||
expect(kp.publicKey).toBeDefined();
|
||||
|
||||
const saveCall = invokeMock.mock.calls.find((c) => c[0] === "save_identity_key");
|
||||
expect(saveCall).toBeDefined();
|
||||
expect((saveCall![1] as { host: string }).host).toBe("chat.example");
|
||||
// Scoped by host AND user id (B3-3) — not just host — so two accounts
|
||||
// signed into the same host never share a keyring blob.
|
||||
expect((saveCall![1] as { host: string }).host).toBe("chat.example:1");
|
||||
});
|
||||
|
||||
it("reloads the persisted keypair on subsequent logins (no regenerate)", async () => {
|
||||
@@ -155,7 +183,7 @@ describe("getOrCreateIdentityKeyPair", () => {
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
const first = await getOrCreateIdentityKeyPair("chat.example");
|
||||
const first = await getOrCreateIdentityKeyPair("chat.example", 1);
|
||||
const firstPub = await exportPublicKey(first.publicKey);
|
||||
|
||||
// Second login: keyring returns the saved blob → same public key, no save.
|
||||
@@ -167,7 +195,7 @@ describe("getOrCreateIdentityKeyPair", () => {
|
||||
if (cmd === "load_identity_key") return Promise.resolve(savedBlob);
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
const second = await getOrCreateIdentityKeyPair("chat.example");
|
||||
const second = await getOrCreateIdentityKeyPair("chat.example", 1);
|
||||
expect(await exportPublicKey(second.publicKey)).toBe(firstPub);
|
||||
expect(invokeMock.mock.calls.some((c) => c[0] === "save_identity_key")).toBe(false);
|
||||
});
|
||||
@@ -177,7 +205,7 @@ describe("getOrCreateIdentityKeyPair", () => {
|
||||
if (cmd === "load_identity_key") return Promise.resolve("!!not-valid-jwk!!");
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
const kp = await getOrCreateIdentityKeyPair("chat.example");
|
||||
const kp = await getOrCreateIdentityKeyPair("chat.example", 1);
|
||||
expect(kp.publicKey).toBeDefined();
|
||||
expect(invokeMock.mock.calls.some((c) => c[0] === "save_identity_key")).toBe(true);
|
||||
});
|
||||
@@ -194,10 +222,10 @@ describe("getOrCreateIdentityKeyPair", () => {
|
||||
});
|
||||
|
||||
const [publishPair, signingPair] = await Promise.all([
|
||||
getOrCreateIdentityKeyPair("chat.example"),
|
||||
getOrCreateIdentityKeyPair("chat.example"),
|
||||
getOrCreateIdentityKeyPair("chat.example", 1),
|
||||
getOrCreateIdentityKeyPair("chat.example", 1),
|
||||
]);
|
||||
const laterPair = await getOrCreateIdentityKeyPair("chat.example");
|
||||
const laterPair = await getOrCreateIdentityKeyPair("chat.example", 1);
|
||||
|
||||
expect(signingPair).toBe(publishPair);
|
||||
expect(laterPair).toBe(publishPair);
|
||||
@@ -210,21 +238,32 @@ describe("getOrCreateIdentityKeyPair", () => {
|
||||
if (cmd === "load_identity_key") return Promise.resolve(null);
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
const a = await getOrCreateIdentityKeyPair("chat.example");
|
||||
const b = await getOrCreateIdentityKeyPair("other.example");
|
||||
const a = await getOrCreateIdentityKeyPair("chat.example", 1);
|
||||
const b = await getOrCreateIdentityKeyPair("other.example", 1);
|
||||
expect(await exportPublicKey(b.publicKey)).not.toBe(await exportPublicKey(a.publicKey));
|
||||
});
|
||||
|
||||
it("[B3-3] keeps the memo per user id, not just per host — two accounts on the same host never share an identity keypair", async () => {
|
||||
invokeMock.mockImplementation((cmd: string) => {
|
||||
if (cmd === "load_identity_key") return Promise.resolve(null);
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
const userA = await getOrCreateIdentityKeyPair("chat.example", 1);
|
||||
const userB = await getOrCreateIdentityKeyPair("chat.example", 2);
|
||||
expect(await exportPublicKey(userB.publicKey)).not.toBe(await exportPublicKey(userA.publicKey));
|
||||
});
|
||||
|
||||
it("reports a credential store that accepts the write but drops the value", async () => {
|
||||
invokeMock.mockImplementation((cmd: string) => {
|
||||
if (cmd === "load_identity_key") return Promise.resolve(null);
|
||||
return Promise.resolve(undefined); // save_identity_key "succeeds"
|
||||
});
|
||||
|
||||
await getOrCreateIdentityKeyPair("chat.example");
|
||||
await getOrCreateIdentityKeyPair("chat.example", 1);
|
||||
|
||||
expect(logMock.error).toHaveBeenCalledWith(expect.stringContaining("did not persist"), {
|
||||
host: "chat.example",
|
||||
userId: 1,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -234,7 +273,7 @@ describe("getOrCreateIdentityKeyPair", () => {
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
await expect(getOrCreateIdentityKeyPair("chat.example")).rejects.toThrow("keychain locked");
|
||||
await expect(getOrCreateIdentityKeyPair("chat.example", 1)).rejects.toThrow("keychain locked");
|
||||
// Must not have minted and saved a brand-new identity over the top of an
|
||||
// unreadable (not necessarily absent) stored key.
|
||||
expect(invokeMock.mock.calls.some((c) => c[0] === "save_identity_key")).toBe(false);
|
||||
@@ -251,7 +290,7 @@ describe("getOrCreateIdentityKeyPair", () => {
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
await getOrCreateIdentityKeyPair("chat.example");
|
||||
await getOrCreateIdentityKeyPair("chat.example", 1);
|
||||
|
||||
expect(logMock.error).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -284,6 +323,20 @@ describe("publishIdentityKey", () => {
|
||||
});
|
||||
|
||||
describe("ensureIdentityKeyPublished (login/ready publish flow)", () => {
|
||||
// Real authStore, not mocked — these tests exercise the normal ready-hook
|
||||
// timing where auth state is already populated. The "not authenticated
|
||||
// yet" guard is its own test below, which overrides `user` back to null.
|
||||
beforeEach(() => {
|
||||
authStore.setState((prev) => ({
|
||||
...prev,
|
||||
user: { id: 1, username: "alex", avatar: null, role: "member" },
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
authStore.setState((prev) => ({ ...prev, user: null }));
|
||||
});
|
||||
|
||||
it("publishes username + identity key when the server copy is absent", async () => {
|
||||
// First-login keyring: nothing stored → a fresh keypair is generated.
|
||||
invokeMock.mockImplementation((cmd: string) => {
|
||||
@@ -354,4 +407,111 @@ describe("ensureIdentityKeyPublished (login/ready publish flow)", () => {
|
||||
ensureIdentityKeyPublished("chat.example", "alex", null, updateProfile),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("does not mint or publish an identity key when no user is authenticated yet (never falls back to a placeholder scope)", async () => {
|
||||
// Auth state not yet populated — e.g. this hook running before auth_ok
|
||||
// has landed. Falling back to a placeholder `?? 0` scope would mint (or
|
||||
// migrate-and-DELETE the real legacy key into) a bogus `host:0` keyring
|
||||
// account; a later authenticated call then mints a SECOND, DIFFERENT
|
||||
// keypair under `host:<realId>`, so the published key and the announce
|
||||
// signing key permanently disagree — a false MITM warning for every peer.
|
||||
authStore.setState((prev) => ({ ...prev, user: null }));
|
||||
invokeMock.mockImplementation((cmd: string) => {
|
||||
if (cmd === "load_identity_key") return Promise.resolve(null);
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
const updateProfile = vi.fn().mockResolvedValue({});
|
||||
|
||||
const published = await ensureIdentityKeyPublished("chat.example", "alex", null, updateProfile);
|
||||
|
||||
expect(published).toBe(false);
|
||||
expect(updateProfile).not.toHaveBeenCalled();
|
||||
// No keyring interaction at all — nothing minted, migrated, or deleted.
|
||||
expect(invokeMock).not.toHaveBeenCalled();
|
||||
expect(logMock.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("authenticated user id"),
|
||||
expect.objectContaining({ host: "chat.example" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not migrate (and delete) the legacy host-only key when no user is authenticated yet", async () => {
|
||||
authStore.setState((prev) => ({ ...prev, user: null }));
|
||||
const legacy = await generateIdentityKeyPair();
|
||||
const legacyBlob = await exportIdentityKeyPair(legacy.privateKey);
|
||||
const store = keyringDouble({ "chat.example": legacyBlob });
|
||||
const updateProfile = vi.fn().mockResolvedValue({});
|
||||
|
||||
await ensureIdentityKeyPublished("chat.example", "alex", null, updateProfile);
|
||||
|
||||
// The legacy key must be untouched: no adopt-then-delete into a bogus
|
||||
// host:0 scope.
|
||||
expect(store.get("chat.example")).toBe(legacyBlob);
|
||||
expect(store.has("chat.example:0")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy identity key migration (pre-B3-3 host-only account)", () => {
|
||||
it("adopts a legacy host-only key: saves it under the scoped account and deletes the legacy one", async () => {
|
||||
const legacy = await generateIdentityKeyPair();
|
||||
const legacyBlob = await exportIdentityKeyPair(legacy.privateKey);
|
||||
const legacyPub = await exportPublicKey(legacy.publicKey);
|
||||
const store = keyringDouble({ "chat.example": legacyBlob });
|
||||
|
||||
const kp = await getOrCreateIdentityKeyPair("chat.example", 1);
|
||||
|
||||
expect(await exportPublicKey(kp.publicKey)).toBe(legacyPub);
|
||||
expect(store.get("chat.example:1")).toBe(legacyBlob);
|
||||
// Deleted so it can never be adopted a second time.
|
||||
expect(store.has("chat.example")).toBe(false);
|
||||
});
|
||||
|
||||
it("a second account on the same host gets its own keypair, not the already-adopted legacy one", async () => {
|
||||
const legacy = await generateIdentityKeyPair();
|
||||
const legacyBlob = await exportIdentityKeyPair(legacy.privateKey);
|
||||
const legacyPub = await exportPublicKey(legacy.publicKey);
|
||||
const store = keyringDouble({ "chat.example": legacyBlob });
|
||||
|
||||
const first = await getOrCreateIdentityKeyPair("chat.example", 1);
|
||||
expect(await exportPublicKey(first.publicKey)).toBe(legacyPub);
|
||||
|
||||
const second = await getOrCreateIdentityKeyPair("chat.example", 2);
|
||||
expect(await exportPublicKey(second.publicKey)).not.toBe(legacyPub);
|
||||
expect(store.get("chat.example:2")).toBeDefined();
|
||||
expect(store.get("chat.example:2")).not.toBe(legacyBlob);
|
||||
});
|
||||
|
||||
it("falls back to fresh generation, without throwing, when the legacy blob is corrupt", async () => {
|
||||
const store = keyringDouble({ "chat.example": "!!not-valid-jwk!!" });
|
||||
|
||||
const kp = await getOrCreateIdentityKeyPair("chat.example", 1);
|
||||
|
||||
expect(kp.publicKey).toBeDefined();
|
||||
expect(store.get("chat.example:1")).toBeDefined();
|
||||
expect(store.get("chat.example:1")).not.toBe("!!not-valid-jwk!!");
|
||||
});
|
||||
|
||||
it("generates fresh, with no delete attempt, when there is no legacy key either (first login)", async () => {
|
||||
keyringDouble();
|
||||
|
||||
const kp = await getOrCreateIdentityKeyPair("chat.example", 1);
|
||||
|
||||
expect(kp.publicKey).toBeDefined();
|
||||
expect(invokeMock.mock.calls.some((c) => c[0] === "delete_identity_key")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the legacy key in place when the scoped save fails, so migration can retry next launch", async () => {
|
||||
const legacy = await generateIdentityKeyPair();
|
||||
const legacyBlob = await exportIdentityKeyPair(legacy.privateKey);
|
||||
const store = keyringDouble({ "chat.example": legacyBlob });
|
||||
const load = invokeMock.getMockImplementation()!;
|
||||
invokeMock.mockImplementation((cmd: string, args?: Record<string, unknown>) => {
|
||||
if (cmd === "save_identity_key") return Promise.reject(new Error("keyring boom"));
|
||||
return load(cmd, args);
|
||||
});
|
||||
|
||||
await getOrCreateIdentityKeyPair("chat.example", 1);
|
||||
|
||||
expect(store.get("chat.example")).toBe(legacyBlob);
|
||||
expect(store.has("chat.example:1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,7 +87,8 @@ import {
|
||||
generateECDHKeyPair,
|
||||
importPublicKey,
|
||||
} from "@lib/e2eeCrypto";
|
||||
import { getOrCreateIdentityKeyPair, storeIdentityPin } from "@lib/identity";
|
||||
import { getOrCreateIdentityKeyPair, getIdentityPin, storeIdentityPin } from "@lib/identity";
|
||||
import { authStore } from "@stores/auth.store";
|
||||
|
||||
const PEER_ID = 42;
|
||||
|
||||
@@ -765,4 +766,98 @@ describe("E2EEManager", () => {
|
||||
// keypair — the loop must abort as soon as it notices the swap.
|
||||
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── Batch B3 findings ───────────────────────────────────────────────────
|
||||
|
||||
it("[B3-2] preserves a key-holder promotion that lands during setupKeyExchange's pre-publish awaits, instead of clobbering it with the stale server value", async () => {
|
||||
const ws = { send: vi.fn() };
|
||||
const mgr = createManager(ws);
|
||||
// After the real holder (PEER_ID) leaves, we (uid 1) are the only
|
||||
// remaining participant — client-side election promotes us.
|
||||
mockVoiceState.voiceUsers.set(1, new Map([[1, {}]]));
|
||||
|
||||
// Stall the identity-key load inside buildAnnouncePayload so a
|
||||
// participant-left promotion can land BEFORE setupKeyExchange assigns
|
||||
// this._isKeyHolder from the (now-stale) server value.
|
||||
let releaseIdentity!: () => void;
|
||||
const stalledIdentity = new Promise<typeof mockIdentityKeyPair>((resolve) => {
|
||||
releaseIdentity = () => resolve(mockIdentityKeyPair);
|
||||
});
|
||||
vi.mocked(getOrCreateIdentityKeyPair).mockReturnValueOnce(stalledIdentity);
|
||||
|
||||
// Server said we are NOT the key holder when we started joining...
|
||||
const setupPromise = mgr.setupKeyExchange(false, 1);
|
||||
await vi.waitFor(() => expect(getOrCreateIdentityKeyPair).toHaveBeenCalled());
|
||||
|
||||
// ...but the real holder leaves before we finish setting up, and since we
|
||||
// are the only participant left, client-side election promotes us.
|
||||
await mgr.handleParticipantLeft(PEER_ID);
|
||||
|
||||
releaseIdentity();
|
||||
// On the buggy path this falls through to the non-holder wait-for-offer
|
||||
// branch and burns the full 10s + 5s timeout before resolving false —
|
||||
// fast-forward past it so the test does not block on a real 15s wait.
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
// The promotion must win: we end up as key holder (generated + applied a
|
||||
// room key and announced) instead of waiting for an offer that only WE
|
||||
// could have sent — the exact interleaving that times out and gets the
|
||||
// joiner ejected from voice.
|
||||
await expect(setupPromise).resolves.toBe(true);
|
||||
expect(mockSetKey).toHaveBeenCalledWith("mock-room-key-base64");
|
||||
});
|
||||
|
||||
it("[B3-7] does not resurrect peer key/verification state into a torn-down session when clearState() runs during verifyPeerAnnounce's pin lookup", async () => {
|
||||
const ws = { send: vi.fn() };
|
||||
const mgr = createManager(ws);
|
||||
await mgr.setupKeyExchange(true, 1); // establishes our keypair
|
||||
|
||||
let releasePin!: (v: { status: "unpinned" }) => void;
|
||||
const stalledPin = new Promise<{ status: "unpinned" }>((resolve) => {
|
||||
releasePin = resolve;
|
||||
});
|
||||
vi.mocked(getIdentityPin).mockReturnValueOnce(stalledPin);
|
||||
|
||||
const announcePromise = mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
||||
await vi.waitFor(() => expect(getIdentityPin).toHaveBeenCalled());
|
||||
|
||||
// Disconnect mid-verify.
|
||||
mgr.clearState();
|
||||
vi.mocked(setPeerVerification).mockClear();
|
||||
|
||||
releasePin({ status: "unpinned" });
|
||||
await announcePromise;
|
||||
|
||||
// The torn-down session's peer map and verification state must not be
|
||||
// resurrected by a continuation that resumes after teardown.
|
||||
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(false);
|
||||
expect(setPeerVerification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("[identity-scope guard] does not mint an identity keypair when no user is authenticated yet — announce goes out unsigned instead of under a placeholder host:0 scope", async () => {
|
||||
// If this ever ran before auth state landed, falling back to `?? 0`
|
||||
// would mint (or migrate-and-DELETE the real legacy key into) a bogus
|
||||
// `host:0` keyring scope; the ready hook's later, authenticated call
|
||||
// then mints a SECOND, DIFFERENT keypair under `host:<realId>` — so the
|
||||
// published key and the announce signing key permanently disagree and
|
||||
// every peer's verifyPeerAnnounce reports a false MITM "mismatch".
|
||||
const ws = { send: vi.fn() };
|
||||
const mgr = createManager(ws);
|
||||
vi.mocked(authStore.getState).mockReturnValueOnce({ user: null } as never);
|
||||
|
||||
const ok = await mgr.setupKeyExchange(true, 1);
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(getOrCreateIdentityKeyPair).not.toHaveBeenCalled();
|
||||
const announces = sendsOfType(ws, "voice_e2ee_announce");
|
||||
expect(announces).toHaveLength(1);
|
||||
// Same contract as "no server host": degrade to an unsigned announce
|
||||
// rather than sign/scope under a placeholder id.
|
||||
expect((announces[0] as any).payload.signature).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ const mockVoiceState = vi.hoisted(() => ({
|
||||
localServerDeafened: false,
|
||||
localCamera: false,
|
||||
localScreenshare: false,
|
||||
pttGated: false,
|
||||
}));
|
||||
|
||||
/** Backing cell for the mocked voice.store PTT-poller-live flag. Boxed so the
|
||||
@@ -206,6 +207,7 @@ import {
|
||||
import { getIdentityPin, storeIdentityPin } from "@lib/identity";
|
||||
import { verifyEphemeralKeySignature } from "@lib/e2eeCrypto";
|
||||
import { setMembers } from "@stores/members.store";
|
||||
import { authStore } from "@stores/auth.store";
|
||||
import type { ReadyMember } from "../../src/lib/types";
|
||||
import {
|
||||
isVoiceConnected,
|
||||
@@ -309,6 +311,7 @@ describe("LiveKitSession", () => {
|
||||
mockVoiceState.localServerDeafened = false;
|
||||
mockVoiceState.localCamera = false;
|
||||
mockVoiceState.localScreenshare = false;
|
||||
mockVoiceState.pttGated = false;
|
||||
session = new LiveKitSession();
|
||||
// Reset mockRoom state
|
||||
mockRoom.state = "connected";
|
||||
@@ -1638,6 +1641,24 @@ describe("LiveKitSession", () => {
|
||||
mockVoiceState.localServerDeafened = false;
|
||||
}
|
||||
});
|
||||
|
||||
// B1_voice_mic-6: restoreLocalVoiceState records a join-time PTT gate in
|
||||
// pttGated (never localMuted, by design) and unpublishes the mic without
|
||||
// touching localMuted — so undeafening before the first PTT press must
|
||||
// not republish a mic that is still supposed to be gated.
|
||||
it("undeafening while push-to-talk still gates the mic keeps it muted", async () => {
|
||||
mockVoiceState.pttGated = true;
|
||||
|
||||
try {
|
||||
session.setDeafened(false);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(setLocalDeafened).toHaveBeenCalledWith(false);
|
||||
expect(mockRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalledWith(true);
|
||||
} finally {
|
||||
mockVoiceState.pttGated = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -1747,6 +1768,29 @@ describe("LiveKitSession", () => {
|
||||
mockVoiceState.localServerMuted = false;
|
||||
}
|
||||
});
|
||||
|
||||
// B1_voice_mic-5: a listen-only user who self-muted before losing mic
|
||||
// access must not come back live just because they regained the device.
|
||||
it("keeps the mic muted when the user self-muted before going listen-only", async () => {
|
||||
session.setServerHost("localhost:7880");
|
||||
session.setWsClient({ send: vi.fn() } as any);
|
||||
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
|
||||
vi.clearAllMocks();
|
||||
mockVoiceState.localMuted = true;
|
||||
|
||||
try {
|
||||
await session.retryMicPermission();
|
||||
|
||||
expect(setListenOnly).toHaveBeenCalledWith(false);
|
||||
// applyMicMuteState(true) re-disables the mic it just enabled — the
|
||||
// user's own mute must survive regaining mic access.
|
||||
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true);
|
||||
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenLastCalledWith(false);
|
||||
expect(setLocalMuted).not.toHaveBeenCalledWith(false);
|
||||
} finally {
|
||||
mockVoiceState.localMuted = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -1807,6 +1851,43 @@ describe("LiveKitSession", () => {
|
||||
expect(errorCb).not.toHaveBeenCalledWith(expect.stringContaining("Microphone unavailable"));
|
||||
});
|
||||
|
||||
// B1_voice_mic-4: mode "reconnect" must read the PTT gate the store is
|
||||
// still carrying from before the disconnect instead of recomputing
|
||||
// pttArmed from scratch (which is always false for mode !== "join") —
|
||||
// otherwise a reconnect during the "joined with PTT armed, key never
|
||||
// pressed yet" window republishes a hot mic.
|
||||
it("mode reconnect keeps the mic gated when pttGated survived from before the disconnect", async () => {
|
||||
mockVoiceState.localMuted = false;
|
||||
mockVoiceState.localDeafened = false;
|
||||
mockVoiceState.pttGated = true;
|
||||
|
||||
// Mirrors the real caller (handleDisconnected via setReconnectAc),
|
||||
// which always sets "reconnecting" before starting this loop.
|
||||
(session as any)._state = {
|
||||
type: "reconnecting",
|
||||
channelId: 7,
|
||||
latestToken: "token",
|
||||
lastUrl: "/lk",
|
||||
lastDirectUrl: "ws://localhost:7880",
|
||||
ac: new AbortController(),
|
||||
};
|
||||
|
||||
const ac = new AbortController();
|
||||
const reconnectPromise = (session as any).attemptAutoReconnect(
|
||||
"reconnect-token",
|
||||
"/lk",
|
||||
7,
|
||||
"ws://localhost:7880",
|
||||
ac.signal,
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3100);
|
||||
await reconnectPromise;
|
||||
|
||||
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false);
|
||||
expect(mockRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("mode join with generic mic error calls error callback", async () => {
|
||||
const errorCb = vi.fn();
|
||||
session.setOnError(errorCb);
|
||||
@@ -2643,6 +2724,53 @@ describe("LiveKitSession", () => {
|
||||
expect(leaveVoiceChannel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// B1_voice_mic-3: the in-loop supersession checks (unlike the post-loop
|
||||
// give-up check) did not test `_state.type`, so a same-channel rejoin
|
||||
// that lands DURING the retry delay — before the loop's own in-flight
|
||||
// check runs — was not caught until the give-up path at the very end.
|
||||
// This exercises the very first in-loop checkpoint, right after the
|
||||
// delay, which the give-up-only tests above never reach.
|
||||
it("aborts before creating a room when a same-channel rejoin lands during the retry delay (zombie reconnect)", async () => {
|
||||
(session as any)._state = {
|
||||
type: "reconnecting",
|
||||
channelId: 5,
|
||||
latestToken: "token",
|
||||
lastUrl: "/livekit",
|
||||
lastDirectUrl: "ws://localhost:7880",
|
||||
ac: new AbortController(),
|
||||
};
|
||||
session.setServerHost("localhost:7880");
|
||||
const ac = new AbortController();
|
||||
|
||||
const reconnectPromise = (session as any).attemptAutoReconnect(
|
||||
"token",
|
||||
"/livekit",
|
||||
5,
|
||||
"ws://localhost:7880",
|
||||
ac.signal,
|
||||
);
|
||||
|
||||
// A fresh, same-channel join completes DURING the retry delay, before
|
||||
// the loop's first in-flight guard has a chance to run.
|
||||
(session as any)._state = {
|
||||
type: "connected",
|
||||
room: mockRoom,
|
||||
channelId: 5,
|
||||
latestToken: "fresh-token",
|
||||
lastUrl: "/livekit",
|
||||
lastDirectUrl: "ws://localhost:7880",
|
||||
};
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3100);
|
||||
await reconnectPromise;
|
||||
|
||||
// The zombie loop must never touch the live session: no second
|
||||
// connect(), and the live room/state must survive untouched.
|
||||
expect(mockRoom.connect).not.toHaveBeenCalled();
|
||||
expect((session as any)._state.type).toBe("connected");
|
||||
expect((session as any)._state.latestToken).toBe("fresh-token");
|
||||
});
|
||||
|
||||
it("cleans up reconnect room when signal aborts after connect resolves (BUG-070)", async () => {
|
||||
(session as any)._state = {
|
||||
type: "reconnecting",
|
||||
@@ -2772,6 +2900,22 @@ describe("LiveKitSession", () => {
|
||||
(getIdentityPin as any).mockResolvedValue({ status: "unpinned" });
|
||||
(storeIdentityPin as any).mockResolvedValue(true);
|
||||
(verifyEphemeralKeySignature as any).mockResolvedValue(true);
|
||||
// Joining voice requires an authenticated session, and the identity
|
||||
// keypair is scoped by host AND user id — with no user the announce is
|
||||
// deliberately sent unsigned rather than scoped under a placeholder id.
|
||||
// Kept below PEER_ID so key-holder election (lowest id wins) is unchanged.
|
||||
authStore.setState((prev) => ({
|
||||
...prev,
|
||||
user: { id: 1, username: "me", role: "member" } as never,
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Do not leak the authenticated user into the rest of the file — an
|
||||
// earlier bug in this suite was one test leaving a role set for every
|
||||
// test after it.
|
||||
authStore.setState((prev) => ({ ...prev, user: null, isAuthenticated: false }));
|
||||
});
|
||||
|
||||
it("signs the ephemeral announce sent on join", async () => {
|
||||
|
||||
@@ -343,6 +343,23 @@ describe("log persistence", () => {
|
||||
expect(mockWriteTextFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// B4_conn_ipc-12: a persistently failing flush logs through this
|
||||
// module's own logger (createLogger("logPersistence")). In production
|
||||
// that log re-enters onLogEntry via the real logger's listener pipeline
|
||||
// (mocked apart here — see the vi.mock("@lib/logger") above), so
|
||||
// onLogEntry must refuse to buffer/reschedule its own entries or a
|
||||
// failing write re-arms the 2s flush timer forever.
|
||||
it("does not buffer or re-arm the flush timer for its own log entries", async () => {
|
||||
const { getListener } = captureListener();
|
||||
const { initLogPersistence } = await freshImport();
|
||||
await initLogPersistence();
|
||||
|
||||
getListener()!(makeEntry({ component: "logPersistence", message: "flush failed" }));
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
||||
expect(mockWriteTextFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not flush when buffer is empty", async () => {
|
||||
captureListener();
|
||||
const { initLogPersistence, flushLogs } = await freshImport();
|
||||
|
||||
@@ -44,6 +44,17 @@ vi.mock("@lib/notifications", () => ({
|
||||
stopRingChime: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockSetAudioVolumeHost } = vi.hoisted(() => ({
|
||||
mockSetAudioVolumeHost: vi.fn(),
|
||||
}));
|
||||
|
||||
// Real audioElements.ts pulls in livekit-client (unmocked elsewhere in this
|
||||
// file's graph) purely to hold the AudioElements class this page never
|
||||
// touches directly — mock out the one export MainPage actually calls.
|
||||
vi.mock("@lib/audioElements", () => ({
|
||||
setAudioVolumeHost: mockSetAudioVolumeHost,
|
||||
}));
|
||||
|
||||
vi.mock("@lib/autoIdle", () => ({
|
||||
startAutoIdle: vi.fn(() => ({ destroy: vi.fn() })),
|
||||
}));
|
||||
@@ -54,12 +65,27 @@ const {
|
||||
mockCreateChannelController,
|
||||
mockCreateSidebarArea,
|
||||
mockCreateChatArea,
|
||||
capturedChatAreaRef,
|
||||
} = vi.hoisted(() => ({
|
||||
mockMountChannel: vi.fn(),
|
||||
mockDestroyChannel: vi.fn(),
|
||||
mockCreateChannelController: vi.fn(),
|
||||
mockCreateSidebarArea: vi.fn(),
|
||||
mockCreateChatArea: vi.fn(),
|
||||
// The ChatArea mock below builds fresh DOM elements per call (a plain
|
||||
// return value can't be read back from a vi.fn() call site) — this is how
|
||||
// tests get at the actual slots/dmProfileSlot MainPage is wiring against.
|
||||
capturedChatAreaRef: {
|
||||
current: null as null | {
|
||||
slots: {
|
||||
messagesSlot: HTMLDivElement;
|
||||
typingSlot: HTMLDivElement;
|
||||
inputSlot: HTMLDivElement;
|
||||
videoGridSlot: HTMLDivElement;
|
||||
};
|
||||
dmProfileSlot: HTMLDivElement;
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/pages/main-page/ChannelController", () => ({
|
||||
@@ -90,17 +116,21 @@ vi.mock("../../src/pages/main-page/SidebarArea", () => ({
|
||||
vi.mock("../../src/pages/main-page/ChatArea", () => ({
|
||||
createChatArea: (...args: unknown[]) => {
|
||||
mockCreateChatArea(...args);
|
||||
const slots = {
|
||||
messagesSlot: document.createElement("div"),
|
||||
typingSlot: document.createElement("div"),
|
||||
inputSlot: document.createElement("div"),
|
||||
videoGridSlot: document.createElement("div"),
|
||||
};
|
||||
const dmProfileSlot = document.createElement("div");
|
||||
capturedChatAreaRef.current = { slots, dmProfileSlot };
|
||||
return {
|
||||
chatArea: document.createElement("div"),
|
||||
slots: {
|
||||
messagesSlot: document.createElement("div"),
|
||||
typingSlot: document.createElement("div"),
|
||||
inputSlot: document.createElement("div"),
|
||||
videoGridSlot: document.createElement("div"),
|
||||
},
|
||||
slots,
|
||||
videoGrid: {
|
||||
addStream: vi.fn(),
|
||||
removeStream: vi.fn(),
|
||||
clearStreams: vi.fn(),
|
||||
hasStreams: vi.fn(() => false),
|
||||
setFocusedTile: vi.fn(),
|
||||
getFocusedTileId: vi.fn(() => null),
|
||||
@@ -115,7 +145,7 @@ vi.mock("../../src/pages/main-page/ChatArea", () => ({
|
||||
callBtn: document.createElement("button"),
|
||||
},
|
||||
searchCtrl: { open: vi.fn(), cleanup: vi.fn() },
|
||||
dmProfileSlot: document.createElement("div"),
|
||||
dmProfileSlot,
|
||||
children: [],
|
||||
unsubscribers: [],
|
||||
};
|
||||
@@ -131,6 +161,7 @@ import { dmStore } from "../../src/stores/dm.store";
|
||||
import type { WsClient, WsListener, ConnectionState } from "../../src/lib/ws";
|
||||
import type { ApiClient } from "../../src/lib/api";
|
||||
import type { ServerMessage } from "../../src/lib/types";
|
||||
import { openImageLightbox } from "../../src/components/message-list/media";
|
||||
|
||||
function resetStores(): void {
|
||||
channelsStore.setState(() => ({ channels: new Map(), activeChannelId: null, roles: [] }));
|
||||
@@ -157,7 +188,12 @@ function resetStores(): void {
|
||||
dmStore.setState(() => ({ channels: [] }));
|
||||
}
|
||||
|
||||
function fakeWs(): WsClient {
|
||||
type FakeWsClient = WsClient & {
|
||||
/** Test-only: drive a registered `ws.on(type, ...)` listener directly. */
|
||||
emit: (type: ServerMessage["type"], payload: unknown) => void;
|
||||
};
|
||||
|
||||
function fakeWs(): FakeWsClient {
|
||||
const listeners = new Map<string, Set<WsListener<ServerMessage["type"]>>>();
|
||||
return {
|
||||
connect: vi.fn(),
|
||||
@@ -170,6 +206,11 @@ function fakeWs(): WsClient {
|
||||
listeners.get(type)?.delete(listener as unknown as WsListener<ServerMessage["type"]>);
|
||||
};
|
||||
},
|
||||
emit(type: ServerMessage["type"], payload: unknown) {
|
||||
for (const listener of listeners.get(type) ?? []) {
|
||||
(listener as (p: unknown, id?: string) => void)(payload);
|
||||
}
|
||||
},
|
||||
onStateChange: vi.fn(() => () => {}),
|
||||
onSendFailure: vi.fn(() => () => {}),
|
||||
onCertMismatch: vi.fn(() => () => {}),
|
||||
@@ -277,3 +318,177 @@ describe("MainPage — activeChannelId subscriber", () => {
|
||||
expect(mockDestroyChannel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function dmChannel(id: number, name: string, position = 0) {
|
||||
return { ...textChannel(id, name, position), type: "dm" as const };
|
||||
}
|
||||
|
||||
describe("MainPage — video grid, DM profile panel, calls, settings", () => {
|
||||
let container: HTMLDivElement;
|
||||
let page: ReturnType<typeof createMainPage>;
|
||||
|
||||
beforeEach(() => {
|
||||
resetStores();
|
||||
mockMountChannel.mockClear();
|
||||
mockDestroyChannel.mockClear();
|
||||
mockCreateChatArea.mockClear();
|
||||
capturedChatAreaRef.current = null;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
page?.destroy?.();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("dismisses the video grid when switching to a non-voice channel (dm/announcement), not just 'text'", () => {
|
||||
channelsStore.setState((prev) => {
|
||||
const ch = new Map(prev.channels);
|
||||
ch.set(1, textChannel(1, "general"));
|
||||
ch.set(2, dmChannel(2, "dm-with-bob"));
|
||||
return { ...prev, channels: ch, activeChannelId: 1 };
|
||||
});
|
||||
|
||||
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
|
||||
page.mount(container);
|
||||
|
||||
// Put the grid into video mode via a local camera start while in voice
|
||||
// (the real, unmocked VideoModeController drives this off voiceStore).
|
||||
voiceStore.setState((prev) => ({
|
||||
...prev,
|
||||
currentChannelId: 9,
|
||||
voiceUsers: new Map([[9, new Map()]]),
|
||||
localCamera: true,
|
||||
}));
|
||||
voiceStore.flush();
|
||||
|
||||
expect(capturedChatAreaRef.current!.slots.messagesSlot.style.display).toBe("none");
|
||||
|
||||
// Switch to a DM while the grid is up — a dm channel mounts a chat
|
||||
// surface just like text/announcement do, so the grid must not survive
|
||||
// the switch and hide it behind an unrelated video grid.
|
||||
setActiveChannel(2);
|
||||
channelsStore.flush();
|
||||
|
||||
expect(capturedChatAreaRef.current!.slots.messagesSlot.style.display).toBe("");
|
||||
});
|
||||
|
||||
it("does not open the 1:1 profile panel for a group DM header click", () => {
|
||||
channelsStore.setState((prev) => {
|
||||
const ch = new Map(prev.channels);
|
||||
ch.set(50, dmChannel(50, "Group Chat"));
|
||||
return { ...prev, channels: ch, activeChannelId: 50 };
|
||||
});
|
||||
dmStore.setState(() => ({
|
||||
channels: [
|
||||
{
|
||||
channelId: 50,
|
||||
recipient: { id: 10, username: "alice", avatar: "", status: "online" },
|
||||
participants: [
|
||||
{ id: 10, username: "alice", avatar: "", status: "online" },
|
||||
{ id: 11, username: "bob", avatar: "", status: "online" },
|
||||
],
|
||||
name: "Group Chat",
|
||||
isGroup: true,
|
||||
lastMessageId: null,
|
||||
lastMessage: "",
|
||||
lastMessageAt: "",
|
||||
unreadCount: 0,
|
||||
mentionCount: 0,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
|
||||
page.mount(container);
|
||||
|
||||
const chatAreaOpts = mockCreateChatArea.mock.calls[0]![0];
|
||||
chatAreaOpts.onToggleDmProfile();
|
||||
|
||||
// dm.store.ts documents .recipient as "the first of participants" for a
|
||||
// group, with the explicit instruction that group-correct code reads
|
||||
// .participants instead — a 1:1 panel built from .recipient would show
|
||||
// one arbitrary member's identity as if it were the whole conversation.
|
||||
expect(
|
||||
capturedChatAreaRef.current!.dmProfileSlot.querySelector(
|
||||
'[data-testid="dm-profile-sidebar"]',
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not ring or announce 'Calling…' when starting a call while the socket is reconnecting", () => {
|
||||
channelsStore.setState((prev) => {
|
||||
const ch = new Map(prev.channels);
|
||||
ch.set(50, dmChannel(50, "dm-alice"));
|
||||
return { ...prev, channels: ch, activeChannelId: 50 };
|
||||
});
|
||||
uiStore.setState((prev) => ({ ...prev, connectionStatus: "reconnecting" }));
|
||||
|
||||
const ws = fakeWs();
|
||||
page = createMainPage({ ws, api: fakeApi() });
|
||||
page.mount(container);
|
||||
|
||||
const chatAreaOpts = mockCreateChatArea.mock.calls[0]![0];
|
||||
chatAreaOpts.onStartCall();
|
||||
|
||||
// onVoiceJoin already silently refuses to join while the socket is down
|
||||
// (VoiceCallbacks.ts's socketLive() guard) — startCall must not still
|
||||
// ring and tell the user "Calling…" over a call nobody can hear.
|
||||
expect(ws.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: "call_ring" }));
|
||||
});
|
||||
|
||||
it("keeps an incoming ring alive when Accept is clicked while the socket is reconnecting", () => {
|
||||
const ws = fakeWs();
|
||||
uiStore.setState((prev) => ({ ...prev, connectionStatus: "connected" }));
|
||||
|
||||
page = createMainPage({ ws, api: fakeApi() });
|
||||
page.mount(container);
|
||||
|
||||
ws.emit("call_incoming", { channel_id: 50, from_user: 10, username: "alice" });
|
||||
|
||||
const banner = document.querySelector('[data-testid="incoming-call-banner"]') as HTMLElement;
|
||||
expect(banner.style.display).not.toBe("none");
|
||||
|
||||
// The socket drops mid-ring.
|
||||
uiStore.setState((prev) => ({ ...prev, connectionStatus: "reconnecting" }));
|
||||
|
||||
const acceptBtn = document.querySelector('[data-testid="incoming-call-accept"]') as HTMLElement;
|
||||
acceptBtn.click();
|
||||
|
||||
// Accepting while reconnecting must not silently consume the ring (the
|
||||
// banner clearing means ringCtrl.accept() ran and nothing rejoins) — the
|
||||
// ring has to survive so the user can accept again once reconnected.
|
||||
expect(banner.style.display).not.toBe("none");
|
||||
});
|
||||
|
||||
it("clears settingsOpen on destroy so the next page (e.g. ConnectPage after logout) doesn't inherit a stale open overlay", () => {
|
||||
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
|
||||
page.mount(container);
|
||||
|
||||
uiStore.setState((prev) => ({ ...prev, settingsOpen: true }));
|
||||
|
||||
page.destroy?.();
|
||||
|
||||
expect(uiStore.getState().settingsOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("scopes per-user volume prefs to the connected host, like channel mutes and the NSFW gate (B3-6)", () => {
|
||||
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
|
||||
page.mount(container);
|
||||
|
||||
expect(mockSetAudioVolumeHost).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it("closes an open image lightbox on destroy so it doesn't survive onto the next page (B6-15)", () => {
|
||||
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
|
||||
page.mount(container);
|
||||
|
||||
openImageLightbox("https://example.com/pic.png", "pic");
|
||||
expect(document.body.querySelector(".image-lightbox")).not.toBeNull();
|
||||
|
||||
page.destroy?.();
|
||||
|
||||
expect(document.body.querySelector(".image-lightbox")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
renderInlineImage,
|
||||
renderYouTubeEmbed,
|
||||
openImageLightbox,
|
||||
closeActiveLightbox,
|
||||
extractUrls,
|
||||
renderUrlEmbeds,
|
||||
clearMediaCaches,
|
||||
@@ -1184,6 +1185,25 @@ describe("media.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// closeActiveLightbox (B6-15: lightbox survives logout/page swap)
|
||||
// =========================================================================
|
||||
|
||||
describe("closeActiveLightbox", () => {
|
||||
it("closes the active lightbox and removes it from the DOM", () => {
|
||||
openImageLightbox("https://example.com/pic.jpg", "Photo");
|
||||
expect(document.body.querySelector(".image-lightbox")).not.toBeNull();
|
||||
|
||||
closeActiveLightbox();
|
||||
|
||||
expect(document.body.querySelector(".image-lightbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("is a no-op when no lightbox is open", () => {
|
||||
expect(() => closeActiveLightbox()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// extractUrls
|
||||
// =========================================================================
|
||||
|
||||
@@ -15,6 +15,18 @@ function resetStore(): void {
|
||||
// Role list drives member-list grouping/colors — reset so tests that seed
|
||||
// roles don't leak into the ones asserting the fallback groups.
|
||||
setRoles([]);
|
||||
// The moderation-menu gate now reads the live authStore role (see "re-reads
|
||||
// the current user's role live..." below), so a user left behind by an
|
||||
// earlier test — the "admin"/"owner" role names in particular fall back to
|
||||
// isLegacyAdminRole's full-access grant when that name isn't in the
|
||||
// per-test role list — would otherwise leak into unrelated gate tests.
|
||||
authStore.setState(() => ({
|
||||
token: null,
|
||||
user: null,
|
||||
serverName: null,
|
||||
motd: null,
|
||||
isAuthenticated: false,
|
||||
}));
|
||||
}
|
||||
|
||||
function makeMember(overrides: Partial<Member> & { id: number; username: string }): Member {
|
||||
@@ -435,6 +447,42 @@ describe("MemberList", () => {
|
||||
expect(contextMenu).toBeNull();
|
||||
});
|
||||
|
||||
// The role NAME passed via opts.currentUserRole is a snapshot taken once at
|
||||
// mount (SidebarMemberSection.ts). dispatcher.ts keeps authStore.user.role
|
||||
// current on every self MEMBER_UPDATE specifically so permission gates can
|
||||
// read it live -- this is the one gate that opted out by threading the
|
||||
// stale prop instead.
|
||||
it("re-reads the current user's role live from authStore instead of the mount-time opts.currentUserRole snapshot", () => {
|
||||
setRoles([{ id: 8, name: "admin", color: null, permissions: Permission.ADMINISTRATOR }]);
|
||||
authStore.setState(() => ({
|
||||
token: "tok",
|
||||
user: { id: 999, username: "Self", avatar: null, role: "member" },
|
||||
serverName: "Test",
|
||||
motd: null,
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
setTestMembers(testMembers);
|
||||
const opts: MemberListOptions = { ...defaultOpts(), currentUserRole: "member" };
|
||||
memberList.destroy?.();
|
||||
memberList = createMemberList(opts);
|
||||
memberList.mount(container);
|
||||
|
||||
// Mid-session promotion after mount -- opts.currentUserRole was only
|
||||
// ever read once, but authStore now says admin.
|
||||
authStore.setState((prev) => ({ ...prev, user: { ...prev.user!, role: "admin" } }));
|
||||
|
||||
const memberItem = container.querySelector('[data-testid="member-3"]') as HTMLDivElement;
|
||||
memberItem.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
|
||||
const menu = document.body.querySelector(".context-menu");
|
||||
expect(menu).not.toBeNull();
|
||||
const labels = Array.from(menu!.children)
|
||||
.filter((el) => el.classList.contains("context-menu__item"))
|
||||
.map((el) => el.firstChild?.textContent ?? "");
|
||||
expect(labels).toEqual(expect.arrayContaining(["Change Role", "Force Logout", "Ban"]));
|
||||
|
||||
document.body.querySelector(".context-menu")?.remove();
|
||||
});
|
||||
|
||||
it("displays member names with role-colored text", () => {
|
||||
setTestMembers([
|
||||
makeMember({ id: 1, username: "OwnerUser", role: "owner", status: "online" as UserStatus }),
|
||||
|
||||
@@ -115,6 +115,20 @@ describe("filterMentionSuggestions", () => {
|
||||
signInAs("owner", Permission.ADMINISTRATOR);
|
||||
expect(filterMentionSuggestions("every").map((s) => s.token)).toEqual(["everyone"]);
|
||||
});
|
||||
|
||||
// The composer's own token scanner and both parsers (client lib/mentions.ts,
|
||||
// server mentions.go) only accept [\p{L}\p{N}_.-]{1,64} -- a username with a
|
||||
// space or "@" truncates the token on insert, so picking it produces a dead
|
||||
// token that resolves to no mention and notifies nobody.
|
||||
it("skips members whose username cannot be expressed as a mention token", () => {
|
||||
seedMembers(["alice", "John Smith", "weird@name"]);
|
||||
expect(filterMentionSuggestions("").map((s) => s.token)).toEqual(["alice"]);
|
||||
});
|
||||
|
||||
it("skips an inexpressible username even when the query would otherwise match it", () => {
|
||||
seedMembers(["John Smith"]);
|
||||
expect(filterMentionSuggestions("john")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMentionAutocomplete", () => {
|
||||
|
||||
@@ -174,6 +174,19 @@ describe("createMessageController", () => {
|
||||
expect(mockSetChannelLoadError).not.toHaveBeenCalled();
|
||||
expect(showError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards a stale tail response if the channel was loaded by something else while the fetch was in flight (e.g. a same-channel jump's around-window)", async () => {
|
||||
// Not loaded when the fetch starts (so it proceeds), but loaded by the
|
||||
// time it resolves — simulating MessageJump's setAroundMessages winning
|
||||
// the race and installing a window this response must not clobber.
|
||||
mockIsChannelLoaded.mockReturnValueOnce(false).mockReturnValueOnce(true);
|
||||
const api = makeApi();
|
||||
const ctrl = createMessageController({ api, showError });
|
||||
|
||||
await ctrl.loadMessages(42, makeAbort().signal);
|
||||
|
||||
expect(mockSetMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadOlderMessages", () => {
|
||||
@@ -248,6 +261,33 @@ describe("createMessageController", () => {
|
||||
|
||||
expect(mockPrependMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards a stale older-page if the window was replaced while the fetch was in flight (e.g. a same-channel jump swapped in an around-window)", async () => {
|
||||
// First read (before the fetch): oldest visible row is id 10. Second
|
||||
// read (after the await resolves): the window has already been
|
||||
// replaced — id 10 is no longer at the front — so splicing this page
|
||||
// onto it would duplicate/misorder rows.
|
||||
mockGetChannelMessages
|
||||
.mockReturnValueOnce([
|
||||
{ id: 10, content: "oldest" },
|
||||
{ id: 20, content: "newest" },
|
||||
])
|
||||
.mockReturnValueOnce([
|
||||
{ id: 77, content: "replaced" },
|
||||
{ id: 78, content: "replaced2" },
|
||||
]);
|
||||
const api = makeApi({
|
||||
getMessages: vi.fn().mockResolvedValue({
|
||||
messages: [{ id: 5, content: "older" }],
|
||||
has_more: true,
|
||||
}),
|
||||
});
|
||||
const ctrl = createMessageController({ api, showError });
|
||||
|
||||
await ctrl.loadOlderMessages(42, makeAbort().signal);
|
||||
|
||||
expect(mockPrependMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -505,6 +505,57 @@ describe("MessageInput", () => {
|
||||
comp.destroy?.();
|
||||
});
|
||||
|
||||
it("upload error makes the (initially hidden) preview bar visible", async () => {
|
||||
const onUploadFile = vi.fn(async () => ({ id: "x", url: "x", filename: "x" }));
|
||||
const opts = makeOptions({ onUploadFile });
|
||||
const comp = createMessageInput(opts);
|
||||
comp.mount(container);
|
||||
|
||||
// Nothing queued yet, so the preview bar starts without the "visible"
|
||||
// class -- app.css only shows it via .visible.
|
||||
const previewBar = container.querySelector(".attachment-preview-bar");
|
||||
expect(previewBar!.classList.contains("visible")).toBe(false);
|
||||
|
||||
const bigFile = new File(["x"], "huge.bin", { type: "application/octet-stream" });
|
||||
Object.defineProperty(bigFile, "size", { value: 101 * 1024 * 1024 });
|
||||
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
Object.defineProperty(fileInput, "files", { value: [bigFile], writable: true });
|
||||
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
expect(container.querySelector(".attachment-upload-error")).not.toBeNull();
|
||||
expect(previewBar!.classList.contains("visible")).toBe(true);
|
||||
|
||||
comp.destroy?.();
|
||||
});
|
||||
|
||||
it("preview bar loses 'visible' again once the error auto-dismisses with nothing else queued", async () => {
|
||||
vi.useFakeTimers();
|
||||
const onUploadFile = vi.fn(async () => ({ id: "x", url: "x", filename: "x" }));
|
||||
const opts = makeOptions({ onUploadFile });
|
||||
const comp = createMessageInput(opts);
|
||||
comp.mount(container);
|
||||
|
||||
const bigFile = new File(["x"], "huge.bin", { type: "application/octet-stream" });
|
||||
Object.defineProperty(bigFile, "size", { value: 101 * 1024 * 1024 });
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
Object.defineProperty(fileInput, "files", { value: [bigFile], writable: true });
|
||||
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
const previewBar = container.querySelector(".attachment-preview-bar");
|
||||
expect(previewBar!.classList.contains("visible")).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
expect(container.querySelector(".attachment-upload-error")).toBeNull();
|
||||
expect(previewBar!.classList.contains("visible")).toBe(false);
|
||||
|
||||
vi.useRealTimers();
|
||||
comp.destroy?.();
|
||||
});
|
||||
|
||||
it("rejects unsupported file types", async () => {
|
||||
const onUploadFile = vi.fn(async () => ({ id: "x", url: "x", filename: "x" }));
|
||||
const opts = makeOptions({ onUploadFile });
|
||||
@@ -672,6 +723,33 @@ describe("MessageInput", () => {
|
||||
comp.destroy?.();
|
||||
});
|
||||
|
||||
it("refuses to attach a file while editing a message", async () => {
|
||||
const onUploadFile = vi.fn(async () => ({
|
||||
id: "srv-1",
|
||||
url: "http://x.png",
|
||||
filename: "x.png",
|
||||
}));
|
||||
const opts = makeOptions({ onUploadFile });
|
||||
const comp = createMessageInput(opts);
|
||||
comp.mount(container);
|
||||
|
||||
comp.startEdit(77, "old content");
|
||||
|
||||
const file = new File(["data"], "photo.png", { type: "image/png" });
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
Object.defineProperty(fileInput, "files", { value: [file], writable: true });
|
||||
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Must not upload, and must not silently ride along with the next
|
||||
// ordinary send once the edit is cancelled.
|
||||
expect(onUploadFile).not.toHaveBeenCalled();
|
||||
expect(container.querySelector(".attachment-upload-error")).not.toBeNull();
|
||||
|
||||
comp.destroy?.();
|
||||
});
|
||||
|
||||
// ── setReplyTo clears edit mode ──
|
||||
|
||||
it("setReplyTo hides edit bar if editing", () => {
|
||||
@@ -694,6 +772,24 @@ describe("MessageInput", () => {
|
||||
comp.destroy?.();
|
||||
});
|
||||
|
||||
it("setReplyTo while editing clears the stale edit text from the textarea", () => {
|
||||
const opts = makeOptions();
|
||||
const comp = createMessageInput(opts);
|
||||
comp.mount(container);
|
||||
|
||||
comp.startEdit(88, "old message body");
|
||||
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
||||
expect(textarea.value).toBe("old message body");
|
||||
|
||||
comp.setReplyTo(55, "replying");
|
||||
|
||||
// The edit text must not survive into reply mode -- otherwise Enter
|
||||
// reposts it verbatim as a duplicate reply.
|
||||
expect(textarea.value).toBe("");
|
||||
|
||||
comp.destroy?.();
|
||||
});
|
||||
|
||||
// ── startEdit clears reply mode ──
|
||||
|
||||
it("startEdit hides reply bar if replying", () => {
|
||||
|
||||
@@ -273,6 +273,35 @@ describe("MessageList", () => {
|
||||
expect(dividers.length).toBe(2);
|
||||
});
|
||||
|
||||
it("day divider breaks grouping even for the same author posting across midnight", () => {
|
||||
// isSameDay compares local calendar days, so the boundary is built with
|
||||
// the local-time Date constructor (not UTC ISO literals) to stay
|
||||
// independent of the machine/CI runner's timezone.
|
||||
const beforeMidnight = new Date(2024, 0, 15, 23, 58, 0).toISOString();
|
||||
const afterMidnight = new Date(2024, 0, 16, 0, 1, 0).toISOString();
|
||||
const messages = [
|
||||
makeMessage({
|
||||
id: 1,
|
||||
user: { id: 1, username: "Alice", avatar: null },
|
||||
timestamp: beforeMidnight,
|
||||
}),
|
||||
makeMessage({
|
||||
id: 2,
|
||||
user: { id: 1, username: "Alice", avatar: null },
|
||||
timestamp: afterMidnight,
|
||||
}),
|
||||
];
|
||||
setMessages(1, messages);
|
||||
msgList.mount(container);
|
||||
|
||||
// 2 dividers: the leading one before the first message, plus one for the
|
||||
// day change (matches "renders day dividers between messages on
|
||||
// different days" above -- the assertion here is on grouping, not count).
|
||||
expect(container.querySelectorAll(".msg-day-divider").length).toBe(2);
|
||||
const row2 = container.querySelector("[data-testid='message-2']")!;
|
||||
expect(row2.classList.contains("grouped")).toBe(false);
|
||||
});
|
||||
|
||||
it("renders DM channel empty state differently from text channels", () => {
|
||||
msgList.destroy?.();
|
||||
const dmOptions: MessageListOptions = {
|
||||
@@ -399,6 +428,42 @@ describe("MessageList", () => {
|
||||
expect(onScrollTop).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not re-trigger onScrollTop from a live tail append while a history fetch is in flight", async () => {
|
||||
setHasMore(1, true);
|
||||
setMessages(1, [makeMessage({ id: 1 })]);
|
||||
messagesStore.flush();
|
||||
|
||||
let resolveLoad: () => void = () => {};
|
||||
const onScrollTop = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveLoad = resolve;
|
||||
}),
|
||||
);
|
||||
msgList = createMessageList({ ...options, onScrollTop });
|
||||
msgList.mount(container);
|
||||
|
||||
const root = container.querySelector(".messages-container") as HTMLDivElement;
|
||||
root.dispatchEvent(new Event("scroll"));
|
||||
expect(onScrollTop).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A live message arrives at the tail while the older-page fetch is still
|
||||
// in flight. messages[0] (the oldest loaded message) is unchanged, so the
|
||||
// latch must stay set -- otherwise the next scroll refires the fetch with
|
||||
// the same unchanged cursor and the same page lands twice.
|
||||
setMessages(1, [
|
||||
...(messagesStore.getState().messagesByChannel.get(1) ?? []),
|
||||
makeMessage({ id: 2 }),
|
||||
]);
|
||||
messagesStore.flush();
|
||||
|
||||
root.dispatchEvent(new Event("scroll"));
|
||||
expect(onScrollTop).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveLoad();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
it("scrollToMessage returns false before mount", () => {
|
||||
// scrollToMessage should be safe to call before mount
|
||||
const unmounted = createMessageList(options);
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
hasMoreMessages,
|
||||
isWindowDetached,
|
||||
hasMessageLoaded,
|
||||
addOptimisticMessage,
|
||||
markSendFailed,
|
||||
} from "../../src/stores/messages.store";
|
||||
import type { ChatMessagePayload, MessageResponse, MessageUser } from "../../src/lib/types";
|
||||
|
||||
@@ -128,6 +130,36 @@ describe("setAroundMessages", () => {
|
||||
expect(isWindowDetached(2)).toBe(false);
|
||||
expect(ids(2)).toEqual([500]);
|
||||
});
|
||||
|
||||
it("carries pending and failed optimistic rows instead of wiping them", () => {
|
||||
// Unlike setMessages, setAroundMessages replaces the window wholesale —
|
||||
// without a carry, a jump elsewhere destroys the user's still-unsent
|
||||
// message and orphans its Retry draft.
|
||||
addOptimisticMessage({
|
||||
correlationId: "c1",
|
||||
channelId: 1,
|
||||
user: USER,
|
||||
content: "still sending",
|
||||
replyTo: null,
|
||||
timestamp: "2026-03-15T10:00:00Z",
|
||||
});
|
||||
addOptimisticMessage({
|
||||
correlationId: "c2",
|
||||
channelId: 1,
|
||||
user: USER,
|
||||
content: "refused",
|
||||
replyTo: null,
|
||||
timestamp: "2026-03-15T10:00:01Z",
|
||||
});
|
||||
markSendFailed("c2", "SLOW_MODE");
|
||||
|
||||
setAroundMessages(1, ascendingWindow(10, 12), true, true);
|
||||
|
||||
const msgs = getChannelMessages(1);
|
||||
expect(msgs.map((m) => m.correlationId)).toEqual([null, null, null, "c1", "c2"]);
|
||||
expect(msgs[3]!.status).toBe("pending");
|
||||
expect(msgs[4]!.status).toBe("failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("live messages while detached", () => {
|
||||
@@ -162,16 +194,32 @@ describe("live messages while detached", () => {
|
||||
});
|
||||
|
||||
describe("reattachToPresent", () => {
|
||||
it("clears the detached flag and the loaded flag so the tail is refetched", () => {
|
||||
it("clears the loaded flag so the tail is refetched, but keeps the detached flag until the tail actually lands", () => {
|
||||
setAroundMessages(1, ascendingWindow(10, 12), true, true);
|
||||
expect(isChannelLoaded(1)).toBe(true);
|
||||
|
||||
reattachToPresent(1);
|
||||
|
||||
expect(isWindowDetached(1)).toBe(false);
|
||||
// Without clearing "loaded", MessageController short-circuits and the
|
||||
// stale window stays on screen forever.
|
||||
expect(isChannelLoaded(1)).toBe(false);
|
||||
// The detached flag must survive until setMessages' tail fetch actually
|
||||
// succeeds (see below) — clearing it eagerly here would let a live
|
||||
// broadcast splice onto stale history if that refetch fails.
|
||||
expect(isWindowDetached(1)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the detached flag set until the tail actually arrives, so a failed refetch does not let a live broadcast splice onto stale history", () => {
|
||||
setAroundMessages(1, ascendingWindow(10, 12), true, true);
|
||||
|
||||
reattachToPresent(1);
|
||||
// The refetch MessageController would normally issue next never landed
|
||||
// (still in flight, or failed) — the window is still the stale
|
||||
// around-window, so a live broadcast must not be appended onto it.
|
||||
addMessage(broadcast(900));
|
||||
|
||||
expect(isWindowDetached(1)).toBe(true);
|
||||
expect(ids(1)).toEqual([10, 11, 12]);
|
||||
});
|
||||
|
||||
it("is a no-op for a channel that was never detached", () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
setChannelLoading,
|
||||
setChannelLoadError,
|
||||
getHistoryLoadState,
|
||||
invalidateLoadedMessageWindows,
|
||||
} from "../../src/stores/messages.store";
|
||||
import type {
|
||||
ChatMessagePayload,
|
||||
@@ -273,6 +274,71 @@ describe("messages store", () => {
|
||||
expect(msgs[1]!.status).toBe("pending");
|
||||
expect(msgs[2]!.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("dedupes an optimistic row against its own real message after a lost ack + resync", () => {
|
||||
// The chat_send_ok ack never arrives (dropped by the same disconnect
|
||||
// that forces a resync), so the row is still "pending" when
|
||||
// invalidateLoadedMessageWindows carries it through.
|
||||
setMessages(1, [makeMessageResponse({ id: 10 })], false);
|
||||
addOptimisticMessage({
|
||||
correlationId: "c1",
|
||||
channelId: 1,
|
||||
user: TEST_USER,
|
||||
content: "ok",
|
||||
replyTo: null,
|
||||
timestamp: "2026-03-15T10:00:00Z",
|
||||
});
|
||||
invalidateLoadedMessageWindows();
|
||||
expect(getChannelMessages(1)).toHaveLength(1);
|
||||
expect(getChannelMessages(1)[0]!.status).toBe("pending");
|
||||
|
||||
// The resync's history fetch reveals the server DID persist the send —
|
||||
// id 500 is the real echo of the lost ack.
|
||||
setMessages(1, [makeMessageResponse({ id: 500, content: "ok", user: TEST_USER })], false);
|
||||
|
||||
const msgs = getChannelMessages(1);
|
||||
expect(msgs).toHaveLength(1);
|
||||
expect(msgs[0]!.id).toBe(500);
|
||||
expect(msgs[0]!.status).toBe("sent");
|
||||
});
|
||||
|
||||
it("keeps two genuinely distinct same-author, same-text sends distinct across the same resync", () => {
|
||||
setMessages(1, [], false);
|
||||
addOptimisticMessage({
|
||||
correlationId: "c1",
|
||||
channelId: 1,
|
||||
user: TEST_USER,
|
||||
content: "ok",
|
||||
replyTo: null,
|
||||
timestamp: "2026-03-15T10:00:00Z",
|
||||
});
|
||||
addOptimisticMessage({
|
||||
correlationId: "c2",
|
||||
channelId: 1,
|
||||
user: TEST_USER,
|
||||
content: "ok",
|
||||
replyTo: null,
|
||||
timestamp: "2026-03-15T10:00:01Z",
|
||||
});
|
||||
invalidateLoadedMessageWindows();
|
||||
expect(getChannelMessages(1)).toHaveLength(2);
|
||||
|
||||
// Both sends actually landed server-side; the resync's fetch returns
|
||||
// both real rows — neither optimistic row may collapse onto the other's.
|
||||
setMessages(
|
||||
1,
|
||||
[
|
||||
makeMessageResponse({ id: 501, content: "ok", user: TEST_USER }),
|
||||
makeMessageResponse({ id: 500, content: "ok", user: TEST_USER }),
|
||||
],
|
||||
false,
|
||||
);
|
||||
|
||||
const msgs = getChannelMessages(1);
|
||||
expect(msgs).toHaveLength(2);
|
||||
expect(msgs.map((m) => m.id)).toEqual([500, 501]);
|
||||
expect(msgs.every((m) => m.status === "sent")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// 4. prependMessages prepends older messages
|
||||
@@ -1197,6 +1263,104 @@ describe("messages store", () => {
|
||||
expect(msgs[0]!.id).toBe(800);
|
||||
expect(msgs[0]!.status).toBe("sent");
|
||||
});
|
||||
|
||||
it("reconciles an OFFLINE-failed row when its echo arrives — no duplicate, no dead Retry", () => {
|
||||
// The dispatcher's offline sweep flips every pending send to
|
||||
// failed/OFFLINE on the first reconnecting/disconnected transition —
|
||||
// it cannot know whether the frame actually reached the server first.
|
||||
addOptimisticMessage({
|
||||
correlationId: "c1",
|
||||
channelId: 1,
|
||||
user: TEST_USER,
|
||||
content: "hi",
|
||||
replyTo: null,
|
||||
timestamp: "2026-03-15T10:00:00Z",
|
||||
});
|
||||
markSendFailed("c1", "OFFLINE");
|
||||
|
||||
// It did reach the server after all — its broadcast (or a reconnect
|
||||
// replay) arrives with the real id.
|
||||
addMessage(makeChatPayload({ id: 900, user: TEST_USER, content: "hi" }));
|
||||
|
||||
const msgs = getChannelMessages(1);
|
||||
expect(msgs).toHaveLength(1);
|
||||
expect(msgs[0]!.id).toBe(900);
|
||||
expect(msgs[0]!.status).toBe("sent");
|
||||
});
|
||||
|
||||
it("does not reconcile a server-rejected (non-OFFLINE) failed row into an unrelated broadcast", () => {
|
||||
// SLOW_MODE/FORBIDDEN etc. are never broadcast by the server, so no echo
|
||||
// can legitimately arrive for them — widening the reconcile must stay
|
||||
// scoped to OFFLINE, or a same-author/same-content coincidence would
|
||||
// silently eat a row the user still needs to retry.
|
||||
addOptimisticMessage({
|
||||
correlationId: "c1",
|
||||
channelId: 1,
|
||||
user: TEST_USER,
|
||||
content: "hi",
|
||||
replyTo: null,
|
||||
timestamp: "2026-03-15T10:00:00Z",
|
||||
});
|
||||
markSendFailed("c1", "SLOW_MODE");
|
||||
|
||||
addMessage(makeChatPayload({ id: 900, user: TEST_USER, content: "hi" }));
|
||||
|
||||
const msgs = getChannelMessages(1);
|
||||
expect(msgs).toHaveLength(2);
|
||||
expect(msgs.find((m) => m.correlationId === "c1")!.status).toBe("failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidateLoadedMessageWindows", () => {
|
||||
it("drops sent rows and clears loaded/hasMore/detached for every loaded channel", () => {
|
||||
setMessages(1, [makeMessageResponse({ id: 10 })], true);
|
||||
setMessages(2, [makeMessageResponse({ id: 20, channel_id: 2 })], false);
|
||||
expect(isChannelLoaded(1)).toBe(true);
|
||||
expect(isChannelLoaded(2)).toBe(true);
|
||||
|
||||
invalidateLoadedMessageWindows();
|
||||
|
||||
expect(getChannelMessages(1)).toEqual([]);
|
||||
expect(getChannelMessages(2)).toEqual([]);
|
||||
expect(isChannelLoaded(1)).toBe(false);
|
||||
expect(isChannelLoaded(2)).toBe(false);
|
||||
expect(hasMoreMessages(1)).toBe(false);
|
||||
expect(isWindowDetached(1)).toBe(false);
|
||||
});
|
||||
|
||||
it("carries pending and failed optimistic rows instead of destroying them", () => {
|
||||
setMessages(1, [makeMessageResponse({ id: 10 })], false);
|
||||
addOptimisticMessage({
|
||||
correlationId: "c1",
|
||||
channelId: 1,
|
||||
user: TEST_USER,
|
||||
content: "still sending",
|
||||
replyTo: null,
|
||||
timestamp: "2026-03-15T10:00:00Z",
|
||||
});
|
||||
addOptimisticMessage({
|
||||
correlationId: "c2",
|
||||
channelId: 1,
|
||||
user: TEST_USER,
|
||||
content: "refused",
|
||||
replyTo: null,
|
||||
timestamp: "2026-03-15T10:00:01Z",
|
||||
});
|
||||
markSendFailed("c2", "SLOW_MODE");
|
||||
|
||||
invalidateLoadedMessageWindows();
|
||||
|
||||
const msgs = getChannelMessages(1);
|
||||
expect(msgs.map((m) => m.correlationId)).toEqual(["c1", "c2"]);
|
||||
expect(msgs[0]!.status).toBe("pending");
|
||||
expect(msgs[1]!.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("is a no-op when no channel is loaded", () => {
|
||||
const before = messagesStore.getState();
|
||||
invalidateLoadedMessageWindows();
|
||||
expect(messagesStore.getState()).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
// 10. First-page history load state
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { notifyIncomingMessage, cleanupNotificationAudio } from "../../src/lib/notifications";
|
||||
import { authStore } from "../../src/stores/auth.store";
|
||||
import { channelsStore } from "../../src/stores/channels.store";
|
||||
import { dmStore } from "../../src/stores/dm.store";
|
||||
import type { DmChannel } from "../../src/stores/dm.store";
|
||||
import type { ChatMessagePayload } from "../../src/lib/types";
|
||||
|
||||
// vi.hoisted ensures testPrefs is available when vi.mock factory runs
|
||||
@@ -136,6 +138,11 @@ describe("notifyIncomingMessage", () => {
|
||||
roles: [],
|
||||
}));
|
||||
|
||||
// DM ids are absent from channelsStore until the conversation is opened
|
||||
// (see dispatcher.ts) -- reset so a DM seeded by one test cannot leak
|
||||
// into another that expects the plain channelsStore fallback.
|
||||
dmStore.setState(() => ({ channels: [] }));
|
||||
|
||||
// Ensure document.hasFocus returns false (simulating unfocused window)
|
||||
vi.spyOn(document, "hasFocus").mockReturnValue(false);
|
||||
});
|
||||
@@ -669,6 +676,49 @@ describe("notifyIncomingMessage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// DM channel ids are never in channelsStore until the conversation is
|
||||
// opened, so a DM notification used to fall back to "Channel <id>" --
|
||||
// dm.store.ts names dmDisplayName as the one place every DM-labelling
|
||||
// surface (sidebar, header, quick switcher, notification title) must
|
||||
// agree, so the title routes through it too, with no "#" (a DM is not a
|
||||
// channel).
|
||||
it("titles a DM notification from dmDisplayName, not the channelsStore fallback", async () => {
|
||||
const { sendNotification } = await import("@tauri-apps/plugin-notification");
|
||||
(sendNotification as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
const dm: DmChannel = {
|
||||
channelId: 55,
|
||||
recipient: { id: 2, username: "bob", avatar: "", status: "online" },
|
||||
participants: [{ id: 2, username: "bob", avatar: "", status: "online" }],
|
||||
name: "",
|
||||
isGroup: false,
|
||||
lastMessageId: null,
|
||||
lastMessage: "",
|
||||
lastMessageAt: "",
|
||||
unreadCount: 0,
|
||||
mentionCount: 0,
|
||||
};
|
||||
dmStore.setState(() => ({ channels: [dm] }));
|
||||
|
||||
testPrefs.set("desktopNotifications", true);
|
||||
testPrefs.set("flashTaskbar", false);
|
||||
testPrefs.set("notificationSounds", false);
|
||||
|
||||
const payload = makePayload({
|
||||
user: { id: 2, username: "bob", avatar: null },
|
||||
channel_id: 55,
|
||||
content: "hey",
|
||||
});
|
||||
notifyIncomingMessage(payload);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(sendNotification).toHaveBeenCalledWith({
|
||||
title: "bob in bob",
|
||||
body: "hey",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("uses fallback channel name with correct channel ID", async () => {
|
||||
const { sendNotification } = await import("@tauri-apps/plugin-notification");
|
||||
(sendNotification as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
@@ -17,6 +17,7 @@ const {
|
||||
mockSearchOverlayMount,
|
||||
mockSearchOverlayDestroy,
|
||||
mockSetActiveChannel,
|
||||
mockSetMessagePinned,
|
||||
} = vi.hoisted(() => ({
|
||||
mockLogError: vi.fn(),
|
||||
mockInviteManagerMount: vi.fn(),
|
||||
@@ -29,6 +30,7 @@ const {
|
||||
mockSearchOverlayMount: vi.fn(),
|
||||
mockSearchOverlayDestroy: vi.fn(),
|
||||
mockSetActiveChannel: vi.fn(),
|
||||
mockSetMessagePinned: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@lib/logger", () => ({
|
||||
@@ -72,6 +74,10 @@ vi.mock("@stores/channels.store", () => ({
|
||||
setActiveChannel: mockSetActiveChannel,
|
||||
}));
|
||||
|
||||
vi.mock("@stores/messages.store", () => ({
|
||||
setMessagePinned: mockSetMessagePinned,
|
||||
}));
|
||||
|
||||
vi.mock("@lib/toast", () => ({
|
||||
initToast: vi.fn(),
|
||||
teardownToast: vi.fn(),
|
||||
@@ -339,11 +345,17 @@ describe("createPinnedPanelController", () => {
|
||||
expect(mockPinnedMessagesDestroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The store's `pinned` flag is the row's only local authority (there is
|
||||
// no server pin/unpin broadcast) — without this the row still says
|
||||
// "Unpin" after a panel unpin, and re-clicking it calls unpinMessage on
|
||||
// an already-unpinned message.
|
||||
expect(mockSetMessagePinned).toHaveBeenCalledWith(42, 1, false);
|
||||
|
||||
// No error toast should be shown
|
||||
expect(mockShowToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("onJumpToMessage calls provided scroll callback and closes panel", async () => {
|
||||
it("onJumpToMessage forwards the panel's own channel id (captured at open time), not just the message id", async () => {
|
||||
const api = makeMockApi();
|
||||
const toast = makeMockToast();
|
||||
const mockScrollToMessage = vi.fn().mockReturnValue(true);
|
||||
@@ -364,7 +376,10 @@ describe("createPinnedPanelController", () => {
|
||||
|
||||
opts.onJumpToMessage(1);
|
||||
|
||||
expect(mockScrollToMessage).toHaveBeenCalledWith(1);
|
||||
// A caller juggling several channels (ChatArea re-reads currentChannelId
|
||||
// live at click time) needs the panel's own channel, not whatever
|
||||
// channel happens to be active when the click lands.
|
||||
expect(mockScrollToMessage).toHaveBeenCalledWith(42, 1);
|
||||
expect(mockPinnedMessagesDestroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -392,7 +407,7 @@ describe("createPinnedPanelController", () => {
|
||||
// The jumper fetches the around-window for an unloaded target and reports
|
||||
// its own failures, so the panel no longer second-guesses it with a
|
||||
// "not in loaded window" toast — it just gets out of the way.
|
||||
expect(mockJump).toHaveBeenCalledWith(999);
|
||||
expect(mockJump).toHaveBeenCalledWith(42, 999);
|
||||
expect(mockPinnedMessagesDestroy).toHaveBeenCalled();
|
||||
expect(mockShowToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -24,9 +24,21 @@ const testPrefs = new Map<string, unknown>();
|
||||
let mockCurrentChannelId: number | null = null;
|
||||
let mockLocalMuted = false;
|
||||
let mockLocalDeafened = false;
|
||||
let mockPttGated = false;
|
||||
const mockSetPttGated = vi.fn();
|
||||
const mockSetPttPollingLive = vi.fn();
|
||||
|
||||
/** Captures the listener passed to voiceStore.subscribe() so tests can fire
|
||||
* a simulated store notification (real createStore() batches these via
|
||||
* queueMicrotask; the mock fires only when a test invokes it explicitly). */
|
||||
let capturedStoreListener: ((state: { localMuted: boolean }) => void) | null = null;
|
||||
const mockSubscribeStore = vi.fn((listener: (state: { localMuted: boolean }) => void) => {
|
||||
capturedStoreListener = listener;
|
||||
return () => {
|
||||
capturedStoreListener = null;
|
||||
};
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module mocks (must be declared before importing the module under test)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -52,7 +64,9 @@ vi.mock("@stores/voice.store", () => ({
|
||||
currentChannelId: mockCurrentChannelId,
|
||||
localMuted: mockLocalMuted,
|
||||
localDeafened: mockLocalDeafened,
|
||||
pttGated: mockPttGated,
|
||||
}),
|
||||
subscribe: (listener: (state: { localMuted: boolean }) => void) => mockSubscribeStore(listener),
|
||||
},
|
||||
setPttGated: (...args: unknown[]) => mockSetPttGated(...args),
|
||||
setPttPollingLive: (...args: unknown[]) => mockSetPttPollingLive(...args),
|
||||
@@ -88,10 +102,19 @@ function resetAll(): void {
|
||||
mockCurrentChannelId = null;
|
||||
mockLocalMuted = false;
|
||||
mockLocalDeafened = false;
|
||||
mockPttGated = false;
|
||||
mockSetPttGated.mockReset();
|
||||
mockSetPttPollingLive.mockReset();
|
||||
mockInvoke.mockReset();
|
||||
mockListen.mockReset();
|
||||
mockSubscribeStore.mockReset();
|
||||
mockSubscribeStore.mockImplementation((listener: (state: { localMuted: boolean }) => void) => {
|
||||
capturedStoreListener = listener;
|
||||
return () => {
|
||||
capturedStoreListener = null;
|
||||
};
|
||||
});
|
||||
capturedStoreListener = null;
|
||||
// Default: invoke resolves with undefined; listen resolves with a no-op unlistener
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
mockListen.mockResolvedValue(() => {});
|
||||
@@ -750,3 +773,395 @@ describe("ptt-state event listener", () => {
|
||||
expect(mockSetMuted).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: pttOwnsMute latch is cleared by a non-PTT unmute (B1_voice_mic-7)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("pttOwnsMute latch reset on external unmute", () => {
|
||||
beforeEach(resetAll);
|
||||
|
||||
it("stays muted on a later press after a widget unmute+re-mute clears a stale PTT-owned latch", async () => {
|
||||
const { setMuted } = await import("../../src/lib/livekitSession");
|
||||
const mockSetMuted = vi.mocked(setMuted);
|
||||
mockSetMuted.mockReset();
|
||||
mockSetMuted.mockImplementation((muted: boolean) => {
|
||||
mockLocalMuted = muted;
|
||||
});
|
||||
|
||||
mockCurrentChannelId = 7;
|
||||
testPrefs.set("pttVk", 0x20);
|
||||
|
||||
let capturedCallback: ((event: { payload: boolean }) => void) | null = null;
|
||||
mockListen.mockImplementation((_event: string, cb: (e: { payload: boolean }) => void) => {
|
||||
capturedCallback = cb;
|
||||
return Promise.resolve(() => {});
|
||||
});
|
||||
|
||||
await initPtt();
|
||||
expect(capturedStoreListener).not.toBeNull();
|
||||
|
||||
// 1. Press then release — the release is PTT's own mute, so pttOwnsMute
|
||||
// latches true.
|
||||
capturedCallback!({ payload: true });
|
||||
await vi.waitFor(() => expect(mockSetMuted).toHaveBeenCalledWith(false));
|
||||
capturedCallback!({ payload: false });
|
||||
await vi.waitFor(() => expect(mockLocalMuted).toBe(true));
|
||||
|
||||
// 2. A non-PTT path unmutes (e.g. the widget's own mic button calling
|
||||
// livekitSession.setMuted(false) directly) — simulate both the write
|
||||
// and the resulting store notification our subscriber reacts to.
|
||||
mockSetMuted(false);
|
||||
capturedStoreListener!({ localMuted: mockLocalMuted });
|
||||
|
||||
// 3. The user then genuinely self-mutes via the same non-PTT path.
|
||||
mockSetMuted(true);
|
||||
capturedStoreListener!({ localMuted: mockLocalMuted });
|
||||
|
||||
// 4. The next PTT press must not lift this genuine self-mute — the
|
||||
// latch from step 1 must not have survived steps 2-3.
|
||||
mockSetMuted.mockClear();
|
||||
capturedCallback!({ payload: true });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(mockSetMuted).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: stopPtt ungates a still-gated mic when the binding is cleared
|
||||
// (B1_voice_mic-9)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("stopPtt ungates the mic when clearing the key mid-gate", () => {
|
||||
beforeEach(resetAll);
|
||||
|
||||
it("clears pttGated and re-opens the mic when nothing else wants it muted", async () => {
|
||||
const { setMuted } = await import("../../src/lib/livekitSession");
|
||||
const mockSetMuted = vi.mocked(setMuted);
|
||||
mockSetMuted.mockClear();
|
||||
|
||||
testPrefs.set("pttVk", 0x20);
|
||||
await initPtt();
|
||||
|
||||
// Simulates livekitSession's join-time gate, still armed because the key
|
||||
// was never pressed before the user cleared the binding.
|
||||
mockPttGated = true;
|
||||
mockLocalMuted = false;
|
||||
mockLocalDeafened = false;
|
||||
|
||||
await stopPtt();
|
||||
|
||||
expect(mockSetPttGated).toHaveBeenCalledWith(false);
|
||||
await vi.waitFor(() => {
|
||||
expect(mockSetMuted).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not force-unmute when the user is separately self-muted or deafened", async () => {
|
||||
const { setMuted } = await import("../../src/lib/livekitSession");
|
||||
const mockSetMuted = vi.mocked(setMuted);
|
||||
mockSetMuted.mockClear();
|
||||
|
||||
testPrefs.set("pttVk", 0x20);
|
||||
await initPtt();
|
||||
|
||||
mockPttGated = true;
|
||||
mockLocalMuted = true;
|
||||
|
||||
await stopPtt();
|
||||
|
||||
expect(mockSetPttGated).toHaveBeenCalledWith(false);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(mockSetMuted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when pttGated was already false", async () => {
|
||||
testPrefs.set("pttVk", 0x20);
|
||||
await initPtt();
|
||||
mockSetPttGated.mockClear();
|
||||
|
||||
mockPttGated = false;
|
||||
await stopPtt();
|
||||
|
||||
expect(mockSetPttGated).not.toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
// B1_voice_mic-11: stopPtt resets pttOwnsMute to false BEFORE calling
|
||||
// ungateMic (a mute must not outlive its binding), so ungateMic cannot
|
||||
// read the module-level latch directly — it has already been zeroed by
|
||||
// the time it runs. The value from before that reset must still decide
|
||||
// the re-open.
|
||||
it("re-opens the mic when stopPtt clears the binding after a PTT release applied the mute (bug fix)", async () => {
|
||||
const { setMuted } = await import("../../src/lib/livekitSession");
|
||||
const mockSetMuted = vi.mocked(setMuted);
|
||||
mockSetMuted.mockReset();
|
||||
mockSetMuted.mockImplementation((muted: boolean) => {
|
||||
mockLocalMuted = muted;
|
||||
});
|
||||
|
||||
mockCurrentChannelId = 7;
|
||||
testPrefs.set("pttVk", 0x20);
|
||||
|
||||
let capturedCallback: ((event: { payload: boolean }) => void) | null = null;
|
||||
mockListen.mockImplementation((_event: string, cb: (e: { payload: boolean }) => void) => {
|
||||
capturedCallback = cb;
|
||||
return Promise.resolve(() => {});
|
||||
});
|
||||
|
||||
await initPtt();
|
||||
|
||||
// Press then release: the release is PTT's own mute — pttOwnsMute
|
||||
// latches true and the store's pttGated closes.
|
||||
capturedCallback!({ payload: true });
|
||||
await vi.waitFor(() => expect(mockSetMuted).toHaveBeenCalledWith(false));
|
||||
capturedCallback!({ payload: false });
|
||||
await vi.waitFor(() => expect(mockLocalMuted).toBe(true));
|
||||
mockPttGated = true; // mirror the gate the release set in the real store
|
||||
mockLocalDeafened = false;
|
||||
|
||||
mockSetMuted.mockClear();
|
||||
await stopPtt();
|
||||
|
||||
expect(mockSetPttGated).toHaveBeenCalledWith(false);
|
||||
await vi.waitFor(() => {
|
||||
expect(mockSetMuted).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not lift a user's own self-mute when stopPtt clears the binding, even after a prior PTT release owned it", async () => {
|
||||
const { setMuted } = await import("../../src/lib/livekitSession");
|
||||
const mockSetMuted = vi.mocked(setMuted);
|
||||
mockSetMuted.mockReset();
|
||||
mockSetMuted.mockImplementation((muted: boolean) => {
|
||||
mockLocalMuted = muted;
|
||||
});
|
||||
|
||||
mockCurrentChannelId = 7;
|
||||
testPrefs.set("pttVk", 0x20);
|
||||
|
||||
let capturedCallback: ((event: { payload: boolean }) => void) | null = null;
|
||||
mockListen.mockImplementation((_event: string, cb: (e: { payload: boolean }) => void) => {
|
||||
capturedCallback = cb;
|
||||
return Promise.resolve(() => {});
|
||||
});
|
||||
|
||||
await initPtt();
|
||||
expect(capturedStoreListener).not.toBeNull();
|
||||
|
||||
// A PTT release once owned the mute...
|
||||
capturedCallback!({ payload: true });
|
||||
await vi.waitFor(() => expect(mockSetMuted).toHaveBeenCalledWith(false));
|
||||
capturedCallback!({ payload: false });
|
||||
await vi.waitFor(() => expect(mockLocalMuted).toBe(true));
|
||||
|
||||
// ...but the user then explicitly unmutes and re-mutes via a non-PTT
|
||||
// path (e.g. the widget's mic button), which clears the PTT-owned latch
|
||||
// — see the "latch reset" tests above. This mute is now the user's own.
|
||||
mockSetMuted(false);
|
||||
capturedStoreListener!({ localMuted: mockLocalMuted });
|
||||
mockSetMuted(true);
|
||||
capturedStoreListener!({ localMuted: mockLocalMuted });
|
||||
|
||||
mockPttGated = true;
|
||||
mockLocalDeafened = false;
|
||||
mockSetMuted.mockClear();
|
||||
|
||||
await stopPtt();
|
||||
|
||||
expect(mockSetPttGated).toHaveBeenCalledWith(false);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(mockSetMuted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not re-open the mic when stopPtt clears the binding while the user is deafened, even if a PTT release owns the mute", async () => {
|
||||
const { setMuted } = await import("../../src/lib/livekitSession");
|
||||
const mockSetMuted = vi.mocked(setMuted);
|
||||
mockSetMuted.mockReset();
|
||||
mockSetMuted.mockImplementation((muted: boolean) => {
|
||||
mockLocalMuted = muted;
|
||||
});
|
||||
|
||||
mockCurrentChannelId = 7;
|
||||
testPrefs.set("pttVk", 0x20);
|
||||
|
||||
let capturedCallback: ((event: { payload: boolean }) => void) | null = null;
|
||||
mockListen.mockImplementation((_event: string, cb: (e: { payload: boolean }) => void) => {
|
||||
capturedCallback = cb;
|
||||
return Promise.resolve(() => {});
|
||||
});
|
||||
|
||||
await initPtt();
|
||||
|
||||
capturedCallback!({ payload: true });
|
||||
await vi.waitFor(() => expect(mockSetMuted).toHaveBeenCalledWith(false));
|
||||
capturedCallback!({ payload: false });
|
||||
await vi.waitFor(() => expect(mockLocalMuted).toBe(true));
|
||||
mockPttGated = true;
|
||||
mockLocalDeafened = true; // deafened independently of the PTT-owned mute
|
||||
|
||||
mockSetMuted.mockClear();
|
||||
await stopPtt();
|
||||
|
||||
expect(mockSetPttGated).toHaveBeenCalledWith(false);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(mockSetMuted).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: 'ptt-error' listener recovers from a backend thread panic
|
||||
// (B1_voice_mic-10)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ptt-error event listener", () => {
|
||||
beforeEach(resetAll);
|
||||
|
||||
it("registers a listener for 'ptt-error' and resets polling-live on a backend panic", async () => {
|
||||
testPrefs.set("pttVk", 0x20);
|
||||
|
||||
const capturedHandlers: Record<string, (e: { payload: unknown }) => void> = {};
|
||||
mockListen.mockImplementation((event: string, cb: (e: { payload: unknown }) => void) => {
|
||||
capturedHandlers[event] = cb;
|
||||
return Promise.resolve(() => {});
|
||||
});
|
||||
|
||||
await initPtt();
|
||||
mockSetPttPollingLive.mockClear();
|
||||
|
||||
expect(capturedHandlers["ptt-error"]).toBeTypeOf("function");
|
||||
capturedHandlers["ptt-error"]!({ payload: "PTT thread panicked" });
|
||||
|
||||
expect(mockSetPttPollingLive).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("re-opens a PTT-gated mic when the backend thread panics", async () => {
|
||||
testPrefs.set("pttVk", 0x20);
|
||||
const { setMuted } = await import("../../src/lib/livekitSession");
|
||||
const mockSetMuted = vi.mocked(setMuted);
|
||||
mockSetMuted.mockClear();
|
||||
|
||||
const capturedHandlers: Record<string, (e: { payload: unknown }) => void> = {};
|
||||
mockListen.mockImplementation((event: string, cb: (e: { payload: unknown }) => void) => {
|
||||
capturedHandlers[event] = cb;
|
||||
return Promise.resolve(() => {});
|
||||
});
|
||||
|
||||
await initPtt();
|
||||
mockPttGated = true;
|
||||
mockLocalMuted = false;
|
||||
mockLocalDeafened = false;
|
||||
|
||||
capturedHandlers["ptt-error"]!({ payload: "PTT thread panicked" });
|
||||
|
||||
expect(mockSetPttGated).toHaveBeenCalledWith(false);
|
||||
await vi.waitFor(() => {
|
||||
expect(mockSetMuted).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
// B1_voice_mic-11: a PTT release calls setMuted(true), which writes
|
||||
// localMuted for every caller (see pttOwnsMute's doc comment) — so at the
|
||||
// moment the poller dies, localMuted is true precisely because PTT's own
|
||||
// release put it there. ungateMic's old `!localMuted` guard treated that
|
||||
// indistinguishably from a user self-mute and never re-opened the mic.
|
||||
it("re-opens the mic when the polling thread panics after a PTT release applied the mute (bug fix)", async () => {
|
||||
testPrefs.set("pttVk", 0x20);
|
||||
const { setMuted } = await import("../../src/lib/livekitSession");
|
||||
const mockSetMuted = vi.mocked(setMuted);
|
||||
mockSetMuted.mockReset();
|
||||
mockSetMuted.mockImplementation((muted: boolean) => {
|
||||
mockLocalMuted = muted;
|
||||
});
|
||||
|
||||
mockCurrentChannelId = 7;
|
||||
let capturedCallback: ((event: { payload: boolean }) => void) | null = null;
|
||||
const capturedHandlers: Record<string, (e: { payload: unknown }) => void> = {};
|
||||
mockListen.mockImplementation((event: string, cb: (e: { payload: never }) => void) => {
|
||||
capturedHandlers[event] = cb as (e: { payload: unknown }) => void;
|
||||
if (event === "ptt-state")
|
||||
capturedCallback = cb as unknown as (e: { payload: boolean }) => void;
|
||||
return Promise.resolve(() => {});
|
||||
});
|
||||
|
||||
await initPtt();
|
||||
|
||||
// Press then release: the release is PTT's own mute — pttOwnsMute
|
||||
// latches true and the store's pttGated closes.
|
||||
capturedCallback!({ payload: true });
|
||||
await vi.waitFor(() => expect(mockSetMuted).toHaveBeenCalledWith(false));
|
||||
capturedCallback!({ payload: false });
|
||||
await vi.waitFor(() => expect(mockLocalMuted).toBe(true));
|
||||
mockPttGated = true; // mirror the gate the release set in the real store
|
||||
mockLocalDeafened = false;
|
||||
|
||||
mockSetMuted.mockClear();
|
||||
capturedHandlers["ptt-error"]!({ payload: "PTT thread panicked" });
|
||||
|
||||
expect(mockSetPttGated).toHaveBeenCalledWith(false);
|
||||
await vi.waitFor(() => {
|
||||
expect(mockSetMuted).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not lift a user's own self-mute when the polling thread panics", async () => {
|
||||
testPrefs.set("pttVk", 0x20);
|
||||
const { setMuted } = await import("../../src/lib/livekitSession");
|
||||
const mockSetMuted = vi.mocked(setMuted);
|
||||
mockSetMuted.mockClear();
|
||||
|
||||
const capturedHandlers: Record<string, (e: { payload: unknown }) => void> = {};
|
||||
mockListen.mockImplementation((event: string, cb: (e: { payload: unknown }) => void) => {
|
||||
capturedHandlers[event] = cb;
|
||||
return Promise.resolve(() => {});
|
||||
});
|
||||
|
||||
await initPtt();
|
||||
// The user self-muted via the widget — PTT was never pressed, so this
|
||||
// mute is not PTT's to lift.
|
||||
mockPttGated = true;
|
||||
mockLocalMuted = true;
|
||||
mockLocalDeafened = false;
|
||||
|
||||
capturedHandlers["ptt-error"]!({ payload: "PTT thread panicked" });
|
||||
|
||||
expect(mockSetPttGated).toHaveBeenCalledWith(false);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(mockSetMuted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not re-open the mic on a backend panic when the user is deafened, even if a PTT release owns the mute", async () => {
|
||||
testPrefs.set("pttVk", 0x20);
|
||||
const { setMuted } = await import("../../src/lib/livekitSession");
|
||||
const mockSetMuted = vi.mocked(setMuted);
|
||||
mockSetMuted.mockReset();
|
||||
mockSetMuted.mockImplementation((muted: boolean) => {
|
||||
mockLocalMuted = muted;
|
||||
});
|
||||
|
||||
mockCurrentChannelId = 7;
|
||||
let capturedCallback: ((event: { payload: boolean }) => void) | null = null;
|
||||
const capturedHandlers: Record<string, (e: { payload: unknown }) => void> = {};
|
||||
mockListen.mockImplementation((event: string, cb: (e: { payload: never }) => void) => {
|
||||
capturedHandlers[event] = cb as (e: { payload: unknown }) => void;
|
||||
if (event === "ptt-state")
|
||||
capturedCallback = cb as unknown as (e: { payload: boolean }) => void;
|
||||
return Promise.resolve(() => {});
|
||||
});
|
||||
|
||||
await initPtt();
|
||||
|
||||
capturedCallback!({ payload: true });
|
||||
await vi.waitFor(() => expect(mockSetMuted).toHaveBeenCalledWith(false));
|
||||
capturedCallback!({ payload: false });
|
||||
await vi.waitFor(() => expect(mockLocalMuted).toBe(true));
|
||||
mockPttGated = true;
|
||||
mockLocalDeafened = true; // deafened independently of the PTT-owned mute
|
||||
|
||||
mockSetMuted.mockClear();
|
||||
capturedHandlers["ptt-error"]!({ payload: "PTT thread panicked" });
|
||||
|
||||
expect(mockSetPttGated).toHaveBeenCalledWith(false);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(mockSetMuted).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,7 @@ const {
|
||||
getRemoteVideoStream,
|
||||
stopManualCameraTrack,
|
||||
stopManualScreenTracks,
|
||||
rollbackPendingVideo,
|
||||
} = await import("@lib/screenShare");
|
||||
|
||||
type VideoTrackDeps = Parameters<typeof enableCamera>[1];
|
||||
@@ -296,6 +297,33 @@ describe("enableCamera", () => {
|
||||
|
||||
expect(old.stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards the track and does not publish when disableCamera runs during device acquisition", async () => {
|
||||
const rig = fakeRoom();
|
||||
const deps = fakeDeps(rig.room);
|
||||
const track = fakeVideoTrack();
|
||||
let resolveTrack!: (t: LocalVideoTrack) => void;
|
||||
createLocalVideoTrack.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveTrack = resolve;
|
||||
}),
|
||||
);
|
||||
const state = { manualCameraTrack: null as LocalVideoTrack | null };
|
||||
|
||||
const enabling = enableCamera(state, deps);
|
||||
// A concurrent disable runs to completion while createLocalVideoTrack is
|
||||
// still awaiting the permission prompt / device handshake.
|
||||
await disableCamera(state, deps);
|
||||
resolveTrack(track);
|
||||
await enabling;
|
||||
|
||||
expect(rig.publishTrack).not.toHaveBeenCalled();
|
||||
expect(track.stop).toHaveBeenCalled();
|
||||
expect(state.manualCameraTrack).toBeNull();
|
||||
// disableCamera already set this false — the superseded enable must not
|
||||
// resurrect it.
|
||||
expect(voiceStore.getState().localCamera).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── disableCamera ──────────────────────────────────────────────────────────
|
||||
@@ -514,6 +542,55 @@ describe("enableScreenshare", () => {
|
||||
|
||||
expect(deps.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unpublishes a track that already published before a later one in the batch fails", async () => {
|
||||
// All quality presets request audio alongside video — a second publish
|
||||
// call (audio) can reject after the first (video) already succeeded.
|
||||
const rig = fakeRoom();
|
||||
const video = fakeVideoTrack();
|
||||
const audio = fakeAudioTrack();
|
||||
createLocalScreenTracks.mockResolvedValue([video, audio]);
|
||||
rig.publishTrack.mockImplementation((track: unknown) =>
|
||||
track === audio ? Promise.reject(new Error("publish failed")) : Promise.resolve(undefined),
|
||||
);
|
||||
const deps = fakeDeps(rig.room);
|
||||
const state = { manualScreenTracks: [] as LocalTrack[] };
|
||||
|
||||
await enableScreenshare(state, deps);
|
||||
|
||||
// track.stop() is programmatic and never fires the DOM "ended" event, so
|
||||
// LiveKit's ended-driven auto-unpublish never runs — without an explicit
|
||||
// unpublish the video track stays live in the room while nothing in this
|
||||
// client can find its publication to clean it up later.
|
||||
expect(rig.unpublishTrack).toHaveBeenCalledWith(video.mediaStreamTrack);
|
||||
expect(video.stop).toHaveBeenCalled();
|
||||
expect(audio.stop).toHaveBeenCalled();
|
||||
expect(state.manualScreenTracks).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards the tracks and does not publish when disableScreenshare runs during capture", async () => {
|
||||
const rig = fakeRoom();
|
||||
const deps = fakeDeps(rig.room);
|
||||
const video = fakeVideoTrack();
|
||||
let resolveTracks!: (t: LocalTrack[]) => void;
|
||||
createLocalScreenTracks.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveTracks = resolve;
|
||||
}),
|
||||
);
|
||||
const state = { manualScreenTracks: [] as LocalTrack[] };
|
||||
|
||||
const enabling = enableScreenshare(state, deps);
|
||||
// A concurrent disable runs to completion while the OS picker is still up.
|
||||
await disableScreenshare(state, deps);
|
||||
resolveTracks([video]);
|
||||
await enabling;
|
||||
|
||||
expect(rig.publishTrack).not.toHaveBeenCalled();
|
||||
expect(video.stop).toHaveBeenCalled();
|
||||
expect(state.manualScreenTracks).toEqual([]);
|
||||
expect(voiceStore.getState().localScreenshare).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── disableScreenshare ─────────────────────────────────────────────────────
|
||||
@@ -552,6 +629,58 @@ describe("disableScreenshare", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── rollbackPendingVideo ───────────────────────────────────────────────────
|
||||
//
|
||||
// dispatcher.ts's ERROR handler correlates a server refusal (FORBIDDEN,
|
||||
// RATE_LIMITED, INTERNAL, ...) of a voice_camera/voice_screenshare enable
|
||||
// with the already-published track via the envelope id ws.send() returns.
|
||||
|
||||
describe("rollbackPendingVideo", () => {
|
||||
it("registers a camera enable's envelope id and resolves it once", async () => {
|
||||
const rig = fakeRoom();
|
||||
createLocalVideoTrack.mockResolvedValue(fakeVideoTrack());
|
||||
const deps = fakeDeps(rig.room);
|
||||
deps.wsSend.mockReturnValue("req-camera-1");
|
||||
|
||||
await enableCamera({ manualCameraTrack: null }, deps);
|
||||
|
||||
expect(rollbackPendingVideo("req-camera-1")).toBe("camera");
|
||||
// Single-use: a second refusal echoing the same id must not roll back
|
||||
// whatever is live by then.
|
||||
expect(rollbackPendingVideo("req-camera-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("registers a screenshare enable's envelope id", async () => {
|
||||
const rig = fakeRoom();
|
||||
createLocalScreenTracks.mockResolvedValue([fakeVideoTrack()]);
|
||||
const deps = fakeDeps(rig.room);
|
||||
deps.wsSend.mockReturnValue("req-screen-1");
|
||||
|
||||
await enableScreenshare({ manualScreenTracks: [] }, deps);
|
||||
|
||||
expect(rollbackPendingVideo("req-screen-1")).toBe("screen");
|
||||
});
|
||||
|
||||
it("returns undefined for an id that was never registered", () => {
|
||||
expect(rollbackPendingVideo("never-seen")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("supersedes the previous camera enable's id when a new one starts", async () => {
|
||||
const rig = fakeRoom();
|
||||
createLocalVideoTrack.mockResolvedValue(fakeVideoTrack());
|
||||
const deps = fakeDeps(rig.room);
|
||||
deps.wsSend.mockReturnValueOnce("req-1").mockReturnValueOnce("req-2");
|
||||
const state = { manualCameraTrack: null as LocalVideoTrack | null };
|
||||
|
||||
await enableCamera(state, deps);
|
||||
await enableCamera(state, deps);
|
||||
|
||||
// Only the latest in-flight camera enable can still be refused.
|
||||
expect(rollbackPendingVideo("req-1")).toBeUndefined();
|
||||
expect(rollbackPendingVideo("req-2")).toBe("camera");
|
||||
});
|
||||
});
|
||||
|
||||
// ── stream getters ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("stream getters", () => {
|
||||
|
||||
@@ -165,6 +165,8 @@ import { createVoiceWidget } from "@components/VoiceWidget";
|
||||
import { createCreateChannelModal } from "@components/CreateChannelModal";
|
||||
import { createEditChannelModal } from "@components/EditChannelModal";
|
||||
import { createDeleteChannelModal } from "@components/DeleteChannelModal";
|
||||
import { createQuickSwitchOverlay } from "@components/QuickSwitchOverlay";
|
||||
import { createProfileManager } from "@lib/profiles";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers to access mock internals
|
||||
@@ -1456,6 +1458,31 @@ describe("SidebarArea", () => {
|
||||
cleanup(result);
|
||||
});
|
||||
|
||||
it("tracks the rename-group prompt so page teardown removes it (every other modal in this file does)", () => {
|
||||
const dm = makeDm({ channelId: 100, isGroup: true, name: "Old Name" });
|
||||
addDmChannel(dm);
|
||||
|
||||
uiStore.setState((prev) => ({ ...prev, sidebarMode: "dms" }));
|
||||
|
||||
const result = createSidebarArea(defaultOpts());
|
||||
container.appendChild(result.sidebarWrapper);
|
||||
|
||||
const dmSidebarCalls = (createDmSidebar as MockedFn).mock.calls;
|
||||
const lastCall = dmSidebarCalls[dmSidebarCalls.length - 1]![0];
|
||||
lastCall.onRenameGroup(100);
|
||||
|
||||
// createPromptModal is the real (unmocked) factory — it mounts to
|
||||
// document.body, outside `root`, so it survives every teardown path
|
||||
// that only removes `root` (a 401/kick/shutdown logout while the
|
||||
// prompt is open) unless SidebarArea tracks and destroys it itself,
|
||||
// same as every sibling modal in this file.
|
||||
expect(document.querySelector(".modal-prompt")).not.toBeNull();
|
||||
|
||||
cleanup(result);
|
||||
|
||||
expect(document.querySelector(".modal-prompt")).toBeNull();
|
||||
});
|
||||
|
||||
it("onCloseDm goes back to channels when last DM is closed", () => {
|
||||
addDmChannel(
|
||||
makeDm({
|
||||
@@ -2007,6 +2034,48 @@ describe("SidebarArea", () => {
|
||||
|
||||
cleanup(result);
|
||||
});
|
||||
|
||||
it("does not mount a second overlay from a double-click while profiles are still loading", async () => {
|
||||
// `quickSwitchInstance` is only assigned after the loadProfiles()
|
||||
// await — a synchronous second call in that window must not slip past
|
||||
// the guard and mount a second, orphaned overlay (the same class of bug
|
||||
// already fixed with an `opening` flag on the sibling overlay
|
||||
// controllers in OverlayManagers.ts).
|
||||
let resolveLoad!: () => void;
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
resolveLoad = resolve;
|
||||
});
|
||||
(createProfileManager as MockedFn).mockReturnValueOnce({
|
||||
loadProfiles: vi.fn().mockReturnValue(pending),
|
||||
getAll: vi.fn().mockReturnValue([]),
|
||||
store: { getState: () => ({ profiles: [], healthStatuses: new Map() }) },
|
||||
});
|
||||
|
||||
// createQuickSwitchOverlay's mock call count is not reset between
|
||||
// tests in this file (resetMocks() doesn't touch it), so compare
|
||||
// against a baseline rather than an absolute count.
|
||||
const callsBefore = (createQuickSwitchOverlay as MockedFn).mock.calls.length;
|
||||
|
||||
const result = createSidebarArea(defaultOpts());
|
||||
container.appendChild(result.sidebarWrapper);
|
||||
|
||||
result.openQuickSwitch();
|
||||
result.openQuickSwitch();
|
||||
|
||||
resolveLoad();
|
||||
// Await the exact promise the source code awaits: its resolution
|
||||
// queues each caller's continuation in registration order, and each
|
||||
// continuation runs to completion (no further await inside) before the
|
||||
// next is dequeued — so once this settles, any second overlay a buggy
|
||||
// double-open would have mounted already exists. vi.waitFor's polling
|
||||
// cannot be trusted here: it can observe the count between the two
|
||||
// continuations and pass before the second one ever runs.
|
||||
await pending;
|
||||
|
||||
expect((createQuickSwitchOverlay as MockedFn).mock.calls.length).toBe(callsBefore + 1);
|
||||
|
||||
cleanup(result);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// Read from disk rather than `import ... ?raw`: vitest stubs CSS modules
|
||||
// (its `css: false` default), which wins over the `?raw` suffix and yields an
|
||||
// empty string. A .ts source can use `?raw`; a stylesheet cannot.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { authStore } from "@stores/auth.store";
|
||||
import { uiStore, setConnectionStatus } from "@stores/ui.store";
|
||||
@@ -185,4 +190,36 @@ describe("StatusPicker wired to UserBar", () => {
|
||||
const wrap = container.querySelector("[data-testid='status-picker-wrap']") as HTMLElement;
|
||||
expect(wrap.classList.contains("ub-status-picker--disabled")).toBe(true);
|
||||
});
|
||||
|
||||
// jsdom never applies app.css, so a computed-style assertion here would
|
||||
// pass whether or not the rules exist. Instead this pins the CSS *source*
|
||||
// to the classes StatusPicker.ts actually emits, so a future edit that
|
||||
// renames/deletes one side without the other goes red immediately (this
|
||||
// is exactly how the trigger dot went invisible: the rules were deleted
|
||||
// but the component still emitted the old names).
|
||||
it("every class StatusPicker.ts emits has a rule in app.css, and both dots have an explicit size", () => {
|
||||
const css = readFileSync(join(process.cwd(), "src/styles/app.css"), "utf8");
|
||||
|
||||
const ruleBody = (selector: string): string => {
|
||||
const match = new RegExp(`\\.${selector}\\s*\\{([^}]*)\\}`).exec(css);
|
||||
expect(match, `expected a \`.${selector} { ... }\` rule in app.css`).not.toBeNull();
|
||||
return match![1]!;
|
||||
};
|
||||
|
||||
ruleBody("status-picker-option");
|
||||
ruleBody("status-picker-option-label");
|
||||
ruleBody("status-picker-option-check");
|
||||
|
||||
// The dot and option-dot are bare elements whose only inline style is
|
||||
// `background` (StatusPicker.ts) -- without an explicit size in CSS
|
||||
// they collapse to 0x0 and are invisible/unclickable.
|
||||
for (const dotSelector of ["status-picker-dot", "status-picker-option-dot"]) {
|
||||
const body = ruleBody(dotSelector);
|
||||
expect(body, `${dotSelector} needs an explicit width`).toMatch(/width\s*:/);
|
||||
expect(body, `${dotSelector} needs an explicit height`).toMatch(/height\s*:/);
|
||||
expect(body, `${dotSelector} needs a border-radius to render as a dot`).toMatch(
|
||||
/border-radius\s*:/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,11 +60,12 @@ describe("createUpdateNotifier download progress", () => {
|
||||
host.remove();
|
||||
});
|
||||
|
||||
async function mountWithAvailableUpdate(): Promise<void> {
|
||||
async function mountWithAvailableUpdate(): Promise<ReturnType<typeof createUpdateNotifier>> {
|
||||
mockCheckForUpdate.mockResolvedValue({ available: true, version: "1.2.0", body: "" });
|
||||
const notifier = createUpdateNotifier({ serverUrl: "https://s.example" });
|
||||
notifier.mount(host);
|
||||
await vi.advanceTimersByTimeAsync(3000); // fire the delayed check + resolve
|
||||
return notifier;
|
||||
}
|
||||
|
||||
function bannerText(): string | null | undefined {
|
||||
@@ -104,4 +105,34 @@ describe("createUpdateNotifier download progress", () => {
|
||||
|
||||
expect(bannerText()).toBe("Update failed. Please try again later.");
|
||||
});
|
||||
|
||||
it("does not throw (unhandled rejection) when destroyed mid-download and the download later fails", async () => {
|
||||
let rejectDownload: (err: Error) => void = () => {};
|
||||
mockDownloadAndInstall.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
rejectDownload = reject;
|
||||
}),
|
||||
);
|
||||
|
||||
const notifier = await mountWithAvailableUpdate();
|
||||
(host.querySelector(".update-banner-install") as HTMLButtonElement).click();
|
||||
|
||||
// Page swap / logout tears the component down while the download is
|
||||
// still in flight -- the banner element is now null.
|
||||
notifier.destroy?.();
|
||||
|
||||
// Real timers so the unhandledRejection check (a macrotask under Node)
|
||||
// can actually run before the assertion. No @types/node in this project
|
||||
// (tsconfig has no "node" lib), so reach the global the same untyped way
|
||||
// other suites reach a browser-only global jsdom doesn't type either
|
||||
// (see audio-pipeline-vad-worklet.test.ts's globalThis casts).
|
||||
vi.useRealTimers();
|
||||
const unhandled = vi.fn();
|
||||
(globalThis as any).process.once("unhandledRejection", unhandled);
|
||||
rejectDownload(new Error("boom"));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(unhandled).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ const mockSetScreenshareAudioVolume = vi.fn();
|
||||
const mockSetUserVolume = vi.fn();
|
||||
const mockGetScreenshareAudioMuted = vi.fn((_userId?: unknown) => false);
|
||||
const mockGetScreenshareAudioVolume = vi.fn((_userId?: unknown) => 1);
|
||||
const mockGetUserVolume = vi.fn((_userId?: unknown) => 100);
|
||||
|
||||
vi.mock("@lib/livekitSession", () => ({
|
||||
muteScreenshareAudio: (...args: unknown[]) => mockMuteScreenshareAudio(...args),
|
||||
@@ -16,6 +17,7 @@ vi.mock("@lib/livekitSession", () => ({
|
||||
setUserVolume: (...args: unknown[]) => mockSetUserVolume(...args),
|
||||
getScreenshareAudioMuted: (userId: unknown) => mockGetScreenshareAudioMuted(userId),
|
||||
getScreenshareAudioVolume: (userId: unknown) => mockGetScreenshareAudioVolume(userId),
|
||||
getUserVolume: (userId: unknown) => mockGetUserVolume(userId),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -454,6 +456,32 @@ describe("VideoGrid", () => {
|
||||
expect(mockSetUserVolume).toHaveBeenCalledWith(77, 50);
|
||||
});
|
||||
|
||||
it("[B3-5] seeds the mic-tile slider from the persisted per-user volume, not a hardcoded 100%", () => {
|
||||
mockGetUserVolume.mockReturnValueOnce(30);
|
||||
const config = makeTileConfig({ isSelf: false, audioUserId: 88, isScreenshare: false });
|
||||
grid.addStream(88, "erin", fakeStream(), config);
|
||||
|
||||
expect(mockGetUserVolume).toHaveBeenCalledWith(88);
|
||||
const slider = container.querySelector(".tile-volume-slider") as HTMLInputElement;
|
||||
expect(slider.value).toBe("30");
|
||||
// Not muted at 30% — the mute button must reflect the real (unmuted) state.
|
||||
const muteBtn = container.querySelector(".tile-mute-btn") as HTMLButtonElement;
|
||||
expect(muteBtn.getAttribute("aria-label")).toBe("Mute");
|
||||
});
|
||||
|
||||
it("[B3-5] starts a mic tile muted when the persisted per-user volume is 0", () => {
|
||||
mockGetUserVolume.mockReturnValueOnce(0);
|
||||
const config = makeTileConfig({ isSelf: false, audioUserId: 89, isScreenshare: false });
|
||||
grid.addStream(89, "frank", fakeStream(), config);
|
||||
|
||||
const slider = container.querySelector(".tile-volume-slider") as HTMLInputElement;
|
||||
expect(slider.value).toBe("0");
|
||||
const muteBtn = container.querySelector(".tile-mute-btn") as HTMLButtonElement;
|
||||
expect(muteBtn.getAttribute("aria-label")).toBe("Unmute");
|
||||
const overlay = container.querySelector(".video-tile-overlay");
|
||||
expect(overlay!.classList.contains("muted")).toBe(true);
|
||||
});
|
||||
|
||||
it("volume slider at 0 triggers mute icon swap and calls setUserVolume(0)", () => {
|
||||
const config = makeTileConfig({ isSelf: false, audioUserId: 77, isScreenshare: false });
|
||||
grid.addStream(77, "dave", fakeStream(), config);
|
||||
@@ -797,4 +825,45 @@ describe("VideoGrid", () => {
|
||||
expect(mainArea!.querySelector('[data-user-id="1"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// clearStreams (B1-8: stale remote tiles survive join -> leave -> join)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("clearStreams", () => {
|
||||
it("removes every tile", () => {
|
||||
grid.addStream(1, "Alice", fakeStream());
|
||||
grid.addStream(2, "Bob", fakeStream());
|
||||
expect(grid.hasStreams()).toBe(true);
|
||||
|
||||
grid.clearStreams();
|
||||
|
||||
expect(grid.hasStreams()).toBe(false);
|
||||
expect(container.querySelectorAll(".video-cell").length).toBe(0);
|
||||
});
|
||||
|
||||
it("clears focus state along with the tiles", () => {
|
||||
grid.addStream(1, "Alice", fakeStream());
|
||||
grid.setFocusedTile(1);
|
||||
expect(grid.getFocusedTileId()).toBe(1);
|
||||
|
||||
grid.clearStreams();
|
||||
|
||||
expect(grid.getFocusedTileId()).toBeNull();
|
||||
});
|
||||
|
||||
it("cleans up track listeners for every cell", () => {
|
||||
const { stream, track } = fakeStreamWithTrack();
|
||||
grid.addStream(1, "Alice", stream);
|
||||
|
||||
grid.clearStreams();
|
||||
|
||||
expect(track.listeners["ended"]?.length ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it("is a no-op on an empty grid", () => {
|
||||
expect(() => grid.clearStreams()).not.toThrow();
|
||||
expect(grid.hasStreams()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,7 @@ function makeVideoGrid(): VideoModeControllerOptions["videoGrid"] {
|
||||
destroy: vi.fn(),
|
||||
addStream: vi.fn(),
|
||||
removeStream: vi.fn(),
|
||||
clearStreams: vi.fn(),
|
||||
hasStreams: vi.fn(() => false),
|
||||
setFocusedTile: vi.fn(),
|
||||
getFocusedTileId: vi.fn(() => null),
|
||||
@@ -624,4 +625,80 @@ describe("createVideoModeController", () => {
|
||||
expect(ctrl.isVideoMode()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Stale remote tile / focus cleanup on close (B1-8, B5-15)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("grid cleanup on close", () => {
|
||||
it("clears videoGrid streams on a real leave (currentChannelId becomes null)", () => {
|
||||
const users = new Map([
|
||||
[1, { userId: 1, camera: false, screenshare: false, username: "me" }],
|
||||
]);
|
||||
const inChannel = makeVoiceState({
|
||||
currentChannelId: 10,
|
||||
localCamera: true,
|
||||
voiceUsers: new Map([[10, users]]),
|
||||
});
|
||||
mockVoiceStoreGetState.mockReturnValue(inChannel);
|
||||
|
||||
const vg = makeVideoGrid();
|
||||
const ctrl = createVideoModeController({
|
||||
slots: makeSlots(),
|
||||
videoGrid: vg,
|
||||
getCurrentUserId: () => 1,
|
||||
});
|
||||
|
||||
ctrl.checkVideoMode();
|
||||
expect(vg.clearStreams).not.toHaveBeenCalled();
|
||||
|
||||
// Leaving voice clears currentChannelId — remote tiles from the ended
|
||||
// session must not survive into the next join.
|
||||
mockVoiceStoreGetState.mockReturnValue({ ...inChannel, currentChannelId: null });
|
||||
ctrl.checkVideoMode();
|
||||
|
||||
expect(vg.clearStreams).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not clear videoGrid streams while merely stopping local video mid-session", () => {
|
||||
// Auto-reconnect keeps currentChannelId set, so a transient no-video
|
||||
// state (all cameras off, still in the channel) must not wipe
|
||||
// in-flight remote tiles.
|
||||
const users = new Map([
|
||||
[1, { userId: 1, camera: false, screenshare: false, username: "me" }],
|
||||
]);
|
||||
mockVoiceStoreGetState.mockReturnValue(
|
||||
makeVoiceState({ currentChannelId: 10, voiceUsers: new Map([[10, users]]) }),
|
||||
);
|
||||
|
||||
const vg = makeVideoGrid();
|
||||
const ctrl = createVideoModeController({
|
||||
slots: makeSlots(),
|
||||
videoGrid: vg,
|
||||
getCurrentUserId: () => 1,
|
||||
});
|
||||
|
||||
ctrl.checkVideoMode();
|
||||
|
||||
expect(vg.clearStreams).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closeVideoGrid clears the videoGrid's own focus state, not just the controller's", () => {
|
||||
const vg = makeVideoGrid();
|
||||
const ctrl = createVideoModeController({
|
||||
slots: makeSlots(),
|
||||
videoGrid: vg,
|
||||
getCurrentUserId: () => 1,
|
||||
});
|
||||
ctrl.showVideoGrid();
|
||||
ctrl.setFocus(42);
|
||||
expect(vg.setFocusedTile).toHaveBeenCalledWith(42);
|
||||
|
||||
ctrl.showChat();
|
||||
|
||||
// Without this, the grid reopens later still pinned in focus mode on
|
||||
// tile 42 even though the controller's own focusedTileId was reset.
|
||||
expect(vg.setFocusedTile).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -186,6 +186,24 @@ describe("dismissal", () => {
|
||||
expect(menuEl()).toBeNull();
|
||||
});
|
||||
|
||||
it("[B3-4] ties the parent-signal abort bridge to the menu's own dismiss signal, so it does not outlive a dismissed menu", () => {
|
||||
// Without a { signal } option, this bridge listener (and the closure
|
||||
// retaining a detached .user-vol-menu subtree) survives every future
|
||||
// right-click for the parent's entire lifetime — the outside-click and
|
||||
// replace-on-reopen dismiss paths remove the menu but cannot remove this
|
||||
// listener, since it is registered directly on the caller's long-lived
|
||||
// signal. Mirrors context-menu.ts's `{ signal: menuAc.signal }` pattern.
|
||||
const parentAc = new AbortController();
|
||||
const addSpy = vi.spyOn(parentAc.signal, "addEventListener");
|
||||
|
||||
showUserVolumeMenu(7, "alice", 0, 0, parentAc.signal);
|
||||
|
||||
expect(addSpy).toHaveBeenCalledTimes(1);
|
||||
const [eventName, , options] = addSpy.mock.calls[0]!;
|
||||
expect(eventName).toBe("abort");
|
||||
expect(options).toEqual(expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
|
||||
it("does not re-attach the dismiss listener when aborted before the timer fires", () => {
|
||||
const ac = new AbortController();
|
||||
showUserVolumeMenu(7, "alice", 0, 0, ac.signal);
|
||||
|
||||
@@ -109,6 +109,47 @@ describe("cert mismatch blocking", () => {
|
||||
expect(reconnectCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("blocks reconnect when the profile host's case differs from the Rust proxy's lowercased event host", async () => {
|
||||
// Regression: config.host is stored as the user typed it (e.g.
|
||||
// "Example.COM:8443" from a profile), but tofu::cert_store_key
|
||||
// case-folds to lowercase before the proxy emits the cert-tofu event.
|
||||
// An un-normalized (case-sensitive) comparison here would miss the
|
||||
// match and never latch this connection's reconnect loop.
|
||||
client.connect({ host: "Example.COM:8443", token: "t" });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
emitTauriEvent("ws-state", "open");
|
||||
|
||||
emitTauriEvent(
|
||||
"ws-message",
|
||||
JSON.stringify({
|
||||
type: "auth_ok",
|
||||
seq: 1,
|
||||
payload: {
|
||||
user: { id: 1, username: "a", avatar: null, role: "admin" },
|
||||
server_name: "S",
|
||||
motd: "",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Rust-normalized event host — lowercase.
|
||||
emitTauriEvent("cert-tofu", {
|
||||
host: "example.com:8443",
|
||||
fingerprint: "sha256:NEW",
|
||||
status: "mismatch",
|
||||
message: "Stored: sha256:OLD",
|
||||
});
|
||||
|
||||
expect(client.getState()).toBe("disconnected");
|
||||
|
||||
emitTauriEvent("ws-state", "closed");
|
||||
|
||||
mockInvoke.mockClear();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
const reconnectCalls = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect");
|
||||
expect(reconnectCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should unblock after acceptCertFingerprint", async () => {
|
||||
client.connect({ host: "localhost:8443", token: "t" });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
@@ -430,6 +471,32 @@ describe("parseStoredFingerprint", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeHostForCertCompare", () => {
|
||||
// Import the pure function directly — it must be exported for main.ts to
|
||||
// share it (see below) instead of keeping its own duplicate.
|
||||
let normalizeHostForCertCompare: typeof import("../../src/lib/ws").normalizeHostForCertCompare;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mod = await import("../../src/lib/ws");
|
||||
normalizeHostForCertCompare = mod.normalizeHostForCertCompare;
|
||||
});
|
||||
|
||||
it("lowercases so a mixed-case saved host matches the Rust proxy's lowercased event host", () => {
|
||||
// This is exactly the guard main.ts's onCertMismatch/onCertFirstUse
|
||||
// handlers evaluate as `evt.host === normalizeHostForCertCompare(lastConnectHost)`
|
||||
// before resuming a connect (accept) or tearing down the live session
|
||||
// (reject). lastConnectHost is stored verbatim from the profile (e.g.
|
||||
// typed as "Example.COM:443"); evt.host arrives from tofu::cert_store_key
|
||||
// (src-tauri/src/tofu.rs), which always lowercases. Without lowercasing
|
||||
// here too, that guard silently fails for any uppercase host — worst
|
||||
// case, onReject's session teardown never fires and the user stays
|
||||
// connected to a server whose changed certificate they just rejected.
|
||||
const evtHost = "example.com";
|
||||
const lastConnectHost = "Example.COM:443";
|
||||
expect(evtHost === normalizeHostForCertCompare(lastConnectHost)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cert-tofu non-mismatch statuses", () => {
|
||||
let client: ReturnType<typeof createWsClient>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user