Compare commits
10 Commits
c42a285f0b
...
1f5a219734
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f5a219734 | |||
| 1dd0270f4f | |||
| 77256462c5 | |||
| ae071fefaa | |||
| 152d35f92a | |||
| 8dd278f47c | |||
| 045d6983dc | |||
| 2f8861be62 | |||
| 6dbe9c0ebb | |||
| 45df132dc6 |
@@ -31,10 +31,6 @@ build-server/
|
||||
.svelte-kit/
|
||||
.next/
|
||||
|
||||
# Rust/Tauri
|
||||
*target/
|
||||
src-tauri/binaries/
|
||||
|
||||
# iOS
|
||||
ios/App/App/public
|
||||
ios/DerivedData
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -1,2 +0,0 @@
|
||||
[toolchain]
|
||||
channel = "1.92.0"
|
||||
@@ -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"]
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
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>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
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>
|
||||
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 668 B |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 926 B |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
@@ -1,2 +0,0 @@
|
||||
[toolchain]
|
||||
channel = "1.92.0"
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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> => {
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
repository.removeEvent(thunk.event.id)
|
||||
pushToast({theme: "error", message})
|
||||
} else {
|
||||
await removeRoomMembership(url, h)
|
||||
goto(makeSpacePath(url))
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -62,8 +62,10 @@
|
||||
</script>
|
||||
|
||||
<SpaceBar>
|
||||
{#snippet title()}
|
||||
{#snippet leading()}
|
||||
<Icon icon={StarFallMinimalistic} />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<strong>Goals</strong>
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -54,17 +54,7 @@ export default defineConfig({
|
||||
svg({
|
||||
svgoOptions: {
|
||||
multipass: true,
|
||||
plugins: [
|
||||
{
|
||||
name: "preset-default",
|
||||
params: {
|
||||
overrides: {
|
||||
removeViewBox: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
"removeDimensions",
|
||||
],
|
||||
plugins: ["preset-default", "removeDimensions"],
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||