Compare commits

...

21 Commits

Author SHA1 Message Date
Jon Staab ae071fefaa Fail more gracefully when svelte sneezes 2026-05-29 08:30:42 -07:00
userAdityaa 152d35f92a Fix deleted rooms persisting in navigation (#285)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-29 15:20:21 +00:00
userAdityaa 8dd278f47c fix: turn on notification defaults and prompt on first DM visit (#284)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-29 15:13:10 +00:00
Jon Staab 045d6983dc Fix some voice room bugs 2026-05-28 12:17:17 -07:00
Jon Staab 2f8861be62 Bump welshman, update pnpm config 2026-05-28 12:14:40 -07:00
userAdityaa 6dbe9c0ebb chore: show space relay icon in mobile topbar headers (#283)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-25 19:18:51 +00:00
Jon Staab 45df132dc6 Remove tauri, bump deps 2026-05-25 10:41:34 -07:00
Jon Staab c42a285f0b Tweak wording 2026-05-25 09:04:14 -07:00
userAdityaa 1e3211ae74 chore: uppdate signup modal copy to focus on joining Flotilla (#282)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-25 16:00:28 +00:00
npub15skvhry ec507b05d6 minimize container size and caching 2026-05-22 15:36:28 -07:00
Jon Staab 339bb1afac Tweak page bar style 2026-05-21 15:17:28 -07:00
userAdityaa c441012e02 fix(video): restyle spotlight pin button on video tiles (#281)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-21 20:59:25 +00:00
userAdityaa 0d61278c56 chore: show call participant mute and camera-off state (#279)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-21 20:58:53 +00:00
userAdityaa ffd06ab561 fix: video blink when toggling mic mute in calls (#277)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-20 16:44:38 +00:00
userAdityaa eb8dd330b6 fix(video): use single-column tile grid when chat is open (#278)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-20 16:42:16 +00:00
userAdityaa 6267e52bdf feat: show unread chat badges during video-only voice calls (#276)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-19 15:36:22 +00:00
Jon Staab ab21008f34 Add etag for immutable assets
Docker / build-and-push-image (push) Successful in 11m19s
2026-05-12 08:46:50 -07:00
Jon Staab 0998639d59 Push to gitea package registry
Docker / build-and-push-image (push) Successful in 11m31s
2026-05-11 13:49:37 -07:00
Jon Staab eccde07d06 Fix dockerfile again
Docker / build-and-push-image (push) Successful in 12m2s
2026-05-11 13:20:47 -07:00
Jon Staab 770cdc5f13 Reduce extra space on android when keyboard is open 2026-05-11 12:44:44 -07:00
Jon Staab 6bafb62414 Fix docker build
Docker / build-and-push-image (push) Successful in 12m9s
2026-05-11 12:28:42 -07:00
105 changed files with 3001 additions and 7858 deletions
-1
View File
@@ -4,7 +4,6 @@ ios
build build
# Git # Git
.git
.gitignore .gitignore
# Env files (keep .env for build; exclude local overrides) # Env files (keep .env for build; exclude local overrides)
@@ -1,12 +1,17 @@
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: ghcr.io REGISTRY: gitea.coracle.social
IMAGE_NAME: coracle-social/flotilla IMAGE_NAME: coracle/flotilla
jobs: jobs:
build-and-push-image: build-and-push-image:
@@ -23,8 +28,8 @@ jobs:
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }} username: hodlbod
password: ${{ secrets.REGISTRY_PASSWORD }} password: ${{ secrets.PACKAGE_TOKEN }}
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
@@ -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 }}
+1 -4
View File
@@ -27,13 +27,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
+23 -32
View File
@@ -1,40 +1,31 @@
# Stage 1: Build # Build and run the Flotilla web server.
# 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 . # 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 # https://pnpm.io/docker#example-3-build-on-cicd
FROM node:24-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends curl ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm@latest RUN corepack enable
WORKDIR /app 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 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 ./ COPY package.json pnpm-lock.yaml ./
RUN pnpm i --prod --frozen-lockfile --ignore-scripts RUN pnpm i --frozen-lockfile
COPY . .
# Copy only the built output and server source - no app source, no .env, no dev deps ARG VITE_BUILD_HASH
COPY --from=builder /app/build ./build RUN pnpm run build
COPY --from=builder /app/server.js ./server.js 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"]
+2 -2
View File
@@ -37,12 +37,12 @@ pnpm run start
Or, if you prefer to use a container: Or, if you prefer to use a container:
```sh ```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: Alternatively, you can copy the build files into a directory of your choice and serve it yourself:
```sh ```sh
mkdir ./mount 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'
``` ```
+1 -1
View File
@@ -36,4 +36,4 @@ include ':capawesome-capacitor-badge'
project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge/android') 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')
+1 -1
View File
@@ -21,7 +21,7 @@ def capacitor_pods
pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications' pod '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
+22 -37
View File
@@ -5,12 +5,9 @@
"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",
@@ -21,26 +18,26 @@
"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", "@sveltejs/kit": "^2.61.1",
"@sveltejs/vite-plugin-svelte": "^4.0.4", "@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": {
@@ -64,24 +61,24 @@
"@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.3",
"@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 +87,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 +99,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.4.0"
"ignoredBuiltDependencies": [
"esbuild"
],
"onlyBuiltDependencies": [
"sharp",
"nostr-signer-capacitor-plugin"
],
"overrides": {
"sharp": "0.35.0-rc.0"
}
},
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
} }
+2476 -2786
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
allowBuilds:
nostr-signer-capacitor-plugin: true
cbor-extract: false
esbuild: false
sharp: true
minimumReleaseAgeExclude:
- '@welshman/app@0.8.16'
- '@welshman/content@0.8.16'
- '@welshman/editor@0.8.16'
- '@welshman/feeds@0.8.16'
- '@welshman/lib@0.8.16'
- '@welshman/net@0.8.16'
- '@welshman/router@0.8.16'
- '@welshman/signer@0.8.16'
- '@welshman/store@0.8.16'
- '@welshman/util@0.8.16'
overrides:
sharp: 0.35.0-rc.0
-2
View File
@@ -1,2 +0,0 @@
[toolchain]
channel = "1.92.0"
+8
View File
@@ -247,6 +247,14 @@ app.use(
: "public, max-age=3600" : "public, max-age=3600"
context.header("Cache-Control", cacheControl) 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)}"`)
}
}, },
}), }),
) )
-4784
View File
File diff suppressed because it is too large Load Diff
-18
View File
@@ -1,18 +0,0 @@
[package]
name = "flotilla"
version = "0.1.0"
edition = "2021"
[lib]
name = "flotilla_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.5.3", features = [] }
[dependencies]
tauri = { version = "2.9.5", features = [] }
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
-3
View File
@@ -1,3 +0,0 @@
fn main() {
tauri_build::build()
}
-7
View File
@@ -1,7 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default desktop capability for the main window",
"windows": ["main"],
"permissions": ["core:default"]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

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

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

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

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 668 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 926 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

