Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0849f7ee4c | |||
| b6b8145901 | |||
| cdc9f927b5 | |||
| 4d57e4e6ed | |||
| 1b8d6e50e2 | |||
| e3e13563d5 | |||
| ee3da3893c | |||
| 91145c38fb | |||
| 1dd0270f4f | |||
| 77256462c5 | |||
| ae071fefaa | |||
| 152d35f92a | |||
| 8dd278f47c | |||
| 045d6983dc | |||
| 2f8861be62 | |||
| 6dbe9c0ebb | |||
| 45df132dc6 | |||
| c42a285f0b | |||
| 1e3211ae74 | |||
| ec507b05d6 | |||
| 339bb1afac | |||
| c441012e02 | |||
| 0d61278c56 | |||
| ffd06ab561 | |||
| eb8dd330b6 | |||
| 6267e52bdf | |||
| ab21008f34 | |||
| 0998639d59 | |||
| eccde07d06 | |||
| 770cdc5f13 | |||
| 6bafb62414 |
@@ -4,7 +4,6 @@ ios
|
||||
build
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Env files (keep .env for build; exclude local overrides)
|
||||
|
||||
@@ -15,3 +15,4 @@ android/capacitor-cordova-android-plugins
|
||||
android/app/src/androidTest
|
||||
android/app/src/test
|
||||
node_modules
|
||||
.svelte-kit
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
name: Docker
|
||||
name: Container Image Build and Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: coracle-social/flotilla
|
||||
REGISTRY: gitea.coracle.social
|
||||
IMAGE_NAME: coracle/flotilla
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
@@ -23,8 +28,8 @@ jobs:
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
username: hodlbod
|
||||
password: ${{ secrets.PACKAGE_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
@@ -32,6 +37,7 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
@@ -45,6 +51,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
target: production
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -6,6 +6,11 @@
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
|
||||
# Generated assets
|
||||
static/favicon.ico
|
||||
static/pwa-64x64.png
|
||||
@@ -27,13 +32,10 @@ android/app/src/main/assets/public/
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
build/
|
||||
build-server/
|
||||
.svelte-kit/
|
||||
.next/
|
||||
|
||||
# Rust/Tauri
|
||||
*target/
|
||||
src-tauri/binaries/
|
||||
|
||||
# iOS
|
||||
ios/App/App/public
|
||||
ios/DerivedData
|
||||
|
||||
@@ -1,40 +1,31 @@
|
||||
# Stage 1: Build
|
||||
# Uses .env from build context for config (logo, branding, etc.)
|
||||
# Optional: docker build --build-arg VITE_BUILD_HASH=$(git rev-parse --short HEAD) -t flotilla .
|
||||
# Build and run the Flotilla web server.
|
||||
#
|
||||
# docker build -t flotilla .
|
||||
# docker run -p 3000:3000 flotilla
|
||||
#
|
||||
# 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.
|
||||
|
||||
FROM node:20-bookworm AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl
|
||||
|
||||
RUN npm install -g pnpm@latest
|
||||
# https://pnpm.io/docker#example-3-build-on-cicd
|
||||
FROM node:24-slim AS builder
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm i
|
||||
|
||||
# Copy everything (including .env when present) - build.sh will source it
|
||||
COPY . .
|
||||
|
||||
ARG VITE_BUILD_HASH
|
||||
ENV VITE_BUILD_HASH=${VITE_BUILD_HASH}
|
||||
|
||||
ENV NODE_OPTIONS=--max_old_space_size=16384
|
||||
RUN pnpm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install production dependencies needed by the Node server runtime
|
||||
RUN npm install -g pnpm@10.33.0
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm i --prod --frozen-lockfile --ignore-scripts
|
||||
|
||||
# Copy only the built output and server source - no app source, no .env, no dev deps
|
||||
COPY --from=builder /app/build ./build
|
||||
COPY --from=builder /app/server.js ./server.js
|
||||
RUN pnpm i --frozen-lockfile
|
||||
COPY . .
|
||||
ARG VITE_BUILD_HASH
|
||||
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
|
||||
|
||||
USER node
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -37,12 +37,12 @@ pnpm run start
|
||||
Or, if you prefer to use a container:
|
||||
|
||||
```sh
|
||||
podman run -d -p 3000:3000 ghcr.io/coracle-social/flotilla:latest
|
||||
docker run -d -p 3000:3000 gitea.coracle.social/coracle/flotilla:latest
|
||||
```
|
||||
|
||||
Alternatively, you can copy the build files into a directory of your choice and serve it yourself:
|
||||
|
||||
```sh
|
||||
mkdir ./mount
|
||||
podman run -v ./mount:/app/mount ghcr.io/coracle-social/flotilla:latest bash -c 'cp -r build/* mount'
|
||||
docker run -v ./mount:/app/mount gitea.coracle.social/coracle/flotilla:latest bash -c 'cp -r build/* mount'
|
||||
```
|
||||
|
||||
@@ -36,4 +36,4 @@ include ':capawesome-capacitor-badge'
|
||||
project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge/android')
|
||||
|
||||
include ':nostr-signer-capacitor-plugin'
|
||||
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_2704ecccfd05fcfb1ad8852744422b7c/node_modules/nostr-signer-capacitor-plugin/android')
|
||||
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_a3c0fb15d5bfa83f24d0070ca2583fc9/node_modules/nostr-signer-capacitor-plugin/android')
|
||||
|
||||
@@ -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 'CapacitorShare', :path => '../../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/share'
|
||||
pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge'
|
||||
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_2704ecccfd05fcfb1ad8852744422b7c/node_modules/nostr-signer-capacitor-plugin'
|
||||
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_a3c0fb15d5bfa83f24d0070ca2583fc9/node_modules/nostr-signer-capacitor-plugin'
|
||||
end
|
||||
|
||||
target 'Flotilla Chat' do
|
||||
|
||||
@@ -12,6 +12,7 @@ if (execSync('git status --porcelain', { encoding: 'utf8' }).trim() && !force) {
|
||||
|
||||
const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
|
||||
|
||||
pkg.pnpm = pkg.pnpm || {}
|
||||
pkg.pnpm.overrides = pkg.pnpm.overrides || {}
|
||||
pkg.pnpm.overrides["@welshman/app"] = "link:../welshman/packages/app"
|
||||
pkg.pnpm.overrides["@welshman/content"] = "link:../welshman/packages/content"
|
||||
|
||||
@@ -5,15 +5,14 @@
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "./build.sh",
|
||||
"build:server": "vite build --config vite.config.server.ts",
|
||||
"start": "node server.js",
|
||||
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"tauri:info": "tauri info",
|
||||
"tauri:icons": "tauri icon assets/logo.png --output src-tauri/icons",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check src && eslint src",
|
||||
"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:all": "prettier --write src",
|
||||
"prepare": "husky"
|
||||
@@ -21,26 +20,27 @@
|
||||
"devDependencies": {
|
||||
"@capacitor/assets": "^3.0.5",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@sveltejs/kit": "^2.50.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@sveltejs/kit": "^2.61.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@tauri-apps/cli": "^2.9.6",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/node": "^25.9.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"classnames": "^2.5.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^9.1.2",
|
||||
"eslint-plugin-svelte": "^2.46.1",
|
||||
"globals": "^15.15.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss": "^8.5.15",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.4.1",
|
||||
"svelte": "^5.48.0",
|
||||
"svelte": "^5.55.9",
|
||||
"svelte-check": "^4.3.5",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.53.1",
|
||||
"vite": "^5.4.21"
|
||||
"vite": "^6.4.2"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -63,25 +63,25 @@
|
||||
"@getalby/sdk": "^5.1.2",
|
||||
"@hono/node-server": "^2.0.0",
|
||||
"@noble/curves": "^1.9.7",
|
||||
"@pomade/core": "^0.2.3",
|
||||
"@poppanator/sveltekit-svg": "^4.2.1",
|
||||
"@pomade/core": "^0.2.5",
|
||||
"@poppanator/sveltekit-svg": "^7.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@tiptap/core": "^2.27.2",
|
||||
"@tiptap/pm": "^2.27.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/throttle-debounce": "^5.0.2",
|
||||
"@vite-pwa/assets-generator": "^0.2.6",
|
||||
"@vite-pwa/sveltekit": "^0.6.8",
|
||||
"@welshman/app": "^0.8.15",
|
||||
"@welshman/content": "^0.8.15",
|
||||
"@welshman/editor": "^0.8.15",
|
||||
"@welshman/feeds": "^0.8.15",
|
||||
"@welshman/lib": "^0.8.15",
|
||||
"@welshman/net": "^0.8.15",
|
||||
"@welshman/router": "^0.8.15",
|
||||
"@welshman/signer": "^0.8.15",
|
||||
"@welshman/store": "^0.8.15",
|
||||
"@welshman/util": "^0.8.15",
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"@welshman/app": "^0.8.16",
|
||||
"@welshman/content": "^0.8.16",
|
||||
"@welshman/editor": "^0.8.16",
|
||||
"@welshman/feeds": "^0.8.16",
|
||||
"@welshman/lib": "^0.8.16",
|
||||
"@welshman/net": "^0.8.16",
|
||||
"@welshman/router": "^0.8.16",
|
||||
"@welshman/signer": "^0.8.16",
|
||||
"@welshman/store": "^0.8.16",
|
||||
"@welshman/util": "^0.8.16",
|
||||
"cheerio": "^1.2.0",
|
||||
"compressorjs-next": "^1.1.2",
|
||||
"daisyui": "^5.5.19",
|
||||
@@ -90,7 +90,7 @@
|
||||
"emoji-picker-element": "^1.28.1",
|
||||
"emoji-picker-element-data": "^1.8.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"hono": "^4.12.15",
|
||||
"hono": "^4.12.23",
|
||||
"husky": "^9.1.7",
|
||||
"idb": "^8.0.3",
|
||||
"livekit-client": "^2.17.2",
|
||||
@@ -102,17 +102,5 @@
|
||||
"throttle-debounce": "^5.0.2",
|
||||
"tippy.js": "^6.3.7"
|
||||
},
|
||||
"pnpm": {
|
||||
"ignoredBuiltDependencies": [
|
||||
"esbuild"
|
||||
],
|
||||
"onlyBuiltDependencies": [
|
||||
"sharp",
|
||||
"nostr-signer-capacitor-plugin"
|
||||
],
|
||||
"overrides": {
|
||||
"sharp": "0.35.0-rc.0"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
|
||||
"packageManager": "pnpm@11.5.1"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -247,6 +247,14 @@ app.use(
|
||||
: "public, max-age=3600"
|
||||
|
||||
context.header("Cache-Control", cacheControl)
|
||||
|
||||
// Immutable assets are content-hashed by Vite, so the filename is itself a
|
||||
// stable content identifier. Exposing it as an ETag lets clients that
|
||||
// revalidate explicitly (e.g. emoji-picker-element checks its data source
|
||||
// on every load) skip re-downloading large files when nothing changed.
|
||||
if (isImmutable) {
|
||||
context.header("ETag", `"${path.basename(filePath)}"`)
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -254,12 +262,6 @@ app.use(
|
||||
// SPA fallback for routes that don't match static files
|
||||
app.get("*", async context => {
|
||||
const requestUrl = requestUrlFromContext(context)
|
||||
|
||||
// If the path has an extension, it's likely a missing static asset, not an SPA route
|
||||
if (path.extname(requestUrl.pathname)) {
|
||||
return context.text("Not found", 404)
|
||||
}
|
||||
|
||||
const metadata = await getMetadataForRoute(requestUrl)
|
||||
const html = metadata ? injectMeta(metadata) : TEMPLATE_HTML
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -423,10 +423,18 @@ progress[value]::-webkit-progress-value {
|
||||
|
||||
/* Keyboard open state adjustments */
|
||||
|
||||
body.keyboard-open {
|
||||
--saib: 0px;
|
||||
}
|
||||
|
||||
body.keyboard-open .hide-on-keyboard {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.keyboard-open .chat__compose {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* chat view */
|
||||
|
||||
.chat__compose {
|
||||
|
||||
@@ -6,15 +6,22 @@ export type VoiceSession = {
|
||||
url: string
|
||||
h: string
|
||||
room: LiveKitRoom
|
||||
muted: boolean
|
||||
cameraOn: 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 VoiceParticipant = {pubkey?: Pubkey; identity: string}
|
||||
|
||||
export type ParticipantMediaState = {
|
||||
muted: boolean
|
||||
cameraOn: boolean
|
||||
}
|
||||
|
||||
export enum VoiceState {
|
||||
Joining = "joining",
|
||||
Connected = "connected",
|
||||
@@ -27,8 +34,6 @@ export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
|
||||
|
||||
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
||||
|
||||
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
|
||||
|
||||
export const pubkeyFromLiveKitIdentity = (identity: string): string | 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 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(
|
||||
speakingParticipants,
|
||||
$participants => (p: VoiceParticipant) =>
|
||||
|
||||
@@ -6,28 +6,31 @@ import {
|
||||
DisconnectReason,
|
||||
LocalParticipant,
|
||||
LocalTrackPublication,
|
||||
Participant,
|
||||
Room as LiveKitRoom,
|
||||
RoomEvent,
|
||||
Track,
|
||||
TrackPublication,
|
||||
supportsAudioOutputSelection,
|
||||
type AudioCaptureOptions,
|
||||
} from "livekit-client"
|
||||
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 {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
|
||||
import {signer} from "@welshman/app"
|
||||
import {getLivekitEndpoint} from "$lib/livekit"
|
||||
import {AbortError, whenAborted, whenTimeout} from "$lib/util"
|
||||
import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util"
|
||||
import {
|
||||
currentVoiceRoom,
|
||||
currentVoiceSession,
|
||||
voiceMicMuted,
|
||||
participantFromLiveKitIdentity,
|
||||
participantKey,
|
||||
participantPubkeyMap,
|
||||
pubkeyFromLiveKitIdentity,
|
||||
participantMediaState,
|
||||
speakingParticipants,
|
||||
VoiceState,
|
||||
type ParticipantMediaState,
|
||||
type VoiceParticipant,
|
||||
voiceState,
|
||||
} from "@app/call/stores"
|
||||
@@ -75,20 +78,51 @@ export const switchVoiceActiveDevice = async (
|
||||
}
|
||||
}
|
||||
|
||||
const addParticipant = (identity: string) => {
|
||||
participantPubkeyMap.update(m => {
|
||||
const participantMediaFrom = (participant: Participant): ParticipantMediaState => ({
|
||||
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)
|
||||
next.set(identity, pubkeyFromLiveKitIdentity(identity) ?? "")
|
||||
next.set(participant.identity, state)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const deleteParticipant = (identity: string) => {
|
||||
participantPubkeyMap.update(m => {
|
||||
const next = new Map(m)
|
||||
next.delete(identity)
|
||||
return next
|
||||
// LiveKit does not emit ParticipantConnected/Disconnected during reconnect.
|
||||
const resyncAfterReconnect = (room: LiveKitRoom) => {
|
||||
if (room !== activeRoom) return
|
||||
|
||||
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 (
|
||||
@@ -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.
|
||||
derived(
|
||||
[
|
||||
participantPubkeyMap,
|
||||
participantMediaState,
|
||||
currentVoiceRoom,
|
||||
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
|
||||
],
|
||||
([$participantPubkeyMap, $currentVoiceRoom, $publishedParticipantList]) => {
|
||||
const inCall = $participantPubkeyMap.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h)
|
||||
([$participantMediaState, $currentVoiceRoom, $publishedParticipantList]) => {
|
||||
const inCall = $participantMediaState.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h)
|
||||
|
||||
if (inCall) {
|
||||
const participants = [...$participantPubkeyMap.keys()].map(participantFromLiveKitIdentity)
|
||||
const participants = [...$participantMediaState.keys()].map(participantFromLiveKitIdentity)
|
||||
return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
|
||||
} else {
|
||||
const latestEvent = $publishedParticipantList as TrustedEvent | undefined
|
||||
@@ -152,6 +186,8 @@ const setUpMicrophone = async (
|
||||
startMuted: boolean,
|
||||
preferredMicId: string | undefined,
|
||||
participant: LocalParticipant,
|
||||
signal?: AbortSignal,
|
||||
settleSignal?: AbortSignal,
|
||||
): Promise<boolean> => {
|
||||
if (startMuted) {
|
||||
return true
|
||||
@@ -163,28 +199,100 @@ const setUpMicrophone = async (
|
||||
capture = {deviceId: preferredMicId}
|
||||
}
|
||||
try {
|
||||
await participant.setMicrophoneEnabled(true, capture)
|
||||
await Promise.race([
|
||||
participant.setMicrophoneEnabled(true, capture),
|
||||
whenTimeout(15_000, {message: "Microphone access timed out.", signal: settleSignal}),
|
||||
whenAborted(signal),
|
||||
])
|
||||
muted = false
|
||||
} catch (e) {
|
||||
pushToast({theme: "error", message: "Could not access microphone"})
|
||||
// Timeout or microphone rejection: join muted, the call is still usable. A
|
||||
// genuine abort is surfaced to the caller so it can tear down the room.
|
||||
if (e instanceof AbortError) throw e
|
||||
if (!(e instanceof TimeoutError)) {
|
||||
pushToast({theme: "error", message: "Could not access microphone"})
|
||||
}
|
||||
}
|
||||
return muted
|
||||
}
|
||||
|
||||
const onRoomDisconnected = (reason?: DisconnectReason) => {
|
||||
// The room whose events are allowed to mutate shared state. Abandoned rooms
|
||||
// (after switching calls or an engine reconnect give-up) must not clobber it.
|
||||
let activeRoom: LiveKitRoom | undefined
|
||||
|
||||
const 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)
|
||||
voiceMicMuted.set(true)
|
||||
currentVoiceSession.set(undefined)
|
||||
resetVideoCallLayout()
|
||||
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
||||
voiceState.set(VoiceState.Disconnected)
|
||||
const message =
|
||||
reason === DisconnectReason.JOIN_FAILURE
|
||||
? "Could not connect to voice room. Please try again."
|
||||
: "Voice connection lost."
|
||||
pushToast({theme: "error", message})
|
||||
if (reason === DisconnectReason.JOIN_FAILURE) {
|
||||
pushToast({theme: "error", message: "Could not connect to voice room. Please try again."})
|
||||
} else if (get(currentVoiceRoom)) {
|
||||
clearReconnectSchedule()
|
||||
scheduleReconnect()
|
||||
} else {
|
||||
pushToast({theme: "error", message: "Voice connection lost."})
|
||||
}
|
||||
}
|
||||
speakingParticipants.set([])
|
||||
participantPubkeyMap.set(new Map())
|
||||
participantMediaState.set(new Map())
|
||||
}
|
||||
|
||||
const onTrackSubscribed = (track: Track) => {
|
||||
@@ -214,8 +322,8 @@ const playJoinSound = () => {
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
|
||||
const onParticipantConnected = (participant: {identity: string}) => {
|
||||
addParticipant(participant.identity)
|
||||
const onParticipantConnected = (participant: Participant) => {
|
||||
syncParticipantMedia(participant)
|
||||
playJoinSound()
|
||||
}
|
||||
|
||||
@@ -236,20 +344,22 @@ const onLocalTrackUnpublished = (
|
||||
|
||||
let joinAbortController: AbortController | undefined
|
||||
|
||||
export const cancelJoinVoiceRoom = () => {
|
||||
const abortJoinVoiceRoom = () => {
|
||||
joinAbortController?.abort()
|
||||
}
|
||||
|
||||
export const cancelJoinVoiceRoom = () => {
|
||||
clearReconnectSchedule()
|
||||
abortJoinVoiceRoom()
|
||||
}
|
||||
|
||||
export const joinVoiceRoom = async (
|
||||
url: string,
|
||||
h: string,
|
||||
startMuted = true,
|
||||
preferredMicId?: string,
|
||||
): Promise<void> => {
|
||||
cancelJoinVoiceRoom()
|
||||
|
||||
const session = get(currentVoiceSession)
|
||||
if (session) await leaveVoiceRoom()
|
||||
abortJoinVoiceRoom()
|
||||
|
||||
currentVoiceRoom.set(get(deriveRoom(url, h)))
|
||||
voiceState.set(VoiceState.Joining)
|
||||
@@ -259,62 +369,123 @@ export const joinVoiceRoom = async (
|
||||
const signal = controller.signal
|
||||
const isActive = () => joinAbortController === controller
|
||||
|
||||
// Self-cleaning controller: aborted in finally so whenTimeout/whenAborted
|
||||
// helpers clear their timers/listeners once the races below have settled.
|
||||
const settle = new AbortController()
|
||||
|
||||
try {
|
||||
const {server_url, participant_token} = await fetchLivekitToken(url, h, signal)
|
||||
// Tear down any existing session before joining. Bound it so a slow leave
|
||||
// (camera/screenshare renegotiation can take ~15s) cannot block this join.
|
||||
if (get(currentVoiceSession)) {
|
||||
await Promise.race([
|
||||
leaveVoiceRoom(),
|
||||
whenTimeout(15_000, {message: "Leaving previous call timed out.", signal: settle.signal}),
|
||||
whenAborted(signal),
|
||||
]).catch(e => {
|
||||
if (e instanceof AbortError) throw e
|
||||
})
|
||||
|
||||
// leaveVoiceRoom flips voiceState to Disconnected; re-assert Joining.
|
||||
voiceState.set(VoiceState.Joining)
|
||||
}
|
||||
|
||||
if (signal.aborted) throw new AbortError()
|
||||
|
||||
const {server_url, participant_token} = await Promise.race([
|
||||
fetchLivekitToken(url, h, signal),
|
||||
whenTimeout(15_000, {
|
||||
message: "Connection timed out. Please check your network and try again.",
|
||||
signal: settle.signal,
|
||||
}),
|
||||
whenAborted(signal),
|
||||
])
|
||||
|
||||
if (signal.aborted) throw new AbortError()
|
||||
|
||||
const liveKitRoom = new LiveKitRoom({adaptiveStream: true, dynacast: true})
|
||||
activeRoom = liveKitRoom
|
||||
|
||||
liveKitRoom.on(RoomEvent.Disconnected, onRoomDisconnected)
|
||||
liveKitRoom.on(RoomEvent.Disconnected, makeOnRoomDisconnected(liveKitRoom))
|
||||
liveKitRoom.on(RoomEvent.Reconnected, makeOnRoomReconnected(liveKitRoom))
|
||||
liveKitRoom.on(RoomEvent.ParticipantConnected, onParticipantConnected)
|
||||
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
||||
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
||||
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
||||
liveKitRoom.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
|
||||
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 {
|
||||
await Promise.race([
|
||||
liveKitRoom.connect(server_url, participant_token, {maxRetries: 0}),
|
||||
whenTimeout(15_000, {
|
||||
message: "Connection timed out. Please check your network and try again.",
|
||||
signal: settle.signal,
|
||||
}),
|
||||
whenAborted(signal),
|
||||
])
|
||||
} catch (e) {
|
||||
if (activeRoom === liveKitRoom) activeRoom = undefined
|
||||
liveKitRoom.removeAllListeners()
|
||||
liveKitRoom.disconnect()
|
||||
throw e
|
||||
}
|
||||
|
||||
participantPubkeyMap.set(new Map())
|
||||
addParticipant(liveKitRoom.localParticipant.identity)
|
||||
participantMediaState.set(new Map())
|
||||
syncParticipantMedia(liveKitRoom.localParticipant)
|
||||
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({
|
||||
url,
|
||||
h,
|
||||
room: liveKitRoom,
|
||||
muted,
|
||||
cameraOn: false,
|
||||
screenShareOn: false,
|
||||
})
|
||||
voiceState.set(VoiceState.Connected)
|
||||
clearReconnectSchedule()
|
||||
playJoinSound()
|
||||
} catch (e) {
|
||||
if (isActive()) voiceState.set(VoiceState.Disconnected)
|
||||
if (e instanceof AbortError) return
|
||||
if (e instanceof AbortError) {
|
||||
clearReconnectSchedule()
|
||||
return
|
||||
}
|
||||
throw e
|
||||
} finally {
|
||||
settle.abort()
|
||||
if (isActive()) joinAbortController = undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const leaveVoiceRoom = async () => {
|
||||
clearReconnectSchedule()
|
||||
const session = get(currentVoiceSession)
|
||||
if (!session) return
|
||||
|
||||
@@ -337,37 +508,40 @@ export const leaveVoiceRoom = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
voiceState.set(VoiceState.Disconnected)
|
||||
videoPrimaryTileKey.set(undefined)
|
||||
currentVoiceSession.set(undefined)
|
||||
resetVideoCallLayout()
|
||||
// Always tear down this room's connection and listeners.
|
||||
if (activeRoom === session.room) activeRoom = undefined
|
||||
session.room.removeAllListeners()
|
||||
session.room.disconnect()
|
||||
speakingParticipants.set([])
|
||||
participantPubkeyMap.set(new Map())
|
||||
}
|
||||
|
||||
export const rejoinVoiceRoom = async (): Promise<void> => {
|
||||
const target = get(currentVoiceRoom)
|
||||
if (!target) return
|
||||
return joinVoiceRoom(target.url, target.h)
|
||||
// Only reset shared UI state if this session is still current. A slow leave
|
||||
// that was superseded by a new join (bounded by a timeout in joinVoiceRoom)
|
||||
// must not clobber the freshly-joined session when it finally completes.
|
||||
if (get(currentVoiceSession) === session) {
|
||||
voiceState.set(VoiceState.Disconnected)
|
||||
videoPrimaryTileKey.set(undefined)
|
||||
voiceMicMuted.set(true)
|
||||
currentVoiceSession.set(undefined)
|
||||
resetVideoCallLayout()
|
||||
speakingParticipants.set([])
|
||||
participantMediaState.set(new Map())
|
||||
}
|
||||
}
|
||||
|
||||
export const toggleMute = async () => {
|
||||
const session = get(currentVoiceSession)
|
||||
if (!session) return
|
||||
|
||||
const muted = !session.muted
|
||||
if (muted) {
|
||||
voiceMicMuted.update(not)
|
||||
if (get(voiceMicMuted)) {
|
||||
// Disable and re-enable microphone to trigger permission prompt
|
||||
session.room.localParticipant.setMicrophoneEnabled(false)
|
||||
currentVoiceSession.set({...session, muted})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await session.room.localParticipant.setMicrophoneEnabled(true)
|
||||
currentVoiceSession.set({...session, muted})
|
||||
} catch (e) {
|
||||
voiceMicMuted.set(true)
|
||||
pushToast({theme: "error", message: "Could not access microphone"})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,7 +280,7 @@
|
||||
</div>
|
||||
</PageBar>
|
||||
|
||||
<PageContent class="flex flex-col-reverse gap-2 py-4">
|
||||
<PageContent class="flex flex-col-reverse gap-2 py-2 !mb-0">
|
||||
{#if missingRelayLists.length > 0}
|
||||
<div class="py-12">
|
||||
<div class="card2 col-2 m-auto max-w-md items-center text-center">
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
<input bind:value={term} class="grow" type="text" placeholder="Search for relays..." />
|
||||
</label>
|
||||
<div class="column -m-6 mt-0 h-[50vh] gap-2 overflow-auto p-6 pt-2" bind:this={element}>
|
||||
{#if customUrl && isShareableRelayUrl(customUrl) && !$relays.includes(normalizeRelayUrl(customUrl))}
|
||||
{#if customUrl && isShareableRelayUrl(customUrl) && !$relays.includes(customUrl)}
|
||||
<RelayItem url={term}>
|
||||
<Button
|
||||
class="btn btn-outline btn-sm flex items-center"
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
repository.removeEvent(thunk.event.id)
|
||||
pushToast({theme: "error", message})
|
||||
} else {
|
||||
await removeRoomMembership(url, h)
|
||||
goto(makeSpacePath(url))
|
||||
}
|
||||
},
|
||||
|
||||
@@ -45,20 +45,11 @@
|
||||
event: TrustedEvent
|
||||
replyTo?: (event: TrustedEvent) => void
|
||||
showPubkey?: boolean
|
||||
addSpaceBelow?: boolean
|
||||
canEdit: (event: TrustedEvent) => boolean
|
||||
onEdit: (event: TrustedEvent) => void
|
||||
}
|
||||
|
||||
const {
|
||||
url,
|
||||
event,
|
||||
replyTo = undefined,
|
||||
showPubkey = false,
|
||||
addSpaceBelow = false,
|
||||
canEdit,
|
||||
onEdit,
|
||||
}: Props = $props()
|
||||
const {url, event, replyTo = undefined, showPubkey = false, canEdit, onEdit}: Props = $props()
|
||||
|
||||
const path = getRoomItemPath(url, event)
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
@@ -95,7 +86,7 @@
|
||||
{onTap}
|
||||
class={cx(
|
||||
"group relative flex w-full cursor-default flex-col px-2 py-0.5 text-left hover:bg-base-100/50",
|
||||
{"mt-1.5": showPubkey, "mb-1.5": addSpaceBelow},
|
||||
{"mt-1.5": showPubkey},
|
||||
)}>
|
||||
<div class="flex w-full gap-3 overflow-auto">
|
||||
{#if showPubkey}
|
||||
@@ -127,7 +118,7 @@
|
||||
<div class:mt-2={showPubkey && event.kind !== MESSAGE}>
|
||||
<RoomItemContent {url} event={$innerEvent ?? event} />
|
||||
{#if thunk}
|
||||
<ThunkFailure showToastOnRetry {thunk} class="mt-2 text-sm" />
|
||||
<ThunkFailure showToastOnRetry {thunk} class="mt-1 flex justify-end" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import LogIn from "@app/components/LogIn.svelte"
|
||||
import InfoNostr from "@app/components/InfoNostr.svelte"
|
||||
import SignUpKey from "@app/components/SignUpKey.svelte"
|
||||
import SignUpEmail from "@app/components/SignUpEmail.svelte"
|
||||
import SignUpProfile from "@app/components/SignUpProfile.svelte"
|
||||
@@ -91,11 +90,9 @@
|
||||
|
||||
<Modal>
|
||||
<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">
|
||||
{PLATFORM_NAME} is built using the
|
||||
<Button class="link" onclick={() => pushModal(InfoNostr)}>nostr protocol</Button>, which gives
|
||||
users control over their digital identity using <strong>cryptographic key pairs</strong>.
|
||||
Censorship resistant digital spaces for communities. Meet new people, own your identity.
|
||||
</p>
|
||||
{#if hasPomade}
|
||||
<Button onclick={flows.email.start} class="btn btn-primary">
|
||||
|
||||
@@ -7,17 +7,19 @@
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import PageBar from "@lib/components/PageBar.svelte"
|
||||
import RelayIcon from "@app/components/RelayIcon.svelte"
|
||||
import {decodeRelay} from "@app/core/state"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
|
||||
interface Props {
|
||||
back?: () => unknown
|
||||
leading?: Snippet
|
||||
title?: Snippet
|
||||
action?: Snippet
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
const {back = () => goto(makeSpacePath(url)), title, action, ...props}: Props = $props()
|
||||
const {back = () => goto(makeSpacePath(url)), leading, title, action, ...props}: Props = $props()
|
||||
|
||||
const url = decodeRelay($page.params.relay!)
|
||||
</script>
|
||||
@@ -30,6 +32,10 @@
|
||||
<div class="ellipsize whitespace-nowrap flex grow items-center justify-between gap-4">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex gap-2 items-center">
|
||||
<RelayIcon {url} size={5} class="rounded-full md:hidden" />
|
||||
<div class="hidden md:contents">
|
||||
{@render leading?.()}
|
||||
</div>
|
||||
{@render title?.()}
|
||||
</div>
|
||||
<div class="text-xs text-primary md:hidden">
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
import SpaceJoinSettings from "@app/components/SpaceJoinSettings.svelte"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
import {Push} from "@app/util/notifications"
|
||||
import {relaysMostlyRestricted, notificationSettings, parseInviteLink} from "@app/core/state"
|
||||
import {Push} from "@app/util/push"
|
||||
import {
|
||||
attemptRelayAccess,
|
||||
addSpaceMembership,
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
import {Push} from "@app/util/notifications"
|
||||
import {Push} from "@app/util/push"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import {writable} from "svelte/store"
|
||||
import {deriveRelay, publishThunk, waitForThunkError} from "@welshman/app"
|
||||
import {makeEvent, THREAD} from "@welshman/util"
|
||||
import {publishThunk, waitForThunkError} from "@welshman/app"
|
||||
import {isMobile, preventDefault} from "@lib/html"
|
||||
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
||||
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
@@ -16,8 +17,12 @@
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||
import RelayIcon from "@app/components/RelayIcon.svelte"
|
||||
import RelayName from "@app/components/RelayName.svelte"
|
||||
import RoomImage from "@app/components/RoomImage.svelte"
|
||||
import RoomName from "@app/components/RoomName.svelte"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {PROTECTED} from "@app/core/state"
|
||||
import {hasNip29, roomsByUrl, PROTECTED} from "@app/core/state"
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {DraftKey} from "@app/util/drafts"
|
||||
import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
|
||||
@@ -33,8 +38,11 @@
|
||||
shareToChat?: boolean
|
||||
}
|
||||
|
||||
const {url, h, shareToChat = false}: Props = $props()
|
||||
const draftKey = new DraftKey<Values>(`thread:${url}:${h ?? ""}`)
|
||||
const {url, h: initialH, shareToChat = false}: Props = $props()
|
||||
const relay = deriveRelay(url)
|
||||
const rooms = $derived(($roomsByUrl.get(url) || []).map(room => room.h))
|
||||
const isNip29 = $derived(hasNip29($relay))
|
||||
const draftKey = new DraftKey<Values>(`thread:${url}:${initialH ?? ""}`)
|
||||
const initialValues = draftKey.get()
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
@@ -44,6 +52,20 @@
|
||||
|
||||
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
|
||||
|
||||
const toggleRoomPicker = () => {
|
||||
showRoomPicker = !showRoomPicker
|
||||
}
|
||||
|
||||
const pickSpace = () => {
|
||||
selectedH = undefined
|
||||
showRoomPicker = false
|
||||
}
|
||||
|
||||
const pickRoom = (h: string) => {
|
||||
selectedH = h
|
||||
showRoomPicker = false
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if ($uploading || loading) return
|
||||
|
||||
@@ -75,8 +97,8 @@
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
|
||||
if (h) {
|
||||
tags.push(["h", h])
|
||||
if (selectedH) {
|
||||
tags.push(["h", selectedH])
|
||||
}
|
||||
|
||||
const threadThunk = publishThunk({
|
||||
@@ -94,7 +116,7 @@
|
||||
history.back()
|
||||
|
||||
if (shareToChat) {
|
||||
publishRoomQuote({url, h, parent: threadThunk.event, protect})
|
||||
publishRoomQuote({url, h: selectedH, parent: threadThunk.event, protect})
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
@@ -102,6 +124,8 @@
|
||||
}
|
||||
|
||||
let loading = $state(false)
|
||||
let showRoomPicker = $state(false)
|
||||
let selectedH: string | undefined = $state(initialH)
|
||||
|
||||
let title = $state(initialValues?.title ?? "")
|
||||
let content = $state(initialValues?.content ?? "")
|
||||
@@ -131,6 +155,53 @@
|
||||
<ModalSubtitle>Share a link, or start a discussion.</ModalSubtitle>
|
||||
</ModalHeader>
|
||||
<div class="col-8 relative">
|
||||
{#if isNip29}
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Room</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="relative">
|
||||
<Button
|
||||
type="button"
|
||||
class="select select-bordered flex w-full items-center gap-2"
|
||||
onclick={toggleRoomPicker}>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
{#if selectedH}
|
||||
<RoomImage {url} h={selectedH} size={4} />
|
||||
<RoomName {url} h={selectedH} class="truncate text-left" />
|
||||
{:else}
|
||||
<RelayIcon {url} size={4} />
|
||||
<RelayName {url} class="truncate text-left" />
|
||||
{/if}
|
||||
</div>
|
||||
<Icon icon={AltArrowDown} size={4} />
|
||||
</Button>
|
||||
{#if showRoomPicker}
|
||||
<div
|
||||
class="absolute z-popover mt-1 w-full rounded-lg border border-base-300 bg-base-100 shadow-xl">
|
||||
<Button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-none border-0 bg-transparent px-3 py-2 hover:bg-base-200"
|
||||
onclick={pickSpace}>
|
||||
<RelayIcon {url} size={4} />
|
||||
<RelayName {url} />
|
||||
</Button>
|
||||
{#each rooms as h (h)}
|
||||
<Button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-none border-0 bg-transparent px-3 py-2 hover:bg-base-200"
|
||||
onclick={() => pickRoom(h)}>
|
||||
<RoomImage {url} {h} size={4} />
|
||||
<RoomName {url} {h} />
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Field>
|
||||
{/if}
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Title*</p>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import {stopPropagation} from "svelte/legacy"
|
||||
import {noop} from "@welshman/lib"
|
||||
import type {AbstractThunk} from "@welshman/app"
|
||||
import {retryThunk, thunkIsComplete, getFailedThunkUrls} from "@welshman/app"
|
||||
import {flattenThunks, getFailedThunkUrls, publishThunk, thunkIsComplete} from "@welshman/app"
|
||||
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Tippy from "@lib/components/Tippy.svelte"
|
||||
@@ -16,40 +16,45 @@
|
||||
class?: string
|
||||
}
|
||||
|
||||
let {thunk, showToastOnRetry, ...restProps}: Props = $props()
|
||||
const {thunk, showToastOnRetry, ...restProps}: Props = $props()
|
||||
|
||||
const retry = () => {
|
||||
thunk = retryThunk(thunk)
|
||||
const showFailure = $derived(thunkIsComplete($thunk) && getFailedThunkUrls($thunk).length > 0)
|
||||
|
||||
if (showToastOnRetry) {
|
||||
pushToast({
|
||||
timeout: 30_000,
|
||||
children: {
|
||||
component: ThunkToast,
|
||||
props: {thunk},
|
||||
},
|
||||
})
|
||||
const retry = (url: string) => {
|
||||
for (const child of flattenThunks([thunk])) {
|
||||
if (!child.options.relays.includes(url)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const retried = publishThunk({...child.options, relays: [url]})
|
||||
|
||||
if (showToastOnRetry) {
|
||||
pushToast({
|
||||
timeout: 30_000,
|
||||
children: {
|
||||
component: ThunkToast,
|
||||
props: {thunk: retried},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const failedUrls = $derived(getFailedThunkUrls($thunk))
|
||||
const showFailure = $derived(thunkIsComplete($thunk) && failedUrls.length > 0)
|
||||
</script>
|
||||
|
||||
{#if showFailure}
|
||||
{@const url = failedUrls[0]}
|
||||
{@const {status, detail: message} = $thunk.results[url]}
|
||||
<button
|
||||
class="flex w-full justify-end px-1 text-xs {restProps.class}"
|
||||
onclick={stopPropagation(noop)}>
|
||||
<Tippy
|
||||
class="flex items-center"
|
||||
component={ThunkStatusDetail}
|
||||
props={{url, message, status, retry}}
|
||||
params={{interactive: true}}>
|
||||
props={{thunk, retry}}
|
||||
params={{interactive: true, maxWidth: "none"}}>
|
||||
{#snippet children()}
|
||||
<span class="flex cursor-pointer items-center gap-1 text-error">
|
||||
<Icon icon={Danger} size={3} />
|
||||
<span class="flex cursor-pointer items-center gap-1 opacity-75">
|
||||
<Icon icon={Danger} class="text-error" size={3} />
|
||||
<span>Failed to send!</span>
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
@@ -6,17 +6,18 @@
|
||||
|
||||
interface Props {
|
||||
thunk: AbstractThunk
|
||||
showToastOnRetry?: boolean
|
||||
class?: string
|
||||
}
|
||||
|
||||
const {thunk, ...restProps}: Props = $props()
|
||||
const {thunk, showToastOnRetry, ...restProps}: Props = $props()
|
||||
|
||||
const showFailure = $derived(thunkIsComplete($thunk) && getFailedThunkUrls($thunk).length > 0)
|
||||
const showPending = $derived(!thunkIsComplete($thunk))
|
||||
</script>
|
||||
|
||||
{#if showFailure}
|
||||
<ThunkFailure class={restProps.class} {thunk} />
|
||||
<ThunkFailure class={restProps.class} {thunk} {showToastOnRetry} />
|
||||
{:else if showPending}
|
||||
<ThunkPending class={restProps.class} {thunk} />
|
||||
{/if}
|
||||
|
||||
@@ -1,32 +1,69 @@
|
||||
<script lang="ts">
|
||||
import {stopPropagation} from "svelte/legacy"
|
||||
import type {AbstractThunk} from "@welshman/app"
|
||||
import {getFailedThunkUrls, getThunkUrlsWithStatus} from "@welshman/app"
|
||||
import {PublishStatus} from "@welshman/net"
|
||||
import {displayRelayUrl} from "@welshman/util"
|
||||
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
||||
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import {addPeriod} from "@lib/util"
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
status: string
|
||||
message: string
|
||||
retry: () => void
|
||||
thunk: AbstractThunk
|
||||
retry: (url: string) => void
|
||||
}
|
||||
|
||||
let {url, status, message = $bindable(), retry}: Props = $props()
|
||||
const {thunk, retry}: Props = $props()
|
||||
|
||||
$effect(() => {
|
||||
if (!message && status === PublishStatus.Timeout) {
|
||||
message = "request timed out"
|
||||
const successUrls = $derived(getThunkUrlsWithStatus(PublishStatus.Success, $thunk))
|
||||
const failedUrls = $derived(getFailedThunkUrls($thunk))
|
||||
const total = $derived(successUrls.length + failedUrls.length)
|
||||
const isPartial = $derived(successUrls.length > 0 && failedUrls.length > 0)
|
||||
|
||||
const title = $derived(
|
||||
isPartial ? `Partial delivery ${successUrls.length}/${total} relays` : "Failed to send!",
|
||||
)
|
||||
|
||||
const relayMessage = (status: PublishStatus | undefined, detail: string | undefined) => {
|
||||
if (detail) {
|
||||
return detail
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
message = "no details recieved"
|
||||
if (status === PublishStatus.Timeout) {
|
||||
return "request timed out"
|
||||
}
|
||||
})
|
||||
|
||||
return "no details received"
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card2 bg-alt col-2 shadow-lg">
|
||||
<p>
|
||||
Failed to publish to {displayRelayUrl(url)}: {addPeriod(message)}
|
||||
</p>
|
||||
<Button class="link" onclick={retry}>Retry</Button>
|
||||
<div class="card2 bg-alt flex min-w-72 max-w-sm flex-col gap-3 px-4 py-3 shadow-lg">
|
||||
<span class="flex items-center gap-2 text-sm font-medium">
|
||||
<Icon icon={Danger} class="text-error" size={4} />
|
||||
{title}
|
||||
</span>
|
||||
<div class="divider my-0"></div>
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each successUrls as url (url)}
|
||||
<div class="flex items-start gap-2 text-sm">
|
||||
<Icon icon={CheckCircle} class="mt-0.5 shrink-0 text-success" size={4} />
|
||||
<span>{displayRelayUrl(url)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{#each failedUrls as url (url)}
|
||||
{@const {detail, status} = $thunk.results[url] || {}}
|
||||
<div class="grid grid-cols-[1rem_1fr_auto] items-start gap-x-3 gap-y-1 text-sm">
|
||||
<Icon icon={Danger} class="mt-0.5 text-error" size={4} />
|
||||
<div class="min-w-0">
|
||||
<p class="break-all">{displayRelayUrl(url)}</p>
|
||||
<p class="text-xs opacity-60">{addPeriod(relayMessage(status, detail))}</p>
|
||||
</div>
|
||||
<Button class="link shrink-0 px-1" onclick={stopPropagation(() => retry(url))}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
$effect(() => {
|
||||
if (!containerEl) return
|
||||
containerEl.addEventListener("touchmove", onTouchMove, {passive: false})
|
||||
return () => containerEl!.removeEventListener("touchmove", onTouchMove)
|
||||
return () => containerEl?.removeEventListener("touchmove", onTouchMove)
|
||||
})
|
||||
|
||||
const onActionClick = () => {
|
||||
@@ -71,6 +71,7 @@
|
||||
|
||||
{#if $toast}
|
||||
{@const theme = $toast.theme || "info"}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={containerEl}
|
||||
transition:fly={{y: -20}}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import VideoCallTile from "@app/components/VideoCallTile.svelte"
|
||||
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
||||
import VoiceParticipantMediaBadges from "@app/components/VoiceParticipantMediaBadges.svelte"
|
||||
import {get} from "svelte/store"
|
||||
import {
|
||||
VideoCallLayout,
|
||||
@@ -18,7 +19,12 @@
|
||||
ViewportSize,
|
||||
videoPrimaryTileKey,
|
||||
} from "@app/call/video"
|
||||
import {currentVoiceSession, currentVoiceRoom, pubkeyFromLiveKitIdentity} from "@app/call/stores"
|
||||
import {
|
||||
currentVoiceSession,
|
||||
currentVoiceRoom,
|
||||
mediaStateByIdentity,
|
||||
pubkeyFromLiveKitIdentity,
|
||||
} from "@app/call/stores"
|
||||
|
||||
type Props = {
|
||||
layout: VideoCallLayout
|
||||
@@ -121,6 +127,25 @@
|
||||
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
|
||||
@@ -144,6 +169,9 @@
|
||||
|
||||
const useSpotlightLayout = $derived(primaryTile !== undefined)
|
||||
const useMultiGrid = $derived(!useSpotlightLayout && videoTiles.length > 2)
|
||||
const multiGridClass = $derived(
|
||||
layout === VideoCallLayout.Split ? "grid-cols-1" : "grid-cols-1 sm:grid-cols-2",
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
const k = $videoPrimaryTileKey
|
||||
@@ -184,6 +212,7 @@
|
||||
</script>
|
||||
|
||||
{#snippet videoTile(tile: VideoTileData, layout: TileLayout)}
|
||||
{@const media = $mediaStateByIdentity(tile.identity)}
|
||||
<div
|
||||
class={cx(
|
||||
"relative isolate overflow-hidden rounded-box shadow-sm",
|
||||
@@ -203,6 +232,15 @@
|
||||
<ProfileCircle pubkey={pubkeyFromLiveKitIdentity(tile.identity)} {url} size={14} />
|
||||
</div>
|
||||
{/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
|
||||
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)" : ""}
|
||||
@@ -213,8 +251,8 @@
|
||||
data-tip={pinned ? "Exit spotlight" : "Spotlight"}
|
||||
aria-pressed={pinned}
|
||||
class={cx(
|
||||
"absolute right-1 top-1 z-20 btn btn-xs btn-square btn-ghost",
|
||||
pinned ? "btn-active bg-primary/25 text-primary" : "bg-base-100/70",
|
||||
"absolute right-1 top-1 z-20 btn btn-xs btn-square",
|
||||
pinned ? "btn-primary" : "btn-ghost bg-base-100",
|
||||
)}
|
||||
onclick={spotlightHandlerFor(tileKey(tile))}>
|
||||
<Icon icon={Pin} size={3} />
|
||||
@@ -238,8 +276,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else if useMultiGrid}
|
||||
<div
|
||||
class="grid min-h-0 flex-1 grid-cols-1 content-start gap-2 overflow-y-auto sm:grid-cols-2">
|
||||
<div class={cx("grid min-h-0 flex-1 content-start gap-2 overflow-y-auto", multiGridClass)}>
|
||||
{#each videoTiles as tile (tileKey(tile))}
|
||||
{@render videoTile(tile, "default")}
|
||||
{/each}
|
||||
@@ -254,8 +291,10 @@
|
||||
{:else}
|
||||
<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">
|
||||
<p>No camera or screen share yet.</p>
|
||||
<p class="text-xs">Use the camera or screen share control to share video.</p>
|
||||
<p>No one is sharing video yet.</p>
|
||||
<p class="text-xs">
|
||||
Participants appear here when they turn on their camera or share their screen.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/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 {pushModal} from "@app/util/modal"
|
||||
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
|
||||
import VoiceParticipantMediaBadges from "@app/components/VoiceParticipantMediaBadges.svelte"
|
||||
import {makeRoomId} from "@app/core/state"
|
||||
import {
|
||||
VoiceState,
|
||||
currentVoiceRoom,
|
||||
isParticipantSpeaking,
|
||||
mediaStateByIdentity,
|
||||
participantKey,
|
||||
voiceState,
|
||||
type VoiceParticipant,
|
||||
@@ -83,9 +85,17 @@
|
||||
)}>
|
||||
<ProfileCircle pubkey={p.pubkey} size={5} class="h-5 w-5" />
|
||||
</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"}
|
||||
</span>
|
||||
{#if isActive}
|
||||
{@const media = $mediaStateByIdentity(p.identity)}
|
||||
<VoiceParticipantMediaBadges
|
||||
muted={media.muted}
|
||||
cameraOn={media.cameraOn}
|
||||
size={3}
|
||||
class="shrink-0" />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import cx from "classnames"
|
||||
import {displayRelayUrl} from "@welshman/util"
|
||||
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 Monitor from "@assets/icons/monitor.svg?dataurl"
|
||||
import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl"
|
||||
@@ -41,8 +41,8 @@
|
||||
VoiceState,
|
||||
currentVoiceSession,
|
||||
currentVoiceRoom,
|
||||
voiceMicMuted,
|
||||
voiceState,
|
||||
isLocalSpeaking,
|
||||
} from "@app/call/stores"
|
||||
import {cancelJoinVoiceRoom, leaveVoiceRoom, toggleMute} from "@app/call/voice"
|
||||
|
||||
@@ -183,18 +183,16 @@
|
||||
</Button>
|
||||
{:else if $voiceState === VoiceState.Connected && $currentVoiceSession}
|
||||
<Button
|
||||
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
|
||||
data-tip={$voiceMicMuted ? "Unmute" : "Mute"}
|
||||
class={cx(
|
||||
mediaToggleClass,
|
||||
"overflow-visible",
|
||||
!$currentVoiceSession.muted && $isLocalSpeaking && "text-primary",
|
||||
$currentVoiceSession.muted &&
|
||||
"text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100",
|
||||
$voiceMicMuted && "text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100",
|
||||
)}
|
||||
onclick={toggleMute}>
|
||||
<span class="relative inline-flex items-center justify-center overflow-visible">
|
||||
<Icon icon={Microphone} size={4} />
|
||||
{#if $currentVoiceSession.muted}
|
||||
{#if $voiceMicMuted}
|
||||
<span
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center overflow-visible"
|
||||
aria-hidden="true">
|
||||
@@ -207,9 +205,17 @@
|
||||
</Button>
|
||||
<Button
|
||||
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}>
|
||||
<Icon icon={$currentVoiceSession.cameraOn ? VideocameraRecord : Videocamera} size={4} />
|
||||
<Icon
|
||||
icon={$currentVoiceSession.cameraOn ? VideocameraRecord : VideocameraOff}
|
||||
size={4} />
|
||||
</Button>
|
||||
{#if !Capacitor.isNativePlatform()}
|
||||
<Button
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
removeFromListByPredicate,
|
||||
updateList,
|
||||
getTag,
|
||||
getTagValue,
|
||||
getListTags,
|
||||
getRelayTagValues,
|
||||
toNostrURI,
|
||||
@@ -391,8 +392,16 @@ export type CommentParams = {
|
||||
url?: string
|
||||
}
|
||||
|
||||
export const makeComment = ({url, event, content, tags = []}: CommentParams) =>
|
||||
makeEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event, url)]})
|
||||
export const makeComment = ({url, event, content, tags = []}: CommentParams) => {
|
||||
const allTags = [...tags, ...tagEventForComment(event, url)]
|
||||
const h = getTagValue("h", event.tags)
|
||||
|
||||
if (h) {
|
||||
allTags.push(["h", h])
|
||||
}
|
||||
|
||||
return makeEvent(COMMENT, {content, tags: allTags})
|
||||
}
|
||||
|
||||
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
|
||||
publishThunk({event: makeComment({url: relays[0], ...params}), relays})
|
||||
|
||||