Compare commits
22 Commits
master
..
fc6a3e44d4
| Author | SHA1 | Date | |
|---|---|---|---|
| fc6a3e44d4 | |||
| e3e13563d5 | |||
| ee3da3893c | |||
| 91145c38fb | |||
| 1dd0270f4f | |||
| 77256462c5 | |||
| ae071fefaa | |||
| 152d35f92a | |||
| 8dd278f47c | |||
| 045d6983dc | |||
| 2f8861be62 | |||
| 6dbe9c0ebb | |||
| 45df132dc6 | |||
| c42a285f0b | |||
| 1e3211ae74 | |||
| ec507b05d6 | |||
| 339bb1afac | |||
| c441012e02 | |||
| 0d61278c56 | |||
| ffd06ab561 | |||
| eb8dd330b6 | |||
| 6267e52bdf |
@@ -15,3 +15,4 @@ android/capacitor-cordova-android-plugins
|
|||||||
android/app/src/androidTest
|
android/app/src/androidTest
|
||||||
android/app/src/test
|
android/app/src/test
|
||||||
node_modules
|
node_modules
|
||||||
|
.svelte-kit
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
name: Docker
|
name: Container Image Build and Publish
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: gitea.coracle.social
|
REGISTRY: gitea.coracle.social
|
||||||
@@ -32,6 +37,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
|
type=sha
|
||||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
|
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@@ -45,6 +51,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
target: production
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -6,6 +6,11 @@
|
|||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/playwright/.cache/
|
||||||
|
|
||||||
# Generated assets
|
# Generated assets
|
||||||
static/favicon.ico
|
static/favicon.ico
|
||||||
static/pwa-64x64.png
|
static/pwa-64x64.png
|
||||||
@@ -27,13 +32,10 @@ android/app/src/main/assets/public/
|
|||||||
node_modules/
|
node_modules/
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
build/
|
build/
|
||||||
|
build-server/
|
||||||
.svelte-kit/
|
.svelte-kit/
|
||||||
.next/
|
.next/
|
||||||
|
|
||||||
# Rust/Tauri
|
|
||||||
*target/
|
|
||||||
src-tauri/binaries/
|
|
||||||
|
|
||||||
# iOS
|
# iOS
|
||||||
ios/App/App/public
|
ios/App/App/public
|
||||||
ios/DerivedData
|
ios/DerivedData
|
||||||
|
|||||||
@@ -6,22 +6,26 @@
|
|||||||
# Pass --build-arg VITE_BUILD_HASH=$(git rev-parse --short HEAD) to stamp the build.
|
# Pass --build-arg VITE_BUILD_HASH=$(git rev-parse --short HEAD) to stamp the build.
|
||||||
# A .env in the build context is picked up by build.sh for branding config.
|
# A .env in the build context is picked up by build.sh for branding config.
|
||||||
|
|
||||||
FROM node:22-bookworm
|
# https://pnpm.io/docker#example-3-build-on-cicd
|
||||||
|
FROM node:24-slim AS builder
|
||||||
RUN npm install -g pnpm@10.33.0
|
ENV PNPM_HOME="/pnpm"
|
||||||
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
|
||||||
|
|
||||||
RUN pnpm i --frozen-lockfile
|
|
||||||
|
|
||||||
ENV NODE_OPTIONS=--max_old_space_size=16384
|
ENV NODE_OPTIONS=--max_old_space_size=16384
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm i --frozen-lockfile
|
||||||
COPY . .
|
COPY . .
|
||||||
|
ARG VITE_BUILD_HASH
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
RUN pnpm run build:server
|
||||||
|
|
||||||
|
FROM node:24-slim AS production
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/build /app/build
|
||||||
|
COPY --from=builder /app/build-server/server.js /app/server.js
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
USER node
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -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')
|
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'
|
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')
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import {expect, test} from "@playwright/test"
|
||||||
|
|
||||||
|
test("boots the SPA on the home page", async ({page}) => {
|
||||||
|
const response = await page.goto("/")
|
||||||
|
|
||||||
|
expect(response?.ok()).toBeTruthy()
|
||||||
|
|
||||||
|
// adapter-static serves an empty shell that hydrates client-side, so the presence of
|
||||||
|
// rendered text proves the Svelte app actually mounted (not just that a file was served).
|
||||||
|
// TODO: tighten this to assert concrete onboarding UI once the markup is settled.
|
||||||
|
await expect(page.locator("body")).toContainText(/\S/, {timeout: 15_000})
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type {SignedEvent} from "@welshman/util"
|
||||||
|
import type {RelayMockConfig} from "../../src/lib/test/relayMocks"
|
||||||
|
|
||||||
|
import relay1Events from "./fixtures/relay1.json"
|
||||||
|
|
||||||
|
// Fake relay urls used by tests. Each maps to a json fixture under ./fixtures/ and an entry in
|
||||||
|
// EVENTS_BY_RELAY below. To add a relay: drop a `<name>.json` file in ./fixtures/, import it, add a
|
||||||
|
// url here, and wire it into EVENTS_BY_RELAY.
|
||||||
|
export const FIXTURE_RELAYS = {
|
||||||
|
relay1: "wss://relay1.test/",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// The events each fake relay serves. The json files hold static, pre-signed events: schnorr
|
||||||
|
// signatures are non-deterministic, so events are signed once and committed verbatim (they pass
|
||||||
|
// verifyEvent, which netContext.isEventValid enforces). Regenerate with @welshman/signer:
|
||||||
|
// await Nip01Signer.fromSecret(secret).sign(makeEvent(kind, {content, created_at}))
|
||||||
|
const EVENTS_BY_RELAY: Record<string, SignedEvent[]> = {
|
||||||
|
[FIXTURE_RELAYS.relay1]: relay1Events as SignedEvent[],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a RelayMockConfig populating the given fixture relays (all of them when none are passed).
|
||||||
|
// Any relay not included returns nothing, keeping tests offline.
|
||||||
|
export const relayFixtures = (...urls: string[]): RelayMockConfig => {
|
||||||
|
const selected = urls.length > 0 ? urls : Object.keys(EVENTS_BY_RELAY)
|
||||||
|
|
||||||
|
return {
|
||||||
|
relays: Object.fromEntries(selected.map(url => [url, EVENTS_BY_RELAY[url] ?? []])),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"kind": 0,
|
||||||
|
"content": "{\"name\":\"Alice\"}",
|
||||||
|
"tags": [],
|
||||||
|
"created_at": 1700000000,
|
||||||
|
"pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
|
||||||
|
"id": "9b3d138641b38364945b20d800268006c2cb7d974bb4b1d63a9f90f5ab974b90",
|
||||||
|
"sig": "de6b86274e7bcf6c02aa881ada1feee9e01ba320691711d4975916b3cd231ab43cf469a47c5db99503ed72707d5db85fede1ad3763c4fbd7c998d04f00eda6bc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": 1,
|
||||||
|
"content": "hello from the fixture relay",
|
||||||
|
"tags": [],
|
||||||
|
"created_at": 1700000000,
|
||||||
|
"pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
|
||||||
|
"id": "b9874875bfa8d830c5c9ef3673104360cf21b94848a311febfaf52f0a652b1a9",
|
||||||
|
"sig": "85df94a2e9884ac3d280145492d5191cde2948d49a824c443a1f5d2143633eff1e1789fa7e8843b6efc3dd2dc0d7e33322edb628125d8e35de8ddca1d06ca970"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": 1,
|
||||||
|
"content": "reply from bob",
|
||||||
|
"tags": [],
|
||||||
|
"created_at": 1700000001,
|
||||||
|
"pubkey": "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5",
|
||||||
|
"id": "171dcbdd63d474ba46da609e8b0104cbcf4801fbb581b6c343d9426280f9e1be",
|
||||||
|
"sig": "eecf26e616a6b70dc67c7eae16fc4fe159314647ba1d4581332257de7f070410aa9a9e41f571b9403ff145d16c9b32766846fce08516201263e25cf08c1ed8f1"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type {Page} from "@playwright/test"
|
||||||
|
import type {RelayMockConfig} from "../../src/lib/test/relayMocks"
|
||||||
|
|
||||||
|
// Must match RELAY_MOCKS_KEY in src/lib/test/relayMocks.ts.
|
||||||
|
const RELAY_MOCKS_KEY = "__RELAY_MOCKS__"
|
||||||
|
|
||||||
|
// Hard safety net: intercept every real websocket so a test can never reach the network, even if
|
||||||
|
// some code path opens a socket directly (e.g. relay AUTH) rather than going through the adapter
|
||||||
|
// layer. We never call route.connectToServer(), so the socket connects to Playwright's in-process
|
||||||
|
// mock and simply receives nothing.
|
||||||
|
export const blockWebsockets = (page: Page) => page.routeWebSocket(/^wss?:\/\//, () => {})
|
||||||
|
|
||||||
|
// Inject the relay-mock config the app reads on startup. addInitScript runs before any page script
|
||||||
|
// on every navigation, so this must be called before page.goto().
|
||||||
|
export const injectRelayConfig = (page: Page, config: RelayMockConfig) =>
|
||||||
|
page.addInitScript(
|
||||||
|
([key, value]) => {
|
||||||
|
Object.assign(window, {[key]: value})
|
||||||
|
},
|
||||||
|
[RELAY_MOCKS_KEY, config] as const,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Full network isolation plus optional fixtures, in one call. With no config, every relay returns
|
||||||
|
// nothing (requirement 1). Pass {relays: {url: events}} to populate specific relays (requirement 2).
|
||||||
|
export const setupRelayMocks = async (page: Page, config: RelayMockConfig = {}) => {
|
||||||
|
await blockWebsockets(page)
|
||||||
|
await injectRelayConfig(page, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type {RelayMockConfig}
|
||||||
@@ -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 '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 '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 '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
|
end
|
||||||
|
|
||||||
target 'Flotilla Chat' do
|
target 'Flotilla Chat' do
|
||||||
|
|||||||
@@ -5,15 +5,14 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "./build.sh",
|
"build": "./build.sh",
|
||||||
|
"build:server": "vite build --config vite.config.server.ts",
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
|
"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": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"lint": "prettier --check src && eslint src",
|
"lint": "prettier --check src && eslint src",
|
||||||
|
"test": "playwright test",
|
||||||
|
"test:ui": "playwright test --ui",
|
||||||
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
|
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
|
||||||
"format:all": "prettier --write src",
|
"format:all": "prettier --write src",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
@@ -21,26 +20,27 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/assets": "^3.0.5",
|
"@capacitor/assets": "^3.0.5",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"@sveltejs/kit": "^2.50.1",
|
"@playwright/test": "^1.49.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",
|
"@tailwindcss/postcss": "^4.2.2",
|
||||||
"@tauri-apps/cli": "^2.9.6",
|
|
||||||
"@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",
|
||||||
"eslint-config-prettier": "^9.1.2",
|
"eslint-config-prettier": "^9.1.2",
|
||||||
"eslint-plugin-svelte": "^2.46.1",
|
"eslint-plugin-svelte": "^2.46.1",
|
||||||
"globals": "^15.15.0",
|
"globals": "^15.15.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.15",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"prettier-plugin-svelte": "^3.4.1",
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
"svelte": "^5.48.0",
|
"svelte": "^5.55.9",
|
||||||
"svelte-check": "^4.3.5",
|
"svelte-check": "^4.3.5",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.53.1",
|
"typescript-eslint": "^8.53.1",
|
||||||
"vite": "^5.4.21"
|
"vite": "^6.4.2"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -63,25 +63,25 @@
|
|||||||
"@getalby/sdk": "^5.1.2",
|
"@getalby/sdk": "^5.1.2",
|
||||||
"@hono/node-server": "^2.0.0",
|
"@hono/node-server": "^2.0.0",
|
||||||
"@noble/curves": "^1.9.7",
|
"@noble/curves": "^1.9.7",
|
||||||
"@pomade/core": "^0.2.3",
|
"@pomade/core": "^0.2.5",
|
||||||
"@poppanator/sveltekit-svg": "^4.2.1",
|
"@poppanator/sveltekit-svg": "^7.0.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@tiptap/core": "^2.27.2",
|
"@tiptap/core": "^2.27.2",
|
||||||
"@tiptap/pm": "^2.27.2",
|
"@tiptap/pm": "^2.27.2",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/throttle-debounce": "^5.0.2",
|
"@types/throttle-debounce": "^5.0.2",
|
||||||
"@vite-pwa/assets-generator": "^0.2.6",
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
"@vite-pwa/sveltekit": "^0.6.8",
|
"@vite-pwa/sveltekit": "^1.1.0",
|
||||||
"@welshman/app": "^0.8.15",
|
"@welshman/app": "^0.8.16",
|
||||||
"@welshman/content": "^0.8.15",
|
"@welshman/content": "^0.8.16",
|
||||||
"@welshman/editor": "^0.8.15",
|
"@welshman/editor": "^0.8.16",
|
||||||
"@welshman/feeds": "^0.8.15",
|
"@welshman/feeds": "^0.8.16",
|
||||||
"@welshman/lib": "^0.8.15",
|
"@welshman/lib": "^0.8.16",
|
||||||
"@welshman/net": "^0.8.15",
|
"@welshman/net": "^0.8.16",
|
||||||
"@welshman/router": "^0.8.15",
|
"@welshman/router": "^0.8.16",
|
||||||
"@welshman/signer": "^0.8.15",
|
"@welshman/signer": "^0.8.16",
|
||||||
"@welshman/store": "^0.8.15",
|
"@welshman/store": "^0.8.16",
|
||||||
"@welshman/util": "^0.8.15",
|
"@welshman/util": "^0.8.16",
|
||||||
"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",
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
"emoji-picker-element": "^1.28.1",
|
"emoji-picker-element": "^1.28.1",
|
||||||
"emoji-picker-element-data": "^1.8.0",
|
"emoji-picker-element-data": "^1.8.0",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"hono": "^4.12.15",
|
"hono": "^4.12.23",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"livekit-client": "^2.17.2",
|
"livekit-client": "^2.17.2",
|
||||||
@@ -102,17 +102,5 @@
|
|||||||
"throttle-debounce": "^5.0.2",
|
"throttle-debounce": "^5.0.2",
|
||||||
"tippy.js": "^6.3.7"
|
"tippy.js": "^6.3.7"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"packageManager": "pnpm@11.5.1"
|
||||||
"ignoredBuiltDependencies": [
|
|
||||||
"esbuild"
|
|
||||||
],
|
|
||||||
"onlyBuiltDependencies": [
|
|
||||||
"sharp",
|
|
||||||
"nostr-signer-capacitor-plugin"
|
|
||||||
],
|
|
||||||
"overrides": {
|
|
||||||
"sharp": "0.35.0-rc.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import {defineConfig, devices} from "@playwright/test"
|
||||||
|
|
||||||
|
// E2E tests live in ./e2e and run against the dev server (port 1847 from vite.config.ts).
|
||||||
|
// Run with `pnpm test:e2e` (after `pnpm exec playwright install` to fetch browsers).
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "e2e",
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
reporter: "html",
|
||||||
|
use: {
|
||||||
|
baseURL: "http://localhost:1847",
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
// Boots the SvelteKit dev server before the suite and reuses one if already running locally.
|
||||||
|
webServer: {
|
||||||
|
command: "pnpm dev",
|
||||||
|
url: "http://localhost:1847",
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{name: "chromium", use: {...devices["Desktop Chrome"]}},
|
||||||
|
{name: "firefox", use: {...devices["Desktop Firefox"]}},
|
||||||
|
{name: "webkit", use: {...devices["Desktop Safari"]}},
|
||||||
|
],
|
||||||
|
})
|
||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,15 +6,22 @@ export type VoiceSession = {
|
|||||||
url: string
|
url: string
|
||||||
h: string
|
h: string
|
||||||
room: LiveKitRoom
|
room: LiveKitRoom
|
||||||
muted: boolean
|
|
||||||
cameraOn: boolean
|
cameraOn: boolean
|
||||||
screenShareOn: boolean
|
screenShareOn: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mic mute state is separate so toggling it does not re-render video tiles. */
|
||||||
|
export const voiceMicMuted = writable(true)
|
||||||
|
|
||||||
export type Pubkey = string
|
export type Pubkey = string
|
||||||
|
|
||||||
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
|
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
|
||||||
|
|
||||||
|
export type ParticipantMediaState = {
|
||||||
|
muted: boolean
|
||||||
|
cameraOn: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export enum VoiceState {
|
export enum VoiceState {
|
||||||
Joining = "joining",
|
Joining = "joining",
|
||||||
Connected = "connected",
|
Connected = "connected",
|
||||||
@@ -27,8 +34,6 @@ export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
|
|||||||
|
|
||||||
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
||||||
|
|
||||||
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
|
|
||||||
|
|
||||||
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
|
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
|
||||||
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
|
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
|
||||||
|
|
||||||
@@ -41,6 +46,19 @@ export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
|
|||||||
|
|
||||||
export const speakingParticipants = writable<VoiceParticipant[]>([])
|
export const speakingParticipants = writable<VoiceParticipant[]>([])
|
||||||
|
|
||||||
|
export const participantMediaState = writable(new Map<string, ParticipantMediaState>())
|
||||||
|
|
||||||
|
export const mediaStateByIdentity = derived(
|
||||||
|
[participantMediaState, currentVoiceSession, voiceMicMuted],
|
||||||
|
([$media, $session, $micMuted]) =>
|
||||||
|
(identity: string) => {
|
||||||
|
if ($session?.room.localParticipant.identity === identity) {
|
||||||
|
return {muted: $micMuted, cameraOn: $session.cameraOn}
|
||||||
|
}
|
||||||
|
return $media.get(identity) ?? {muted: true, cameraOn: false}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
export const isParticipantSpeaking = derived(
|
export const isParticipantSpeaking = derived(
|
||||||
speakingParticipants,
|
speakingParticipants,
|
||||||
$participants => (p: VoiceParticipant) =>
|
$participants => (p: VoiceParticipant) =>
|
||||||
|
|||||||
@@ -6,28 +6,31 @@ import {
|
|||||||
DisconnectReason,
|
DisconnectReason,
|
||||||
LocalParticipant,
|
LocalParticipant,
|
||||||
LocalTrackPublication,
|
LocalTrackPublication,
|
||||||
|
Participant,
|
||||||
Room as LiveKitRoom,
|
Room as LiveKitRoom,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
Track,
|
Track,
|
||||||
|
TrackPublication,
|
||||||
supportsAudioOutputSelection,
|
supportsAudioOutputSelection,
|
||||||
type AudioCaptureOptions,
|
type AudioCaptureOptions,
|
||||||
} from "livekit-client"
|
} from "livekit-client"
|
||||||
import {derived, get} from "svelte/store"
|
import {derived, get} from "svelte/store"
|
||||||
import {map, removeUndefined, uniqBy} from "@welshman/lib"
|
import {map, not, nthEq, reject, removeUndefined, uniqBy} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
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, whenAborted, whenTimeout} from "$lib/util"
|
import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util"
|
||||||
import {
|
import {
|
||||||
currentVoiceRoom,
|
currentVoiceRoom,
|
||||||
currentVoiceSession,
|
currentVoiceSession,
|
||||||
|
voiceMicMuted,
|
||||||
participantFromLiveKitIdentity,
|
participantFromLiveKitIdentity,
|
||||||
participantKey,
|
participantKey,
|
||||||
participantPubkeyMap,
|
participantMediaState,
|
||||||
pubkeyFromLiveKitIdentity,
|
|
||||||
speakingParticipants,
|
speakingParticipants,
|
||||||
VoiceState,
|
VoiceState,
|
||||||
|
type ParticipantMediaState,
|
||||||
type VoiceParticipant,
|
type VoiceParticipant,
|
||||||
voiceState,
|
voiceState,
|
||||||
} from "@app/call/stores"
|
} from "@app/call/stores"
|
||||||
@@ -75,20 +78,51 @@ export const switchVoiceActiveDevice = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const addParticipant = (identity: string) => {
|
const participantMediaFrom = (participant: Participant): ParticipantMediaState => ({
|
||||||
participantPubkeyMap.update(m => {
|
muted: !participant.isMicrophoneEnabled,
|
||||||
|
cameraOn: participant.isCameraEnabled,
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteParticipant = (identity: string) => {
|
||||||
|
participantMediaState.update(m => new Map(reject(nthEq(0, identity), [...m])))
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncParticipantMedia = (participant: Participant) => {
|
||||||
|
const state = participantMediaFrom(participant)
|
||||||
|
participantMediaState.update(m => {
|
||||||
|
const prev = m.get(participant.identity)
|
||||||
|
if (prev?.muted === state.muted && prev?.cameraOn === state.cameraOn) return m
|
||||||
const next = new Map(m)
|
const next = new Map(m)
|
||||||
next.set(identity, pubkeyFromLiveKitIdentity(identity) ?? "")
|
next.set(participant.identity, state)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteParticipant = (identity: string) => {
|
// LiveKit does not emit ParticipantConnected/Disconnected during reconnect.
|
||||||
participantPubkeyMap.update(m => {
|
const resyncAfterReconnect = (room: LiveKitRoom) => {
|
||||||
const next = new Map(m)
|
if (room !== activeRoom) return
|
||||||
next.delete(identity)
|
|
||||||
return next
|
const next = new Map<string, ParticipantMediaState>()
|
||||||
|
for (const p of [room.localParticipant, ...room.remoteParticipants.values()]) {
|
||||||
|
next.set(p.identity, participantMediaFrom(p))
|
||||||
|
}
|
||||||
|
participantMediaState.set(next)
|
||||||
|
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
|
if (!session) 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchLivekitToken = async (
|
const fetchLivekitToken = async (
|
||||||
@@ -124,15 +158,15 @@ export const deriveVoiceParticipants = (url: string, h: string) =>
|
|||||||
// We use the livekit identity list while in a call, and fall back to the list in kind 39004.
|
// We use the livekit identity list while in a call, and fall back to the list in kind 39004.
|
||||||
derived(
|
derived(
|
||||||
[
|
[
|
||||||
participantPubkeyMap,
|
participantMediaState,
|
||||||
currentVoiceRoom,
|
currentVoiceRoom,
|
||||||
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
|
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
|
||||||
],
|
],
|
||||||
([$participantPubkeyMap, $currentVoiceRoom, $publishedParticipantList]) => {
|
([$participantMediaState, $currentVoiceRoom, $publishedParticipantList]) => {
|
||||||
const inCall = $participantPubkeyMap.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h)
|
const inCall = $participantMediaState.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h)
|
||||||
|
|
||||||
if (inCall) {
|
if (inCall) {
|
||||||
const participants = [...$participantPubkeyMap.keys()].map(participantFromLiveKitIdentity)
|
const participants = [...$participantMediaState.keys()].map(participantFromLiveKitIdentity)
|
||||||
return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
|
return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
|
||||||
} else {
|
} else {
|
||||||
const latestEvent = $publishedParticipantList as TrustedEvent | undefined
|
const latestEvent = $publishedParticipantList as TrustedEvent | undefined
|
||||||
@@ -152,6 +186,8 @@ 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
|
||||||
@@ -163,28 +199,100 @@ const setUpMicrophone = async (
|
|||||||
capture = {deviceId: preferredMicId}
|
capture = {deviceId: preferredMicId}
|
||||||
}
|
}
|
||||||
try {
|
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
|
muted = false
|
||||||
} catch (e) {
|
} 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
|
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 RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
||||||
|
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let reconnectAttempt = 0
|
||||||
|
|
||||||
|
const clearReconnectSchedule = () => {
|
||||||
|
if (reconnectTimeout !== undefined) {
|
||||||
|
clearTimeout(reconnectTimeout)
|
||||||
|
reconnectTimeout = undefined
|
||||||
|
}
|
||||||
|
reconnectAttempt = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const attemptReconnect = async () => {
|
||||||
|
const target = get(currentVoiceRoom)
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await joinVoiceRoom(target.url, target.h)
|
||||||
|
} catch {
|
||||||
|
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
|
||||||
|
pushToast({theme: "error", message: "Voice connection lost."})
|
||||||
|
clearReconnectSchedule()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleReconnect = () => {
|
||||||
|
if (reconnectTimeout !== undefined) return
|
||||||
|
if (!get(currentVoiceRoom)) return
|
||||||
|
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
|
||||||
|
pushToast({theme: "error", message: "Voice connection lost."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = RECONNECT_DELAYS[reconnectAttempt]!
|
||||||
|
reconnectAttempt++
|
||||||
|
reconnectTimeout = setTimeout(() => {
|
||||||
|
reconnectTimeout = undefined
|
||||||
|
void attemptReconnect()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
videoPrimaryTileKey.set(undefined)
|
||||||
|
voiceMicMuted.set(true)
|
||||||
currentVoiceSession.set(undefined)
|
currentVoiceSession.set(undefined)
|
||||||
resetVideoCallLayout()
|
resetVideoCallLayout()
|
||||||
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
||||||
voiceState.set(VoiceState.Disconnected)
|
voiceState.set(VoiceState.Disconnected)
|
||||||
const message =
|
if (reason === DisconnectReason.JOIN_FAILURE) {
|
||||||
reason === DisconnectReason.JOIN_FAILURE
|
pushToast({theme: "error", message: "Could not connect to voice room. Please try again."})
|
||||||
? "Could not connect to voice room. Please try again."
|
} else if (get(currentVoiceRoom)) {
|
||||||
: "Voice connection lost."
|
clearReconnectSchedule()
|
||||||
pushToast({theme: "error", message})
|
scheduleReconnect()
|
||||||
|
} else {
|
||||||
|
pushToast({theme: "error", message: "Voice connection lost."})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
speakingParticipants.set([])
|
speakingParticipants.set([])
|
||||||
participantPubkeyMap.set(new Map())
|
participantMediaState.set(new Map())
|
||||||
}
|
}
|
||||||
|
|
||||||
const onTrackSubscribed = (track: Track) => {
|
const onTrackSubscribed = (track: Track) => {
|
||||||
@@ -214,8 +322,8 @@ const playJoinSound = () => {
|
|||||||
audio.play().catch(() => {})
|
audio.play().catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onParticipantConnected = (participant: {identity: string}) => {
|
const onParticipantConnected = (participant: Participant) => {
|
||||||
addParticipant(participant.identity)
|
syncParticipantMedia(participant)
|
||||||
playJoinSound()
|
playJoinSound()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,20 +344,22 @@ const onLocalTrackUnpublished = (
|
|||||||
|
|
||||||
let joinAbortController: AbortController | undefined
|
let joinAbortController: AbortController | undefined
|
||||||
|
|
||||||
export const cancelJoinVoiceRoom = () => {
|
const abortJoinVoiceRoom = () => {
|
||||||
joinAbortController?.abort()
|
joinAbortController?.abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const cancelJoinVoiceRoom = () => {
|
||||||
|
clearReconnectSchedule()
|
||||||
|
abortJoinVoiceRoom()
|
||||||
|
}
|
||||||
|
|
||||||
export const joinVoiceRoom = async (
|
export const joinVoiceRoom = async (
|
||||||
url: string,
|
url: string,
|
||||||
h: string,
|
h: string,
|
||||||
startMuted = true,
|
startMuted = true,
|
||||||
preferredMicId?: string,
|
preferredMicId?: string,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
cancelJoinVoiceRoom()
|
abortJoinVoiceRoom()
|
||||||
|
|
||||||
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)
|
||||||
@@ -259,62 +369,123 @@ 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 {
|
||||||
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()
|
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, onRoomDisconnected)
|
liveKitRoom.on(RoomEvent.Disconnected, makeOnRoomDisconnected(liveKitRoom))
|
||||||
|
liveKitRoom.on(RoomEvent.Reconnected, makeOnRoomReconnected(liveKitRoom))
|
||||||
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)
|
||||||
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
||||||
liveKitRoom.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
|
liveKitRoom.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
|
||||||
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
||||||
|
liveKitRoom.on(RoomEvent.TrackMuted, onParticipantMediaChanged)
|
||||||
|
liveKitRoom.on(RoomEvent.TrackUnmuted, onParticipantMediaChanged)
|
||||||
|
liveKitRoom.on(RoomEvent.TrackPublished, onParticipantMediaChanged)
|
||||||
|
liveKitRoom.on(RoomEvent.TrackUnpublished, onParticipantMediaChanged)
|
||||||
|
liveKitRoom.on(RoomEvent.LocalTrackPublished, onParticipantMediaChanged)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
participantPubkeyMap.set(new Map())
|
participantMediaState.set(new Map())
|
||||||
addParticipant(liveKitRoom.localParticipant.identity)
|
syncParticipantMedia(liveKitRoom.localParticipant)
|
||||||
for (const p of liveKitRoom.remoteParticipants.values()) {
|
for (const p of liveKitRoom.remoteParticipants.values()) {
|
||||||
addParticipant(p.identity)
|
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({
|
currentVoiceSession.set({
|
||||||
url,
|
url,
|
||||||
h,
|
h,
|
||||||
room: liveKitRoom,
|
room: liveKitRoom,
|
||||||
muted,
|
|
||||||
cameraOn: false,
|
cameraOn: false,
|
||||||
screenShareOn: false,
|
screenShareOn: false,
|
||||||
})
|
})
|
||||||
voiceState.set(VoiceState.Connected)
|
voiceState.set(VoiceState.Connected)
|
||||||
|
clearReconnectSchedule()
|
||||||
playJoinSound()
|
playJoinSound()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isActive()) voiceState.set(VoiceState.Disconnected)
|
if (isActive()) voiceState.set(VoiceState.Disconnected)
|
||||||
if (e instanceof AbortError) return
|
if (e instanceof AbortError) {
|
||||||
|
clearReconnectSchedule()
|
||||||
|
return
|
||||||
|
}
|
||||||
throw e
|
throw e
|
||||||
} finally {
|
} finally {
|
||||||
|
settle.abort()
|
||||||
if (isActive()) joinAbortController = undefined
|
if (isActive()) joinAbortController = undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const leaveVoiceRoom = async () => {
|
export const leaveVoiceRoom = async () => {
|
||||||
|
clearReconnectSchedule()
|
||||||
const session = get(currentVoiceSession)
|
const session = get(currentVoiceSession)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
||||||
@@ -337,37 +508,40 @@ export const leaveVoiceRoom = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
voiceState.set(VoiceState.Disconnected)
|
// Always tear down this room's connection and listeners.
|
||||||
videoPrimaryTileKey.set(undefined)
|
if (activeRoom === session.room) activeRoom = undefined
|
||||||
currentVoiceSession.set(undefined)
|
session.room.removeAllListeners()
|
||||||
resetVideoCallLayout()
|
|
||||||
session.room.disconnect()
|
session.room.disconnect()
|
||||||
speakingParticipants.set([])
|
|
||||||
participantPubkeyMap.set(new Map())
|
|
||||||
}
|
|
||||||
|
|
||||||
export const rejoinVoiceRoom = async (): Promise<void> => {
|
// Only reset shared UI state if this session is still current. A slow leave
|
||||||
const target = get(currentVoiceRoom)
|
// that was superseded by a new join (bounded by a timeout in joinVoiceRoom)
|
||||||
if (!target) return
|
// must not clobber the freshly-joined session when it finally completes.
|
||||||
return joinVoiceRoom(target.url, target.h)
|
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 toggleMute = async () => {
|
export const toggleMute = async () => {
|
||||||
const session = get(currentVoiceSession)
|
const session = get(currentVoiceSession)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
||||||
const muted = !session.muted
|
voiceMicMuted.update(not)
|
||||||
if (muted) {
|
if (get(voiceMicMuted)) {
|
||||||
// Disable and re-enable microphone to trigger permission prompt
|
// Disable and re-enable microphone to trigger permission prompt
|
||||||
session.room.localParticipant.setMicrophoneEnabled(false)
|
session.room.localParticipant.setMicrophoneEnabled(false)
|
||||||
currentVoiceSession.set({...session, muted})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await session.room.localParticipant.setMicrophoneEnabled(true)
|
await session.room.localParticipant.setMicrophoneEnabled(true)
|
||||||
currentVoiceSession.set({...session, muted})
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
voiceMicMuted.set(true)
|
||||||
pushToast({theme: "error", message: "Could not access microphone"})
|
pushToast({theme: "error", message: "Could not access microphone"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,7 @@
|
|||||||
repository.removeEvent(thunk.event.id)
|
repository.removeEvent(thunk.event.id)
|
||||||
pushToast({theme: "error", message})
|
pushToast({theme: "error", message})
|
||||||
} else {
|
} else {
|
||||||
|
await removeRoomMembership(url, h)
|
||||||
goto(makeSpacePath(url))
|
goto(makeSpacePath(url))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
import Modal from "@lib/components/Modal.svelte"
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
import LogIn from "@app/components/LogIn.svelte"
|
import LogIn from "@app/components/LogIn.svelte"
|
||||||
import InfoNostr from "@app/components/InfoNostr.svelte"
|
|
||||||
import SignUpKey from "@app/components/SignUpKey.svelte"
|
import SignUpKey from "@app/components/SignUpKey.svelte"
|
||||||
import SignUpEmail from "@app/components/SignUpEmail.svelte"
|
import SignUpEmail from "@app/components/SignUpEmail.svelte"
|
||||||
import SignUpProfile from "@app/components/SignUpProfile.svelte"
|
import SignUpProfile from "@app/components/SignUpProfile.svelte"
|
||||||
@@ -91,11 +90,9 @@
|
|||||||
|
|
||||||
<Modal>
|
<Modal>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<h1 class="heading">Sign up with Nostr</h1>
|
<h1 class="heading">Join {PLATFORM_NAME}</h1>
|
||||||
<p class="m-auto max-w-sm text-center">
|
<p class="m-auto max-w-sm text-center">
|
||||||
{PLATFORM_NAME} is built using the
|
Censorship resistant digital spaces for communities. Meet new people, own your identity.
|
||||||
<Button class="link" onclick={() => pushModal(InfoNostr)}>nostr protocol</Button>, which gives
|
|
||||||
users control over their digital identity using <strong>cryptographic key pairs</strong>.
|
|
||||||
</p>
|
</p>
|
||||||
{#if hasPomade}
|
{#if hasPomade}
|
||||||
<Button onclick={flows.email.start} class="btn btn-primary">
|
<Button onclick={flows.email.start} class="btn btn-primary">
|
||||||
|
|||||||
@@ -7,17 +7,19 @@
|
|||||||
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 PageBar from "@lib/components/PageBar.svelte"
|
import PageBar from "@lib/components/PageBar.svelte"
|
||||||
|
import RelayIcon from "@app/components/RelayIcon.svelte"
|
||||||
import {decodeRelay} from "@app/core/state"
|
import {decodeRelay} from "@app/core/state"
|
||||||
import {makeSpacePath} from "@app/util/routes"
|
import {makeSpacePath} from "@app/util/routes"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
back?: () => unknown
|
back?: () => unknown
|
||||||
|
leading?: Snippet
|
||||||
title?: Snippet
|
title?: Snippet
|
||||||
action?: Snippet
|
action?: Snippet
|
||||||
[key: string]: any
|
[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!)
|
const url = decodeRelay($page.params.relay!)
|
||||||
</script>
|
</script>
|
||||||
@@ -30,6 +32,10 @@
|
|||||||
<div class="ellipsize whitespace-nowrap flex grow items-center justify-between gap-4">
|
<div class="ellipsize whitespace-nowrap flex grow items-center justify-between gap-4">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div class="flex gap-2 items-center">
|
<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?.()}
|
{@render title?.()}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-primary md:hidden">
|
<div class="text-xs text-primary md:hidden">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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/notifications"
|
import {Push} from "@app/util/push"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!containerEl) return
|
if (!containerEl) return
|
||||||
containerEl.addEventListener("touchmove", onTouchMove, {passive: false})
|
containerEl.addEventListener("touchmove", onTouchMove, {passive: false})
|
||||||
return () => containerEl!.removeEventListener("touchmove", onTouchMove)
|
return () => containerEl?.removeEventListener("touchmove", onTouchMove)
|
||||||
})
|
})
|
||||||
|
|
||||||
const onActionClick = () => {
|
const onActionClick = () => {
|
||||||
@@ -71,6 +71,7 @@
|
|||||||
|
|
||||||
{#if $toast}
|
{#if $toast}
|
||||||
{@const theme = $toast.theme || "info"}
|
{@const theme = $toast.theme || "info"}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
bind:this={containerEl}
|
bind:this={containerEl}
|
||||||
transition:fly={{y: -20}}
|
transition:fly={{y: -20}}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||||
import VideoCallTile from "@app/components/VideoCallTile.svelte"
|
import VideoCallTile from "@app/components/VideoCallTile.svelte"
|
||||||
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
||||||
|
import VoiceParticipantMediaBadges from "@app/components/VoiceParticipantMediaBadges.svelte"
|
||||||
import {get} from "svelte/store"
|
import {get} from "svelte/store"
|
||||||
import {
|
import {
|
||||||
VideoCallLayout,
|
VideoCallLayout,
|
||||||
@@ -18,7 +19,12 @@
|
|||||||
ViewportSize,
|
ViewportSize,
|
||||||
videoPrimaryTileKey,
|
videoPrimaryTileKey,
|
||||||
} from "@app/call/video"
|
} from "@app/call/video"
|
||||||
import {currentVoiceSession, currentVoiceRoom, pubkeyFromLiveKitIdentity} from "@app/call/stores"
|
import {
|
||||||
|
currentVoiceSession,
|
||||||
|
currentVoiceRoom,
|
||||||
|
mediaStateByIdentity,
|
||||||
|
pubkeyFromLiveKitIdentity,
|
||||||
|
} from "@app/call/stores"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
layout: VideoCallLayout
|
layout: VideoCallLayout
|
||||||
@@ -121,6 +127,25 @@
|
|||||||
source: Track.Source.ScreenShare,
|
source: Track.Source.ScreenShare,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (!videoTiles.some(t => t.identity === rp.identity)) {
|
||||||
|
videoTiles.push({
|
||||||
|
identity: rp.identity,
|
||||||
|
isLocal: false,
|
||||||
|
trackSid: `avatar-${rp.identity}`,
|
||||||
|
track: undefined,
|
||||||
|
source: Track.Source.Camera,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!videoTiles.some(t => t.identity === user.identity)) {
|
||||||
|
videoTiles.push({
|
||||||
|
identity: user.identity,
|
||||||
|
isLocal: true,
|
||||||
|
trackSid: "local-avatar",
|
||||||
|
track: undefined,
|
||||||
|
source: Track.Source.Camera,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return videoTiles
|
return videoTiles
|
||||||
@@ -144,6 +169,9 @@
|
|||||||
|
|
||||||
const useSpotlightLayout = $derived(primaryTile !== undefined)
|
const useSpotlightLayout = $derived(primaryTile !== undefined)
|
||||||
const useMultiGrid = $derived(!useSpotlightLayout && videoTiles.length > 2)
|
const useMultiGrid = $derived(!useSpotlightLayout && videoTiles.length > 2)
|
||||||
|
const multiGridClass = $derived(
|
||||||
|
layout === VideoCallLayout.Split ? "grid-cols-1" : "grid-cols-1 sm:grid-cols-2",
|
||||||
|
)
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const k = $videoPrimaryTileKey
|
const k = $videoPrimaryTileKey
|
||||||
@@ -184,6 +212,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet videoTile(tile: VideoTileData, layout: TileLayout)}
|
{#snippet videoTile(tile: VideoTileData, layout: TileLayout)}
|
||||||
|
{@const media = $mediaStateByIdentity(tile.identity)}
|
||||||
<div
|
<div
|
||||||
class={cx(
|
class={cx(
|
||||||
"relative isolate overflow-hidden rounded-box shadow-sm",
|
"relative isolate overflow-hidden rounded-box shadow-sm",
|
||||||
@@ -203,6 +232,15 @@
|
|||||||
<ProfileCircle pubkey={pubkeyFromLiveKitIdentity(tile.identity)} {url} size={14} />
|
<ProfileCircle pubkey={pubkeyFromLiveKitIdentity(tile.identity)} {url} size={14} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if tile.track}
|
||||||
|
<div class="pointer-events-none absolute left-1 top-1 z-10">
|
||||||
|
<VoiceParticipantMediaBadges
|
||||||
|
muted={media.muted}
|
||||||
|
cameraOn={media.cameraOn}
|
||||||
|
showCamera={tile.source === Track.Source.Camera}
|
||||||
|
size={3} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<span
|
<span
|
||||||
class="pointer-events-none absolute bottom-1 left-1 max-w-[calc(100%-0.5rem)] truncate rounded bg-base-100/80 px-1.5 py-0.5 text-xs">
|
class="pointer-events-none absolute bottom-1 left-1 max-w-[calc(100%-0.5rem)] truncate rounded bg-base-100/80 px-1.5 py-0.5 text-xs">
|
||||||
{labelFor(tile.identity, tile.source)}{tile.isLocal ? " (you)" : ""}
|
{labelFor(tile.identity, tile.source)}{tile.isLocal ? " (you)" : ""}
|
||||||
@@ -213,8 +251,8 @@
|
|||||||
data-tip={pinned ? "Exit spotlight" : "Spotlight"}
|
data-tip={pinned ? "Exit spotlight" : "Spotlight"}
|
||||||
aria-pressed={pinned}
|
aria-pressed={pinned}
|
||||||
class={cx(
|
class={cx(
|
||||||
"absolute right-1 top-1 z-20 btn btn-xs btn-square btn-ghost",
|
"absolute right-1 top-1 z-20 btn btn-xs btn-square",
|
||||||
pinned ? "btn-active bg-primary/25 text-primary" : "bg-base-100/70",
|
pinned ? "btn-primary" : "btn-ghost bg-base-100",
|
||||||
)}
|
)}
|
||||||
onclick={spotlightHandlerFor(tileKey(tile))}>
|
onclick={spotlightHandlerFor(tileKey(tile))}>
|
||||||
<Icon icon={Pin} size={3} />
|
<Icon icon={Pin} size={3} />
|
||||||
@@ -238,8 +276,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if useMultiGrid}
|
{:else if useMultiGrid}
|
||||||
<div
|
<div class={cx("grid min-h-0 flex-1 content-start gap-2 overflow-y-auto", multiGridClass)}>
|
||||||
class="grid min-h-0 flex-1 grid-cols-1 content-start gap-2 overflow-y-auto sm:grid-cols-2">
|
|
||||||
{#each videoTiles as tile (tileKey(tile))}
|
{#each videoTiles as tile (tileKey(tile))}
|
||||||
{@render videoTile(tile, "default")}
|
{@render videoTile(tile, "default")}
|
||||||
{/each}
|
{/each}
|
||||||
@@ -254,8 +291,10 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="flex min-h-[12rem] flex-1 flex-col items-center justify-center gap-2 rounded-box bg-base-200/50 p-4 text-center text-sm opacity-80">
|
class="flex min-h-[12rem] flex-1 flex-col items-center justify-center gap-2 rounded-box bg-base-200/50 p-4 text-center text-sm opacity-80">
|
||||||
<p>No camera or screen share yet.</p>
|
<p>No one is sharing video yet.</p>
|
||||||
<p class="text-xs">Use the camera or screen share control to share video.</p>
|
<p class="text-xs">
|
||||||
|
Participants appear here when they turn on their camera or share their screen.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import cx from "classnames"
|
||||||
|
import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl"
|
||||||
|
import VideocameraOff from "@assets/icons/videocamera-off.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
muted: boolean
|
||||||
|
cameraOn: boolean
|
||||||
|
showCamera?: boolean
|
||||||
|
size?: number
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {muted, cameraOn, showCamera = true, size = 3, class: className = ""}: Props = $props()
|
||||||
|
|
||||||
|
const badgeClass =
|
||||||
|
"inline-flex size-4 shrink-0 items-center justify-center rounded bg-base-100/80 p-0.5 text-error"
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if muted || (showCamera && !cameraOn)}
|
||||||
|
<div class={cx("flex items-center gap-1", className)}>
|
||||||
|
{#if muted}
|
||||||
|
<span class={badgeClass} aria-label="Muted">
|
||||||
|
<Icon icon={MicrophoneOff} {size} />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if showCamera && !cameraOn}
|
||||||
|
<span class={badgeClass} aria-label="Camera off">
|
||||||
|
<Icon icon={VideocameraOff} {size} />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -9,11 +9,13 @@
|
|||||||
import {makeRoomPath} from "@app/util/routes"
|
import {makeRoomPath} from "@app/util/routes"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
|
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
|
||||||
|
import VoiceParticipantMediaBadges from "@app/components/VoiceParticipantMediaBadges.svelte"
|
||||||
import {makeRoomId} from "@app/core/state"
|
import {makeRoomId} from "@app/core/state"
|
||||||
import {
|
import {
|
||||||
VoiceState,
|
VoiceState,
|
||||||
currentVoiceRoom,
|
currentVoiceRoom,
|
||||||
isParticipantSpeaking,
|
isParticipantSpeaking,
|
||||||
|
mediaStateByIdentity,
|
||||||
participantKey,
|
participantKey,
|
||||||
voiceState,
|
voiceState,
|
||||||
type VoiceParticipant,
|
type VoiceParticipant,
|
||||||
@@ -83,9 +85,17 @@
|
|||||||
)}>
|
)}>
|
||||||
<ProfileCircle pubkey={p.pubkey} size={5} class="h-5 w-5" />
|
<ProfileCircle pubkey={p.pubkey} size={5} class="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<span class="ellipsize text-xs opacity-70">
|
<span class="ellipsize min-w-0 flex-1 text-xs opacity-70">
|
||||||
{p.pubkey ? displayProfileByPubkey(p.pubkey) : "Unknown"}
|
{p.pubkey ? displayProfileByPubkey(p.pubkey) : "Unknown"}
|
||||||
</span>
|
</span>
|
||||||
|
{#if isActive}
|
||||||
|
{@const media = $mediaStateByIdentity(p.identity)}
|
||||||
|
<VoiceParticipantMediaBadges
|
||||||
|
muted={media.muted}
|
||||||
|
cameraOn={media.cameraOn}
|
||||||
|
size={3}
|
||||||
|
class="shrink-0" />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
import {displayRelayUrl} from "@welshman/util"
|
import {displayRelayUrl} from "@welshman/util"
|
||||||
import Microphone from "@assets/icons/microphone.svg?dataurl"
|
import Microphone from "@assets/icons/microphone.svg?dataurl"
|
||||||
import Videocamera from "@assets/icons/videocamera.svg?dataurl"
|
import VideocameraOff from "@assets/icons/videocamera-off.svg?dataurl"
|
||||||
import VideocameraRecord from "@assets/icons/videocamera-record.svg?dataurl"
|
import VideocameraRecord from "@assets/icons/videocamera-record.svg?dataurl"
|
||||||
import Monitor from "@assets/icons/monitor.svg?dataurl"
|
import Monitor from "@assets/icons/monitor.svg?dataurl"
|
||||||
import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl"
|
import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl"
|
||||||
@@ -41,8 +41,8 @@
|
|||||||
VoiceState,
|
VoiceState,
|
||||||
currentVoiceSession,
|
currentVoiceSession,
|
||||||
currentVoiceRoom,
|
currentVoiceRoom,
|
||||||
|
voiceMicMuted,
|
||||||
voiceState,
|
voiceState,
|
||||||
isLocalSpeaking,
|
|
||||||
} from "@app/call/stores"
|
} from "@app/call/stores"
|
||||||
import {cancelJoinVoiceRoom, leaveVoiceRoom, toggleMute} from "@app/call/voice"
|
import {cancelJoinVoiceRoom, leaveVoiceRoom, toggleMute} from "@app/call/voice"
|
||||||
|
|
||||||
@@ -183,18 +183,16 @@
|
|||||||
</Button>
|
</Button>
|
||||||
{:else if $voiceState === VoiceState.Connected && $currentVoiceSession}
|
{:else if $voiceState === VoiceState.Connected && $currentVoiceSession}
|
||||||
<Button
|
<Button
|
||||||
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
|
data-tip={$voiceMicMuted ? "Unmute" : "Mute"}
|
||||||
class={cx(
|
class={cx(
|
||||||
mediaToggleClass,
|
mediaToggleClass,
|
||||||
"overflow-visible",
|
"overflow-visible",
|
||||||
!$currentVoiceSession.muted && $isLocalSpeaking && "text-primary",
|
$voiceMicMuted && "text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100",
|
||||||
$currentVoiceSession.muted &&
|
|
||||||
"text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100",
|
|
||||||
)}
|
)}
|
||||||
onclick={toggleMute}>
|
onclick={toggleMute}>
|
||||||
<span class="relative inline-flex items-center justify-center overflow-visible">
|
<span class="relative inline-flex items-center justify-center overflow-visible">
|
||||||
<Icon icon={Microphone} size={4} />
|
<Icon icon={Microphone} size={4} />
|
||||||
{#if $currentVoiceSession.muted}
|
{#if $voiceMicMuted}
|
||||||
<span
|
<span
|
||||||
class="pointer-events-none absolute inset-0 flex items-center justify-center overflow-visible"
|
class="pointer-events-none absolute inset-0 flex items-center justify-center overflow-visible"
|
||||||
aria-hidden="true">
|
aria-hidden="true">
|
||||||
@@ -207,9 +205,17 @@
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
data-tip={$currentVoiceSession.cameraOn ? "Turn off camera" : "Turn on camera"}
|
data-tip={$currentVoiceSession.cameraOn ? "Turn off camera" : "Turn on camera"}
|
||||||
class={cx(mediaToggleClass, $currentVoiceSession.cameraOn && "text-primary")}
|
class={cx(
|
||||||
|
mediaToggleClass,
|
||||||
|
"overflow-visible",
|
||||||
|
$currentVoiceSession.cameraOn && "text-primary",
|
||||||
|
!$currentVoiceSession.cameraOn &&
|
||||||
|
"text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100",
|
||||||
|
)}
|
||||||
onclick={toggleCamera}>
|
onclick={toggleCamera}>
|
||||||
<Icon icon={$currentVoiceSession.cameraOn ? VideocameraRecord : Videocamera} size={4} />
|
<Icon
|
||||||
|
icon={$currentVoiceSession.cameraOn ? VideocameraRecord : VideocameraOff}
|
||||||
|
size={4} />
|
||||||
</Button>
|
</Button>
|
||||||
{#if !Capacitor.isNativePlatform()}
|
{#if !Capacitor.isNativePlatform()}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {derived, readable, writable} from "svelte/store"
|
|||||||
import * as nip19 from "nostr-tools/nip19"
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
import {
|
import {
|
||||||
on,
|
on,
|
||||||
gt,
|
gte,
|
||||||
max,
|
max,
|
||||||
spec,
|
spec,
|
||||||
call,
|
call,
|
||||||
@@ -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: false,
|
sound: true,
|
||||||
badge: false,
|
badge: false,
|
||||||
spaces: true,
|
spaces: true,
|
||||||
mentions: true,
|
mentions: true,
|
||||||
@@ -620,7 +620,7 @@ export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByU
|
|||||||
for (const event of metaEvents) {
|
for (const event of metaEvents) {
|
||||||
const meta = tryCatch(() => readRoomMeta(event))
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,7 +651,10 @@ export const loadRoom = call(() => {
|
|||||||
|
|
||||||
await load({
|
await load({
|
||||||
relays: [url],
|
relays: [url],
|
||||||
filters: [{kinds: [ROOM_META], "#d": [h]}],
|
filters: [
|
||||||
|
{kinds: [ROOM_META], "#d": [h]},
|
||||||
|
{kinds: [ROOM_DELETE], "#h": [h]},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -769,6 +772,8 @@ export const getSpaceRoomsFromGroupList = (url: string, groupList: List | undefi
|
|||||||
|
|
||||||
export const userGroupList = makeUserData(groupListsByPubkey, loadGroupList)
|
export const userGroupList = makeUserData(groupListsByPubkey, loadGroupList)
|
||||||
|
|
||||||
|
export const deriveUserGroupList = userGroupList
|
||||||
|
|
||||||
export const loadUserGroupList = makeUserLoader(loadGroupList)
|
export const loadUserGroupList = makeUserLoader(loadGroupList)
|
||||||
|
|
||||||
export const userSpaceUrls = derived(userGroupList, getSpaceUrlsFromGroupList)
|
export const userSpaceUrls = derived(userGroupList, getSpaceUrlsFromGroupList)
|
||||||
|
|||||||
@@ -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,11 +1,17 @@
|
|||||||
import {derived} from "svelte/store"
|
import {derived, get, writable} from "svelte/store"
|
||||||
import {Badge} from "@capawesome/capacitor-badge"
|
import {Badge} from "@capawesome/capacitor-badge"
|
||||||
import {synced, throttled, withGetter} from "@welshman/store"
|
import {synced, throttled, withGetter} from "@welshman/store"
|
||||||
import {pubkey, tracker, repository, relaysByUrl} from "@welshman/app"
|
import {pubkey, signer, tracker, repository, relaysByUrl} from "@welshman/app"
|
||||||
import {assoc, prop, first, identity, groupBy, now} from "@welshman/lib"
|
import {assoc, prop, first, identity, groupBy, now, throttle, parseJson, gt} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {SignedEvent, TrustedEvent} from "@welshman/util"
|
||||||
import {deriveEventsByIdByUrl} from "@welshman/store"
|
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 {makeSpacePath, makeRoomPath, makeSpaceChatPath, makeChatPath} from "@app/util/routes"
|
||||||
import {
|
import {
|
||||||
CONTENT_KINDS,
|
CONTENT_KINDS,
|
||||||
@@ -15,10 +21,11 @@ import {
|
|||||||
getSpaceUrlsFromGroupList,
|
getSpaceUrlsFromGroupList,
|
||||||
makeCommentFilter,
|
makeCommentFilter,
|
||||||
hasNip29,
|
hasNip29,
|
||||||
|
dufflepud,
|
||||||
|
DUFFLEPUD_URL,
|
||||||
} 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
|
||||||
|
|
||||||
@@ -36,6 +43,9 @@ export const deriveChecked = (key: string) => derived(checked, prop<number>(key)
|
|||||||
|
|
||||||
export const setChecked = (key: string) => checked.update(assoc(key, now()))
|
export const setChecked = (key: string) => checked.update(assoc(key, now()))
|
||||||
|
|
||||||
|
/** Room path while video call UI hides chat; checked + badge stay active until chat is shown. */
|
||||||
|
export const deferredRoomPath = writable<string | undefined>(undefined)
|
||||||
|
|
||||||
export const syncChecked = () => {
|
export const syncChecked = () => {
|
||||||
let prev = ""
|
let prev = ""
|
||||||
|
|
||||||
@@ -57,8 +67,11 @@ export const syncChecked = () => {
|
|||||||
|
|
||||||
// Set checked when we visit a given page - but delay it a tad
|
// Set checked when we visit a given page - but delay it a tad
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
const defer = get(deferredRoomPath)
|
||||||
|
|
||||||
checked.update($checked => {
|
checked.update($checked => {
|
||||||
for (const path of getPaths($page.url.pathname)) {
|
for (const path of getPaths($page.url.pathname)) {
|
||||||
|
if (defer && path === defer) continue
|
||||||
$checked[path] = now()
|
$checked[path] = now()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,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
|
// Derived notifications state
|
||||||
|
|
||||||
export const allNotifications = derived(
|
export const allNotifications = derived(
|
||||||
@@ -151,9 +258,17 @@ export const allNotifications = derived(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
export const notifications = derived([page, allNotifications], ([$page, $allNotifications]) => {
|
export const notifications = derived(
|
||||||
return new Set([...$allNotifications].filter(p => !$page.url.pathname.startsWith(p)))
|
[page, allNotifications, deferredRoomPath],
|
||||||
})
|
([$page, $allNotifications, $deferredRoomPath]) =>
|
||||||
|
new Set(
|
||||||
|
[...$allNotifications].filter(p => {
|
||||||
|
if (!$page.url.pathname.startsWith(p)) return true
|
||||||
|
if ($deferredRoomPath && p === $deferredRoomPath) return true
|
||||||
|
return false
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
// Badges
|
// Badges
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2 11.5C2 8.21252 2 6.56878 2.90796 5.46243C3.07418 5.25989 3.25989 5.07418 3.46243 4.90796C4.56878 4 6.21252 4 9.5 4C12.7875 4 14.4312 4 15.5376 4.90796C15.7401 5.07418 15.9258 5.25989 16.092 5.46243C17 6.56878 17 8.21252 17 11.5V12.5C17 15.7875 17 17.4312 16.092 18.5376C15.9258 18.7401 15.7401 18.9258 15.5376 19.092C14.4312 20 12.7875 20 9.5 20C6.21252 20 4.56878 20 3.46243 19.092C3.25989 18.9258 3.07418 18.7401 2.90796 18.5376C2 17.4312 2 15.7875 2 12.5V11.5Z" stroke="#000000" stroke-width="1.5"/>
|
||||||
|
<path d="M17 9.50019L17.6584 9.17101C19.6042 8.19807 20.5772 7.7116 21.2886 8.15127C22 8.59094 22 9.67872 22 11.8543V12.1461C22 14.3217 22 15.4094 21.2886 15.8491C20.5772 16.2888 19.6042 15.8023 17.6584 14.8294L17 14.5002V9.50019Z" stroke="#000000" stroke-width="1.5"/>
|
||||||
|
<path d="M22 2L2 22" stroke="#000000" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 970 B |
@@ -9,8 +9,8 @@
|
|||||||
const {children, ...props}: Props = $props()
|
const {children, ...props}: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div data-component="PageBar" class="relative z-nav p-2 -mb-4 {props.class}">
|
<div
|
||||||
<div class="rounded-xl bg-base-100 p-4 shadow-md h-20 md:h-12 flex flex-col justify-center">
|
data-component="PageBar"
|
||||||
{@render children?.()}
|
class="relative bg-base-100 p-4 shadow-md h-20 md:h-12 flex flex-col justify-center z-nav {props.class}">
|
||||||
</div>
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ export const createScroller = ({
|
|||||||
: element.closest(".scroll-container")
|
: element.closest(".scroll-container")
|
||||||
|
|
||||||
const check = async () => {
|
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
|
// 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
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import {on} from "@welshman/lib"
|
||||||
|
import {isRelayUrl, normalizeRelayUrl} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {AbstractAdapter, AdapterEvent, LocalAdapter, Repository, netContext} from "@welshman/net"
|
||||||
|
import type {ClientMessage, NetContext, RelayMessage} from "@welshman/net"
|
||||||
|
|
||||||
|
// The window key Playwright writes the mock config to (see e2e/support/relayMocks.ts). Keep it in
|
||||||
|
// sync with the literal duplicated there.
|
||||||
|
export const RELAY_MOCKS_KEY = "__RELAY_MOCKS__"
|
||||||
|
|
||||||
|
export type RelayMockConfig = {
|
||||||
|
// Map of relay url -> events that relay should return. Any relay NOT listed returns nothing (an
|
||||||
|
// immediate EOSE), which is what keeps tests offline and reproducible by default.
|
||||||
|
relays?: Record<string, TrustedEvent[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wraps welshman's LocalAdapter so we reuse its REQ/EVENT/CLOSE handling against an in-memory
|
||||||
|
// Repository, but re-emits its messages under the real relay url instead of LOCAL_RELAY_URL. That
|
||||||
|
// keeps relay attribution / relay-scoped behaviour (e.g. NIP-29 groups) working as it would over a
|
||||||
|
// real socket. (Composition rather than inheritance because LocalAdapter emits via a private method
|
||||||
|
// that hardcodes LOCAL_RELAY_URL.)
|
||||||
|
class FixtureAdapter extends AbstractAdapter {
|
||||||
|
readonly local: LocalAdapter
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly url: string,
|
||||||
|
repository: Repository,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.local = new LocalAdapter(repository)
|
||||||
|
|
||||||
|
const forward = (message: RelayMessage) => this.emit(AdapterEvent.Receive, message, this.url)
|
||||||
|
|
||||||
|
this._unsubscribers.push(on(this.local, AdapterEvent.Receive, forward), () =>
|
||||||
|
this.local.cleanup(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
get sockets() {
|
||||||
|
return this.local.sockets
|
||||||
|
}
|
||||||
|
|
||||||
|
get urls() {
|
||||||
|
return [this.url]
|
||||||
|
}
|
||||||
|
|
||||||
|
send(message: ClientMessage) {
|
||||||
|
this.local.send(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override netContext.getAdapter so every real relay url is served from memory and no websocket is
|
||||||
|
// ever created for it. Non-relay urls (e.g. the local:// repository relay) fall through to
|
||||||
|
// welshman's default handling.
|
||||||
|
export const installRelayMocks = (config: RelayMockConfig) => {
|
||||||
|
const reposByUrl = new Map<string, Repository>()
|
||||||
|
|
||||||
|
for (const [url, events] of Object.entries(config.relays ?? {})) {
|
||||||
|
const repository = new Repository()
|
||||||
|
repository.load(events)
|
||||||
|
reposByUrl.set(normalizeRelayUrl(url), repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyRepository = new Repository()
|
||||||
|
const fallback = netContext.getAdapter
|
||||||
|
|
||||||
|
netContext.getAdapter = ((url: string, context: NetContext) => {
|
||||||
|
if (!isRelayUrl(url)) {
|
||||||
|
return fallback ? fallback(url, context) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const repository = reposByUrl.get(normalizeRelayUrl(url)) ?? emptyRepository
|
||||||
|
|
||||||
|
return new FixtureAdapter(url, repository)
|
||||||
|
}) as NetContext["getAdapter"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called once on app startup. Installs the mocks only when Playwright has injected a config, so it
|
||||||
|
// is a no-op for real users.
|
||||||
|
export const maybeInstallRelayMocks = () => {
|
||||||
|
const config = (globalThis as Record<string, unknown>)[RELAY_MOCKS_KEY] as
|
||||||
|
| RelayMockConfig
|
||||||
|
| undefined
|
||||||
|
|
||||||
|
if (config) {
|
||||||
|
installRelayMocks(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(config)
|
||||||
|
}
|
||||||
@@ -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} = {}) => {
|
* Returns a promise that rejects with TimeoutError after ms. Use with Promise.race.
|
||||||
return new Promise<never>((_, reject) =>
|
* Pass an optional signal to clear the timer when that signal aborts (self-cleaning).
|
||||||
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[]) => {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
import * as app from "@welshman/app"
|
import * as app from "@welshman/app"
|
||||||
import {isMobile} from "@lib/html"
|
import {isMobile} from "@lib/html"
|
||||||
import * as implicit from "@lib/implicit"
|
import * as implicit from "@lib/implicit"
|
||||||
|
import {maybeInstallRelayMocks} from "@lib/test/relayMocks"
|
||||||
import AppContainer from "@app/components/AppContainer.svelte"
|
import AppContainer from "@app/components/AppContainer.svelte"
|
||||||
import ModalContainer from "@app/components/ModalContainer.svelte"
|
import ModalContainer from "@app/components/ModalContainer.svelte"
|
||||||
import {setupHistory} from "@app/util/history"
|
import {setupHistory} from "@app/util/history"
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
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"
|
||||||
@@ -45,6 +47,12 @@
|
|||||||
|
|
||||||
const {children} = $props()
|
const {children} = $props()
|
||||||
|
|
||||||
|
// Test-only: when Playwright has injected window.__RELAY_MOCKS__, serve relays from in-memory
|
||||||
|
// fixtures instead of the network. No-op for real users; stripped from production builds.
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
maybeInstallRelayMocks()
|
||||||
|
}
|
||||||
|
|
||||||
const policies = [authPolicy, blockPolicy, trustPolicy, mostlyRestrictedPolicy]
|
const policies = [authPolicy, blockPolicy, trustPolicy, mostlyRestrictedPolicy]
|
||||||
|
|
||||||
// Add stuff to window for convenience
|
// Add stuff to window for convenience
|
||||||
@@ -174,8 +182,11 @@
|
|||||||
// Subscribe to page history to update checked state
|
// Subscribe to page history to update checked state
|
||||||
unsubscribers.push(notifications.syncChecked())
|
unsubscribers.push(notifications.syncChecked())
|
||||||
|
|
||||||
|
// Sync checked state across devices
|
||||||
|
unsubscribers.push(notifications.syncCheckedRemote())
|
||||||
|
|
||||||
// Initialize background notifications
|
// Initialize background notifications
|
||||||
unsubscribers.push(notifications.Push.sync())
|
unsubscribers.push(Push.sync())
|
||||||
|
|
||||||
// Listen for signer errors, report to user via toast
|
// Listen for signer errors, report to user via toast
|
||||||
unsubscribers.push(
|
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">
|
<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} />
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
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 {Push, clearBadges} from "@app/util/notifications"
|
import {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 = () => {
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
|
<script module lang="ts">
|
||||||
|
const joinPrompted = new Set<string>()
|
||||||
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {Snippet} from "svelte"
|
import type {Snippet} from "svelte"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
import {once} from "@welshman/lib"
|
import {once} from "@welshman/lib"
|
||||||
|
import {pubkey} from "@welshman/app"
|
||||||
import Page from "@lib/components/Page.svelte"
|
import Page from "@lib/components/Page.svelte"
|
||||||
import SecondaryNav from "@lib/components/SecondaryNav.svelte"
|
import SecondaryNav from "@lib/components/SecondaryNav.svelte"
|
||||||
import SpaceMenu from "@app/components/SpaceMenu.svelte"
|
import SpaceMenu from "@app/components/SpaceMenu.svelte"
|
||||||
import SpaceAuthError from "@app/components/SpaceAuthError.svelte"
|
import SpaceAuthError from "@app/components/SpaceAuthError.svelte"
|
||||||
import SpaceTrustRelay from "@app/components/SpaceTrustRelay.svelte"
|
import SpaceTrustRelay from "@app/components/SpaceTrustRelay.svelte"
|
||||||
|
import SpaceJoin from "@app/components/SpaceJoin.svelte"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
import {makeSpacePath} from "@app/util/routes"
|
import {makeSpacePath} from "@app/util/routes"
|
||||||
import {decodeRelay, relaysPendingTrust} from "@app/core/state"
|
import {
|
||||||
|
decodeRelay,
|
||||||
|
deriveUserGroupList,
|
||||||
|
relaysPendingTrust,
|
||||||
|
userSpaceUrls,
|
||||||
|
} from "@app/core/state"
|
||||||
import {deriveRelayAuthError} from "@app/core/commands"
|
import {deriveRelayAuthError} from "@app/core/commands"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -36,6 +47,27 @@
|
|||||||
showPendingTrust()
|
showPendingTrust()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Direct links skip Discover — prompt to join when relay is not in the user's space list.
|
||||||
|
const shouldPromptJoin = $derived.by(() => {
|
||||||
|
void $deriveUserGroupList
|
||||||
|
|
||||||
|
return (
|
||||||
|
Boolean($pubkey) &&
|
||||||
|
!$userSpaceUrls.includes(url) &&
|
||||||
|
!$authError &&
|
||||||
|
!$relaysPendingTrust.includes(url)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!shouldPromptJoin || joinPrompted.has(url)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
joinPrompted.add(url)
|
||||||
|
pushModal(SpaceJoin, {url})
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $page.url.pathname === makeSpacePath(url)}
|
{#if $page.url.pathname === makeSpacePath(url)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {onDestroy, onMount} from "svelte"
|
||||||
import {readable} from "svelte/store"
|
import {readable} from "svelte/store"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
import {goto} from "$app/navigation"
|
import {goto} from "$app/navigation"
|
||||||
@@ -49,7 +49,8 @@
|
|||||||
import {VideoCallLayout, videoCallLayout, videoTileCount} from "@app/call/video"
|
import {VideoCallLayout, videoCallLayout, videoTileCount} from "@app/call/video"
|
||||||
import {makeFeed} from "@app/core/requests"
|
import {makeFeed} from "@app/core/requests"
|
||||||
import {popKey} from "@lib/implicit"
|
import {popKey} from "@lib/implicit"
|
||||||
import {checked} from "@app/util/notifications"
|
import {checked, deferredRoomPath, setChecked} from "@app/util/notifications"
|
||||||
|
import {makeRoomPath} from "@app/util/routes"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
@@ -77,6 +78,21 @@
|
|||||||
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video,
|
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const roomPath = makeRoomPath(url, h)
|
||||||
|
|
||||||
|
const videoCallChatHidden = $derived(
|
||||||
|
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video,
|
||||||
|
)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
deferredRoomPath.set(videoCallChatHidden ? roomPath : undefined)
|
||||||
|
if (voiceConnectedHere && !videoCallChatHidden) {
|
||||||
|
setChecked(roomPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => deferredRoomPath.set(undefined))
|
||||||
|
|
||||||
let prevVideoTileCount = $state(0)
|
let prevVideoTileCount = $state(0)
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -414,8 +430,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SpaceBar>
|
<SpaceBar>
|
||||||
{#snippet title()}
|
{#snippet leading()}
|
||||||
<RoomImage {url} {h} />
|
<RoomImage {url} {h} />
|
||||||
|
{/snippet}
|
||||||
|
{#snippet title()}
|
||||||
<RoomName {url} {h} />
|
<RoomName {url} {h} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet action()}
|
{#snippet action()}
|
||||||
|
|||||||