Compare commits

..

1 Commits

Author SHA1 Message Date
userAdityaa 4c737b26c0 fix: deleted rooms persisting in navigation 2026-05-29 12:05:16 +05:30
14 changed files with 2459 additions and 2335 deletions
+23 -12
View File
@@ -22,7 +22,6 @@
"@sveltejs/vite-plugin-svelte": "^5.1.1", "@sveltejs/vite-plugin-svelte": "^5.1.1",
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.2",
"@types/eslint": "^9.6.1", "@types/eslint": "^9.6.1",
"@types/node": "^25.9.1",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"eslint": "^9.39.2", "eslint": "^9.39.2",
@@ -69,16 +68,16 @@
"@types/throttle-debounce": "^5.0.2", "@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",
"@vite-pwa/sveltekit": "^1.1.0", "@vite-pwa/sveltekit": "^1.1.0",
"@welshman/app": "^0.8.16", "@welshman/app": "^0.8.15",
"@welshman/content": "^0.8.16", "@welshman/content": "^0.8.15",
"@welshman/editor": "^0.8.16", "@welshman/editor": "^0.8.15",
"@welshman/feeds": "^0.8.16", "@welshman/feeds": "^0.8.15",
"@welshman/lib": "^0.8.16", "@welshman/lib": "^0.8.15",
"@welshman/net": "^0.8.16", "@welshman/net": "^0.8.15",
"@welshman/router": "^0.8.16", "@welshman/router": "^0.8.15",
"@welshman/signer": "^0.8.16", "@welshman/signer": "^0.8.15",
"@welshman/store": "^0.8.16", "@welshman/store": "^0.8.15",
"@welshman/util": "^0.8.16", "@welshman/util": "^0.8.15",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"compressorjs-next": "^1.1.2", "compressorjs-next": "^1.1.2",
"daisyui": "^5.5.19", "daisyui": "^5.5.19",
@@ -99,5 +98,17 @@
"throttle-debounce": "^5.0.2", "throttle-debounce": "^5.0.2",
"tippy.js": "^6.3.7" "tippy.js": "^6.3.7"
}, },
"packageManager": "pnpm@11.4.0" "pnpm": {
"ignoredBuiltDependencies": [
"esbuild"
],
"onlyBuiltDependencies": [
"sharp",
"nostr-signer-capacitor-plugin"
],
"overrides": {
"sharp": "0.35.0-rc.0"
}
},
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
} }
+2406 -2152
View File
File diff suppressed because it is too large Load Diff
-18
View File
@@ -1,18 +0,0 @@
allowBuilds:
nostr-signer-capacitor-plugin: true
cbor-extract: false
esbuild: false
sharp: true
minimumReleaseAgeExclude:
- '@welshman/app@0.8.16'
- '@welshman/content@0.8.16'
- '@welshman/editor@0.8.16'
- '@welshman/feeds@0.8.16'
- '@welshman/lib@0.8.16'
- '@welshman/net@0.8.16'
- '@welshman/router@0.8.16'
- '@welshman/signer@0.8.16'
- '@welshman/store@0.8.16'
- '@welshman/util@0.8.16'
overrides:
sharp: 0.35.0-rc.0
+17 -94
View File
@@ -20,7 +20,7 @@ import type {TrustedEvent} from "@welshman/util"
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util" import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
import {signer} from "@welshman/app" import {signer} from "@welshman/app"
import {getLivekitEndpoint} from "$lib/livekit" import {getLivekitEndpoint} from "$lib/livekit"
import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util" import {AbortError, whenAborted, whenTimeout} from "$lib/util"
import { import {
currentVoiceRoom, currentVoiceRoom,
currentVoiceSession, currentVoiceSession,
@@ -157,8 +157,6 @@ const setUpMicrophone = async (
startMuted: boolean, startMuted: boolean,
preferredMicId: string | undefined, preferredMicId: string | undefined,
participant: LocalParticipant, participant: LocalParticipant,
signal?: AbortSignal,
settleSignal?: AbortSignal,
): Promise<boolean> => { ): Promise<boolean> => {
if (startMuted) { if (startMuted) {
return true return true
@@ -170,34 +168,15 @@ const setUpMicrophone = async (
capture = {deviceId: preferredMicId} capture = {deviceId: preferredMicId}
} }
try { try {
await Promise.race([ await participant.setMicrophoneEnabled(true, capture)
participant.setMicrophoneEnabled(true, capture),
whenTimeout(15_000, {message: "Microphone access timed out.", signal: settleSignal}),
whenAborted(signal),
])
muted = false muted = false
} catch (e) { } catch (e) {
// Timeout or microphone rejection: join muted, the call is still usable. A pushToast({theme: "error", message: "Could not access microphone"})
// genuine abort is surfaced to the caller so it can tear down the room.
if (e instanceof AbortError) throw e
if (!(e instanceof TimeoutError)) {
pushToast({theme: "error", message: "Could not access microphone"})
}
} }
return muted return muted
} }
// The room whose events are allowed to mutate shared state. Abandoned rooms const onRoomDisconnected = (reason?: DisconnectReason) => {
// (after switching calls or an engine reconnect give-up) must not clobber it.
let activeRoom: LiveKitRoom | undefined
const makeOnRoomDisconnected = (room: LiveKitRoom) => (reason?: DisconnectReason) => {
// Ignore disconnects from rooms that are no longer the active session.
if (room !== activeRoom) return
activeRoom = undefined
room.removeAllListeners()
videoPrimaryTileKey.set(undefined) videoPrimaryTileKey.set(undefined)
voiceMicMuted.set(true) voiceMicMuted.set(true)
currentVoiceSession.set(undefined) currentVoiceSession.set(undefined)
@@ -275,6 +254,9 @@ export const joinVoiceRoom = async (
): Promise<void> => { ): Promise<void> => {
cancelJoinVoiceRoom() cancelJoinVoiceRoom()
const session = get(currentVoiceSession)
if (session) await leaveVoiceRoom()
currentVoiceRoom.set(get(deriveRoom(url, h))) currentVoiceRoom.set(get(deriveRoom(url, h)))
voiceState.set(VoiceState.Joining) voiceState.set(VoiceState.Joining)
@@ -283,43 +265,14 @@ export const joinVoiceRoom = async (
const signal = controller.signal const signal = controller.signal
const isActive = () => joinAbortController === controller const isActive = () => joinAbortController === controller
// Self-cleaning controller: aborted in finally so whenTimeout/whenAborted
// helpers clear their timers/listeners once the races below have settled.
const settle = new AbortController()
try { try {
// Tear down any existing session before joining. Bound it so a slow leave const {server_url, participant_token} = await fetchLivekitToken(url, h, signal)
// (camera/screenshare renegotiation can take ~15s) cannot block this join.
if (get(currentVoiceSession)) {
await Promise.race([
leaveVoiceRoom(),
whenTimeout(15_000, {message: "Leaving previous call timed out.", signal: settle.signal}),
whenAborted(signal),
]).catch(e => {
if (e instanceof AbortError) throw e
})
// leaveVoiceRoom flips voiceState to Disconnected; re-assert Joining.
voiceState.set(VoiceState.Joining)
}
if (signal.aborted) throw new AbortError()
const {server_url, participant_token} = await Promise.race([
fetchLivekitToken(url, h, signal),
whenTimeout(15_000, {
message: "Connection timed out. Please check your network and try again.",
signal: settle.signal,
}),
whenAborted(signal),
])
if (signal.aborted) throw new AbortError() if (signal.aborted) throw new AbortError()
const liveKitRoom = new LiveKitRoom({adaptiveStream: true, dynacast: true}) const liveKitRoom = new LiveKitRoom({adaptiveStream: true, dynacast: true})
activeRoom = liveKitRoom
liveKitRoom.on(RoomEvent.Disconnected, makeOnRoomDisconnected(liveKitRoom)) liveKitRoom.on(RoomEvent.Disconnected, onRoomDisconnected)
liveKitRoom.on(RoomEvent.ParticipantConnected, onParticipantConnected) liveKitRoom.on(RoomEvent.ParticipantConnected, onParticipantConnected)
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected) liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed) liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
@@ -337,13 +290,10 @@ export const joinVoiceRoom = async (
liveKitRoom.connect(server_url, participant_token, {maxRetries: 0}), liveKitRoom.connect(server_url, participant_token, {maxRetries: 0}),
whenTimeout(15_000, { whenTimeout(15_000, {
message: "Connection timed out. Please check your network and try again.", message: "Connection timed out. Please check your network and try again.",
signal: settle.signal,
}), }),
whenAborted(signal), whenAborted(signal),
]) ])
} catch (e) { } catch (e) {
if (activeRoom === liveKitRoom) activeRoom = undefined
liveKitRoom.removeAllListeners()
liveKitRoom.disconnect() liveKitRoom.disconnect()
throw e throw e
} }
@@ -354,24 +304,7 @@ export const joinVoiceRoom = async (
syncParticipantMedia(p) syncParticipantMedia(p)
} }
// Bounded against timeout/abort inside setUpMicrophone: a stuck permission const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
// prompt resolves to muted rather than hanging the join forever.
const muted = await setUpMicrophone(
startMuted,
preferredMicId,
liveKitRoom.localParticipant,
signal,
settle.signal,
)
// A cancel during the mic step must tear down the connected room rather
// than leaking it.
if (signal.aborted) {
if (activeRoom === liveKitRoom) activeRoom = undefined
liveKitRoom.removeAllListeners()
liveKitRoom.disconnect()
throw new AbortError()
}
voiceMicMuted.set(muted) voiceMicMuted.set(muted)
currentVoiceSession.set({ currentVoiceSession.set({
@@ -388,7 +321,6 @@ export const joinVoiceRoom = async (
if (e instanceof AbortError) return if (e instanceof AbortError) return
throw e throw e
} finally { } finally {
settle.abort()
if (isActive()) joinAbortController = undefined if (isActive()) joinAbortController = undefined
} }
} }
@@ -416,23 +348,14 @@ export const leaveVoiceRoom = async () => {
} }
} }
// Always tear down this room's connection and listeners. voiceState.set(VoiceState.Disconnected)
if (activeRoom === session.room) activeRoom = undefined videoPrimaryTileKey.set(undefined)
session.room.removeAllListeners() voiceMicMuted.set(true)
currentVoiceSession.set(undefined)
resetVideoCallLayout()
session.room.disconnect() session.room.disconnect()
speakingParticipants.set([])
// Only reset shared UI state if this session is still current. A slow leave participantMediaState.set(new Map())
// that was superseded by a new join (bounded by a timeout in joinVoiceRoom)
// must not clobber the freshly-joined session when it finally completes.
if (get(currentVoiceSession) === session) {
voiceState.set(VoiceState.Disconnected)
videoPrimaryTileKey.set(undefined)
voiceMicMuted.set(true)
currentVoiceSession.set(undefined)
resetVideoCallLayout()
speakingParticipants.set([])
participantMediaState.set(new Map())
}
} }
export const rejoinVoiceRoom = async (): Promise<void> => { export const rejoinVoiceRoom = async (): Promise<void> => {
+1 -1
View File
@@ -23,8 +23,8 @@
import SpaceJoinSettings from "@app/components/SpaceJoinSettings.svelte" import SpaceJoinSettings from "@app/components/SpaceJoinSettings.svelte"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {makeSpacePath} from "@app/util/routes" import {makeSpacePath} from "@app/util/routes"
import {Push} from "@app/util/notifications"
import {relaysMostlyRestricted, notificationSettings, parseInviteLink} from "@app/core/state" import {relaysMostlyRestricted, notificationSettings, parseInviteLink} from "@app/core/state"
import {Push} from "@app/util/push"
import { import {
attemptRelayAccess, attemptRelayAccess,
addSpaceMembership, addSpaceMembership,
+1 -1
View File
@@ -24,7 +24,7 @@
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {makeSpacePath} from "@app/util/routes" import {makeSpacePath} from "@app/util/routes"
import {Push} from "@app/util/push" import {Push} from "@app/util/notifications"
type Props = { type Props = {
url: string url: string
+1 -1
View File
@@ -418,7 +418,7 @@ export const device = withGetter(writable(randomId()))
export const notificationSettings = withGetter( export const notificationSettings = withGetter(
writable({ writable({
push: false, push: false,
sound: true, sound: false,
badge: false, badge: false,
spaces: true, spaces: true,
mentions: true, mentions: true,
+1 -1
View File
@@ -1,6 +1,6 @@
import {db, kv, ss} from "@app/core/storage" import {db, kv, ss} from "@app/core/storage"
import {Push} from "@app/util/notifications"
import {deactivateCurrentPomadeSession} from "@app/util/pomade" import {deactivateCurrentPomadeSession} from "@app/util/pomade"
import {Push} from "@app/util/push"
export const logout = async () => { export const logout = async () => {
await deactivateCurrentPomadeSession() await deactivateCurrentPomadeSession()
+1
View File
@@ -18,6 +18,7 @@ import {
} from "@app/core/state" } from "@app/core/state"
import {kv} from "@app/core/storage" import {kv} from "@app/core/storage"
import {page} from "$app/stores" import {page} from "$app/stores"
export {Push} from "@app/util/push"
// Checked state // Checked state
+1 -4
View File
@@ -44,10 +44,7 @@ export const createScroller = ({
: element.closest(".scroll-container") : element.closest(".scroll-container")
const check = async () => { const check = async () => {
const isHidden = (el: Element) => if (container) {
(el as HTMLElement).offsetParent === null || el.clientHeight === 0
if (container && !isHidden(container)) {
// While we have empty space, fill it // While we have empty space, fill it
const {scrollY, innerHeight} = window const {scrollY, innerHeight} = window
const {scrollHeight, scrollTop, clientHeight} = container const {scrollHeight, scrollTop, clientHeight} = container
+5 -9
View File
@@ -44,15 +44,11 @@ export const whenAborted = (signal?: AbortSignal) => {
}) })
} }
/** /** Returns a promise that rejects with TimeoutError after ms. Use with Promise.race. */
* Returns a promise that rejects with TimeoutError after ms. Use with Promise.race. export const whenTimeout = (ms: number, opts: {message?: string} = {}) => {
* Pass an optional signal to clear the timer when that signal aborts (self-cleaning). return new Promise<never>((_, reject) =>
*/ setTimeout(() => reject(new TimeoutError(opts.message)), ms),
export const whenTimeout = (ms: number, opts: {message?: string; signal?: AbortSignal} = {}) => { )
return new Promise<never>((_, reject) => {
const timeout = setTimeout(() => reject(new TimeoutError(opts.message)), ms)
opts.signal?.addEventListener("abort", () => clearTimeout(timeout), {once: true})
})
} }
export const buildUrl = (base: string | URL, ...pathname: string[]) => { export const buildUrl = (base: string | URL, ...pathname: string[]) => {
+1 -2
View File
@@ -37,7 +37,6 @@
import {theme} from "@app/util/theme" import {theme} from "@app/util/theme"
import {toast, pushToast} from "@app/util/toast" import {toast, pushToast} from "@app/util/toast"
import * as notifications from "@app/util/notifications" import * as notifications from "@app/util/notifications"
import {Push} from "@app/util/push"
import {onPushNotificationAction} from "@app/util/push/adapters/common" import {onPushNotificationAction} from "@app/util/push/adapters/common"
import * as storage from "@app/util/storage" import * as storage from "@app/util/storage"
import {syncKeyboard} from "@app/util/keyboard" import {syncKeyboard} from "@app/util/keyboard"
@@ -176,7 +175,7 @@
unsubscribers.push(notifications.syncChecked()) unsubscribers.push(notifications.syncChecked())
// Initialize background notifications // Initialize background notifications
unsubscribers.push(Push.sync()) unsubscribers.push(notifications.Push.sync())
// Listen for signer errors, report to user via toast // Listen for signer errors, report to user via toast
unsubscribers.push( unsubscribers.push(
-38
View File
@@ -1,51 +1,13 @@
<script context="module" lang="ts">
import {synced} from "@welshman/store"
import {kv} from "@app/core/storage"
const dmNotificationsPrompted = synced({
key: "dmNotificationsPrompted",
defaultValue: false,
storage: kv,
})
</script>
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte"
import {page} from "$app/stores" import {page} from "$app/stores"
import type {MakeNonOptional} from "@welshman/lib" import type {MakeNonOptional} from "@welshman/lib"
import {append, uniq} from "@welshman/lib" import {append, uniq} from "@welshman/lib"
import {pubkey} from "@welshman/app" import {pubkey} from "@welshman/app"
import Chat from "@app/components/Chat.svelte" import Chat from "@app/components/Chat.svelte"
import {splitChatId} from "@app/core/state" import {splitChatId} from "@app/core/state"
import {notificationSettings} from "@app/core/state"
import {pushToast} from "@app/util/toast"
import {Push} from "@app/util/push"
const {chat} = $page.params as MakeNonOptional<typeof $page.params> const {chat} = $page.params as MakeNonOptional<typeof $page.params>
const pubkeys = uniq(append($pubkey!, splitChatId(chat))) const pubkeys = uniq(append($pubkey!, splitChatId(chat)))
onMount(async () => {
if (!$dmNotificationsPrompted) {
dmNotificationsPrompted.set(true)
const permission = await Push.request()
if (!permission.startsWith("granted")) {
return pushToast({
theme: "error",
message: `Failed to request notification permissions (${permission}).`,
})
}
notificationSettings.update(current => ({
...current,
push: true,
messages: true,
}))
pushToast({message: "Notifications enabled!"})
}
})
</script> </script>
<Chat {pubkeys} /> <Chat {pubkeys} />
+1 -2
View File
@@ -10,8 +10,7 @@
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {clearBadges} from "@app/util/notifications" import {Push, clearBadges} from "@app/util/notifications"
import {Push} from "@app/util/push"
import {notificationSettings} from "@app/core/state" import {notificationSettings} from "@app/core/state"
const reset = () => { const reset = () => {