Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd1b12b14a | |||
| c397ee38bb |
@@ -4,6 +4,7 @@ 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,6 +1,5 @@
|
|||||||
VITE_DEFAULT_PUBKEYS=06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71,266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed,6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e,76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa,7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3
|
VITE_DEFAULT_PUBKEYS=06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71,266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed,6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e,76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa,7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3
|
||||||
VITE_DEFAULT_BLOSSOM_SERVERS=https://blossom.primal.net/
|
VITE_DEFAULT_BLOSSOM_SERVERS=https://blossom.primal.net/
|
||||||
VITE_DEFAULT_SPACES=https://chat.flotilla.social/
|
|
||||||
VITE_POMADE_SIGNERS=https://pomade.coracle.social,https://pomade.fiatjaf.com,https://pomade.nostrver.se,https://pomade.scuttle.works
|
VITE_POMADE_SIGNERS=https://pomade.coracle.social,https://pomade.fiatjaf.com,https://pomade.nostrver.se,https://pomade.scuttle.works
|
||||||
VITE_PLATFORM_URL=https://app.flotilla.social
|
VITE_PLATFORM_URL=https://app.flotilla.social
|
||||||
VITE_PLATFORM_TERMS=https://flotilla.social/terms
|
VITE_PLATFORM_TERMS=https://flotilla.social/terms
|
||||||
@@ -20,6 +19,6 @@ VITE_DEFAULT_SEARCH_RELAYS=relay.ditto.pub,antiprimal.net,relay.vertexlab.io
|
|||||||
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub
|
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub
|
||||||
VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
|
VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
|
||||||
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
|
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
|
||||||
VITE_THUMBNAIL_URL=https://vthumbs.coracle.social
|
VITE_THUMBNAIL_URL=
|
||||||
VITE_GLITCHTIP_API_KEY=
|
VITE_GLITCHTIP_API_KEY=
|
||||||
GLITCHTIP_AUTH_TOKEN=
|
GLITCHTIP_AUTH_TOKEN=
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
src/assets
|
src/assets
|
||||||
.claude
|
|
||||||
target
|
target
|
||||||
build
|
build
|
||||||
.idea
|
.idea
|
||||||
@@ -14,5 +13,4 @@ ios/App/Pods/
|
|||||||
android/capacitor-cordova-android-plugins
|
android/capacitor-cordova-android-plugins
|
||||||
android/app/src/androidTest
|
android/app/src/androidTest
|
||||||
android/app/src/test
|
android/app/src/test
|
||||||
node_modules
|
|
||||||
.svelte-kit
|
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
name: Container Image Build and Publish
|
name: Docker
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: gitea.coracle.social
|
REGISTRY: ghcr.io
|
||||||
IMAGE_NAME: coracle/flotilla
|
IMAGE_NAME: coracle-social/flotilla
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push-image:
|
build-and-push-image:
|
||||||
@@ -28,8 +23,8 @@ jobs:
|
|||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: hodlbod
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
password: ${{ secrets.PACKAGE_TOKEN }}
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
id: meta
|
id: meta
|
||||||
@@ -37,7 +32,6 @@ 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
|
||||||
@@ -51,7 +45,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
target: production
|
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -6,11 +6,6 @@
|
|||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
# Playwright
|
|
||||||
/test-results/
|
|
||||||
/playwright-report/
|
|
||||||
/playwright/.cache/
|
|
||||||
|
|
||||||
# Generated assets
|
# Generated assets
|
||||||
static/favicon.ico
|
static/favicon.ico
|
||||||
static/pwa-64x64.png
|
static/pwa-64x64.png
|
||||||
@@ -32,10 +27,13 @@ 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
|
||||||
@@ -72,8 +70,6 @@ GoogleService-Info.plist
|
|||||||
.roo
|
.roo
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
.claude/
|
|
||||||
.local/
|
|
||||||
|
|
||||||
# OS generated
|
# OS generated
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -1,62 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
# 1.8.0
|
|
||||||
|
|
||||||
* Fix relay badge overflow
|
|
||||||
* Suppress programmatic scroll when user is scrolling
|
|
||||||
* Fix vertical alignment of emoji and overflow buttons in shared event action row
|
|
||||||
* Use type=email for signup/login email inputs, validate password
|
|
||||||
* Improve toggle switch placement on settings screens
|
|
||||||
* Fix relay auth privacy toggle
|
|
||||||
* Improve field layout
|
|
||||||
* Add progress bar to signup flow
|
|
||||||
* Bundle emojis properly
|
|
||||||
* Rework hosting page
|
|
||||||
* Fix padding on pages on small screens
|
|
||||||
* Add richer link preview support
|
|
||||||
* Fix pasting into event summary
|
|
||||||
* Publish fewer join/claim requests
|
|
||||||
* Fix new messages not rendering in safari
|
|
||||||
* Avoid capturing stale cleanup function in chat
|
|
||||||
* Hide keyboard on app resume
|
|
||||||
* Add email rendering support
|
|
||||||
* Fix bunker login
|
|
||||||
* Fix undefined chat draft key
|
|
||||||
* Allow sharing to chat without a message
|
|
||||||
* Make sure to show date on calendar events when embedded
|
|
||||||
* Improve space search
|
|
||||||
|
|
||||||
# 1.7.4
|
|
||||||
|
|
||||||
* Fix safe area inset for FAB
|
|
||||||
|
|
||||||
# 1.7.3
|
|
||||||
|
|
||||||
* Add native share support for space invites
|
|
||||||
* Stop sending duplicate requests per room
|
|
||||||
* Add more robust thumbnail url generation
|
|
||||||
* Make space reordering discoverable with smoother drag animation
|
|
||||||
* Improve relay member list
|
|
||||||
* Add room mentions and clickable room/relay refs
|
|
||||||
* Support native clipboard image paste on mobile
|
|
||||||
* publish kind 9 quote after room content creation for cross-client interoperability
|
|
||||||
* Improve feed pagination logic and performance
|
|
||||||
* Support Aegis URL scheme for NIP-46 login
|
|
||||||
* Various UI and bug fixes
|
|
||||||
* Raise message size limit in chat
|
|
||||||
* Fix realtime updates for room members and admins
|
|
||||||
* Add video to calls
|
|
||||||
* Remove follow graph building
|
|
||||||
* Add start chat FAB
|
|
||||||
* Add drafts
|
|
||||||
* Redesign toast notifications
|
|
||||||
* Remove room/space leave indications
|
|
||||||
* Hide report badge for non-admin users
|
|
||||||
* Add polls
|
|
||||||
* Add search to recent activity page
|
|
||||||
* Fix notification badge on mobile nav
|
|
||||||
* Change audio devices in call
|
|
||||||
|
|
||||||
# 1.7.2
|
# 1.7.2
|
||||||
|
|
||||||
* Fix race condition in nip 46
|
* Fix race condition in nip 46
|
||||||
|
|||||||
@@ -1,31 +1,32 @@
|
|||||||
# Build and run the Flotilla web server.
|
# Stage 1: Build
|
||||||
#
|
# Uses .env from build context for config (logo, branding, etc.)
|
||||||
# docker build -t flotilla .
|
# Optional: docker build --build-arg VITE_BUILD_HASH=$(git rev-parse --short HEAD) -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.
|
|
||||||
|
|
||||||
# https://pnpm.io/docker#example-3-build-on-cicd
|
FROM node:20-bookworm AS builder
|
||||||
FROM node:24-slim AS builder
|
|
||||||
ENV PNPM_HOME="/pnpm"
|
RUN apt-get update && apt-get install -y --no-install-recommends curl
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
|
||||||
RUN corepack enable
|
RUN npm install -g pnpm@latest
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_OPTIONS=--max_old_space_size=16384
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-lock.yaml ./
|
||||||
RUN pnpm i --frozen-lockfile
|
RUN pnpm i
|
||||||
COPY . .
|
|
||||||
ARG VITE_BUILD_HASH
|
# Copy everything (including .env when present) - build.sh will source it
|
||||||
RUN pnpm run build
|
COPY . .
|
||||||
RUN pnpm run build:server
|
|
||||||
|
ARG VITE_BUILD_HASH
|
||||||
|
ENV VITE_BUILD_HASH=${VITE_BUILD_HASH}
|
||||||
|
|
||||||
|
ENV NODE_OPTIONS=--max_old_space_size=16384
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
FROM node:24-slim AS production
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/build /app/build
|
|
||||||
COPY --from=builder /app/build-server/server.js /app/server.js
|
# Copy only the built output - no source, no .env, no dev deps
|
||||||
EXPOSE 3000
|
COPY --from=builder /app/build ./build
|
||||||
USER node
|
|
||||||
CMD ["node", "server.js"]
|
CMD ["npx", "serve", "-s", "build"]
|
||||||
|
|||||||
@@ -8,34 +8,13 @@ If you would like to be interoperable with Flotilla, please check out this guide
|
|||||||
|
|
||||||
You can also optionally create an `.env.local` file and populate it with the following environment variables (see `.env.template` for examples):
|
You can also optionally create an `.env.local` file and populate it with the following environment variables (see `.env.template` for examples):
|
||||||
|
|
||||||
**Platform branding**
|
- `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust
|
||||||
- `VITE_PLATFORM_URL` - The url where the app will be hosted
|
- `VITE_PLATFORM_URL` - The url where the app will be hosted
|
||||||
- `VITE_PLATFORM_NAME` - The name of the app
|
- `VITE_PLATFORM_NAME` - The name of the app
|
||||||
- `VITE_PLATFORM_LOGO` - A logo url for the app. Can be a local path or https link. Must be a PNG file.
|
- `VITE_PLATFORM_LOGO` - A logo url for the app. Can be a local path or https link. Must be a PNG file.
|
||||||
|
- `VITE_PLATFORM_RELAYS` - A list of comma-separated relay urls that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the first platform relay the home page.
|
||||||
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
|
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
|
||||||
- `VITE_PLATFORM_DESCRIPTION` - A description of the app
|
- `VITE_PLATFORM_DESCRIPTION` - A description of the app
|
||||||
- `VITE_PLATFORM_TERMS` - URL to your terms of service page
|
|
||||||
- `VITE_PLATFORM_PRIVACY` - URL to your privacy policy page
|
|
||||||
|
|
||||||
**Platform mode**
|
|
||||||
- `VITE_PLATFORM_RELAYS` - A comma-separated list of relay urls that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the first platform relay the home page.
|
|
||||||
|
|
||||||
**Defaults**
|
|
||||||
- `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust
|
|
||||||
- `VITE_DEFAULT_SPACES` - A comma-separated list of relay urls that new users will be automatically joined to on signup
|
|
||||||
- `VITE_DEFAULT_RELAYS` - A comma-separated list of relay urls used as default outbox/inbox relays
|
|
||||||
- `VITE_DEFAULT_MESSAGING_RELAYS` - A comma-separated list of relay urls used for encrypted direct messages
|
|
||||||
- `VITE_DEFAULT_BLOSSOM_SERVERS` - A comma-separated list of blossom server urls used for file uploads
|
|
||||||
|
|
||||||
**Infrastructure**
|
|
||||||
- `VITE_INDEXER_RELAYS` - A comma-separated list of relay urls used for user profile/key lookup
|
|
||||||
- `VITE_SIGNER_RELAYS` - A comma-separated list of relay urls used for NIP-55 remote signers
|
|
||||||
- `VITE_BLOCKED_RELAYS` - A comma-separated list of relay urls that will be blocked
|
|
||||||
- `VITE_PUSH_SERVER` - URL of the push notification server
|
|
||||||
- `VITE_PUSH_BRIDGE` - WebSocket URL of the push notification relay bridge
|
|
||||||
- `VITE_VAPID_PUBLIC_KEY` - VAPID public key for web push notifications
|
|
||||||
- `VITE_POMADE_SIGNERS` - A comma-separated list of Pomade signer server URLs (3+ required to enable email signup)
|
|
||||||
- `VITE_THUMBNAIL_URL` - URL of the image thumbnail service
|
|
||||||
|
|
||||||
These values **won't** be used for a built version. Instead, env variables should be provided to `build.sh` directly or to the built container.
|
These values **won't** be used for a built version. Instead, env variables should be provided to `build.sh` directly or to the built container.
|
||||||
|
|
||||||
@@ -52,18 +31,18 @@ To run your own Flotilla, it's as simple as:
|
|||||||
```sh
|
```sh
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run build
|
pnpm run build
|
||||||
pnpm run start
|
npx serve -s build
|
||||||
```
|
```
|
||||||
|
|
||||||
Or, if you prefer to use a container:
|
Or, if you prefer to use a container:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run -d -p 3000:3000 gitea.coracle.social/coracle/flotilla:latest
|
podman run -d -p 3000:3000 ghcr.io/coracle-social/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
|
||||||
docker run -v ./mount:/app/mount gitea.coracle.social/coracle/flotilla:latest bash -c 'cp -r build/* mount'
|
podman run -v ./mount:/app/mount ghcr.io/coracle-social/flotilla:latest bash -c 'cp -r build/* mount'
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ android {
|
|||||||
applicationId "social.flotilla"
|
applicationId "social.flotilla"
|
||||||
minSdk rootProject.ext.minSdkVersion
|
minSdk rootProject.ext.minSdkVersion
|
||||||
targetSdk rootProject.ext.targetSdkVersion
|
targetSdk rootProject.ext.targetSdkVersion
|
||||||
versionCode 47
|
versionCode 44
|
||||||
versionName "1.8.0"
|
versionName "1.7.2"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -12,12 +12,10 @@ dependencies {
|
|||||||
implementation project(':aparajita-capacitor-secure-storage')
|
implementation project(':aparajita-capacitor-secure-storage')
|
||||||
implementation project(':capacitor-community-safe-area')
|
implementation project(':capacitor-community-safe-area')
|
||||||
implementation project(':capacitor-app')
|
implementation project(':capacitor-app')
|
||||||
implementation project(':capacitor-clipboard')
|
|
||||||
implementation project(':capacitor-filesystem')
|
implementation project(':capacitor-filesystem')
|
||||||
implementation project(':capacitor-keyboard')
|
implementation project(':capacitor-keyboard')
|
||||||
implementation project(':capacitor-preferences')
|
implementation project(':capacitor-preferences')
|
||||||
implementation project(':capacitor-push-notifications')
|
implementation project(':capacitor-push-notifications')
|
||||||
implementation project(':capacitor-share')
|
|
||||||
implementation project(':capawesome-capacitor-android-dark-mode-support')
|
implementation project(':capawesome-capacitor-android-dark-mode-support')
|
||||||
implementation project(':capawesome-capacitor-badge')
|
implementation project(':capawesome-capacitor-badge')
|
||||||
implementation project(':nostr-signer-capacitor-plugin')
|
implementation project(':nostr-signer-capacitor-plugin')
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import androidx.work.ExistingPeriodicWorkPolicy
|
|||||||
import androidx.work.ExistingWorkPolicy
|
import androidx.work.ExistingWorkPolicy
|
||||||
import androidx.work.NetworkType
|
import androidx.work.NetworkType
|
||||||
import androidx.work.OneTimeWorkRequest
|
import androidx.work.OneTimeWorkRequest
|
||||||
import androidx.work.OutOfQuotaPolicy
|
|
||||||
import androidx.work.PeriodicWorkRequest
|
import androidx.work.PeriodicWorkRequest
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import com.getcapacitor.JSObject
|
import com.getcapacitor.JSObject
|
||||||
@@ -77,7 +76,6 @@ class AndroidPushFallbackPlugin : Plugin() {
|
|||||||
|
|
||||||
val immediate = OneTimeWorkRequest.Builder(AndroidPushFallbackWorker::class.java)
|
val immediate = OneTimeWorkRequest.Builder(AndroidPushFallbackWorker::class.java)
|
||||||
.setConstraints(constraints)
|
.setConstraints(constraints)
|
||||||
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
workManager.enqueueUniquePeriodicWork(
|
workManager.enqueueUniquePeriodicWork(
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
|
|||||||
private const val TAG = "PushFallback"
|
private const val TAG = "PushFallback"
|
||||||
private const val CHANNEL_ID = "flotilla_fallback"
|
private const val CHANNEL_ID = "flotilla_fallback"
|
||||||
private const val CURSOR_PREFIX = "androidPushFallback.cursor."
|
private const val CURSOR_PREFIX = "androidPushFallback.cursor."
|
||||||
private const val SOCKET_TIMEOUT_SECONDS = 30L
|
private const val SOCKET_TIMEOUT_SECONDS = 20L
|
||||||
private const val REJECTED = "__REJECTED__"
|
private const val REJECTED = "__REJECTED__"
|
||||||
private const val KIND_RELAY_AUTH = 22242
|
private const val KIND_RELAY_AUTH = 22242
|
||||||
private const val KIND_NIP46_RPC = 24133
|
private const val KIND_NIP46_RPC = 24133
|
||||||
@@ -72,8 +72,6 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun doWork(): Result {
|
override fun doWork(): Result {
|
||||||
Log.i(TAG, "doWork() started")
|
|
||||||
|
|
||||||
if (isAppInForeground()) {
|
if (isAppInForeground()) {
|
||||||
return Result.success()
|
return Result.success()
|
||||||
}
|
}
|
||||||
@@ -90,7 +88,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
|
|||||||
|
|
||||||
val activeSince = state.optLong("activeSince", 0L)
|
val activeSince = state.optLong("activeSince", 0L)
|
||||||
val seen = mutableSetOf<String>()
|
val seen = mutableSetOf<String>()
|
||||||
val newEvents = mutableListOf<Pair<String, JSONObject>>()
|
var latestPair: Pair<String, JSONObject>? = null
|
||||||
|
|
||||||
for (sub in subscriptions) {
|
for (sub in subscriptions) {
|
||||||
val cursorKey = CURSOR_PREFIX + sub.relay + ":" + sub.key
|
val cursorKey = CURSOR_PREFIX + sub.relay + ":" + sub.key
|
||||||
@@ -104,19 +102,23 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
|
|||||||
for (event in result.events) {
|
for (event in result.events) {
|
||||||
val id = event.optString("id", "")
|
val id = event.optString("id", "")
|
||||||
if (id.isNotEmpty() && seen.add(id)) {
|
if (id.isNotEmpty() && seen.add(id)) {
|
||||||
newEvents.add(Pair(sub.relay, event))
|
val createdAt = event.optLong("created_at", 0L)
|
||||||
|
if (latestPair == null || createdAt > latestPair!!.second.optLong("created_at", 0L)) {
|
||||||
|
latestPair = Pair(sub.relay, event)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for ((relay, event) in newEvents) {
|
if (latestPair != null) {
|
||||||
|
val (relay, event) = latestPair!!
|
||||||
postNotification(relay, event)
|
postNotification(relay, event)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.success()
|
return Result.success()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Worker failed", e)
|
Log.e(TAG, "Worker failed", e)
|
||||||
return Result.retry()
|
return Result.success()
|
||||||
} finally {
|
} finally {
|
||||||
pool.closeAll()
|
pool.closeAll()
|
||||||
client.dispatcher.executorService.shutdown()
|
client.dispatcher.executorService.shutdown()
|
||||||
@@ -212,8 +214,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
|
|||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(pendingIntent)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val notificationId = id.hashCode().let { if (it == 0) 1 else it }
|
NotificationManagerCompat.from(context).notify(1, notification)
|
||||||
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun matchesFilter(filter: JSONObject, event: JSONObject): Boolean {
|
private fun matchesFilter(filter: JSONObject, event: JSONObject): Boolean {
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ project(':capacitor-community-safe-area').projectDir = new File('../node_modules
|
|||||||
include ':capacitor-app'
|
include ':capacitor-app'
|
||||||
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app/android')
|
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app/android')
|
||||||
|
|
||||||
include ':capacitor-clipboard'
|
|
||||||
project(':capacitor-clipboard').projectDir = new File('../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/clipboard/android')
|
|
||||||
|
|
||||||
include ':capacitor-filesystem'
|
include ':capacitor-filesystem'
|
||||||
project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem/android')
|
project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem/android')
|
||||||
|
|
||||||
@@ -26,9 +23,6 @@ project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@
|
|||||||
include ':capacitor-push-notifications'
|
include ':capacitor-push-notifications'
|
||||||
project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications/android')
|
project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications/android')
|
||||||
|
|
||||||
include ':capacitor-share'
|
|
||||||
project(':capacitor-share').projectDir = new File('../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/share/android')
|
|
||||||
|
|
||||||
include ':capawesome-capacitor-android-dark-mode-support'
|
include ':capawesome-capacitor-android-dark-mode-support'
|
||||||
project(':capawesome-capacitor-android-dark-mode-support').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-android-dark-mode-support@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-android-dark-mode-support/android')
|
project(':capawesome-capacitor-android-dark-mode-support').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-android-dark-mode-support@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-android-dark-mode-support/android')
|
||||||
|
|
||||||
@@ -36,4 +30,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_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')
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import {expect, test} from "@playwright/test"
|
|
||||||
|
|
||||||
test("boots the SPA on the home page", async ({page}) => {
|
|
||||||
const response = await page.goto("/")
|
|
||||||
|
|
||||||
expect(response?.ok()).toBeTruthy()
|
|
||||||
|
|
||||||
// adapter-static serves an empty shell that hydrates client-side, so the presence of
|
|
||||||
// rendered text proves the Svelte app actually mounted (not just that a file was served).
|
|
||||||
// TODO: tighten this to assert concrete onboarding UI once the markup is settled.
|
|
||||||
await expect(page.locator("body")).toContainText(/\S/, {timeout: 15_000})
|
|
||||||
})
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import type {SignedEvent} from "@welshman/util"
|
|
||||||
import type {RelayMockConfig} from "../../src/lib/test/relayMocks"
|
|
||||||
|
|
||||||
import relay1Events from "./fixtures/relay1.json"
|
|
||||||
|
|
||||||
// Fake relay urls used by tests. Each maps to a json fixture under ./fixtures/ and an entry in
|
|
||||||
// EVENTS_BY_RELAY below. To add a relay: drop a `<name>.json` file in ./fixtures/, import it, add a
|
|
||||||
// url here, and wire it into EVENTS_BY_RELAY.
|
|
||||||
export const FIXTURE_RELAYS = {
|
|
||||||
relay1: "wss://relay1.test/",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
// The events each fake relay serves. The json files hold static, pre-signed events: schnorr
|
|
||||||
// signatures are non-deterministic, so events are signed once and committed verbatim (they pass
|
|
||||||
// verifyEvent, which netContext.isEventValid enforces). Regenerate with @welshman/signer:
|
|
||||||
// await Nip01Signer.fromSecret(secret).sign(makeEvent(kind, {content, created_at}))
|
|
||||||
const EVENTS_BY_RELAY: Record<string, SignedEvent[]> = {
|
|
||||||
[FIXTURE_RELAYS.relay1]: relay1Events as SignedEvent[],
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a RelayMockConfig populating the given fixture relays (all of them when none are passed).
|
|
||||||
// Any relay not included returns nothing, keeping tests offline.
|
|
||||||
export const relayFixtures = (...urls: string[]): RelayMockConfig => {
|
|
||||||
const selected = urls.length > 0 ? urls : Object.keys(EVENTS_BY_RELAY)
|
|
||||||
|
|
||||||
return {
|
|
||||||
relays: Object.fromEntries(selected.map(url => [url, EVENTS_BY_RELAY[url] ?? []])),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"kind": 0,
|
|
||||||
"content": "{\"name\":\"Alice\"}",
|
|
||||||
"tags": [],
|
|
||||||
"created_at": 1700000000,
|
|
||||||
"pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
|
|
||||||
"id": "9b3d138641b38364945b20d800268006c2cb7d974bb4b1d63a9f90f5ab974b90",
|
|
||||||
"sig": "de6b86274e7bcf6c02aa881ada1feee9e01ba320691711d4975916b3cd231ab43cf469a47c5db99503ed72707d5db85fede1ad3763c4fbd7c998d04f00eda6bc"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": 1,
|
|
||||||
"content": "hello from the fixture relay",
|
|
||||||
"tags": [],
|
|
||||||
"created_at": 1700000000,
|
|
||||||
"pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
|
|
||||||
"id": "b9874875bfa8d830c5c9ef3673104360cf21b94848a311febfaf52f0a652b1a9",
|
|
||||||
"sig": "85df94a2e9884ac3d280145492d5191cde2948d49a824c443a1f5d2143633eff1e1789fa7e8843b6efc3dd2dc0d7e33322edb628125d8e35de8ddca1d06ca970"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": 1,
|
|
||||||
"content": "reply from bob",
|
|
||||||
"tags": [],
|
|
||||||
"created_at": 1700000001,
|
|
||||||
"pubkey": "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5",
|
|
||||||
"id": "171dcbdd63d474ba46da609e8b0104cbcf4801fbb581b6c343d9426280f9e1be",
|
|
||||||
"sig": "eecf26e616a6b70dc67c7eae16fc4fe159314647ba1d4581332257de7f070410aa9a9e41f571b9403ff145d16c9b32766846fce08516201263e25cf08c1ed8f1"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import type {Page} from "@playwright/test"
|
|
||||||
import type {RelayMockConfig} from "../../src/lib/test/relayMocks"
|
|
||||||
|
|
||||||
// Must match RELAY_MOCKS_KEY in src/lib/test/relayMocks.ts.
|
|
||||||
const RELAY_MOCKS_KEY = "__RELAY_MOCKS__"
|
|
||||||
|
|
||||||
// Hard safety net: intercept every real websocket so a test can never reach the network, even if
|
|
||||||
// some code path opens a socket directly (e.g. relay AUTH) rather than going through the adapter
|
|
||||||
// layer. We never call route.connectToServer(), so the socket connects to Playwright's in-process
|
|
||||||
// mock and simply receives nothing.
|
|
||||||
export const blockWebsockets = (page: Page) => page.routeWebSocket(/^wss?:\/\//, () => {})
|
|
||||||
|
|
||||||
// Inject the relay-mock config the app reads on startup. addInitScript runs before any page script
|
|
||||||
// on every navigation, so this must be called before page.goto().
|
|
||||||
export const injectRelayConfig = (page: Page, config: RelayMockConfig) =>
|
|
||||||
page.addInitScript(
|
|
||||||
([key, value]) => {
|
|
||||||
Object.assign(window, {[key]: value})
|
|
||||||
},
|
|
||||||
[RELAY_MOCKS_KEY, config] as const,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Full network isolation plus optional fixtures, in one call. With no config, every relay returns
|
|
||||||
// nothing (requirement 1). Pass {relays: {url: events}} to populate specific relays (requirement 2).
|
|
||||||
export const setupRelayMocks = async (page: Page, config: RelayMockConfig = {}) => {
|
|
||||||
await blockWebsockets(page)
|
|
||||||
await injectRelayConfig(page, config)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type {RelayMockConfig}
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 54;
|
objectVersion = 48;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@@ -131,9 +131,8 @@
|
|||||||
504EC2FC1FED79650016851F /* Project object */ = {
|
504EC2FC1FED79650016851F /* Project object */ = {
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = YES;
|
|
||||||
LastSwiftUpdateCheck = 920;
|
LastSwiftUpdateCheck = 920;
|
||||||
LastUpgradeCheck = 2630;
|
LastUpgradeCheck = 920;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
504EC3031FED79650016851F = {
|
504EC3031FED79650016851F = {
|
||||||
CreatedOnToolsVersion = 9.2;
|
CreatedOnToolsVersion = 9.2;
|
||||||
@@ -258,7 +257,6 @@
|
|||||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
CLANG_WARN_COMMA = YES;
|
CLANG_WARN_COMMA = YES;
|
||||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
|
||||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
CLANG_WARN_EMPTY_BODY = YES;
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
@@ -266,10 +264,8 @@
|
|||||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
CLANG_WARN_INT_CONVERSION = YES;
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
|
||||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
@@ -279,10 +275,8 @@
|
|||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = S26U9DYW3A;
|
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
GCC_DYNAMIC_NO_PIC = NO;
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
@@ -301,7 +295,6 @@
|
|||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
};
|
};
|
||||||
@@ -321,7 +314,6 @@
|
|||||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
CLANG_WARN_COMMA = YES;
|
CLANG_WARN_COMMA = YES;
|
||||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
|
||||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
CLANG_WARN_EMPTY_BODY = YES;
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
@@ -329,10 +321,8 @@
|
|||||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
CLANG_WARN_INT_CONVERSION = YES;
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
|
||||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
@@ -342,10 +332,8 @@
|
|||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = S26U9DYW3A;
|
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
@@ -357,9 +345,7 @@
|
|||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
@@ -372,16 +358,14 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 38;
|
CURRENT_PROJECT_VERSION = 35;
|
||||||
|
DEVELOPMENT_TEAM = S26U9DYW3A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
"$(inherited)",
|
MARKETING_VERSION = 1.7.2;
|
||||||
"@executable_path/Frameworks",
|
|
||||||
);
|
|
||||||
MARKETING_VERSION = 1.8.0;
|
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -401,16 +385,14 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 38;
|
CURRENT_PROJECT_VERSION = 35;
|
||||||
|
DEVELOPMENT_TEAM = S26U9DYW3A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
"$(inherited)",
|
MARKETING_VERSION = 1.7.2;
|
||||||
"@executable_path/Frameworks",
|
|
||||||
);
|
|
||||||
MARKETING_VERSION = 1.8.0;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
|||||||
@@ -14,14 +14,12 @@ def capacitor_pods
|
|||||||
pod 'AparajitaCapacitorSecureStorage', :path => '../../node_modules/.pnpm/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage'
|
pod 'AparajitaCapacitorSecureStorage', :path => '../../node_modules/.pnpm/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage'
|
||||||
pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor-community/safe-area'
|
pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor-community/safe-area'
|
||||||
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app'
|
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app'
|
||||||
pod 'CapacitorClipboard', :path => '../../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/clipboard'
|
|
||||||
pod 'CapacitorFilesystem', :path => '../../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem'
|
pod 'CapacitorFilesystem', :path => '../../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem'
|
||||||
pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/keyboard'
|
pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/keyboard'
|
||||||
pod 'CapacitorPreferences', :path => '../../node_modules/.pnpm/@capacitor+preferences@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/preferences'
|
pod 'CapacitorPreferences', :path => '../../node_modules/.pnpm/@capacitor+preferences@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/preferences'
|
||||||
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 '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_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
|
end
|
||||||
|
|
||||||
target 'Flotilla Chat' do
|
target 'Flotilla Chat' do
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ if (execSync('git status --porcelain', { encoding: 'utf8' }).trim() && !force) {
|
|||||||
|
|
||||||
const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
|
const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
|
||||||
|
|
||||||
pkg.pnpm = pkg.pnpm || {}
|
|
||||||
pkg.pnpm.overrides = pkg.pnpm.overrides || {}
|
pkg.pnpm.overrides = pkg.pnpm.overrides || {}
|
||||||
pkg.pnpm.overrides["@welshman/app"] = "link:../welshman/packages/app"
|
pkg.pnpm.overrides["@welshman/app"] = "link:../welshman/packages/app"
|
||||||
pkg.pnpm.overrides["@welshman/content"] = "link:../welshman/packages/content"
|
pkg.pnpm.overrides["@welshman/content"] = "link:../welshman/packages/content"
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "flotilla",
|
"name": "flotilla",
|
||||||
"version": "1.8.0",
|
"version": "1.7.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"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",
|
|
||||||
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
|
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
|
||||||
|
"tauri:dev": "tauri dev",
|
||||||
|
"tauri:build": "tauri build",
|
||||||
|
"tauri:info": "tauri info",
|
||||||
|
"tauri:icons": "tauri icon assets/logo.png --output src-tauri/icons",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"lint": "prettier --check src && eslint src",
|
"lint": "prettier --check src && eslint src",
|
||||||
"test": "playwright test",
|
|
||||||
"test:ui": "playwright test --ui",
|
|
||||||
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
|
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
|
||||||
"format:all": "prettier --write src",
|
"format:all": "prettier --write src",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
@@ -20,27 +20,26 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/assets": "^3.0.5",
|
"@capacitor/assets": "^3.0.5",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"@playwright/test": "^1.49.1",
|
"@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.15",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"prettier-plugin-svelte": "^3.4.1",
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
"svelte": "^5.55.9",
|
"svelte": "^5.48.0",
|
||||||
"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": "^6.4.2"
|
"vite": "^5.4.21"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -49,48 +48,41 @@
|
|||||||
"@capacitor/android": "^8.0.1",
|
"@capacitor/android": "^8.0.1",
|
||||||
"@capacitor/app": "^8.0.0",
|
"@capacitor/app": "^8.0.0",
|
||||||
"@capacitor/cli": "^8.0.1",
|
"@capacitor/cli": "^8.0.1",
|
||||||
"@capacitor/clipboard": "^8.0.1",
|
|
||||||
"@capacitor/core": "^8.0.1",
|
"@capacitor/core": "^8.0.1",
|
||||||
"@capacitor/filesystem": "^8.1.0",
|
"@capacitor/filesystem": "^8.1.0",
|
||||||
"@capacitor/ios": "^8.0.1",
|
"@capacitor/ios": "^8.0.1",
|
||||||
"@capacitor/keyboard": "^8.0.0",
|
"@capacitor/keyboard": "^8.0.0",
|
||||||
"@capacitor/preferences": "^8.0.0",
|
"@capacitor/preferences": "^8.0.0",
|
||||||
"@capacitor/push-notifications": "^8.0.0",
|
"@capacitor/push-notifications": "^8.0.0",
|
||||||
"@capacitor/share": "^8.0.1",
|
|
||||||
"@capawesome/capacitor-android-dark-mode-support": "^8.0.0",
|
"@capawesome/capacitor-android-dark-mode-support": "^8.0.0",
|
||||||
"@capawesome/capacitor-badge": "^8.0.0",
|
"@capawesome/capacitor-badge": "^8.0.0",
|
||||||
"@getalby/lightning-tools": "^6.1.0",
|
"@getalby/lightning-tools": "^6.1.0",
|
||||||
"@getalby/sdk": "^5.1.2",
|
"@getalby/sdk": "^5.1.2",
|
||||||
"@hono/node-server": "^2.0.0",
|
|
||||||
"@noble/curves": "^1.9.7",
|
"@noble/curves": "^1.9.7",
|
||||||
"@pomade/core": "^0.2.5",
|
"@pomade/core": "^0.2.2",
|
||||||
"@poppanator/sveltekit-svg": "^7.0.0",
|
"@poppanator/sveltekit-svg": "^4.2.1",
|
||||||
"@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",
|
|
||||||
"@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": "^1.0.2",
|
"@vite-pwa/assets-generator": "^0.2.6",
|
||||||
"@vite-pwa/sveltekit": "^1.1.0",
|
"@vite-pwa/sveltekit": "^0.6.8",
|
||||||
"@welshman/app": "^0.8.16",
|
"@welshman/app": "^0.8.12",
|
||||||
"@welshman/content": "^0.8.16",
|
"@welshman/content": "^0.8.12",
|
||||||
"@welshman/editor": "^0.8.16",
|
"@welshman/editor": "^0.8.12",
|
||||||
"@welshman/feeds": "^0.8.16",
|
"@welshman/feeds": "^0.8.12",
|
||||||
"@welshman/lib": "^0.8.16",
|
"@welshman/lib": "^0.8.12",
|
||||||
"@welshman/net": "^0.8.16",
|
"@welshman/net": "^0.8.12",
|
||||||
"@welshman/router": "^0.8.16",
|
"@welshman/router": "^0.8.12",
|
||||||
"@welshman/signer": "^0.8.16",
|
"@welshman/signer": "^0.8.12",
|
||||||
"@welshman/store": "^0.8.16",
|
"@welshman/store": "^0.8.12",
|
||||||
"@welshman/util": "^0.8.16",
|
"@welshman/util": "^0.8.12",
|
||||||
"cheerio": "^1.2.0",
|
|
||||||
"compressorjs-next": "^1.1.2",
|
"compressorjs-next": "^1.1.2",
|
||||||
"daisyui": "^5.5.19",
|
"daisyui": "^5.5.19",
|
||||||
"date-picker-svelte": "^2.17.0",
|
"date-picker-svelte": "^2.17.0",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"emoji-picker-element": "^1.28.1",
|
"emoji-picker-element": "^1.28.1",
|
||||||
"emoji-picker-element-data": "^1.8.0",
|
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"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,5 +94,16 @@
|
|||||||
"throttle-debounce": "^5.0.2",
|
"throttle-debounce": "^5.0.2",
|
||||||
"tippy.js": "^6.3.7"
|
"tippy.js": "^6.3.7"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.5.1"
|
"pnpm": {
|
||||||
|
"ignoredBuiltDependencies": [
|
||||||
|
"esbuild"
|
||||||
|
],
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"sharp",
|
||||||
|
"nostr-signer-capacitor-plugin"
|
||||||
|
],
|
||||||
|
"overrides": {
|
||||||
|
"sharp": "0.35.0-rc.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import {defineConfig, devices} from "@playwright/test"
|
|
||||||
|
|
||||||
// E2E tests live in ./e2e and run against the dev server (port 1847 from vite.config.ts).
|
|
||||||
// Run with `pnpm test:e2e` (after `pnpm exec playwright install` to fetch browsers).
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: "e2e",
|
|
||||||
fullyParallel: true,
|
|
||||||
forbidOnly: !!process.env.CI,
|
|
||||||
retries: process.env.CI ? 2 : 0,
|
|
||||||
reporter: "html",
|
|
||||||
use: {
|
|
||||||
baseURL: "http://localhost:1847",
|
|
||||||
trace: "on-first-retry",
|
|
||||||
},
|
|
||||||
// Boots the SvelteKit dev server before the suite and reuses one if already running locally.
|
|
||||||
webServer: {
|
|
||||||
command: "pnpm dev",
|
|
||||||
url: "http://localhost:1847",
|
|
||||||
reuseExistingServer: !process.env.CI,
|
|
||||||
timeout: 120_000,
|
|
||||||
},
|
|
||||||
projects: [
|
|
||||||
{name: "chromium", use: {...devices["Desktop Chrome"]}},
|
|
||||||
{name: "firefox", use: {...devices["Desktop Firefox"]}},
|
|
||||||
{name: "webkit", use: {...devices["Desktop Safari"]}},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
@@ -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"
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
import path from "node:path"
|
|
||||||
import {promises as fs} from "node:fs"
|
|
||||||
import {fileURLToPath} from "node:url"
|
|
||||||
|
|
||||||
import "dotenv/config"
|
|
||||||
import {serve} from "@hono/node-server"
|
|
||||||
import {serveStatic} from "@hono/node-server/serve-static"
|
|
||||||
import {loadRelay} from "@welshman/app"
|
|
||||||
import {displayRelayUrl, normalizeRelayUrl} from "@welshman/util"
|
|
||||||
import {load} from "cheerio"
|
|
||||||
import {Hono} from "hono"
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
|
||||||
const __dirname = path.dirname(__filename)
|
|
||||||
|
|
||||||
const BUILD_DIR = path.join(__dirname, "build")
|
|
||||||
const INDEX_PATH = path.join(BUILD_DIR, "index.html")
|
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT || "", 10) || 3000
|
|
||||||
const HOST = process.env.HOST || "0.0.0.0"
|
|
||||||
|
|
||||||
let TEMPLATE_HTML = ""
|
|
||||||
try {
|
|
||||||
TEMPLATE_HTML = await fs.readFile(INDEX_PATH, "utf8")
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Unable to read ${INDEX_PATH}. Run "pnpm run build" first.`)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const PLATFORM_NAME = process.env.VITE_PLATFORM_NAME
|
|
||||||
const PLATFORM_DESCRIPTION = process.env.VITE_PLATFORM_DESCRIPTION
|
|
||||||
|
|
||||||
// Match client-side decode logic
|
|
||||||
const decodeRelay = url => {
|
|
||||||
try {
|
|
||||||
return normalizeRelayUrl(decodeURIComponent(url))
|
|
||||||
} catch {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestUrlFromContext = context => {
|
|
||||||
const requestUrl = new URL(context.req.url)
|
|
||||||
const forwardedProto = context.req.header("x-forwarded-proto")?.split(",")[0]?.trim()
|
|
||||||
const forwardedHost = context.req.header("x-forwarded-host")?.split(",")[0]?.trim()
|
|
||||||
|
|
||||||
if (forwardedProto === "http" || forwardedProto === "https") {
|
|
||||||
requestUrl.protocol = `${forwardedProto}:`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (forwardedHost) {
|
|
||||||
requestUrl.host = forwardedHost
|
|
||||||
}
|
|
||||||
|
|
||||||
return requestUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchRelayMeta = async relayUrl => {
|
|
||||||
if (!relayUrl) return undefined
|
|
||||||
try {
|
|
||||||
return await loadRelay(normalizeRelayUrl(relayUrl))
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to fetch relay metadata for ${relayUrl}:`, err)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildDefaultImage = requestUrl => {
|
|
||||||
return new URL("/maskable-icon-512x512.png", requestUrl.origin).toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMetadataForInvite = async (url, match) => {
|
|
||||||
const relayParam = url.searchParams.get("r")
|
|
||||||
if (!relayParam) return undefined
|
|
||||||
|
|
||||||
const relayMetadata = await fetchRelayMeta(relayParam)
|
|
||||||
if (!relayMetadata) return undefined
|
|
||||||
|
|
||||||
const relayDisplay = displayRelayUrl(relayParam)
|
|
||||||
const spaceName = relayMetadata.name
|
|
||||||
const relayDescription = relayMetadata.description
|
|
||||||
|
|
||||||
const title = spaceName
|
|
||||||
? `Invite to ${spaceName} on ${PLATFORM_NAME}`
|
|
||||||
: `Invite to a Space on ${PLATFORM_NAME}`
|
|
||||||
|
|
||||||
const parts = []
|
|
||||||
if (spaceName) {
|
|
||||||
parts.push(`You are invited to join ${spaceName} on ${PLATFORM_NAME}.`)
|
|
||||||
} else {
|
|
||||||
parts.push(`You are invited to join a space on ${PLATFORM_NAME}.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (relayDisplay) parts.push(`Relay: ${relayDisplay}.`)
|
|
||||||
if (relayDescription) parts.push(relayDescription)
|
|
||||||
else parts.push(PLATFORM_DESCRIPTION)
|
|
||||||
|
|
||||||
const description = parts.join(" ")
|
|
||||||
const image =
|
|
||||||
relayMetadata.icon ||
|
|
||||||
relayMetadata.picture ||
|
|
||||||
relayMetadata.image ||
|
|
||||||
buildDefaultImage(url)
|
|
||||||
|
|
||||||
return {
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
image,
|
|
||||||
url: url.toString(),
|
|
||||||
site: url.origin,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMetadataForSpace = async (url, match) => {
|
|
||||||
const relayParam = decodeRelay(match[1])
|
|
||||||
if (!relayParam) return undefined
|
|
||||||
|
|
||||||
const relayMetadata = await fetchRelayMeta(relayParam)
|
|
||||||
if (!relayMetadata) return undefined
|
|
||||||
|
|
||||||
const spaceName = relayMetadata.name || displayRelayUrl(relayParam)
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: `${spaceName} on ${PLATFORM_NAME}`,
|
|
||||||
description: relayMetadata.description || PLATFORM_DESCRIPTION,
|
|
||||||
image:
|
|
||||||
relayMetadata.icon ||
|
|
||||||
relayMetadata.picture ||
|
|
||||||
relayMetadata.image ||
|
|
||||||
buildDefaultImage(url),
|
|
||||||
url: url.toString(),
|
|
||||||
site: url.origin,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMetadataForSpaceSection = async (url, match) => {
|
|
||||||
const spaceMeta = await getMetadataForSpace(url, match)
|
|
||||||
if (!spaceMeta) return undefined
|
|
||||||
|
|
||||||
const section = match[2]
|
|
||||||
const sectionName = section.charAt(0).toUpperCase() + section.slice(1)
|
|
||||||
spaceMeta.title = `${sectionName} on ${spaceMeta.title}`
|
|
||||||
return spaceMeta
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMetadataForSpaceItem = async (url, match) => {
|
|
||||||
const spaceMeta = await getMetadataForSpace(url, match)
|
|
||||||
if (!spaceMeta) return undefined
|
|
||||||
|
|
||||||
const section = match[2]
|
|
||||||
let itemType = "Item"
|
|
||||||
if (section === "calendar") itemType = "Event"
|
|
||||||
if (section === "threads") itemType = "Thread"
|
|
||||||
if (section === "polls") itemType = "Poll"
|
|
||||||
if (section === "goals") itemType = "Goal"
|
|
||||||
if (section === "classifieds") itemType = "Listing"
|
|
||||||
|
|
||||||
spaceMeta.title = `${itemType} on ${spaceMeta.title}`
|
|
||||||
return spaceMeta
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMetadataForRoom = async (url, match) => {
|
|
||||||
const spaceMeta = await getMetadataForSpace(url, match)
|
|
||||||
if (!spaceMeta) return undefined
|
|
||||||
|
|
||||||
// Room metadata requires fetching from Nostr, which can be added later.
|
|
||||||
spaceMeta.title = `Room on ${spaceMeta.title}`
|
|
||||||
return spaceMeta
|
|
||||||
}
|
|
||||||
|
|
||||||
const routes = [
|
|
||||||
[/^\/join\/?$/, getMetadataForInvite],
|
|
||||||
[/^\/spaces\/([^/]+)\/(calendar|chat|threads|polls|goals|classifieds|recent)\/?$/, getMetadataForSpaceSection],
|
|
||||||
[/^\/spaces\/([^/]+)\/(calendar|threads|polls|goals|classifieds)\/([^/]+)\/?$/, getMetadataForSpaceItem],
|
|
||||||
[/^\/spaces\/([^/]+)\/([^/]+)\/?$/, getMetadataForRoom],
|
|
||||||
[/^\/spaces\/([^/]+)\/?$/, getMetadataForSpace],
|
|
||||||
]
|
|
||||||
|
|
||||||
const getMetadataForRoute = async url => {
|
|
||||||
for (const [regex, getMetadata] of routes) {
|
|
||||||
const match = url.pathname.match(regex)
|
|
||||||
if (match) {
|
|
||||||
try {
|
|
||||||
return await getMetadata(url, match)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Error generating metadata for route ${url.pathname}:`, err)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const injectMeta = metadata => {
|
|
||||||
const $ = load(TEMPLATE_HTML)
|
|
||||||
|
|
||||||
if (metadata.title) {
|
|
||||||
$("title").text(metadata.title)
|
|
||||||
$('meta[property="og:title"]').attr("content", metadata.title)
|
|
||||||
$('meta[name="twitter:title"]').attr("content", metadata.title)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metadata.description) {
|
|
||||||
$('meta[name="description"]').attr("content", metadata.description)
|
|
||||||
$('meta[property="og:description"]').attr("content", metadata.description)
|
|
||||||
$('meta[name="twitter:description"]').attr("content", metadata.description)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metadata.image) {
|
|
||||||
$('meta[property="og:image"]').attr("content", metadata.image)
|
|
||||||
$('meta[name="twitter:image"]').attr("content", metadata.image)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metadata.url) {
|
|
||||||
$('meta[property="og:url"]').attr("content", metadata.url)
|
|
||||||
$('meta[name="twitter:site"]').attr("content", metadata.site)
|
|
||||||
$('meta[name="twitter:url"]').attr("content", metadata.url)
|
|
||||||
$('link[rel="canonical"]').attr("href", metadata.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
return $.html()
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = new Hono()
|
|
||||||
|
|
||||||
// Only allow GET and HEAD requests
|
|
||||||
app.use("*", async (context, next) => {
|
|
||||||
const method = context.req.method
|
|
||||||
if (method !== "GET" && method !== "HEAD") {
|
|
||||||
return context.text("Method Not Allowed", 405, {Allow: "GET, HEAD"})
|
|
||||||
}
|
|
||||||
await next()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Serve static assets with appropriate caching
|
|
||||||
app.use(
|
|
||||||
"*",
|
|
||||||
serveStatic({
|
|
||||||
root: BUILD_DIR,
|
|
||||||
onFound: (filePath, context) => {
|
|
||||||
const isImmutable = filePath.split(path.sep).join("/").includes("/_app/immutable/")
|
|
||||||
const cacheControl =
|
|
||||||
path.basename(filePath) === "index.html"
|
|
||||||
? "no-cache"
|
|
||||||
: isImmutable
|
|
||||||
? "public, max-age=31536000, immutable"
|
|
||||||
: "public, max-age=3600"
|
|
||||||
|
|
||||||
context.header("Cache-Control", cacheControl)
|
|
||||||
|
|
||||||
// Immutable assets are content-hashed by Vite, so the filename is itself a
|
|
||||||
// stable content identifier. Exposing it as an ETag lets clients that
|
|
||||||
// revalidate explicitly (e.g. emoji-picker-element checks its data source
|
|
||||||
// on every load) skip re-downloading large files when nothing changed.
|
|
||||||
if (isImmutable) {
|
|
||||||
context.header("ETag", `"${path.basename(filePath)}"`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// SPA fallback for routes that don't match static files
|
|
||||||
app.get("*", async context => {
|
|
||||||
const requestUrl = requestUrlFromContext(context)
|
|
||||||
const metadata = await getMetadataForRoute(requestUrl)
|
|
||||||
const html = metadata ? injectMeta(metadata) : TEMPLATE_HTML
|
|
||||||
|
|
||||||
return context.html(html, 200, {
|
|
||||||
"Cache-Control": metadata ? "no-store" : "no-cache",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
serve(
|
|
||||||
{
|
|
||||||
fetch: app.fetch,
|
|
||||||
hostname: HOST,
|
|
||||||
port: PORT,
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
console.log(`Flotilla server running on http://${HOST}:${PORT}`)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,16 +2,6 @@
|
|||||||
|
|
||||||
@config "../tailwind.config.js";
|
@config "../tailwind.config.js";
|
||||||
|
|
||||||
/* root */
|
|
||||||
|
|
||||||
:root {
|
|
||||||
font-family: Lato;
|
|
||||||
--sait: var(--safe-area-inset-top, env(safe-area-inset-top));
|
|
||||||
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
|
|
||||||
--sail: var(--safe-area-inset-left, env(safe-area-inset-left));
|
|
||||||
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
|
|
||||||
}
|
|
||||||
|
|
||||||
@utility pt-sai {
|
@utility pt-sai {
|
||||||
padding-top: var(--sait);
|
padding-top: var(--sait);
|
||||||
}
|
}
|
||||||
@@ -32,6 +22,16 @@
|
|||||||
@apply pl-sai pr-sai;
|
@apply pl-sai pr-sai;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* root */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-family: Lato;
|
||||||
|
--sait: var(--safe-area-inset-top, env(safe-area-inset-top));
|
||||||
|
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
|
||||||
|
--sail: var(--safe-area-inset-left, env(safe-area-inset-left));
|
||||||
|
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
|
||||||
|
}
|
||||||
|
|
||||||
@utility py-sai {
|
@utility py-sai {
|
||||||
@apply pt-sai pb-sai;
|
@apply pt-sai pb-sai;
|
||||||
}
|
}
|
||||||
@@ -235,7 +235,6 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: Lato;
|
font-family: Lato;
|
||||||
text-size-adjust: 100%;
|
|
||||||
--sait: var(--safe-area-inset-top, env(safe-area-inset-top));
|
--sait: var(--safe-area-inset-top, env(safe-area-inset-top));
|
||||||
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
|
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
|
||||||
--sail: var(--safe-area-inset-left, env(safe-area-inset-left));
|
--sail: var(--safe-area-inset-left, env(safe-area-inset-left));
|
||||||
@@ -328,12 +327,12 @@
|
|||||||
|
|
||||||
.note-editor .tiptap {
|
.note-editor .tiptap {
|
||||||
--tiptap-object-bg: var(--color-base-200);
|
--tiptap-object-bg: var(--color-base-200);
|
||||||
@apply input rounded-box block h-auto min-h-32 w-full p-[.65rem] pb-6;
|
@apply input rounded-box h-auto min-h-32 p-[.65rem] pb-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-editor .tiptap {
|
.input-editor .tiptap {
|
||||||
--tiptap-object-bg: var(--color-base-200);
|
--tiptap-object-bg: var(--color-base-200);
|
||||||
@apply input block h-auto p-[.65rem];
|
@apply input h-auto p-[.65rem];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* link-content, based on tiptap */
|
/* link-content, based on tiptap */
|
||||||
@@ -417,28 +416,16 @@ progress[value]::-webkit-progress-value {
|
|||||||
@apply md:left-[calc(18.5rem+var(--sail))];
|
@apply md:left-[calc(18.5rem+var(--sail))];
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-content-full {
|
|
||||||
@apply md:left-[calc(3.5rem+var(--sail))];
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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 {
|
||||||
@apply z-compose relative mb-14 shrink-0 md:mb-0;
|
@apply relative z-compose mb-14 shrink-0 md:mb-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat__compose .chat__compose-inner {
|
.chat__compose .chat__compose-inner {
|
||||||
|
|||||||
@@ -2,18 +2,15 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>{NAME}</title>
|
|
||||||
<link rel="canonical" href="{URL}" />
|
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||||
<meta name="theme-color" content="{ACCENT}" />
|
<meta name="theme-color" content="{ACCENT}" />
|
||||||
<meta name="description" content="{DESCRIPTION}" />
|
<meta name="description" content="{DESCRIPTION}" />
|
||||||
<meta property="og:url" content="{URL}" />
|
<meta name="og:url" content="{URL}" />
|
||||||
<meta property="og:type" content="website" />
|
<meta name="og:type" content="website" />
|
||||||
<meta property="og:title" content="{NAME}" />
|
<meta name="og:title" content="{NAME}" />
|
||||||
<meta property="og:description" content="{DESCRIPTION}" />
|
<meta name="og:description" content="{DESCRIPTION}" />
|
||||||
<meta property="og:image" content="" />
|
|
||||||
<meta name="twitter:card" content="summary" />
|
<meta name="twitter:card" content="summary" />
|
||||||
<meta name="twitter:site" content="{URL}" />
|
<meta name="twitter:site" content="{URL}" />
|
||||||
<meta name="twitter:title" content="{NAME}" />
|
<meta name="twitter:title" content="{NAME}" />
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
import {
|
|
||||||
REPORT,
|
|
||||||
ROOM_ADD_MEMBER,
|
|
||||||
ROOM_JOIN,
|
|
||||||
ROOM_LEAVE,
|
|
||||||
ROOM_MEMBERS,
|
|
||||||
ROOM_REMOVE_MEMBER,
|
|
||||||
getPubkeyTagValues,
|
|
||||||
getTagValue,
|
|
||||||
sortEventsDesc,
|
|
||||||
} from "@welshman/util"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
|
||||||
import {first, groupBy, removeUndefined} from "@welshman/lib"
|
|
||||||
import {derived} from "svelte/store"
|
|
||||||
import {deriveEventsForUrl} from "@app/repository"
|
|
||||||
import {getRoomMembers} from "@app/members"
|
|
||||||
// Action items (admin review queue)
|
|
||||||
|
|
||||||
export const deriveSpaceActionItems = (url: string) =>
|
|
||||||
derived(
|
|
||||||
deriveEventsForUrl(url, [
|
|
||||||
{
|
|
||||||
kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER],
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
$events => {
|
|
||||||
const getRoomId = (e: TrustedEvent) =>
|
|
||||||
getTagValue(e.kind === ROOM_MEMBERS ? "d" : "h", e.tags)
|
|
||||||
const reports = $events.filter(e => e.kind === REPORT)
|
|
||||||
const pendingJoins: TrustedEvent[] = []
|
|
||||||
|
|
||||||
// Room-level join requests — most recent per pubkey+h
|
|
||||||
for (const [h, roomEvents] of groupBy(getRoomId, $events)) {
|
|
||||||
if (!h) continue
|
|
||||||
|
|
||||||
const roomJoins: TrustedEvent[] = []
|
|
||||||
const roomLeaves: TrustedEvent[] = []
|
|
||||||
const roomMembershipEvents: TrustedEvent[] = []
|
|
||||||
|
|
||||||
for (const event of roomEvents) {
|
|
||||||
switch (event.kind) {
|
|
||||||
case ROOM_JOIN:
|
|
||||||
roomJoins.push(event)
|
|
||||||
break
|
|
||||||
case ROOM_LEAVE:
|
|
||||||
roomLeaves.push(event)
|
|
||||||
break
|
|
||||||
case ROOM_MEMBERS:
|
|
||||||
case ROOM_ADD_MEMBER:
|
|
||||||
case ROOM_REMOVE_MEMBER:
|
|
||||||
roomMembershipEvents.push(event)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const roomMembers = new Set(getRoomMembers(url, h, roomMembershipEvents))
|
|
||||||
|
|
||||||
pendingJoins.push(
|
|
||||||
...removeUndefined(
|
|
||||||
Array.from(groupBy(e => e.pubkey, roomJoins).values()).map(events =>
|
|
||||||
first(sortEventsDesc(events)),
|
|
||||||
),
|
|
||||||
).filter(({pubkey, created_at}) => {
|
|
||||||
if (roomMembers.has(pubkey)) return false
|
|
||||||
if (
|
|
||||||
roomMembershipEvents.some(event => {
|
|
||||||
if (event.created_at <= created_at) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.kind === ROOM_MEMBERS) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return getPubkeyTagValues(event.tags).includes(pubkey)
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (roomLeaves.some(e => e.pubkey === pubkey && e.created_at > created_at)) return false
|
|
||||||
|
|
||||||
return true
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return sortEventsDesc([...reports, ...pendingJoins])
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,27 +1,20 @@
|
|||||||
import {Room as LiveKitRoom} from "livekit-client"
|
import {Room as LiveKitRoom} from "livekit-client"
|
||||||
import {derived, writable} from "svelte/store"
|
import {derived, writable} from "svelte/store"
|
||||||
import type {Room} from "@app/groups"
|
import {type Room} from "@app/core/state"
|
||||||
|
|
||||||
export type VoiceSession = {
|
export type VoiceSession = {
|
||||||
url: string
|
url: string
|
||||||
h: string
|
h: string
|
||||||
room: LiveKitRoom
|
room: LiveKitRoom
|
||||||
|
muted: boolean
|
||||||
cameraOn: boolean
|
cameraOn: boolean
|
||||||
screenShareOn: boolean
|
screenShareOn: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mic mute state is separate so toggling it does not re-render video tiles. */
|
|
||||||
export const voiceMicMuted = writable(true)
|
|
||||||
|
|
||||||
export type Pubkey = string
|
export type Pubkey = string
|
||||||
|
|
||||||
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
|
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
|
||||||
|
|
||||||
export type ParticipantMediaState = {
|
|
||||||
muted: boolean
|
|
||||||
cameraOn: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum VoiceState {
|
export enum VoiceState {
|
||||||
Joining = "joining",
|
Joining = "joining",
|
||||||
Connected = "connected",
|
Connected = "connected",
|
||||||
@@ -34,6 +27,8 @@ 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
|
||||||
|
|
||||||
@@ -46,19 +41,6 @@ export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
|
|||||||
|
|
||||||
export const speakingParticipants = writable<VoiceParticipant[]>([])
|
export const speakingParticipants = writable<VoiceParticipant[]>([])
|
||||||
|
|
||||||
export const participantMediaState = writable(new Map<string, ParticipantMediaState>())
|
|
||||||
|
|
||||||
export const mediaStateByIdentity = derived(
|
|
||||||
[participantMediaState, currentVoiceSession, voiceMicMuted],
|
|
||||||
([$media, $session, $micMuted]) =>
|
|
||||||
(identity: string) => {
|
|
||||||
if ($session?.room.localParticipant.identity === identity) {
|
|
||||||
return {muted: $micMuted, cameraOn: $session.cameraOn}
|
|
||||||
}
|
|
||||||
return $media.get(identity) ?? {muted: true, cameraOn: false}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
export const isParticipantSpeaking = derived(
|
export const isParticipantSpeaking = derived(
|
||||||
speakingParticipants,
|
speakingParticipants,
|
||||||
$participants => (p: VoiceParticipant) =>
|
$participants => (p: VoiceParticipant) =>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {Track} from "livekit-client"
|
|||||||
import {MediaQuery} from "svelte/reactivity"
|
import {MediaQuery} from "svelte/reactivity"
|
||||||
import {derived, get, writable} from "svelte/store"
|
import {derived, get, writable} from "svelte/store"
|
||||||
import {currentVoiceSession, VoiceState, type VoiceSession, voiceState} from "@app/call/stores"
|
import {currentVoiceSession, VoiceState, type VoiceSession, voiceState} from "@app/call/stores"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
export enum VideoCallLayout {
|
export enum VideoCallLayout {
|
||||||
Chat = "chat",
|
Chat = "chat",
|
||||||
|
|||||||
@@ -6,39 +6,34 @@ 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, not, nthEq, reject, removeUndefined, uniqBy} from "@welshman/lib"
|
import {map, 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 {load} from "@welshman/net"
|
|
||||||
import {getLivekitEndpoint} from "$lib/livekit"
|
import {getLivekitEndpoint} from "$lib/livekit"
|
||||||
import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util"
|
import {AbortError, whenAborted, whenTimeout} from "$lib/util"
|
||||||
import {
|
import {
|
||||||
currentVoiceRoom,
|
currentVoiceRoom,
|
||||||
currentVoiceSession,
|
currentVoiceSession,
|
||||||
voiceMicMuted,
|
|
||||||
participantFromLiveKitIdentity,
|
participantFromLiveKitIdentity,
|
||||||
participantKey,
|
participantKey,
|
||||||
participantMediaState,
|
participantPubkeyMap,
|
||||||
|
pubkeyFromLiveKitIdentity,
|
||||||
speakingParticipants,
|
speakingParticipants,
|
||||||
VoiceState,
|
VoiceState,
|
||||||
type ParticipantMediaState,
|
|
||||||
type VoiceParticipant,
|
type VoiceParticipant,
|
||||||
voiceState,
|
voiceState,
|
||||||
} from "@app/call/stores"
|
} from "@app/call/stores"
|
||||||
import {resetVideoCallLayout, triggerVideoFeedCount, videoPrimaryTileKey} from "@app/call/video"
|
import {resetVideoCallLayout, triggerVideoFeedCount, videoPrimaryTileKey} from "@app/call/video"
|
||||||
import {deriveLatestEventForUrl} from "@app/repository"
|
import {deriveLatestEventForUrl, deriveRoom, makeRoomId} from "@app/core/state"
|
||||||
import {deriveRoom, makeRoomId} from "@app/groups"
|
import {pushToast} from "@app/util/toast"
|
||||||
import {pushToast} from "@app/toast"
|
|
||||||
|
|
||||||
export const LIVEKIT_PARTICIPANTS = 39004
|
export const LIVEKIT_PARTICIPANTS = 39004
|
||||||
|
|
||||||
@@ -80,51 +75,20 @@ export const switchVoiceActiveDevice = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const participantMediaFrom = (participant: Participant): ParticipantMediaState => ({
|
const addParticipant = (identity: string) => {
|
||||||
muted: !participant.isMicrophoneEnabled,
|
participantPubkeyMap.update(m => {
|
||||||
cameraOn: participant.isCameraEnabled,
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteParticipant = (identity: string) => {
|
|
||||||
participantMediaState.update(m => new Map(reject(nthEq(0, identity), [...m])))
|
|
||||||
}
|
|
||||||
|
|
||||||
const syncParticipantMedia = (participant: Participant) => {
|
|
||||||
const state = participantMediaFrom(participant)
|
|
||||||
participantMediaState.update(m => {
|
|
||||||
const prev = m.get(participant.identity)
|
|
||||||
if (prev?.muted === state.muted && prev?.cameraOn === state.cameraOn) return m
|
|
||||||
const next = new Map(m)
|
const next = new Map(m)
|
||||||
next.set(participant.identity, state)
|
next.set(identity, pubkeyFromLiveKitIdentity(identity) ?? "")
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// LiveKit does not emit ParticipantConnected/Disconnected during reconnect.
|
const deleteParticipant = (identity: string) => {
|
||||||
const resyncAfterReconnect = (room: LiveKitRoom) => {
|
participantPubkeyMap.update(m => {
|
||||||
if (room !== activeRoom) return
|
const next = new Map(m)
|
||||||
|
next.delete(identity)
|
||||||
const next = new Map<string, ParticipantMediaState>()
|
return next
|
||||||
for (const p of [room.localParticipant, ...room.remoteParticipants.values()]) {
|
|
||||||
next.set(p.identity, participantMediaFrom(p))
|
|
||||||
}
|
|
||||||
participantMediaState.set(next)
|
|
||||||
|
|
||||||
const session = get(currentVoiceSession)
|
|
||||||
if (!session) return
|
|
||||||
|
|
||||||
const {localParticipant} = room
|
|
||||||
voiceMicMuted.set(!localParticipant.isMicrophoneEnabled)
|
|
||||||
currentVoiceSession.set({
|
|
||||||
...session,
|
|
||||||
cameraOn: localParticipant.isCameraEnabled,
|
|
||||||
screenShareOn: localParticipant.isScreenShareEnabled,
|
|
||||||
})
|
})
|
||||||
triggerVideoFeedCount()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onParticipantMediaChanged = (_publication: TrackPublication, participant: Participant) => {
|
|
||||||
syncParticipantMedia(participant)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchLivekitToken = async (
|
const fetchLivekitToken = async (
|
||||||
@@ -156,32 +120,26 @@ const fetchLivekitToken = async (
|
|||||||
return response.json()
|
return response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadVoiceParticipants = (url: string, h: string) =>
|
|
||||||
load({
|
|
||||||
relays: [url],
|
|
||||||
filters: [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}],
|
|
||||||
})
|
|
||||||
|
|
||||||
export const deriveVoiceParticipants = (url: string, h: string) =>
|
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(
|
||||||
[
|
[
|
||||||
participantMediaState,
|
participantPubkeyMap,
|
||||||
currentVoiceRoom,
|
currentVoiceRoom,
|
||||||
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
|
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
|
||||||
],
|
],
|
||||||
([$participantMediaState, $currentVoiceRoom, $publishedParticipantList]) => {
|
([$participantPubkeyMap, $currentVoiceRoom, $publishedParticipantList]) => {
|
||||||
const inCall = $participantMediaState.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h)
|
const inCall = $participantPubkeyMap.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h)
|
||||||
|
|
||||||
if (inCall) {
|
if (inCall) {
|
||||||
const participants = [...$participantMediaState.keys()].map(participantFromLiveKitIdentity)
|
const participants = [...$participantPubkeyMap.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
|
||||||
if (!latestEvent) return []
|
if (!latestEvent) return []
|
||||||
const participants = removeUndefined(
|
const participants = removeUndefined(
|
||||||
map(
|
map(
|
||||||
(tag: string[]) => (tag[1] ? participantFromLiveKitIdentity(tag[1]) : undefined),
|
(tag: string[]) => (tag[1] ? {pubkey: tag[1], identity: tag[1]} : undefined),
|
||||||
getTags("participant", latestEvent.tags),
|
getTags("participant", latestEvent.tags),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -194,8 +152,6 @@ 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
|
||||||
@@ -207,100 +163,28 @@ const setUpMicrophone = async (
|
|||||||
capture = {deviceId: preferredMicId}
|
capture = {deviceId: preferredMicId}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await Promise.race([
|
await participant.setMicrophoneEnabled(true, capture)
|
||||||
participant.setMicrophoneEnabled(true, capture),
|
|
||||||
whenTimeout(15_000, {message: "Microphone access timed out.", signal: settleSignal}),
|
|
||||||
whenAborted(signal),
|
|
||||||
])
|
|
||||||
muted = false
|
muted = false
|
||||||
} catch (e) {
|
} 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
|
return muted
|
||||||
}
|
}
|
||||||
|
|
||||||
// The room whose events are allowed to mutate shared state. Abandoned rooms
|
const onRoomDisconnected = (reason?: DisconnectReason) => {
|
||||||
// (after switching calls or an engine reconnect give-up) must not clobber it.
|
|
||||||
let activeRoom: LiveKitRoom | undefined
|
|
||||||
|
|
||||||
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
|
||||||
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined
|
|
||||||
let reconnectAttempt = 0
|
|
||||||
|
|
||||||
const clearReconnectSchedule = () => {
|
|
||||||
if (reconnectTimeout !== undefined) {
|
|
||||||
clearTimeout(reconnectTimeout)
|
|
||||||
reconnectTimeout = undefined
|
|
||||||
}
|
|
||||||
reconnectAttempt = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const attemptReconnect = async () => {
|
|
||||||
const target = get(currentVoiceRoom)
|
|
||||||
if (!target) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await joinVoiceRoom(target.url, target.h)
|
|
||||||
} catch {
|
|
||||||
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
|
|
||||||
pushToast({theme: "error", message: "Voice connection lost."})
|
|
||||||
clearReconnectSchedule()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scheduleReconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheduleReconnect = () => {
|
|
||||||
if (reconnectTimeout !== undefined) return
|
|
||||||
if (!get(currentVoiceRoom)) return
|
|
||||||
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
|
|
||||||
pushToast({theme: "error", message: "Voice connection lost."})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const delay = RECONNECT_DELAYS[reconnectAttempt]!
|
|
||||||
reconnectAttempt++
|
|
||||||
reconnectTimeout = setTimeout(() => {
|
|
||||||
reconnectTimeout = undefined
|
|
||||||
void attemptReconnect()
|
|
||||||
}, delay)
|
|
||||||
}
|
|
||||||
|
|
||||||
const makeOnRoomReconnected = (room: LiveKitRoom) => () => {
|
|
||||||
if (room !== activeRoom) return
|
|
||||||
resyncAfterReconnect(room)
|
|
||||||
}
|
|
||||||
|
|
||||||
const makeOnRoomDisconnected = (room: LiveKitRoom) => (reason?: DisconnectReason) => {
|
|
||||||
// Ignore disconnects from rooms that are no longer the active session.
|
|
||||||
if (room !== activeRoom) return
|
|
||||||
|
|
||||||
activeRoom = undefined
|
|
||||||
room.removeAllListeners()
|
|
||||||
|
|
||||||
videoPrimaryTileKey.set(undefined)
|
videoPrimaryTileKey.set(undefined)
|
||||||
voiceMicMuted.set(true)
|
|
||||||
currentVoiceSession.set(undefined)
|
currentVoiceSession.set(undefined)
|
||||||
resetVideoCallLayout()
|
resetVideoCallLayout()
|
||||||
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
||||||
voiceState.set(VoiceState.Disconnected)
|
voiceState.set(VoiceState.Disconnected)
|
||||||
if (reason === DisconnectReason.JOIN_FAILURE) {
|
const message =
|
||||||
pushToast({theme: "error", message: "Could not connect to voice room. Please try again."})
|
reason === DisconnectReason.JOIN_FAILURE
|
||||||
} else if (get(currentVoiceRoom)) {
|
? "Could not connect to voice room. Please try again."
|
||||||
clearReconnectSchedule()
|
: "Voice connection lost."
|
||||||
scheduleReconnect()
|
pushToast({theme: "error", message})
|
||||||
} else {
|
|
||||||
pushToast({theme: "error", message: "Voice connection lost."})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
speakingParticipants.set([])
|
speakingParticipants.set([])
|
||||||
participantMediaState.set(new Map())
|
participantPubkeyMap.set(new Map())
|
||||||
}
|
}
|
||||||
|
|
||||||
const onTrackSubscribed = (track: Track) => {
|
const onTrackSubscribed = (track: Track) => {
|
||||||
@@ -330,8 +214,8 @@ const playJoinSound = () => {
|
|||||||
audio.play().catch(() => {})
|
audio.play().catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onParticipantConnected = (participant: Participant) => {
|
const onParticipantConnected = (participant: {identity: string}) => {
|
||||||
syncParticipantMedia(participant)
|
addParticipant(participant.identity)
|
||||||
playJoinSound()
|
playJoinSound()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,13 +236,8 @@ const onLocalTrackUnpublished = (
|
|||||||
|
|
||||||
let joinAbortController: AbortController | undefined
|
let joinAbortController: AbortController | undefined
|
||||||
|
|
||||||
const abortJoinVoiceRoom = () => {
|
|
||||||
joinAbortController?.abort()
|
|
||||||
}
|
|
||||||
|
|
||||||
export const cancelJoinVoiceRoom = () => {
|
export const cancelJoinVoiceRoom = () => {
|
||||||
clearReconnectSchedule()
|
joinAbortController?.abort()
|
||||||
abortJoinVoiceRoom()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const joinVoiceRoom = async (
|
export const joinVoiceRoom = async (
|
||||||
@@ -367,7 +246,10 @@ export const joinVoiceRoom = async (
|
|||||||
startMuted = true,
|
startMuted = true,
|
||||||
preferredMicId?: string,
|
preferredMicId?: string,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
abortJoinVoiceRoom()
|
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)
|
||||||
@@ -377,123 +259,62 @@ 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 {
|
||||||
// Tear down any existing session before joining. Bound it so a slow leave
|
const {server_url, participant_token} = await fetchLivekitToken(url, h, signal)
|
||||||
// (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, makeOnRoomDisconnected(liveKitRoom))
|
liveKitRoom.on(RoomEvent.Disconnected, onRoomDisconnected)
|
||||||
liveKitRoom.on(RoomEvent.Reconnected, makeOnRoomReconnected(liveKitRoom))
|
|
||||||
liveKitRoom.on(RoomEvent.ParticipantConnected, onParticipantConnected)
|
liveKitRoom.on(RoomEvent.ParticipantConnected, onParticipantConnected)
|
||||||
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
||||||
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
||||||
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
||||||
liveKitRoom.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
|
liveKitRoom.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
|
||||||
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
||||||
liveKitRoom.on(RoomEvent.TrackMuted, onParticipantMediaChanged)
|
|
||||||
liveKitRoom.on(RoomEvent.TrackUnmuted, onParticipantMediaChanged)
|
|
||||||
liveKitRoom.on(RoomEvent.TrackPublished, onParticipantMediaChanged)
|
|
||||||
liveKitRoom.on(RoomEvent.TrackUnpublished, onParticipantMediaChanged)
|
|
||||||
liveKitRoom.on(RoomEvent.LocalTrackPublished, onParticipantMediaChanged)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
liveKitRoom.connect(server_url, participant_token, {maxRetries: 0}),
|
liveKitRoom.connect(server_url, participant_token, {maxRetries: 0}),
|
||||||
whenTimeout(15_000, {
|
whenTimeout(5_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
|
||||||
}
|
}
|
||||||
|
|
||||||
participantMediaState.set(new Map())
|
participantPubkeyMap.set(new Map())
|
||||||
syncParticipantMedia(liveKitRoom.localParticipant)
|
addParticipant(liveKitRoom.localParticipant.identity)
|
||||||
for (const p of liveKitRoom.remoteParticipants.values()) {
|
for (const p of liveKitRoom.remoteParticipants.values()) {
|
||||||
syncParticipantMedia(p)
|
addParticipant(p.identity)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bounded against timeout/abort inside setUpMicrophone: a stuck permission
|
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
|
||||||
// prompt resolves to muted rather than hanging the join forever.
|
|
||||||
const muted = await setUpMicrophone(
|
|
||||||
startMuted,
|
|
||||||
preferredMicId,
|
|
||||||
liveKitRoom.localParticipant,
|
|
||||||
signal,
|
|
||||||
settle.signal,
|
|
||||||
)
|
|
||||||
|
|
||||||
// A cancel during the mic step must tear down the connected room rather
|
|
||||||
// than leaking it.
|
|
||||||
if (signal.aborted) {
|
|
||||||
if (activeRoom === liveKitRoom) activeRoom = undefined
|
|
||||||
liveKitRoom.removeAllListeners()
|
|
||||||
liveKitRoom.disconnect()
|
|
||||||
throw new AbortError()
|
|
||||||
}
|
|
||||||
|
|
||||||
voiceMicMuted.set(muted)
|
|
||||||
currentVoiceSession.set({
|
currentVoiceSession.set({
|
||||||
url,
|
url,
|
||||||
h,
|
h,
|
||||||
room: liveKitRoom,
|
room: liveKitRoom,
|
||||||
|
muted,
|
||||||
cameraOn: false,
|
cameraOn: false,
|
||||||
screenShareOn: false,
|
screenShareOn: false,
|
||||||
})
|
})
|
||||||
voiceState.set(VoiceState.Connected)
|
voiceState.set(VoiceState.Connected)
|
||||||
clearReconnectSchedule()
|
|
||||||
playJoinSound()
|
playJoinSound()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isActive()) voiceState.set(VoiceState.Disconnected)
|
if (isActive()) voiceState.set(VoiceState.Disconnected)
|
||||||
if (e instanceof AbortError) {
|
if (e instanceof AbortError) return
|
||||||
clearReconnectSchedule()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw e
|
throw e
|
||||||
} finally {
|
} finally {
|
||||||
settle.abort()
|
|
||||||
if (isActive()) joinAbortController = undefined
|
if (isActive()) joinAbortController = undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const leaveVoiceRoom = async () => {
|
export const leaveVoiceRoom = async () => {
|
||||||
clearReconnectSchedule()
|
|
||||||
const session = get(currentVoiceSession)
|
const session = get(currentVoiceSession)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
||||||
@@ -516,40 +337,37 @@ export const leaveVoiceRoom = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always tear down this room's connection and listeners.
|
|
||||||
if (activeRoom === session.room) activeRoom = undefined
|
|
||||||
session.room.removeAllListeners()
|
|
||||||
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)
|
voiceState.set(VoiceState.Disconnected)
|
||||||
videoPrimaryTileKey.set(undefined)
|
videoPrimaryTileKey.set(undefined)
|
||||||
voiceMicMuted.set(true)
|
|
||||||
currentVoiceSession.set(undefined)
|
currentVoiceSession.set(undefined)
|
||||||
resetVideoCallLayout()
|
resetVideoCallLayout()
|
||||||
|
session.room.disconnect()
|
||||||
speakingParticipants.set([])
|
speakingParticipants.set([])
|
||||||
participantMediaState.set(new Map())
|
participantPubkeyMap.set(new Map())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const rejoinVoiceRoom = async (): Promise<void> => {
|
||||||
|
const target = get(currentVoiceRoom)
|
||||||
|
if (!target) return
|
||||||
|
return joinVoiceRoom(target.url, target.h)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const toggleMute = async () => {
|
export const toggleMute = async () => {
|
||||||
const session = get(currentVoiceSession)
|
const session = get(currentVoiceSession)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
||||||
voiceMicMuted.update(not)
|
const muted = !session.muted
|
||||||
if (get(voiceMicMuted)) {
|
if (muted) {
|
||||||
// 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,139 +0,0 @@
|
|||||||
import {DELETE, PROFILE, getPubkeyTagValues} from "@welshman/util"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
|
||||||
import {append, call, on, reject, remove, sort, sortBy, spec, uniq, uniqBy} from "@welshman/lib"
|
|
||||||
import type {Override} from "@welshman/lib"
|
|
||||||
import {createSearch, displayProfileByPubkey, pubkey, repository} from "@welshman/app"
|
|
||||||
import {derived, readable} from "svelte/store"
|
|
||||||
import {DM_KINDS} from "@app/content"
|
|
||||||
import type {RepositoryUpdate} from "@welshman/net"
|
|
||||||
import {makeDeriveItem, throttled} from "@welshman/store"
|
|
||||||
export type Chat = {
|
|
||||||
id: string
|
|
||||||
pubkeys: string[]
|
|
||||||
messages: TrustedEvent[]
|
|
||||||
last_activity: number
|
|
||||||
search_text: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getChatPubkeys = (pubkeys: string[]) => sort(uniq(append(pubkey.get()!, pubkeys)))
|
|
||||||
|
|
||||||
export const getChatPubkeysFromEvent = (event: TrustedEvent) =>
|
|
||||||
getChatPubkeys(getPubkeyTagValues(event.tags).concat(event.pubkey))
|
|
||||||
|
|
||||||
export const makeChatId = (pubkeys: string[]) => {
|
|
||||||
const userPubkey = pubkey.get()!
|
|
||||||
const otherPubkeys = remove(userPubkey, uniq(pubkeys))
|
|
||||||
const visiblePubkeys = otherPubkeys.length === 0 ? [userPubkey] : otherPubkeys
|
|
||||||
|
|
||||||
return sort(visiblePubkeys).join(",")
|
|
||||||
}
|
|
||||||
|
|
||||||
export const splitChatId = (id: string) => getChatPubkeys(id.split(","))
|
|
||||||
|
|
||||||
export const chatsById = call(() => {
|
|
||||||
const chatsById = new Map<string, Chat>()
|
|
||||||
const chatsByPubkey = new Map<string, string[]>()
|
|
||||||
|
|
||||||
const addSearchText = (chat: Override<Chat, {search_text?: string}>) => {
|
|
||||||
chat.search_text =
|
|
||||||
chat.pubkeys.length === 1
|
|
||||||
? displayProfileByPubkey(chat.pubkeys[0]) + " note to self"
|
|
||||||
: remove(pubkey.get()!, chat.pubkeys).map(displayProfileByPubkey).join(" ")
|
|
||||||
|
|
||||||
return chat as Chat
|
|
||||||
}
|
|
||||||
|
|
||||||
return readable(chatsById, set => {
|
|
||||||
const indexChatByPubkeys = (chat: Chat) => {
|
|
||||||
for (const pubkey of chat.pubkeys) {
|
|
||||||
chatsByPubkey.set(pubkey, uniq(append(chat.id, chatsByPubkey.get(pubkey) || [])))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const addEvents = (events: TrustedEvent[]) => {
|
|
||||||
let dirty = false
|
|
||||||
for (const event of events) {
|
|
||||||
if (DM_KINDS.includes(event.kind)) {
|
|
||||||
const pubkeys = getChatPubkeysFromEvent(event)
|
|
||||||
const id = makeChatId(pubkeys)
|
|
||||||
const chat = chatsById.get(id)
|
|
||||||
const messages = sortBy(
|
|
||||||
e => -e.created_at,
|
|
||||||
uniqBy(e => e.id, append(event, chat?.messages || [])),
|
|
||||||
)
|
|
||||||
const last_activity = Math.max(chat?.last_activity || 0, event.created_at)
|
|
||||||
const updatedChat = addSearchText({id, pubkeys, messages, last_activity})
|
|
||||||
|
|
||||||
chatsById.set(id, updatedChat)
|
|
||||||
indexChatByPubkeys(updatedChat)
|
|
||||||
|
|
||||||
dirty = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.kind === PROFILE) {
|
|
||||||
for (const chatId of chatsByPubkey.get(event.pubkey) || []) {
|
|
||||||
const chat = chatsById.get(chatId)
|
|
||||||
|
|
||||||
if (chat) {
|
|
||||||
addSearchText(chat)
|
|
||||||
dirty = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dirty) {
|
|
||||||
set(chatsById)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeEvents = (removed: Set<string>) => {
|
|
||||||
let dirty = false
|
|
||||||
|
|
||||||
for (const id of removed) {
|
|
||||||
const event = repository.getEvent(id)
|
|
||||||
|
|
||||||
if (event && DM_KINDS.includes(event.kind)) {
|
|
||||||
for (const chatId of chatsByPubkey.get(event.pubkey) || []) {
|
|
||||||
const chat = chatsById.get(chatId)
|
|
||||||
|
|
||||||
if (chat) {
|
|
||||||
chat.messages = reject(spec({id: event.id}), chat.messages)
|
|
||||||
dirty = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dirty) {
|
|
||||||
set(chatsById)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addEvents(repository.query([{kinds: [...DM_KINDS, DELETE, PROFILE]}]))
|
|
||||||
|
|
||||||
const unsubscribers = [
|
|
||||||
on(repository, "update", ({added, removed}: RepositoryUpdate) => {
|
|
||||||
// Do this async so that profiles are populated
|
|
||||||
setTimeout(() => {
|
|
||||||
addEvents(added)
|
|
||||||
removeEvents(removed)
|
|
||||||
}, 200)
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
|
|
||||||
return () => unsubscribers.forEach(call)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
export const deriveChat = makeDeriveItem(chatsById)
|
|
||||||
|
|
||||||
export const chatSearch = derived(throttled(1500, chatsById), $chatsByPubkey => {
|
|
||||||
return createSearch(
|
|
||||||
sortBy(c => -c.last_activity, Array.from($chatsByPubkey.values())),
|
|
||||||
{
|
|
||||||
getValue: (chat: Chat) => chat.id,
|
|
||||||
fuseOptions: {keys: ["search_text"]},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import type {TrustedEvent} from "@welshman/util"
|
|
||||||
import {COMMENT, makeEvent} from "@welshman/util"
|
|
||||||
import {publishThunk, tagEventForComment} from "@welshman/app"
|
|
||||||
|
|
||||||
export type CommentParams = {
|
|
||||||
event: TrustedEvent
|
|
||||||
content: string
|
|
||||||
tags?: string[][]
|
|
||||||
url?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeComment = ({url, event, content, tags = []}: CommentParams) =>
|
|
||||||
makeEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event, url)]})
|
|
||||||
|
|
||||||
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
|
|
||||||
publishThunk({event: makeComment({url: relays[0], ...params}), relays})
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
import Landing from "@app/components/Landing.svelte"
|
import Landing from "@app/components/Landing.svelte"
|
||||||
import Toast from "@app/components/Toast.svelte"
|
import Toast from "@app/components/Toast.svelte"
|
||||||
import PrimaryNav from "@app/components/PrimaryNav.svelte"
|
import PrimaryNav from "@app/components/PrimaryNav.svelte"
|
||||||
import {modal} from "@app/modal"
|
import {modal} from "@app/util/modal"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Snippet
|
children: Snippet
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import QRCode from "@app/components/QRCode.svelte"
|
import QRCode from "@app/components/QRCode.svelte"
|
||||||
import type {Nip46Controller} from "@app/nip46"
|
import type {Nip46Controller} from "@app/util/nip46"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
controller: Nip46Controller
|
controller: Nip46Controller
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
import QrCode from "@assets/icons/qr-code.svg?dataurl"
|
import QrCode from "@assets/icons/qr-code.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import InfoBunker from "@app/components/InfoBunker.svelte"
|
import InfoBunker from "@app/components/InfoBunker.svelte"
|
||||||
import type {Nip46Controller} from "@app/nip46"
|
import type {Nip46Controller} from "@app/util/nip46"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
controller: Nip46Controller
|
controller: Nip46Controller
|
||||||
|
|||||||
@@ -12,11 +12,9 @@
|
|||||||
import EventActivity from "@app/components/EventActivity.svelte"
|
import EventActivity from "@app/components/EventActivity.svelte"
|
||||||
import EventActions from "@app/components/EventActions.svelte"
|
import EventActions from "@app/components/EventActions.svelte"
|
||||||
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
|
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
|
||||||
import {publishDelete} from "@app/deletes"
|
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||||
import {publishReaction} from "@app/reactions"
|
import {makeCalendarPath, makeSpacePath} from "@app/util/routes"
|
||||||
import {canEnforceNip70} from "@app/relays"
|
import {pushModal} from "@app/util/modal"
|
||||||
import {makeCalendarPath, makeSpacePath} from "@app/routes"
|
|
||||||
import {pushModal} from "@app/modal"
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
|
|||||||
@@ -18,11 +18,11 @@
|
|||||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
|
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
|
||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
import {PROTECTED, publishRoomQuote} from "@app/groups"
|
import {PROTECTED} from "@app/core/state"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
import {DraftKey} from "@app/drafts"
|
import {DraftKey} from "@app/util/drafts"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
import {canEnforceNip70} from "@app/relays"
|
import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
|
||||||
|
|
||||||
type Values = {
|
type Values = {
|
||||||
d: string
|
d: string
|
||||||
|
|||||||
@@ -19,17 +19,15 @@
|
|||||||
const end = $derived(parseInt(meta.end))
|
const end = $derived(parseInt(meta.end))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col justify-between gap-1">
|
<div class="flex grow flex-wrap justify-between gap-2">
|
||||||
<p class="text-lg">{meta.title || meta.name}</p>
|
<p class="text-xl">{meta.title || meta.name}</p>
|
||||||
{#if !isNaN(start) && !isNaN(end)}
|
{#if !isNaN(start) && !isNaN(end)}
|
||||||
{@const startDateDisplay = formatTimestampAsDate(start)}
|
{@const startDateDisplay = formatTimestampAsDate(start)}
|
||||||
{@const endDateDisplay = formatTimestampAsDate(end)}
|
{@const endDateDisplay = formatTimestampAsDate(end)}
|
||||||
{@const isSingleDay = startDateDisplay === endDateDisplay}
|
{@const isSingleDay = startDateDisplay === endDateDisplay}
|
||||||
<div class="flex flex-wrap gap-2 text-xs">
|
<div class="flex items-center gap-2 text-sm">
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Icon icon={ClockCircle} size={4} />
|
<Icon icon={ClockCircle} size={4} />
|
||||||
{formatTimestampAsDate(start)}
|
<span class="hidden sm:block">{formatTimestampAsDate(start)}</span>
|
||||||
</div>
|
|
||||||
{formatTimestampAsTime(start)} — {isSingleDay
|
{formatTimestampAsTime(start)} — {isSingleDay
|
||||||
? formatTimestampAsTime(end)
|
? formatTimestampAsTime(end)
|
||||||
: formatTimestamp(end)}
|
: formatTimestamp(end)}
|
||||||
|
|||||||