-2
View File
@@ -1,2 +0,0 @@
[toolchain]
channel = "1.92.0"
-6
View File
@@ -1,6 +0,0 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
-6
View File
@@ -1,6 +0,0 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
flotilla_lib::run();
}
-37
View File
@@ -1,37 +0,0 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Flotilla",
"mainBinaryName": "flotilla",
"identifier": "social.flotilla.app",
"build": {
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build",
"devUrl": "http://localhost:1847",
"frontendDist": "../build"
},
"app": {
"security": {
"capabilities": ["default"]
},
"windows": [
{
"label": "main",
"title": "Flotilla",
"width": 1240,
"height": 775,
"resizable": true
}
]
},
"bundle": {
"active": false,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+8
View File
@@ -423,10 +423,18 @@ progress[value]::-webkit-progress-value {
/* Keyboard open state adjustments */ /* Keyboard open state adjustments */
body.keyboard-open {
--saib: 0px;
}
body.keyboard-open .hide-on-keyboard { body.keyboard-open .hide-on-keyboard {
display: none; display: none;
} }
body.keyboard-open .chat__compose {
margin-bottom: 0;
}
/* chat view */ /* chat view */
.chat__compose { .chat__compose {
+18 -3
View File
@@ -6,11 +6,13 @@ 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}
@@ -27,8 +29,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 +41,21 @@ 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, {muted: boolean; cameraOn: boolean}>(),
)
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) =>
+131 -43
View File
@@ -6,26 +6,28 @@ 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 VoiceParticipant, type VoiceParticipant,
@@ -75,20 +77,23 @@ export const switchVoiceActiveDevice = async (
} }
} }
const addParticipant = (identity: string) => { const deleteParticipant = (identity: string) => {
participantPubkeyMap.update(m => { participantMediaState.update(m => new Map(reject(nthEq(0, identity), [...m])))
}
const syncParticipantMedia = (participant: Participant) => {
const state = {muted: !participant.isMicrophoneEnabled, cameraOn: participant.isCameraEnabled}
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) => { const onParticipantMediaChanged = (_publication: TrackPublication, participant: Participant) => {
participantPubkeyMap.update(m => { syncParticipantMedia(participant)
const next = new Map(m)
next.delete(identity)
return next
})
} }
const fetchLivekitToken = async ( const fetchLivekitToken = async (
@@ -124,15 +129,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 +157,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,16 +170,36 @@ 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 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) {
@@ -184,7 +211,7 @@ const onRoomDisconnected = (reason?: DisconnectReason) => {
pushToast({theme: "error", message}) pushToast({theme: "error", message})
} }
speakingParticipants.set([]) speakingParticipants.set([])
participantPubkeyMap.set(new Map()) participantMediaState.set(new Map())
} }
const onTrackSubscribed = (track: Track) => { const onTrackSubscribed = (track: Track) => {
@@ -214,8 +241,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()
} }
@@ -248,9 +275,6 @@ export const joinVoiceRoom = async (
): Promise<void> => { ): Promise<void> => {
cancelJoinVoiceRoom() cancelJoinVoiceRoom()
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,47 +283,101 @@ 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.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,
}) })
@@ -310,6 +388,7 @@ export const joinVoiceRoom = async (
if (e instanceof AbortError) return if (e instanceof AbortError) return
throw e throw e
} finally { } finally {
settle.abort()
if (isActive()) joinAbortController = undefined if (isActive()) joinAbortController = undefined
} }
} }
@@ -337,13 +416,23 @@ 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()) // Only reset shared UI state if this session is still current. A slow leave
// that was superseded by a new join (bounded by a timeout in joinVoiceRoom)
// must not clobber the freshly-joined session when it finally completes.
if (get(currentVoiceSession) === session) {
voiceState.set(VoiceState.Disconnected)
videoPrimaryTileKey.set(undefined)
voiceMicMuted.set(true)
currentVoiceSession.set(undefined)
resetVideoCallLayout()
speakingParticipants.set([])
participantMediaState.set(new Map())
}
} }
export const rejoinVoiceRoom = async (): Promise<void> => { export const rejoinVoiceRoom = async (): Promise<void> => {
@@ -356,18 +445,17 @@ 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"})
} }
} }
+1
View File
@@ -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))
} }
}, },
+2 -5
View File
@@ -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 -1
View File
@@ -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">
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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}}
+46 -7
View File
@@ -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}
+11 -1
View File
@@ -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}
+15 -9
View File
@@ -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
+7 -4
View File
@@ -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]},
],
}) })
} }
+1 -1
View File
@@ -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()
+18 -5
View File
@@ -1,4 +1,4 @@
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, tracker, repository, relaysByUrl} from "@welshman/app"
@@ -18,7 +18,6 @@ import {
} 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 +35,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 +59,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()
} }
@@ -151,9 +156,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
+5
View File
@@ -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

