Compare commits

...

10 Commits

Author SHA1 Message Date
userAdityaa 1f5a219734 fix: resync voice state after LiveKit reconnect 2026-06-01 11:30:30 +05:30
Jon Staab 1dd0270f4f Bump pomade 2026-05-29 16:31:29 -07:00
userAdityaa 77256462c5 feat: sync checked read state to Dufflepud for cross-device badges (#288)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-29 21:15:56 +00:00
Jon Staab ae071fefaa Fail more gracefully when svelte sneezes 2026-05-29 08:30:42 -07:00
userAdityaa 152d35f92a Fix deleted rooms persisting in navigation (#285)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-29 15:20:21 +00:00
userAdityaa 8dd278f47c fix: turn on notification defaults and prompt on first DM visit (#284)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-29 15:13:10 +00:00
Jon Staab 045d6983dc Fix some voice room bugs 2026-05-28 12:17:17 -07:00
Jon Staab 2f8861be62 Bump welshman, update pnpm config 2026-05-28 12:14:40 -07:00
userAdityaa 6dbe9c0ebb chore: show space relay icon in mobile topbar headers (#283)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-25 19:18:51 +00:00
Jon Staab 45df132dc6 Remove tauri, bump deps 2026-05-25 10:41:34 -07:00
91 changed files with 2866 additions and 7767 deletions
-4
View File
@@ -31,10 +31,6 @@ build-server/
.svelte-kit/
.next/
# Rust/Tauri
*target/
src-tauri/binaries/
# iOS
ios/App/App/public
ios/DerivedData
+1 -1
View File
@@ -36,4 +36,4 @@ include ':capawesome-capacitor-badge'
project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge/android')
include ':nostr-signer-capacitor-plugin'
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_2704ecccfd05fcfb1ad8852744422b7c/node_modules/nostr-signer-capacitor-plugin/android')
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_a3c0fb15d5bfa83f24d0070ca2583fc9/node_modules/nostr-signer-capacitor-plugin/android')
+1 -1
View File
@@ -21,7 +21,7 @@ def capacitor_pods
pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications'
pod 'CapacitorShare', :path => '../../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/share'
pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge'
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_2704ecccfd05fcfb1ad8852744422b7c/node_modules/nostr-signer-capacitor-plugin'
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_a3c0fb15d5bfa83f24d0070ca2583fc9/node_modules/nostr-signer-capacitor-plugin'
end
target 'Flotilla Chat' do
+22 -38
View File
@@ -8,10 +8,6 @@
"build:server": "vite build --config vite.config.server.ts",
"start": "node server.js",
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"tauri:info": "tauri info",
"tauri:icons": "tauri icon assets/logo.png --output src-tauri/icons",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check src && eslint src",
@@ -22,26 +18,26 @@
"devDependencies": {
"@capacitor/assets": "^3.0.5",
"@eslint/js": "^9.39.2",
"@sveltejs/kit": "^2.50.1",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@sveltejs/kit": "^2.61.1",
"@sveltejs/vite-plugin-svelte": "^5.1.1",
"@tailwindcss/postcss": "^4.2.2",
"@tauri-apps/cli": "^2.9.6",
"@types/eslint": "^9.6.1",
"@types/node": "^25.9.1",
"autoprefixer": "^10.4.23",
"classnames": "^2.5.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^9.1.2",
"eslint-plugin-svelte": "^2.46.1",
"globals": "^15.15.0",
"postcss": "^8.5.6",
"postcss": "^8.5.15",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.48.0",
"svelte": "^5.55.9",
"svelte-check": "^4.3.5",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.53.1",
"vite": "^5.4.21"
"vite": "^6.4.2"
},
"type": "module",
"dependencies": {
@@ -64,25 +60,25 @@
"@getalby/sdk": "^5.1.2",
"@hono/node-server": "^2.0.0",
"@noble/curves": "^1.9.7",
"@pomade/core": "^0.2.3",
"@poppanator/sveltekit-svg": "^4.2.1",
"@pomade/core": "^0.2.5",
"@poppanator/sveltekit-svg": "^7.0.0",
"@sveltejs/adapter-static": "^3.0.10",
"@tiptap/core": "^2.27.2",
"@tiptap/pm": "^2.27.2",
"@types/qrcode": "^1.5.6",
"@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.8",
"@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",
"@vite-pwa/assets-generator": "^1.0.2",
"@vite-pwa/sveltekit": "^1.1.0",
"@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",
@@ -91,7 +87,7 @@
"emoji-picker-element": "^1.28.1",
"emoji-picker-element-data": "^1.8.0",
"fuse.js": "^7.1.0",
"hono": "^4.12.15",
"hono": "^4.12.23",
"husky": "^9.1.7",
"idb": "^8.0.3",
"livekit-client": "^2.17.2",
@@ -103,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"
}
+2480 -2790
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
allowBuilds:
nostr-signer-capacitor-plugin: true
cbor-extract: false
esbuild: false
sharp: true
minimumReleaseAgeExclude:
- '@pomade/core'
- '@welshman/app'
- '@welshman/content'
- '@welshman/editor'
- '@welshman/feeds'
- '@welshman/lib'
- '@welshman/net'
- '@welshman/router'
- '@welshman/signer'
- '@welshman/store'
- '@welshman/util'
overrides:
sharp: 0.35.0-rc.0
-2
View File
@@ -1,2 +0,0 @@
[toolchain]
channel = "1.92.0"
-4784
View File
File diff suppressed because it is too large Load Diff
-18
View File
@@ -1,18 +0,0 @@
[package]
name = "flotilla"
version = "0.1.0"
edition = "2021"
[lib]
name = "flotilla_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.5.3", features = [] }
[dependencies]
tauri = { version = "2.9.5", features = [] }
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
-3
View File
@@ -1,3 +0,0 @@
fn main() {
tauri_build::build()
}
-7
View File
@@ -1,7 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default desktop capability for the main window",
"windows": ["main"],
"permissions": ["core:default"]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 668 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 926 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

-2
View File
@@ -1,2 +0,0 @@
[toolchain]
channel = "1.92.0"
-6
View File
@@ -1,6 +0,0 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
-6
View File
@@ -1,6 +0,0 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
flotilla_lib::run();
}
-37
View File
@@ -1,37 +0,0 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Flotilla",
"mainBinaryName": "flotilla",
"identifier": "social.flotilla.app",
"build": {
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build",
"devUrl": "http://localhost:1847",
"frontendDist": "../build"
},
"app": {
"security": {
"capabilities": ["default"]
},
"windows": [
{
"label": "main",
"title": "Flotilla",
"width": 1240,
"height": 775,
"resizable": true
}
]
},
"bundle": {
"active": false,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+121 -17
View File
@@ -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,
@@ -92,6 +92,27 @@ const syncParticipantMedia = (participant: Participant) => {
})
}
// LiveKit does not emit ParticipantConnected/Disconnected during reconnect.
const resyncAfterReconnect = (room: LiveKitRoom) => {
const next = new Map<string, {muted: boolean; cameraOn: boolean}>()
for (const p of [room.localParticipant, ...room.remoteParticipants.values()]) {
next.set(p.identity, {muted: !p.isMicrophoneEnabled, cameraOn: p.isCameraEnabled})
}
participantMediaState.set(next)
const session = get(currentVoiceSession)
if (session?.room !== room) return
const {localParticipant} = room
voiceMicMuted.set(!localParticipant.isMicrophoneEnabled)
currentVoiceSession.set({
...session,
cameraOn: localParticipant.isCameraEnabled,
screenShareOn: localParticipant.isScreenShareEnabled,
})
triggerVideoFeedCount()
}
const onParticipantMediaChanged = (_publication: TrackPublication, participant: Participant) => {
syncParticipantMedia(participant)
}
@@ -157,6 +178,8 @@ const setUpMicrophone = async (
startMuted: boolean,
preferredMicId: string | undefined,
participant: LocalParticipant,
signal?: AbortSignal,
settleSignal?: AbortSignal,
): Promise<boolean> => {
if (startMuted) {
return true
@@ -168,15 +191,39 @@ 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 makeOnRoomReconnected = (room: LiveKitRoom) => () => {
if (room !== activeRoom) return
resyncAfterReconnect(room)
}
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 +301,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 +309,44 @@ 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.Reconnected, makeOnRoomReconnected(liveKitRoom))
liveKitRoom.on(RoomEvent.ParticipantConnected, onParticipantConnected)
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
@@ -290,10 +364,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 +381,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 +415,7 @@ export const joinVoiceRoom = async (
if (e instanceof AbortError) return
throw e
} finally {
settle.abort()
if (isActive()) joinAbortController = undefined
}
}
@@ -348,14 +443,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
View File
@@ -122,6 +122,7 @@
repository.removeEvent(thunk.event.id)
pushToast({theme: "error", message})
} else {
await removeRoomMembership(url, h)
goto(makeSpacePath(url))
}
},
+7 -1
View File
@@ -7,17 +7,19 @@
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import {decodeRelay} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
interface Props {
back?: () => unknown
leading?: Snippet
title?: Snippet
action?: Snippet
[key: string]: any
}
const {back = () => goto(makeSpacePath(url)), title, action, ...props}: Props = $props()
const {back = () => goto(makeSpacePath(url)), leading, title, action, ...props}: Props = $props()
const url = decodeRelay($page.params.relay!)
</script>
@@ -30,6 +32,10 @@
<div class="ellipsize whitespace-nowrap flex grow items-center justify-between gap-4">
<div class="flex flex-col">
<div class="flex gap-2 items-center">
<RelayIcon {url} size={5} class="rounded-full md:hidden" />
<div class="hidden md:contents">
{@render leading?.()}
</div>
{@render title?.()}
</div>
<div class="text-xs text-primary md:hidden">
+1 -1
View File
@@ -23,8 +23,8 @@
import SpaceJoinSettings from "@app/components/SpaceJoinSettings.svelte"
import {pushToast} from "@app/util/toast"
import {makeSpacePath} from "@app/util/routes"
import {Push} from "@app/util/notifications"
import {relaysMostlyRestricted, notificationSettings, parseInviteLink} from "@app/core/state"
import {Push} from "@app/util/push"
import {
attemptRelayAccess,
addSpaceMembership,
+1 -1
View File
@@ -24,7 +24,7 @@
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {makeSpacePath} from "@app/util/routes"
import {Push} from "@app/util/notifications"
import {Push} from "@app/util/push"
type Props = {
url: string
+2 -1
View File
@@ -22,7 +22,7 @@
$effect(() => {
if (!containerEl) return
containerEl.addEventListener("touchmove", onTouchMove, {passive: false})
return () => containerEl!.removeEventListener("touchmove", onTouchMove)
return () => containerEl?.removeEventListener("touchmove", onTouchMove)
})
const onActionClick = () => {
@@ -71,6 +71,7 @@
{#if $toast}
{@const theme = $toast.theme || "info"}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={containerEl}
transition:fly={{y: -20}}
+13 -1
View File
@@ -44,7 +44,7 @@
voiceMicMuted,
voiceState,
} from "@app/call/stores"
import {cancelJoinVoiceRoom, leaveVoiceRoom, toggleMute} from "@app/call/voice"
import {cancelJoinVoiceRoom, joinVoiceRoom, leaveVoiceRoom, toggleMute} from "@app/call/voice"
const {relay, h} = $derived($page.params)
const url = $derived(relay ? decodeRelay(relay) : undefined)
@@ -86,6 +86,11 @@
pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h})
}
const onReconnect = () => {
if (!targetRoom) return
void joinVoiceRoom(targetRoom.url, targetRoom.h)
}
const goToRoom = () => {
if (!targetRoom) return
const path = makeRoomPath(targetRoom.url, targetRoom.h)
@@ -237,6 +242,13 @@
onclick={leaveVoiceRoom}>
<Icon icon={PhoneRounded} size={4} />
</Button>
{:else if $currentVoiceRoom}
<Button
data-tip="Reconnect"
class="center tooltip tooltip-top btn btn-sm btn-square btn-success"
onclick={onReconnect}>
<Icon icon={PhoneCallingRounded} size={4} />
</Button>
{:else}
<Button
data-tip="Join Voice"
+7 -4
View File
@@ -5,7 +5,7 @@ import {derived, readable, writable} from "svelte/store"
import * as nip19 from "nostr-tools/nip19"
import {
on,
gt,
gte,
max,
spec,
call,
@@ -418,7 +418,7 @@ export const device = withGetter(writable(randomId()))
export const notificationSettings = withGetter(
writable({
push: false,
sound: false,
sound: true,
badge: false,
spaces: true,
mentions: true,
@@ -620,7 +620,7 @@ export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByU
for (const event of metaEvents) {
const meta = tryCatch(() => readRoomMeta(event))
if (!meta || gt(deletedByH.get(meta.h), meta.event.created_at)) {
if (!meta || gte(deletedByH.get(meta.h), meta.event.created_at)) {
continue
}
@@ -651,7 +651,10 @@ export const loadRoom = call(() => {
await load({
relays: [url],
filters: [{kinds: [ROOM_META], "#d": [h]}],
filters: [
{kinds: [ROOM_META], "#d": [h]},
{kinds: [ROOM_DELETE], "#h": [h]},
],
})
}
+1 -1
View File
@@ -1,6 +1,6 @@
import {db, kv, ss} from "@app/core/storage"
import {Push} from "@app/util/notifications"
import {deactivateCurrentPomadeSession} from "@app/util/pomade"
import {Push} from "@app/util/push"
export const logout = async () => {
await deactivateCurrentPomadeSession()
+106 -5
View File
@@ -1,11 +1,17 @@
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, parseJson, gt} 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,
makeHttpAuth,
makeHttpAuthHeader,
} from "@welshman/util"
import {makeSpacePath, makeRoomPath, makeSpaceChatPath, makeChatPath} from "@app/util/routes"
import {
CONTENT_KINDS,
@@ -15,10 +21,11 @@ import {
getSpaceUrlsFromGroupList,
makeCommentFilter,
hasNip29,
dufflepud,
DUFFLEPUD_URL,
} from "@app/core/state"
import {kv} from "@app/core/storage"
import {page} from "$app/stores"
export {Push} from "@app/util/push"
// Checked state
@@ -76,6 +83,100 @@ export const syncChecked = () => {
})
}
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(await makeHttpAuth(DUFFLEPUD_URL, "GET"))
}
return makeHttpAuthHeader(nip98Auth)
}
const pullCheckedRemote = async () => {
const authorization = await nip98Header()
if (!authorization) {
return
}
const res = await fetch(dufflepud(`kv/${CHECKED_KV_KEY}`), {headers: {authorization}})
if (!res.ok) {
return
}
const remote = parseJson<Record<string, number>>(await res.text())
if (!remote) {
return
}
checked.update($checked => {
for (const [path, ts] of Object.entries(remote)) {
if (gt(ts, $checked[path])) {
$checked[path] = ts
}
}
return $checked
})
}
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
View File
@@ -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
View File
@@ -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[]) => {
+5 -1
View File
@@ -37,6 +37,7 @@
import {theme} from "@app/util/theme"
import {toast, pushToast} from "@app/util/toast"
import * as notifications from "@app/util/notifications"
import {Push} from "@app/util/push"
import {onPushNotificationAction} from "@app/util/push/adapters/common"
import * as storage from "@app/util/storage"
import {syncKeyboard} from "@app/util/keyboard"
@@ -174,8 +175,11 @@
// 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())
unsubscribers.push(Push.sync())
// Listen for signer errors, report to user via toast
unsubscribers.push(
+38
View File
@@ -1,13 +1,51 @@
<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">
import {onMount} from "svelte"
import {page} from "$app/stores"
import type {MakeNonOptional} from "@welshman/lib"
import {append, uniq} from "@welshman/lib"
import {pubkey} from "@welshman/app"
import Chat from "@app/components/Chat.svelte"
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 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>
<Chat {pubkeys} />
+2 -1
View File
@@ -10,7 +10,8 @@
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import {pushToast} from "@app/util/toast"
import {Push, clearBadges} from "@app/util/notifications"
import {clearBadges} from "@app/util/notifications"
import {Push} from "@app/util/push"
import {notificationSettings} from "@app/core/state"
const reset = () => {
+3 -1
View File
@@ -430,8 +430,10 @@
</script>
<SpaceBar>
{#snippet title()}
{#snippet leading()}
<RoomImage {url} {h} />
{/snippet}
{#snippet title()}
<RoomName {url} {h} />
{/snippet}
{#snippet action()}
@@ -111,8 +111,10 @@
</script>
<SpaceBar>
{#snippet title()}
{#snippet leading()}
<Icon icon={CalendarMinimalistic} />
{/snippet}
{#snippet title()}
<strong>Calendar</strong>
{/snippet}
{#snippet action()}
+3 -1
View File
@@ -307,8 +307,10 @@
</script>
<SpaceBar>
{#snippet title()}
{#snippet leading()}
<Icon icon={ChatRound} />
{/snippet}
{#snippet title()}
<strong>Chat</strong>
{/snippet}
{#snippet action()}
@@ -63,8 +63,10 @@
</script>
<SpaceBar>
{#snippet title()}
{#snippet leading()}
<Icon icon={CaseMinimalistic} />
{/snippet}
{#snippet title()}
<strong>Classifieds</strong>
{/snippet}
{#snippet action()}
+3 -1
View File
@@ -62,8 +62,10 @@
</script>
<SpaceBar>
{#snippet title()}
{#snippet leading()}
<Icon icon={StarFallMinimalistic} />
{/snippet}
{#snippet title()}
<strong>Goals</strong>
{/snippet}
{#snippet action()}
+3 -1
View File
@@ -62,8 +62,10 @@
</script>
<SpaceBar>
{#snippet title()}
{#snippet leading()}
<Icon icon={PollIcon} />
{/snippet}
{#snippet title()}
<strong>Polls</strong>
{/snippet}
{#snippet action()}
@@ -211,8 +211,10 @@
</script>
<SpaceBar>
{#snippet title()}
{#snippet leading()}
<Icon icon={History} />
{/snippet}
{#snippet title()}
<strong>Recent Activity</strong>
{/snippet}
{#snippet action()}
@@ -63,8 +63,10 @@
</script>
<SpaceBar>
{#snippet title()}
{#snippet leading()}
<Icon icon={NotesMinimalistic} />
{/snippet}
{#snippet title()}
<strong>Threads</strong>
{/snippet}
{#snippet action()}
+1 -11
View File
@@ -54,17 +54,7 @@ export default defineConfig({
svg({
svgoOptions: {
multipass: true,
plugins: [
{
name: "preset-default",
params: {
overrides: {
removeViewBox: false,
},
},
},
"removeDimensions",
],
plugins: ["preset-default", "removeDimensions"],
},
}),
],