Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 631dbed375 |
@@ -1,13 +1,8 @@
|
||||
name: Container Image Build and Publish
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: gitea.coracle.social
|
||||
@@ -37,7 +32,6 @@ 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
|
||||
@@ -51,7 +45,6 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
target: production
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -27,10 +27,13 @@ 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
|
||||
|
||||
@@ -6,26 +6,22 @@
|
||||
# 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.
|
||||
|
||||
# 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
|
||||
FROM node:22-bookworm
|
||||
|
||||
RUN npm install -g pnpm@10.33.0
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_OPTIONS=--max_old_space_size=16384
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
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
|
||||
RUN pnpm i --frozen-lockfile
|
||||
|
||||
ENV NODE_OPTIONS=--max_old_space_size=16384
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm run build
|
||||
|
||||
EXPOSE 3000
|
||||
USER node
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -36,4 +36,4 @@ include ':capawesome-capacitor-badge'
|
||||
project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge/android')
|
||||
|
||||
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_a3c0fb15d5bfa83f24d0070ca2583fc9/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_2704ecccfd05fcfb1ad8852744422b7c/node_modules/nostr-signer-capacitor-plugin/android')
|
||||
|
||||
@@ -21,7 +21,7 @@ def capacitor_pods
|
||||
pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications'
|
||||
pod 'CapacitorShare', :path => '../../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/share'
|
||||
pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge'
|
||||
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_a3c0fb15d5bfa83f24d0070ca2583fc9/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_2704ecccfd05fcfb1ad8852744422b7c/node_modules/nostr-signer-capacitor-plugin'
|
||||
end
|
||||
|
||||
target 'Flotilla Chat' do
|
||||
|
||||
@@ -5,9 +5,12 @@
|
||||
"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",
|
||||
@@ -18,26 +21,26 @@
|
||||
"devDependencies": {
|
||||
"@capacitor/assets": "^3.0.5",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@sveltejs/kit": "^2.61.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
||||
"@sveltejs/kit": "^2.50.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||
"@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.15",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.4.1",
|
||||
"svelte": "^5.55.9",
|
||||
"svelte": "^5.48.0",
|
||||
"svelte-check": "^4.3.5",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.53.1",
|
||||
"vite": "^6.4.2"
|
||||
"vite": "^5.4.21"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -60,25 +63,25 @@
|
||||
"@getalby/sdk": "^5.1.2",
|
||||
"@hono/node-server": "^2.0.0",
|
||||
"@noble/curves": "^1.9.7",
|
||||
"@pomade/core": "^0.2.5",
|
||||
"@poppanator/sveltekit-svg": "^7.0.0",
|
||||
"@pomade/core": "^0.2.3",
|
||||
"@poppanator/sveltekit-svg": "^4.2.1",
|
||||
"@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": "^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",
|
||||
"@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",
|
||||
"cheerio": "^1.2.0",
|
||||
"compressorjs-next": "^1.1.2",
|
||||
"daisyui": "^5.5.19",
|
||||
@@ -87,7 +90,7 @@
|
||||
"emoji-picker-element": "^1.28.1",
|
||||
"emoji-picker-element-data": "^1.8.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"hono": "^4.12.23",
|
||||
"hono": "^4.12.15",
|
||||
"husky": "^9.1.7",
|
||||
"idb": "^8.0.3",
|
||||
"livekit-client": "^2.17.2",
|
||||
@@ -99,5 +102,17 @@
|
||||
"throttle-debounce": "^5.0.2",
|
||||
"tippy.js": "^6.3.7"
|
||||
},
|
||||
"packageManager": "pnpm@11.4.0"
|
||||
"pnpm": {
|
||||
"ignoredBuiltDependencies": [
|
||||
"esbuild"
|
||||
],
|
||||
"onlyBuiltDependencies": [
|
||||
"sharp",
|
||||
"nostr-signer-capacitor-plugin"
|
||||
],
|
||||
"overrides": {
|
||||
"sharp": "0.35.0-rc.0"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "1.92.0"
|
||||
@@ -0,0 +1,18 @@
|
||||
[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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default desktop capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,5 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 668 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 926 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "1.92.0"
|
||||
@@ -0,0 +1,6 @@
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
flotilla_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ 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,21 +43,6 @@ export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
|
||||
|
||||
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(
|
||||
speakingParticipants,
|
||||
$participants => (p: VoiceParticipant) =>
|
||||
|
||||
@@ -6,28 +6,27 @@ 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, not, nthEq, reject, removeUndefined, uniqBy} from "@welshman/lib"
|
||||
import {map, not, 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, TimeoutError, whenAborted, whenTimeout} from "$lib/util"
|
||||
import {AbortError, whenAborted, whenTimeout} from "$lib/util"
|
||||
import {
|
||||
currentVoiceRoom,
|
||||
currentVoiceSession,
|
||||
voiceMicMuted,
|
||||
participantFromLiveKitIdentity,
|
||||
participantKey,
|
||||
participantMediaState,
|
||||
participantPubkeyMap,
|
||||
pubkeyFromLiveKitIdentity,
|
||||
speakingParticipants,
|
||||
VoiceState,
|
||||
type VoiceParticipant,
|
||||
@@ -77,23 +76,20 @@ export const switchVoiceActiveDevice = async (
|
||||
}
|
||||
}
|
||||
|
||||
const deleteParticipant = (identity: string) => {
|
||||
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 addParticipant = (identity: string) => {
|
||||
participantPubkeyMap.update(m => {
|
||||
const next = new Map(m)
|
||||
next.set(participant.identity, state)
|
||||
next.set(identity, pubkeyFromLiveKitIdentity(identity) ?? "")
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const onParticipantMediaChanged = (_publication: TrackPublication, participant: Participant) => {
|
||||
syncParticipantMedia(participant)
|
||||
const deleteParticipant = (identity: string) => {
|
||||
participantPubkeyMap.update(m => {
|
||||
const next = new Map(m)
|
||||
next.delete(identity)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const fetchLivekitToken = async (
|
||||
@@ -129,15 +125,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(
|
||||
[
|
||||
participantMediaState,
|
||||
participantPubkeyMap,
|
||||
currentVoiceRoom,
|
||||
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
|
||||
],
|
||||
([$participantMediaState, $currentVoiceRoom, $publishedParticipantList]) => {
|
||||
const inCall = $participantMediaState.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h)
|
||||
([$participantPubkeyMap, $currentVoiceRoom, $publishedParticipantList]) => {
|
||||
const inCall = $participantPubkeyMap.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h)
|
||||
|
||||
if (inCall) {
|
||||
const participants = [...$participantMediaState.keys()].map(participantFromLiveKitIdentity)
|
||||
const participants = [...$participantPubkeyMap.keys()].map(participantFromLiveKitIdentity)
|
||||
return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
|
||||
} else {
|
||||
const latestEvent = $publishedParticipantList as TrustedEvent | undefined
|
||||
@@ -157,8 +153,6 @@ const setUpMicrophone = async (
|
||||
startMuted: boolean,
|
||||
preferredMicId: string | undefined,
|
||||
participant: LocalParticipant,
|
||||
signal?: AbortSignal,
|
||||
settleSignal?: AbortSignal,
|
||||
): Promise<boolean> => {
|
||||
if (startMuted) {
|
||||
return true
|
||||
@@ -170,34 +164,15 @@ const setUpMicrophone = async (
|
||||
capture = {deviceId: preferredMicId}
|
||||
}
|
||||
try {
|
||||
await Promise.race([
|
||||
participant.setMicrophoneEnabled(true, capture),
|
||||
whenTimeout(15_000, {message: "Microphone access timed out.", signal: settleSignal}),
|
||||
whenAborted(signal),
|
||||
])
|
||||
await participant.setMicrophoneEnabled(true, capture)
|
||||
muted = false
|
||||
} catch (e) {
|
||||
// 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"})
|
||||
}
|
||||
pushToast({theme: "error", message: "Could not access microphone"})
|
||||
}
|
||||
return muted
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
const onRoomDisconnected = (reason?: DisconnectReason) => {
|
||||
videoPrimaryTileKey.set(undefined)
|
||||
voiceMicMuted.set(true)
|
||||
currentVoiceSession.set(undefined)
|
||||
@@ -211,7 +186,7 @@ const makeOnRoomDisconnected = (room: LiveKitRoom) => (reason?: DisconnectReason
|
||||
pushToast({theme: "error", message})
|
||||
}
|
||||
speakingParticipants.set([])
|
||||
participantMediaState.set(new Map())
|
||||
participantPubkeyMap.set(new Map())
|
||||
}
|
||||
|
||||
const onTrackSubscribed = (track: Track) => {
|
||||
@@ -241,8 +216,8 @@ const playJoinSound = () => {
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
|
||||
const onParticipantConnected = (participant: Participant) => {
|
||||
syncParticipantMedia(participant)
|
||||
const onParticipantConnected = (participant: {identity: string}) => {
|
||||
addParticipant(participant.identity)
|
||||
playJoinSound()
|
||||
}
|
||||
|
||||
@@ -275,6 +250,9 @@ export const joinVoiceRoom = async (
|
||||
): Promise<void> => {
|
||||
cancelJoinVoiceRoom()
|
||||
|
||||
const session = get(currentVoiceSession)
|
||||
if (session) await leaveVoiceRoom()
|
||||
|
||||
currentVoiceRoom.set(get(deriveRoom(url, h)))
|
||||
voiceState.set(VoiceState.Joining)
|
||||
|
||||
@@ -283,95 +261,41 @@ 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 {
|
||||
// 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),
|
||||
])
|
||||
const {server_url, participant_token} = await fetchLivekitToken(url, h, signal)
|
||||
|
||||
if (signal.aborted) throw new AbortError()
|
||||
|
||||
const liveKitRoom = new LiveKitRoom({adaptiveStream: true, dynacast: true})
|
||||
activeRoom = liveKitRoom
|
||||
|
||||
liveKitRoom.on(RoomEvent.Disconnected, makeOnRoomDisconnected(liveKitRoom))
|
||||
liveKitRoom.on(RoomEvent.Disconnected, onRoomDisconnected)
|
||||
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
|
||||
}
|
||||
|
||||
participantMediaState.set(new Map())
|
||||
syncParticipantMedia(liveKitRoom.localParticipant)
|
||||
participantPubkeyMap.set(new Map())
|
||||
addParticipant(liveKitRoom.localParticipant.identity)
|
||||
for (const p of liveKitRoom.remoteParticipants.values()) {
|
||||
syncParticipantMedia(p)
|
||||
addParticipant(p.identity)
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
|
||||
|
||||
voiceMicMuted.set(muted)
|
||||
currentVoiceSession.set({
|
||||
@@ -388,7 +312,6 @@ export const joinVoiceRoom = async (
|
||||
if (e instanceof AbortError) return
|
||||
throw e
|
||||
} finally {
|
||||
settle.abort()
|
||||
if (isActive()) joinAbortController = undefined
|
||||
}
|
||||
}
|
||||
@@ -416,23 +339,14 @@ export const leaveVoiceRoom = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Always tear down this room's connection and listeners.
|
||||
if (activeRoom === session.room) activeRoom = undefined
|
||||
session.room.removeAllListeners()
|
||||
voiceState.set(VoiceState.Disconnected)
|
||||
videoPrimaryTileKey.set(undefined)
|
||||
voiceMicMuted.set(true)
|
||||
currentVoiceSession.set(undefined)
|
||||
resetVideoCallLayout()
|
||||
session.room.disconnect()
|
||||
|
||||
// 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())
|
||||
}
|
||||
speakingParticipants.set([])
|
||||
participantPubkeyMap.set(new Map())
|
||||
}
|
||||
|
||||
export const rejoinVoiceRoom = async (): Promise<void> => {
|
||||
|
||||
@@ -122,7 +122,6 @@
|
||||
repository.removeEvent(thunk.event.id)
|
||||
pushToast({theme: "error", message})
|
||||
} else {
|
||||
await removeRoomMembership(url, h)
|
||||
goto(makeSpacePath(url))
|
||||
}
|
||||
},
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
<div class:mt-2={showPubkey && event.kind !== MESSAGE}>
|
||||
<RoomItemContent {url} event={$innerEvent ?? event} />
|
||||
{#if thunk}
|
||||
<ThunkFailure showToastOnRetry {thunk} class="mt-1 flex justify-end" />
|
||||
<ThunkFailure showToastOnRetry {thunk} class="mt-2 text-sm" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,11 +11,7 @@
|
||||
const {url, h, ...props}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex grow items-center justify-between gap-4 {props.class}">
|
||||
<div class="flex items-center gap-2">
|
||||
<RoomImage {url} {h} />
|
||||
<div class="min-w-0 overflow-hidden text-ellipsis">
|
||||
<RoomName {url} {h} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center gap-2 {props.class}">
|
||||
<RoomImage {url} {h} />
|
||||
<RoomName {url} {h} />
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
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"
|
||||
@@ -90,9 +91,11 @@
|
||||
|
||||
<Modal>
|
||||
<ModalBody>
|
||||
<h1 class="heading">Join {PLATFORM_NAME}</h1>
|
||||
<h1 class="heading">Sign up with Nostr</h1>
|
||||
<p class="m-auto max-w-sm text-center">
|
||||
Censorship resistant digital spaces for communities. Meet new people, own your identity.
|
||||
{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>.
|
||||
</p>
|
||||
{#if hasPomade}
|
||||
<Button onclick={flows.email.start} class="btn btn-primary">
|
||||
|
||||
@@ -7,42 +7,36 @@
|
||||
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)), leading, title, action, ...props}: Props = $props()
|
||||
const {back = () => goto(makeSpacePath(url)), title, action, ...props}: Props = $props()
|
||||
|
||||
const url = decodeRelay($page.params.relay!)
|
||||
</script>
|
||||
|
||||
<PageBar {...props}>
|
||||
<div class="flex">
|
||||
<Button onclick={back} class="place-self-start pr-3 md:hidden">
|
||||
<Icon icon={ArrowLeft} size={7} />
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<Button onclick={back} class="btn btn-ghost btn-square shrink-0 md:hidden">
|
||||
<Icon icon={ArrowLeft} size={6} />
|
||||
</Button>
|
||||
<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>
|
||||
<div class="flex min-w-0 flex-1 items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
{@render title?.()}
|
||||
</div>
|
||||
<div class="text-xs text-primary md:hidden">
|
||||
<div class="truncate text-xs leading-4 text-primary md:hidden">
|
||||
{displayRelayUrl(url)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-start">
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
{@render action?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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/push"
|
||||
import {Push} from "@app/util/notifications"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import {stopPropagation} from "svelte/legacy"
|
||||
import {noop} from "@welshman/lib"
|
||||
import type {AbstractThunk} from "@welshman/app"
|
||||
import {flattenThunks, getFailedThunkUrls, publishThunk, thunkIsComplete} from "@welshman/app"
|
||||
import {retryThunk, thunkIsComplete, getFailedThunkUrls} 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,45 +16,40 @@
|
||||
class?: string
|
||||
}
|
||||
|
||||
const {thunk, showToastOnRetry, ...restProps}: Props = $props()
|
||||
let {thunk, showToastOnRetry, ...restProps}: Props = $props()
|
||||
|
||||
const showFailure = $derived(thunkIsComplete($thunk) && getFailedThunkUrls($thunk).length > 0)
|
||||
const retry = () => {
|
||||
thunk = retryThunk(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
|
||||
if (showToastOnRetry) {
|
||||
pushToast({
|
||||
timeout: 30_000,
|
||||
children: {
|
||||
component: ThunkToast,
|
||||
props: {thunk},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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={{thunk, retry}}
|
||||
params={{interactive: true, maxWidth: "none"}}>
|
||||
props={{url, message, status, retry}}
|
||||
params={{interactive: true}}>
|
||||
{#snippet children()}
|
||||
<span class="flex cursor-pointer items-center gap-1 opacity-75">
|
||||
<Icon icon={Danger} class="text-error" size={3} />
|
||||
<span class="flex cursor-pointer items-center gap-1 text-error">
|
||||
<Icon icon={Danger} size={3} />
|
||||
<span>Failed to send!</span>
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
@@ -6,18 +6,17 @@
|
||||
|
||||
interface Props {
|
||||
thunk: AbstractThunk
|
||||
showToastOnRetry?: boolean
|
||||
class?: string
|
||||
}
|
||||
|
||||
const {thunk, showToastOnRetry, ...restProps}: Props = $props()
|
||||
const {thunk, ...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} {showToastOnRetry} />
|
||||
<ThunkFailure class={restProps.class} {thunk} />
|
||||
{:else if showPending}
|
||||
<ThunkPending class={restProps.class} {thunk} />
|
||||
{/if}
|
||||
|
||||
@@ -1,69 +1,32 @@
|
||||
<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 {
|
||||
thunk: AbstractThunk
|
||||
retry: (url: string) => void
|
||||
url: string
|
||||
status: string
|
||||
message: string
|
||||
retry: () => void
|
||||
}
|
||||
|
||||
const {thunk, retry}: Props = $props()
|
||||
let {url, status, message = $bindable(), retry}: Props = $props()
|
||||
|
||||
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
|
||||
$effect(() => {
|
||||
if (!message && status === PublishStatus.Timeout) {
|
||||
message = "request timed out"
|
||||
}
|
||||
|
||||
if (status === PublishStatus.Timeout) {
|
||||
return "request timed out"
|
||||
if (!message) {
|
||||
message = "no details recieved"
|
||||
}
|
||||
|
||||
return "no details received"
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<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 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>
|
||||
|
||||
@@ -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,7 +71,6 @@
|
||||
|
||||
{#if $toast}
|
||||
{@const theme = $toast.theme || "info"}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={containerEl}
|
||||
transition:fly={{y: -20}}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
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,
|
||||
@@ -19,12 +18,7 @@
|
||||
ViewportSize,
|
||||
videoPrimaryTileKey,
|
||||
} from "@app/call/video"
|
||||
import {
|
||||
currentVoiceSession,
|
||||
currentVoiceRoom,
|
||||
mediaStateByIdentity,
|
||||
pubkeyFromLiveKitIdentity,
|
||||
} from "@app/call/stores"
|
||||
import {currentVoiceSession, currentVoiceRoom, pubkeyFromLiveKitIdentity} from "@app/call/stores"
|
||||
|
||||
type Props = {
|
||||
layout: VideoCallLayout
|
||||
@@ -127,25 +121,6 @@
|
||||
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
|
||||
@@ -212,7 +187,6 @@
|
||||
</script>
|
||||
|
||||
{#snippet videoTile(tile: VideoTileData, layout: TileLayout)}
|
||||
{@const media = $mediaStateByIdentity(tile.identity)}
|
||||
<div
|
||||
class={cx(
|
||||
"relative isolate overflow-hidden rounded-box shadow-sm",
|
||||
@@ -232,15 +206,6 @@
|
||||
<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)" : ""}
|
||||
@@ -251,8 +216,8 @@
|
||||
data-tip={pinned ? "Exit spotlight" : "Spotlight"}
|
||||
aria-pressed={pinned}
|
||||
class={cx(
|
||||
"absolute right-1 top-1 z-20 btn btn-xs btn-square",
|
||||
pinned ? "btn-primary" : "btn-ghost bg-base-100",
|
||||
"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",
|
||||
)}
|
||||
onclick={spotlightHandlerFor(tileKey(tile))}>
|
||||
<Icon icon={Pin} size={3} />
|
||||
@@ -291,10 +256,8 @@
|
||||
{: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 one is sharing video yet.</p>
|
||||
<p class="text-xs">
|
||||
Participants appear here when they turn on their camera or share their screen.
|
||||
</p>
|
||||
<p>No camera or screen share yet.</p>
|
||||
<p class="text-xs">Use the camera or screen share control to share video.</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<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,13 +9,11 @@
|
||||
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,
|
||||
@@ -85,17 +83,9 @@
|
||||
)}>
|
||||
<ProfileCircle pubkey={p.pubkey} size={5} class="h-5 w-5" />
|
||||
</div>
|
||||
<span class="ellipsize min-w-0 flex-1 text-xs opacity-70">
|
||||
<span class="ellipsize 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 VideocameraOff from "@assets/icons/videocamera-off.svg?dataurl"
|
||||
import Videocamera from "@assets/icons/videocamera.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"
|
||||
@@ -43,6 +43,7 @@
|
||||
currentVoiceRoom,
|
||||
voiceMicMuted,
|
||||
voiceState,
|
||||
isLocalSpeaking,
|
||||
} from "@app/call/stores"
|
||||
import {cancelJoinVoiceRoom, leaveVoiceRoom, toggleMute} from "@app/call/voice"
|
||||
|
||||
@@ -187,6 +188,7 @@
|
||||
class={cx(
|
||||
mediaToggleClass,
|
||||
"overflow-visible",
|
||||
!$voiceMicMuted && $isLocalSpeaking && "text-primary",
|
||||
$voiceMicMuted && "text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100",
|
||||
)}
|
||||
onclick={toggleMute}>
|
||||
@@ -205,17 +207,9 @@
|
||||
</Button>
|
||||
<Button
|
||||
data-tip={$currentVoiceSession.cameraOn ? "Turn off camera" : "Turn on camera"}
|
||||
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",
|
||||
)}
|
||||
class={cx(mediaToggleClass, $currentVoiceSession.cameraOn && "text-primary")}
|
||||
onclick={toggleCamera}>
|
||||
<Icon
|
||||
icon={$currentVoiceSession.cameraOn ? VideocameraRecord : VideocameraOff}
|
||||
size={4} />
|
||||
<Icon icon={$currentVoiceSession.cameraOn ? VideocameraRecord : Videocamera} size={4} />
|
||||
</Button>
|
||||
{#if !Capacitor.isNativePlatform()}
|
||||
<Button
|
||||
|
||||
@@ -5,7 +5,7 @@ import {derived, readable, writable} from "svelte/store"
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {
|
||||
on,
|
||||
gte,
|
||||
gt,
|
||||
max,
|
||||
spec,
|
||||
call,
|
||||
@@ -418,7 +418,7 @@ export const device = withGetter(writable(randomId()))
|
||||
export const notificationSettings = withGetter(
|
||||
writable({
|
||||
push: false,
|
||||
sound: true,
|
||||
sound: false,
|
||||
badge: false,
|
||||
spaces: true,
|
||||
mentions: true,
|
||||
@@ -620,7 +620,7 @@ export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByU
|
||||
for (const event of metaEvents) {
|
||||
const meta = tryCatch(() => readRoomMeta(event))
|
||||
|
||||
if (!meta || gte(deletedByH.get(meta.h), meta.event.created_at)) {
|
||||
if (!meta || gt(deletedByH.get(meta.h), meta.event.created_at)) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -651,10 +651,7 @@ export const loadRoom = call(() => {
|
||||
|
||||
await load({
|
||||
relays: [url],
|
||||
filters: [
|
||||
{kinds: [ROOM_META], "#d": [h]},
|
||||
{kinds: [ROOM_DELETE], "#h": [h]},
|
||||
],
|
||||
filters: [{kinds: [ROOM_META], "#d": [h]}],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {db, kv, ss} from "@app/core/storage"
|
||||
import {Push} from "@app/util/notifications"
|
||||
import {deactivateCurrentPomadeSession} from "@app/util/pomade"
|
||||
import {Push} from "@app/util/push"
|
||||
|
||||
export const logout = async () => {
|
||||
await deactivateCurrentPomadeSession()
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import {derived, get, writable} from "svelte/store"
|
||||
import {Badge} from "@capawesome/capacitor-badge"
|
||||
import {synced, throttled, withGetter} from "@welshman/store"
|
||||
import {pubkey, signer, tracker, repository, relaysByUrl} from "@welshman/app"
|
||||
import {assoc, prop, first, identity, groupBy, now, throttle, parseJson, gt} from "@welshman/lib"
|
||||
import type {SignedEvent, TrustedEvent} from "@welshman/util"
|
||||
import {pubkey, tracker, repository, relaysByUrl} from "@welshman/app"
|
||||
import {assoc, prop, first, identity, groupBy, now} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {deriveEventsByIdByUrl} from "@welshman/store"
|
||||
import {
|
||||
sortEventsDesc,
|
||||
getTagValue,
|
||||
MESSAGE,
|
||||
makeHttpAuth,
|
||||
makeHttpAuthHeader,
|
||||
} from "@welshman/util"
|
||||
import {sortEventsDesc, getTagValue, MESSAGE} from "@welshman/util"
|
||||
import {makeSpacePath, makeRoomPath, makeSpaceChatPath, makeChatPath} from "@app/util/routes"
|
||||
import {
|
||||
CONTENT_KINDS,
|
||||
@@ -21,11 +15,10 @@ import {
|
||||
getSpaceUrlsFromGroupList,
|
||||
makeCommentFilter,
|
||||
hasNip29,
|
||||
dufflepud,
|
||||
DUFFLEPUD_URL,
|
||||
} from "@app/core/state"
|
||||
import {kv} from "@app/core/storage"
|
||||
import {page} from "$app/stores"
|
||||
export {Push} from "@app/util/push"
|
||||
|
||||
// Checked state
|
||||
|
||||
@@ -83,100 +76,6 @@ export const syncChecked = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const CHECKED_KV_KEY = "checked"
|
||||
const NIP98_MAX_AGE = 23 * 60 * 60
|
||||
|
||||
let nip98Auth: SignedEvent | undefined
|
||||
|
||||
const nip98Header = async () => {
|
||||
const $signer = signer.get()
|
||||
|
||||
if (!$signer) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!nip98Auth || now() - nip98Auth.created_at > NIP98_MAX_AGE) {
|
||||
nip98Auth = await $signer.sign(await makeHttpAuth(DUFFLEPUD_URL, "GET"))
|
||||
}
|
||||
|
||||
return makeHttpAuthHeader(nip98Auth)
|
||||
}
|
||||
|
||||
const pullCheckedRemote = async () => {
|
||||
const authorization = await nip98Header()
|
||||
|
||||
if (!authorization) {
|
||||
return
|
||||
}
|
||||
|
||||
const res = await fetch(dufflepud(`kv/${CHECKED_KV_KEY}`), {headers: {authorization}})
|
||||
|
||||
if (!res.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
const remote = parseJson<Record<string, number>>(await res.text())
|
||||
|
||||
if (!remote) {
|
||||
return
|
||||
}
|
||||
|
||||
checked.update($checked => {
|
||||
for (const [path, ts] of Object.entries(remote)) {
|
||||
if (gt(ts, $checked[path])) {
|
||||
$checked[path] = ts
|
||||
}
|
||||
}
|
||||
|
||||
return $checked
|
||||
})
|
||||
}
|
||||
|
||||
const pushCheckedRemote = throttle(3000, async () => {
|
||||
const authorization = await nip98Header()
|
||||
|
||||
if (!authorization) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(dufflepud(`kv/${CHECKED_KV_KEY}`), {
|
||||
method: "POST",
|
||||
headers: {authorization},
|
||||
body: JSON.stringify(checked.get()),
|
||||
})
|
||||
} catch {
|
||||
// pass
|
||||
}
|
||||
})
|
||||
|
||||
export const syncCheckedRemote = () => {
|
||||
let ready = false
|
||||
|
||||
const unsubscribePubkey = pubkey.subscribe($pubkey => {
|
||||
ready = false
|
||||
nip98Auth = undefined
|
||||
|
||||
if ($pubkey) {
|
||||
pullCheckedRemote().then(() => {
|
||||
ready = true
|
||||
pushCheckedRemote()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const unsubscribeChecked = checked.subscribe(() => {
|
||||
if (ready && pubkey.get()) {
|
||||
pushCheckedRemote()
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribePubkey()
|
||||
unsubscribeChecked()
|
||||
}
|
||||
}
|
||||
|
||||
// Derived notifications state
|
||||
|
||||
export const allNotifications = derived(
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 970 B |
@@ -11,6 +11,9 @@
|
||||
|
||||
<div
|
||||
data-component="PageBar"
|
||||
class="relative bg-base-100 p-4 shadow-md h-20 md:h-12 flex flex-col justify-center z-nav {props.class}">
|
||||
{@render children?.()}
|
||||
class="relative z-nav -mx-sai -mt-sai shrink-0 md:mx-0 md:-mb-4 md:mt-0 md:p-2 {props.class}">
|
||||
<div
|
||||
class="border-base-300 bg-base-100 flex min-h-[calc(4rem+var(--sait))] items-center border-b px-4 pb-2 pt-sai md:h-12 md:min-h-0 md:rounded-xl md:border-0 md:p-4 md:shadow-md">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,10 +44,7 @@ export const createScroller = ({
|
||||
: element.closest(".scroll-container")
|
||||
|
||||
const check = async () => {
|
||||
const isHidden = (el: Element) =>
|
||||
(el as HTMLElement).offsetParent === null || el.clientHeight === 0
|
||||
|
||||
if (container && !isHidden(container)) {
|
||||
if (container) {
|
||||
// While we have empty space, fill it
|
||||
const {scrollY, innerHeight} = window
|
||||
const {scrollHeight, scrollTop, clientHeight} = container
|
||||
|
||||
@@ -44,15 +44,11 @@ export const whenAborted = (signal?: AbortSignal) => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that rejects with TimeoutError after ms. Use with Promise.race.
|
||||
* Pass an optional signal to clear the timer when that signal aborts (self-cleaning).
|
||||
*/
|
||||
export const whenTimeout = (ms: number, opts: {message?: string; signal?: AbortSignal} = {}) => {
|
||||
return new Promise<never>((_, reject) => {
|
||||
const timeout = setTimeout(() => reject(new TimeoutError(opts.message)), ms)
|
||||
opts.signal?.addEventListener("abort", () => clearTimeout(timeout), {once: true})
|
||||
})
|
||||
/** Returns a promise that rejects with TimeoutError after ms. Use with Promise.race. */
|
||||
export const whenTimeout = (ms: number, opts: {message?: string} = {}) => {
|
||||
return new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new TimeoutError(opts.message)), ms),
|
||||
)
|
||||
}
|
||||
|
||||
export const buildUrl = (base: string | URL, ...pathname: string[]) => {
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
import {theme} from "@app/util/theme"
|
||||
import {toast, pushToast} from "@app/util/toast"
|
||||
import * as notifications from "@app/util/notifications"
|
||||
import {Push} from "@app/util/push"
|
||||
import {onPushNotificationAction} from "@app/util/push/adapters/common"
|
||||
import * as storage from "@app/util/storage"
|
||||
import {syncKeyboard} from "@app/util/keyboard"
|
||||
@@ -175,11 +174,8 @@
|
||||
// Subscribe to page history to update checked state
|
||||
unsubscribers.push(notifications.syncChecked())
|
||||
|
||||
// Sync checked state across devices
|
||||
unsubscribers.push(notifications.syncCheckedRemote())
|
||||
|
||||
// Initialize background notifications
|
||||
unsubscribers.push(Push.sync())
|
||||
unsubscribers.push(notifications.Push.sync())
|
||||
|
||||
// Listen for signer errors, report to user via toast
|
||||
unsubscribers.push(
|
||||
|
||||
@@ -1,51 +1,13 @@
|
||||
<script context="module" lang="ts">
|
||||
import {synced} from "@welshman/store"
|
||||
import {kv} from "@app/core/storage"
|
||||
|
||||
const dmNotificationsPrompted = synced({
|
||||
key: "dmNotificationsPrompted",
|
||||
defaultValue: false,
|
||||
storage: kv,
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {page} from "$app/stores"
|
||||
import type {MakeNonOptional} from "@welshman/lib"
|
||||
import {append, uniq} from "@welshman/lib"
|
||||
import {pubkey} from "@welshman/app"
|
||||
import Chat from "@app/components/Chat.svelte"
|
||||
import {splitChatId} from "@app/core/state"
|
||||
import {notificationSettings} from "@app/core/state"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {Push} from "@app/util/push"
|
||||
|
||||
const {chat} = $page.params as MakeNonOptional<typeof $page.params>
|
||||
const pubkeys = uniq(append($pubkey!, splitChatId(chat)))
|
||||
|
||||
onMount(async () => {
|
||||
if (!$dmNotificationsPrompted) {
|
||||
dmNotificationsPrompted.set(true)
|
||||
|
||||
const permission = await Push.request()
|
||||
|
||||
if (!permission.startsWith("granted")) {
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
message: `Failed to request notification permissions (${permission}).`,
|
||||
})
|
||||
}
|
||||
|
||||
notificationSettings.update(current => ({
|
||||
...current,
|
||||
push: true,
|
||||
messages: true,
|
||||
}))
|
||||
|
||||
pushToast({message: "Notifications enabled!"})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<Chat {pubkeys} />
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {clearBadges} from "@app/util/notifications"
|
||||
import {Push} from "@app/util/push"
|
||||
import {Push, clearBadges} from "@app/util/notifications"
|
||||
import {notificationSettings} from "@app/core/state"
|
||||
|
||||
const reset = () => {
|
||||
|
||||
@@ -24,9 +24,9 @@
|
||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||
import RoomCompose from "@app/components/RoomCompose.svelte"
|
||||
import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
|
||||
import RoomImage from "@app/components/RoomImage.svelte"
|
||||
import RoomDetail from "@app/components/RoomDetail.svelte"
|
||||
import RoomItem from "@app/components/RoomItem.svelte"
|
||||
import RoomImage from "@app/components/RoomImage.svelte"
|
||||
import RoomName from "@app/components/RoomName.svelte"
|
||||
import SpaceSearch from "@app/components/SpaceSearch.svelte"
|
||||
import ThunkToast from "@app/components/ThunkToast.svelte"
|
||||
@@ -430,10 +430,8 @@
|
||||
</script>
|
||||
|
||||
<SpaceBar>
|
||||
{#snippet leading()}
|
||||
<RoomImage {url} {h} />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<RoomImage {url} {h} />
|
||||
<RoomName {url} {h} />
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
@@ -470,7 +468,7 @@
|
||||
bind:element
|
||||
onscroll={onScroll}
|
||||
class={cx(
|
||||
"flex-col-reverse pb-0! pt-4",
|
||||
"flex-col-reverse pb-0! pt-2 md:pt-4",
|
||||
showMobileVideoPanel ? "hidden md:flex md:flex-col-reverse" : "flex",
|
||||
pageContentHiddenDesktopVideoOnly && "md:hidden",
|
||||
)}>
|
||||
|
||||
@@ -111,10 +111,8 @@
|
||||
</script>
|
||||
|
||||
<SpaceBar>
|
||||
{#snippet leading()}
|
||||
<Icon icon={CalendarMinimalistic} />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<Icon icon={CalendarMinimalistic} />
|
||||
<strong>Calendar</strong>
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
|
||||
@@ -307,10 +307,8 @@
|
||||
</script>
|
||||
|
||||
<SpaceBar>
|
||||
{#snippet leading()}
|
||||
<Icon icon={ChatRound} />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<Icon icon={ChatRound} />
|
||||
<strong>Chat</strong>
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
@@ -318,7 +316,7 @@
|
||||
{/snippet}
|
||||
</SpaceBar>
|
||||
|
||||
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4 pb-0!">
|
||||
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-2 pb-0! md:pt-4">
|
||||
{#if loadingForward}
|
||||
<p class="py-20 flex justify-center">
|
||||
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
|
||||
|
||||
@@ -63,10 +63,8 @@
|
||||
</script>
|
||||
|
||||
<SpaceBar>
|
||||
{#snippet leading()}
|
||||
<Icon icon={CaseMinimalistic} />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<Icon icon={CaseMinimalistic} />
|
||||
<strong>Classifieds</strong>
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
|
||||