+4 -4
View File
@@ -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>
+4 -1
View File
@@ -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
+9 -5
View File
@@ -44,11 +44,15 @@ export const whenAborted = (signal?: AbortSignal) => {
}) })
} }
/** Returns a promise that rejects with TimeoutError after ms. Use with Promise.race. */ /**
export const whenTimeout = (ms: number, opts: {message?: string} = {}) => { * 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[]) => {
+2 -1
View File
@@ -37,6 +37,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"
@@ -175,7 +176,7 @@
unsubscribers.push(notifications.syncChecked()) unsubscribers.push(notifications.syncChecked())
// 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(
+38
View File
@@ -1,13 +1,51 @@
<script context="module" lang="ts">
import {synced} from "@welshman/store"
import {kv} from "@app/core/storage"
const dmNotificationsPrompted = synced({
key: "dmNotificationsPrompted",
defaultValue: false,
storage: kv,
})
</script>
<script lang="ts"> <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} />
+2 -1
View File
@@ -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 = () => {
+21 -3
View File
@@ -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()}
@@ -111,8 +111,10 @@
</script> </script>
<SpaceBar> <SpaceBar>
{#snippet title()} {#snippet leading()}
<Icon icon={CalendarMinimalistic} /> <Icon icon={CalendarMinimalistic} />
{/snippet}
{#snippet title()}
<strong>Calendar</strong> <strong>Calendar</strong>
{/snippet} {/snippet}
{#snippet action()} {#snippet action()}
+3 -1
View File
@@ -307,8 +307,10 @@
</script> </script>
<SpaceBar> <SpaceBar>
{#snippet title()} {#snippet leading()}
<Icon icon={ChatRound} /> <Icon icon={ChatRound} />
{/snippet}
{#snippet title()}
<strong>Chat</strong> <strong>Chat</strong>
{/snippet} {/snippet}
{#snippet action()} {#snippet action()}
@@ -63,8 +63,10 @@
</script> </script>
<SpaceBar> <SpaceBar>
{#snippet title()} {#snippet leading()}
<Icon icon={CaseMinimalistic} /> <Icon icon={CaseMinimalistic} />
{/snippet}
{#snippet title()}
<strong>Classifieds</strong> <strong>Classifieds</strong>
{/snippet} {/snippet}
{#snippet action()} {#snippet action()}
+3 -1
View File
@@ -62,8 +62,10 @@
</script> </script>
<SpaceBar> <SpaceBar>
{#snippet title()} {#snippet leading()}
<Icon icon={StarFallMinimalistic} /> <Icon icon={StarFallMinimalistic} />
{/snippet}
{#snippet title()}
<strong>Goals</strong> <strong>Goals</strong>
{/snippet} {/snippet}
{#snippet action()} {#snippet action()}

Some files were not shown because too many files have changed in this diff Show More