forked from coracle/flotilla
Compare commits
3 Commits
6dbe9c0ebb
...
a2b7b13b71
| Author | SHA1 | Date | |
|---|---|---|---|
| a2b7b13b71 | |||
| 045d6983dc | |||
| 2f8861be62 |
+12
-23
@@ -22,6 +22,7 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/node": "^25.9.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"classnames": "^2.5.1",
|
||||
"eslint": "^9.39.2",
|
||||
@@ -68,16 +69,16 @@
|
||||
"@types/throttle-debounce": "^5.0.2",
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"@welshman/app": "^0.8.15",
|
||||
"@welshman/content": "^0.8.15",
|
||||
"@welshman/editor": "^0.8.15",
|
||||
"@welshman/feeds": "^0.8.15",
|
||||
"@welshman/lib": "^0.8.15",
|
||||
"@welshman/net": "^0.8.15",
|
||||
"@welshman/router": "^0.8.15",
|
||||
"@welshman/signer": "^0.8.15",
|
||||
"@welshman/store": "^0.8.15",
|
||||
"@welshman/util": "^0.8.15",
|
||||
"@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",
|
||||
"cheerio": "^1.2.0",
|
||||
"compressorjs-next": "^1.1.2",
|
||||
"daisyui": "^5.5.19",
|
||||
@@ -98,17 +99,5 @@
|
||||
"throttle-debounce": "^5.0.2",
|
||||
"tippy.js": "^6.3.7"
|
||||
},
|
||||
"pnpm": {
|
||||
"ignoredBuiltDependencies": [
|
||||
"esbuild"
|
||||
],
|
||||
"onlyBuiltDependencies": [
|
||||
"sharp",
|
||||
"nostr-signer-capacitor-plugin"
|
||||
],
|
||||
"overrides": {
|
||||
"sharp": "0.35.0-rc.0"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
|
||||
"packageManager": "pnpm@11.4.0"
|
||||
}
|
||||
|
||||
Generated
+2152
-2406
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
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
|
||||
+94
-17
@@ -20,7 +20,7 @@ import type {TrustedEvent} from "@welshman/util"
|
||||
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
|
||||
import {signer} from "@welshman/app"
|
||||
import {getLivekitEndpoint} from "$lib/livekit"
|
||||
import {AbortError, whenAborted, whenTimeout} from "$lib/util"
|
||||
import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util"
|
||||
import {
|
||||
currentVoiceRoom,
|
||||
currentVoiceSession,
|
||||
@@ -157,6 +157,8 @@ const setUpMicrophone = async (
|
||||
startMuted: boolean,
|
||||
preferredMicId: string | undefined,
|
||||
participant: LocalParticipant,
|
||||
signal?: AbortSignal,
|
||||
settleSignal?: AbortSignal,
|
||||
): Promise<boolean> => {
|
||||
if (startMuted) {
|
||||
return true
|
||||
@@ -168,15 +170,34 @@ const setUpMicrophone = async (
|
||||
capture = {deviceId: preferredMicId}
|
||||
}
|
||||
try {
|
||||
await participant.setMicrophoneEnabled(true, capture)
|
||||
await Promise.race([
|
||||
participant.setMicrophoneEnabled(true, capture),
|
||||
whenTimeout(15_000, {message: "Microphone access timed out.", signal: settleSignal}),
|
||||
whenAborted(signal),
|
||||
])
|
||||
muted = false
|
||||
} catch (e) {
|
||||
pushToast({theme: "error", message: "Could not access microphone"})
|
||||
// Timeout or microphone rejection: join muted, the call is still usable. A
|
||||
// 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
|
||||
}
|
||||
|
||||
const onRoomDisconnected = (reason?: DisconnectReason) => {
|
||||
// The room whose events are allowed to mutate shared state. Abandoned rooms
|
||||
// (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)
|
||||
voiceMicMuted.set(true)
|
||||
currentVoiceSession.set(undefined)
|
||||
@@ -254,9 +275,6 @@ export const joinVoiceRoom = async (
|
||||
): Promise<void> => {
|
||||
cancelJoinVoiceRoom()
|
||||
|
||||
const session = get(currentVoiceSession)
|
||||
if (session) await leaveVoiceRoom()
|
||||
|
||||
currentVoiceRoom.set(get(deriveRoom(url, h)))
|
||||
voiceState.set(VoiceState.Joining)
|
||||
|
||||
@@ -265,14 +283,43 @@ export const joinVoiceRoom = async (
|
||||
const signal = controller.signal
|
||||
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 {
|
||||
const {server_url, participant_token} = await fetchLivekitToken(url, h, signal)
|
||||
// Tear down any existing session before joining. Bound it so a slow leave
|
||||
// (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()
|
||||
|
||||
const liveKitRoom = new LiveKitRoom({adaptiveStream: true, dynacast: true})
|
||||
activeRoom = liveKitRoom
|
||||
|
||||
liveKitRoom.on(RoomEvent.Disconnected, onRoomDisconnected)
|
||||
liveKitRoom.on(RoomEvent.Disconnected, makeOnRoomDisconnected(liveKitRoom))
|
||||
liveKitRoom.on(RoomEvent.ParticipantConnected, onParticipantConnected)
|
||||
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
||||
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
||||
@@ -290,10 +337,13 @@ export const joinVoiceRoom = async (
|
||||
liveKitRoom.connect(server_url, participant_token, {maxRetries: 0}),
|
||||
whenTimeout(15_000, {
|
||||
message: "Connection timed out. Please check your network and try again.",
|
||||
signal: settle.signal,
|
||||
}),
|
||||
whenAborted(signal),
|
||||
])
|
||||
} catch (e) {
|
||||
if (activeRoom === liveKitRoom) activeRoom = undefined
|
||||
liveKitRoom.removeAllListeners()
|
||||
liveKitRoom.disconnect()
|
||||
throw e
|
||||
}
|
||||
@@ -304,7 +354,24 @@ export const joinVoiceRoom = async (
|
||||
syncParticipantMedia(p)
|
||||
}
|
||||
|
||||
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
|
||||
// Bounded against timeout/abort inside setUpMicrophone: a stuck permission
|
||||
// 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)
|
||||
currentVoiceSession.set({
|
||||
@@ -321,6 +388,7 @@ export const joinVoiceRoom = async (
|
||||
if (e instanceof AbortError) return
|
||||
throw e
|
||||
} finally {
|
||||
settle.abort()
|
||||
if (isActive()) joinAbortController = undefined
|
||||
}
|
||||
}
|
||||
@@ -348,14 +416,23 @@ export const leaveVoiceRoom = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
voiceState.set(VoiceState.Disconnected)
|
||||
videoPrimaryTileKey.set(undefined)
|
||||
voiceMicMuted.set(true)
|
||||
currentVoiceSession.set(undefined)
|
||||
resetVideoCallLayout()
|
||||
// Always tear down this room's connection and listeners.
|
||||
if (activeRoom === session.room) activeRoom = undefined
|
||||
session.room.removeAllListeners()
|
||||
session.room.disconnect()
|
||||
speakingParticipants.set([])
|
||||
participantMediaState.set(new Map())
|
||||
|
||||
// Only reset shared UI state if this session is still current. A slow leave
|
||||
// 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> => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {derived, get, writable} from "svelte/store"
|
||||
import {Badge} from "@capawesome/capacitor-badge"
|
||||
import {synced, throttled, withGetter} from "@welshman/store"
|
||||
import {pubkey, tracker, repository, relaysByUrl} from "@welshman/app"
|
||||
import {assoc, prop, first, identity, groupBy, now} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {pubkey, signer, tracker, repository, relaysByUrl} from "@welshman/app"
|
||||
import {assoc, prop, first, identity, groupBy, now, throttle} from "@welshman/lib"
|
||||
import type {SignedEvent, TrustedEvent} from "@welshman/util"
|
||||
import {deriveEventsByIdByUrl} from "@welshman/store"
|
||||
import {sortEventsDesc, getTagValue, MESSAGE} from "@welshman/util"
|
||||
import {sortEventsDesc, getTagValue, MESSAGE, makeEvent} from "@welshman/util"
|
||||
import {makeSpacePath, makeRoomPath, makeSpaceChatPath, makeChatPath} from "@app/util/routes"
|
||||
import {
|
||||
CONTENT_KINDS,
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
getSpaceUrlsFromGroupList,
|
||||
makeCommentFilter,
|
||||
hasNip29,
|
||||
dufflepud,
|
||||
DUFFLEPUD_URL,
|
||||
} from "@app/core/state"
|
||||
import {kv} from "@app/core/storage"
|
||||
import {page} from "$app/stores"
|
||||
@@ -76,6 +78,107 @@ export const syncChecked = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const HTTP_AUTH = 27235
|
||||
const CHECKED_KV_KEY = "checked"
|
||||
const NIP98_MAX_AGE = 23 * 60 * 60
|
||||
|
||||
let nip98Auth: SignedEvent | undefined
|
||||
|
||||
const nip98Header = async () => {
|
||||
const $signer = signer.get()
|
||||
|
||||
if (!$signer) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!nip98Auth || now() - nip98Auth.created_at > NIP98_MAX_AGE) {
|
||||
nip98Auth = await $signer.sign(
|
||||
makeEvent(HTTP_AUTH, {content: "", tags: [["u", DUFFLEPUD_URL]]}),
|
||||
)
|
||||
}
|
||||
|
||||
return `Nostr ${btoa(JSON.stringify(nip98Auth))}`
|
||||
}
|
||||
|
||||
const pullCheckedRemote = async () => {
|
||||
const authorization = await nip98Header()
|
||||
|
||||
if (!authorization) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(dufflepud(`kv/${CHECKED_KV_KEY}`), {headers: {authorization}})
|
||||
|
||||
if (res.status === 404) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
const remote = JSON.parse(await res.text()) as Record<string, number>
|
||||
|
||||
checked.update($checked => {
|
||||
for (const [path, ts] of Object.entries(remote)) {
|
||||
if (ts > ($checked[path] || 0)) {
|
||||
$checked[path] = ts
|
||||
}
|
||||
}
|
||||
|
||||
return $checked
|
||||
})
|
||||
} catch {
|
||||
// pass
|
||||
}
|
||||
}
|
||||
|
||||
const pushCheckedRemote = throttle(3000, async () => {
|
||||
const authorization = await nip98Header()
|
||||
|
||||
if (!authorization) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(dufflepud(`kv/${CHECKED_KV_KEY}`), {
|
||||
method: "POST",
|
||||
headers: {authorization},
|
||||
body: JSON.stringify(checked.get()),
|
||||
})
|
||||
} catch {
|
||||
// pass
|
||||
}
|
||||
})
|
||||
|
||||
export const syncCheckedRemote = () => {
|
||||
let ready = false
|
||||
|
||||
const unsubscribePubkey = pubkey.subscribe($pubkey => {
|
||||
ready = false
|
||||
nip98Auth = undefined
|
||||
|
||||
if ($pubkey) {
|
||||
pullCheckedRemote().then(() => {
|
||||
ready = true
|
||||
pushCheckedRemote()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const unsubscribeChecked = checked.subscribe(() => {
|
||||
if (ready && pubkey.get()) {
|
||||
pushCheckedRemote()
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribePubkey()
|
||||
unsubscribeChecked()
|
||||
}
|
||||
}
|
||||
|
||||
// Derived notifications state
|
||||
|
||||
export const allNotifications = derived(
|
||||
|
||||
+4
-1
@@ -44,7 +44,10 @@ export const createScroller = ({
|
||||
: element.closest(".scroll-container")
|
||||
|
||||
const check = async () => {
|
||||
if (container) {
|
||||
const isHidden = (el: Element) =>
|
||||
(el as HTMLElement).offsetParent === null || el.clientHeight === 0
|
||||
|
||||
if (container && !isHidden(container)) {
|
||||
// While we have empty space, fill it
|
||||
const {scrollY, innerHeight} = window
|
||||
const {scrollHeight, scrollTop, clientHeight} = container
|
||||
|
||||
+9
-5
@@ -44,11 +44,15 @@ export const whenAborted = (signal?: AbortSignal) => {
|
||||
})
|
||||
}
|
||||
|
||||
/** Returns a promise that rejects with TimeoutError after ms. Use with Promise.race. */
|
||||
export const whenTimeout = (ms: number, opts: {message?: string} = {}) => {
|
||||
return new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new TimeoutError(opts.message)), ms),
|
||||
)
|
||||
/**
|
||||
* Returns a promise that rejects with TimeoutError after ms. Use with Promise.race.
|
||||
* Pass an optional signal to clear the timer when that signal aborts (self-cleaning).
|
||||
*/
|
||||
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[]) => {
|
||||
|
||||
@@ -174,6 +174,9 @@
|
||||
// Subscribe to page history to update checked state
|
||||
unsubscribers.push(notifications.syncChecked())
|
||||
|
||||
// Sync checked state across devices
|
||||
unsubscribers.push(notifications.syncCheckedRemote())
|
||||
|
||||
// Initialize background notifications
|
||||
unsubscribers.push(notifications.Push.sync())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user