Compare commits
122 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6dbe9c0ebb | |||
| 45df132dc6 | |||
| c42a285f0b | |||
| 1e3211ae74 | |||
| ec507b05d6 | |||
| 339bb1afac | |||
| c441012e02 | |||
| 0d61278c56 | |||
| ffd06ab561 | |||
| eb8dd330b6 | |||
| 6267e52bdf | |||
| ab21008f34 | |||
| 0998639d59 | |||
| eccde07d06 | |||
| 770cdc5f13 | |||
| 6bafb62414 | |||
| 6ce0fbbbe6 | |||
| 8fe42e6f22 | |||
| 47a6209730 | |||
| 24d3f867f8 | |||
| 9db60374e4 | |||
| 8ef4b21dab | |||
| 8f56812dd1 | |||
| 3833cb093d | |||
| 94db65b85e | |||
| 6f731e48d2 | |||
| 99fe0e543c | |||
| c6b0799b2a | |||
| 861f2286db | |||
| 9af3e3b2e9 | |||
| 341c1b45b2 | |||
| 89f5d8cdf5 | |||
| ca3270437d | |||
| bbbc6f7363 | |||
| 8a0abacf6f | |||
| 976ccdabd4 | |||
| 99b26680b6 | |||
| c5be477855 | |||
| 32c1501e9c | |||
| 463837e7d4 | |||
| d74f142cdd | |||
| 53954aae89 | |||
| 24aa62a503 | |||
| 2618bb9c63 | |||
| 32a31045ef | |||
| 56edad77a8 | |||
| fdb604e350 | |||
| 3c66dfd83c | |||
| 81633b0a1e | |||
| 4a967de184 | |||
| 59961cbdb5 | |||
| 95d9d8bf23 | |||
| 2fd9741a2b | |||
| fe9c325580 | |||
| 61e93d4071 | |||
| 1e4a4e43dc | |||
| e1a7b051bd | |||
| 7a7af58f5c | |||
| 016ae86d50 | |||
| 2bff060a5e | |||
| 68231504d0 | |||
| 0658a8ee44 | |||
| 43fb3d35e6 | |||
| 4cc1cc95ca | |||
| 964ef441ec | |||
| 796f37d320 | |||
| b46fd94578 | |||
| bdc8e75640 | |||
| ef08821796 | |||
| 9f386f6968 | |||
| ec0b6a99e2 | |||
| f6d9e52c6e | |||
| 90f86b833d | |||
| 29bb33c26c | |||
| c740bd21d4 | |||
| 1d92709c76 | |||
| a42e1df1a7 | |||
| e33beee17d | |||
| b10ea04cb3 | |||
| e8c94177ca | |||
| f1f2083c88 | |||
| f42889c3c2 | |||
| a75e1f96eb | |||
| 85c5293082 | |||
| 37efa6a62c | |||
| 1d5f91fb6c | |||
| ef18655776 | |||
| b786e858d9 | |||
| f4ebc4e99e | |||
| 65ca8a7fd8 | |||
| 7f1e98dcb2 | |||
| 4c19ee823b | |||
| 8e2dd8b278 | |||
| 8d35b3aad2 | |||
| 613cad31c0 | |||
| 3779a90f26 | |||
| 7470f28f31 | |||
| 17fb4e780b | |||
| 30c2a6ef79 | |||
| 0547e9513f | |||
| 70e5172f1b | |||
| 61c568a112 | |||
| ae2ba6f44d | |||
| f84006fbe4 | |||
| fed34a2747 | |||
| 80df16f97b | |||
| 18cb245599 | |||
| fd6cc84be6 | |||
| 9311cab3b2 | |||
| fceccf47be | |||
| fe20fbfd28 | |||
| 4f3a2a1660 | |||
| 1c8457a4bf | |||
| 8710043a02 | |||
| dc46b42cb6 | |||
| 2f1972e70a | |||
| c5fcf12165 | |||
| 61ed632579 | |||
| 86f4b75c52 | |||
| b26ab916d5 | |||
| c882198206 | |||
| 4aef27ffd5 |
@@ -4,7 +4,6 @@ ios
|
|||||||
build
|
build
|
||||||
|
|
||||||
# Git
|
# Git
|
||||||
.git
|
|
||||||
.gitignore
|
.gitignore
|
||||||
|
|
||||||
# Env files (keep .env for build; exclude local overrides)
|
# Env files (keep .env for build; exclude local overrides)
|
||||||
|
|||||||
@@ -19,5 +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_GLITCHTIP_API_KEY=
|
VITE_GLITCHTIP_API_KEY=
|
||||||
GLITCHTIP_AUTH_TOKEN=
|
GLITCHTIP_AUTH_TOKEN=
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
src/assets
|
src/assets
|
||||||
|
.claude
|
||||||
target
|
target
|
||||||
build
|
build
|
||||||
.idea
|
.idea
|
||||||
@@ -13,4 +14,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
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
name: Docker
|
name: Container Image Build and Publish
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: gitea.coracle.social
|
||||||
IMAGE_NAME: coracle-social/flotilla
|
IMAGE_NAME: coracle/flotilla
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push-image:
|
build-and-push-image:
|
||||||
@@ -23,8 +28,8 @@ jobs:
|
|||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
username: hodlbod
|
||||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
password: ${{ secrets.PACKAGE_TOKEN }}
|
||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
id: meta
|
id: meta
|
||||||
@@ -32,6 +37,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
|
type=sha
|
||||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
|
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@@ -45,6 +51,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
target: production
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Env
|
# Env
|
||||||
.env
|
|
||||||
.env.local
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
@@ -27,11 +27,9 @@ android/app/src/main/assets/public/
|
|||||||
node_modules/
|
node_modules/
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
build/
|
build/
|
||||||
|
build-server/
|
||||||
.svelte-kit/
|
.svelte-kit/
|
||||||
|
.next/
|
||||||
# Rust/Tauri
|
|
||||||
*target/
|
|
||||||
src-tauri/binaries/
|
|
||||||
|
|
||||||
# iOS
|
# iOS
|
||||||
ios/App/App/public
|
ios/App/App/public
|
||||||
@@ -69,7 +67,9 @@ GoogleService-Info.plist
|
|||||||
.roo
|
.roo
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.claude/
|
||||||
|
|
||||||
# OS generated
|
# OS generated
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
package-lock.json
|
||||||
|
|||||||
@@ -1,5 +1,62 @@
|
|||||||
# 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
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Flotilla is a Nostr "relays as groups" community chat client. It implements NIP-29 (relay-based groups) to create Discord-like spaces (servers) and rooms (channels).
|
||||||
|
|
||||||
|
Please visit our [issue tracker](https://gitea.coracle.social/coracle/flotilla/issues) to contribute. Any new issues should be opened without a milestone, label, or project and the project owners will triage them.
|
||||||
|
|
||||||
|
### Milestones
|
||||||
|
|
||||||
|
Milestones indicate how soon a given task should be tackled.
|
||||||
|
|
||||||
|
- [Current](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=1) issues are immediately actionable.
|
||||||
|
- [Next](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=2) means an issue is blocked.
|
||||||
|
- [Future](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=3) means we're deferring work until a later date.
|
||||||
|
|
||||||
|
### Labels
|
||||||
|
|
||||||
|
- [Design](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=15) issues need design work before being implemented. This might take the form of a high-quality mockup, wireframes, user flows, or just a couple notes about where things go, depending on the nature of the task.
|
||||||
|
- [Dev](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=16) issues are ready to be implemented. Most of the work will be related to architecting and writing code.
|
||||||
|
- [Easy](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=14) issues have no dependencies, and are scoped quite narrowly.
|
||||||
|
- [Priority](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=6) issues include bugs and urgent feature requests. These should get attention first if possible, although sometimes long-standing performance issues or subtle bugs might end up here for a while.
|
||||||
|
- [Ideas](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=13) are for things that aren't scoped out yet, or which need protocol work before getting designed or implemented.
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
|
||||||
|
Issues may or may not have a project. Projects are used to group issues thematically just for organization.
|
||||||
|
|
||||||
|
## Coding conventions
|
||||||
|
|
||||||
|
There are a few conventions that are helpful to know right out of the gate.
|
||||||
|
|
||||||
|
- Most nostr protocol functionality is implemented via the [welshman library](https://welshman.coracle.social/)
|
||||||
|
- Use Svelte 4 **stores** rather than runes for all state outside UI components
|
||||||
|
- Most global state flows through Welshman's `repository` (unidirectional)
|
||||||
|
- Query state using `deriveEventsMapped` or `deriveProfile` etc
|
||||||
|
- Events are published via `publishThunk`, which allows for optimistic UI updates during signing/pow generation.
|
||||||
|
- Components should have minimal props - e.g. instead of passing a whole `relay` through, pass its `url`.
|
||||||
|
- Use `AbortController` when possible instead of request ids
|
||||||
|
- Use `undefined` or optional properties instead of `null`
|
||||||
|
- Do not use `any`. If there are type errors related to `unknown`, they are likely because the upstream definition of the data is incorrect.
|
||||||
|
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
|
||||||
|
- When dynamically building classes, use `cx` from `classnames`.
|
||||||
|
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates.
|
||||||
|
|
||||||
|
## Contributing Workflow
|
||||||
|
|
||||||
|
To contribute, do the following:
|
||||||
|
|
||||||
|
- Find or create an issue and assign yourself (comment instead if you're not able to self-assign)
|
||||||
|
- If the issue is a design task, attach or link out to any mockups/wireframes/flowcharts
|
||||||
|
- Once a design task is completed, a maintainer will remove the `design` label and add the `dev` label
|
||||||
|
- If the issue is a development task, fork the repository and create a branch prefixed by the issue number, e.g. `105-deep-links`
|
||||||
|
- Before requesting a review, be sure to review any agent-generated code, run the pre-commit hooks, and test the changes.
|
||||||
|
- Open a PR and request a review. A maintainer will get back to you with requested changes, or will merge the PR.
|
||||||
|
- Keep your PR up-to-date by rebasing frequently on `dev`. Avoid force-pushing to `dev`.
|
||||||
|
- PRs are rebased, squashed, and merged to keep commit history simple.
|
||||||
|
- An issue may have multiple PRs. Once complete, it can be closed.
|
||||||
@@ -1,32 +1,31 @@
|
|||||||
# Stage 1: Build
|
# Build and run the Flotilla web server.
|
||||||
# Uses .env from build context for config (logo, branding, etc.)
|
#
|
||||||
# Optional: docker build --build-arg VITE_BUILD_HASH=$(git rev-parse --short HEAD) -t flotilla .
|
# docker build -t flotilla .
|
||||||
|
# docker run -p 3000:3000 flotilla
|
||||||
|
#
|
||||||
|
# Pass --build-arg VITE_BUILD_HASH=$(git rev-parse --short HEAD) to stamp the build.
|
||||||
|
# A .env in the build context is picked up by build.sh for branding config.
|
||||||
|
|
||||||
FROM node:20-bookworm AS builder
|
# https://pnpm.io/docker#example-3-build-on-cicd
|
||||||
|
FROM node:24-slim AS builder
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl
|
ENV PNPM_HOME="/pnpm"
|
||||||
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN npm install -g pnpm@latest
|
RUN corepack enable
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
|
||||||
RUN pnpm i
|
|
||||||
|
|
||||||
# Copy everything (including .env when present) - build.sh will source it
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
ARG VITE_BUILD_HASH
|
|
||||||
ENV VITE_BUILD_HASH=${VITE_BUILD_HASH}
|
|
||||||
|
|
||||||
ENV NODE_OPTIONS=--max_old_space_size=16384
|
ENV NODE_OPTIONS=--max_old_space_size=16384
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm i --frozen-lockfile
|
||||||
|
COPY . .
|
||||||
|
ARG VITE_BUILD_HASH
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
RUN pnpm run build:server
|
||||||
|
|
||||||
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 only the built output - no source, no .env, no dev deps
|
COPY --from=builder /app/build-server/server.js /app/server.js
|
||||||
COPY --from=builder /app/build ./build
|
EXPOSE 3000
|
||||||
|
USER node
|
||||||
CMD ["npx", "serve", "-s", "build"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ You can also optionally create an `.env.local` file and populate it with the fol
|
|||||||
- `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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer.
|
If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
See [CONTRIBUTING.md](AGENTS.md).
|
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
@@ -29,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
|
||||||
npx serve -s build
|
pnpm run start
|
||||||
```
|
```
|
||||||
|
|
||||||
Or, if you prefer to use a container:
|
Or, if you prefer to use a container:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
podman run -d -p 3000:3000 ghcr.io/coracle-social/flotilla:latest
|
docker run -d -p 3000:3000 gitea.coracle.social/coracle/flotilla:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Alternatively, you can copy the build files into a directory of your choice and serve it yourself:
|
Alternatively, you can copy the build files into a directory of your choice and serve it yourself:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
mkdir ./mount
|
mkdir ./mount
|
||||||
podman run -v ./mount:/app/mount ghcr.io/coracle-social/flotilla:latest bash -c 'cp -r build/* mount'
|
docker run -v ./mount:/app/mount gitea.coracle.social/coracle/flotilla:latest bash -c 'cp -r build/* mount'
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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 44
|
versionCode 47
|
||||||
versionName "1.7.2"
|
versionName "1.8.0"
|
||||||
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,10 +12,12 @@ 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')
|
||||||
|
|||||||
@@ -44,4 +44,7 @@
|
|||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
|
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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
|
||||||
@@ -76,6 +77,7 @@ 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 = 20L
|
private const val SOCKET_TIMEOUT_SECONDS = 30L
|
||||||
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,6 +72,8 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -88,7 +90,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>()
|
||||||
var latestPair: Pair<String, JSONObject>? = null
|
val newEvents = mutableListOf<Pair<String, JSONObject>>()
|
||||||
|
|
||||||
for (sub in subscriptions) {
|
for (sub in subscriptions) {
|
||||||
val cursorKey = CURSOR_PREFIX + sub.relay + ":" + sub.key
|
val cursorKey = CURSOR_PREFIX + sub.relay + ":" + sub.key
|
||||||
@@ -102,23 +104,19 @@ 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)) {
|
||||||
val createdAt = event.optLong("created_at", 0L)
|
newEvents.add(Pair(sub.relay, event))
|
||||||
if (latestPair == null || createdAt > latestPair!!.second.optLong("created_at", 0L)) {
|
|
||||||
latestPair = Pair(sub.relay, event)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (latestPair != null) {
|
for ((relay, event) in newEvents) {
|
||||||
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.success()
|
return Result.retry()
|
||||||
} finally {
|
} finally {
|
||||||
pool.closeAll()
|
pool.closeAll()
|
||||||
client.dispatcher.executorService.shutdown()
|
client.dispatcher.executorService.shutdown()
|
||||||
@@ -214,7 +212,8 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
|
|||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(pendingIntent)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
NotificationManagerCompat.from(context).notify(1, notification)
|
val notificationId = id.hashCode().let { if (it == 0) 1 else it }
|
||||||
|
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun matchesFilter(filter: JSONObject, event: JSONObject): Boolean {
|
private fun matchesFilter(filter: JSONObject, event: JSONObject): Boolean {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ 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')
|
||||||
|
|
||||||
@@ -23,6 +26,9 @@ 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')
|
||||||
|
|
||||||
@@ -30,4 +36,4 @@ include ':capawesome-capacitor-badge'
|
|||||||
project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge/android')
|
project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge/android')
|
||||||
|
|
||||||
include ':nostr-signer-capacitor-plugin'
|
include ':nostr-signer-capacitor-plugin'
|
||||||
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_2704ecccfd05fcfb1ad8852744422b7c/node_modules/nostr-signer-capacitor-plugin/android')
|
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_a3c0fb15d5bfa83f24d0070ca2583fc9/node_modules/nostr-signer-capacitor-plugin/android')
|
||||||
|
|||||||
@@ -2,11 +2,8 @@
|
|||||||
|
|
||||||
temp_env=$(declare -p -x)
|
temp_env=$(declare -p -x)
|
||||||
|
|
||||||
if [ -f .env.template ]; then
|
if [ -f .env ]; then
|
||||||
source .env.template
|
source .env
|
||||||
fi
|
|
||||||
if [ -f .env.local ]; then
|
|
||||||
source .env.local
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Avoid overwriting env vars provided directly
|
# Avoid overwriting env vars provided directly
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 48;
|
objectVersion = 54;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@@ -131,8 +131,9 @@
|
|||||||
504EC2FC1FED79650016851F /* Project object */ = {
|
504EC2FC1FED79650016851F /* Project object */ = {
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = YES;
|
||||||
LastSwiftUpdateCheck = 920;
|
LastSwiftUpdateCheck = 920;
|
||||||
LastUpgradeCheck = 920;
|
LastUpgradeCheck = 2630;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
504EC3031FED79650016851F = {
|
504EC3031FED79650016851F = {
|
||||||
CreatedOnToolsVersion = 9.2;
|
CreatedOnToolsVersion = 9.2;
|
||||||
@@ -257,6 +258,7 @@
|
|||||||
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;
|
||||||
@@ -264,8 +266,10 @@
|
|||||||
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;
|
||||||
@@ -275,8 +279,10 @@
|
|||||||
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;
|
||||||
@@ -295,6 +301,7 @@
|
|||||||
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";
|
||||||
};
|
};
|
||||||
@@ -314,6 +321,7 @@
|
|||||||
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;
|
||||||
@@ -321,8 +329,10 @@
|
|||||||
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;
|
||||||
@@ -332,8 +342,10 @@
|
|||||||
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;
|
||||||
@@ -345,7 +357,9 @@
|
|||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
@@ -358,14 +372,16 @@
|
|||||||
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 = 35;
|
CURRENT_PROJECT_VERSION = 38;
|
||||||
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 = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
MARKETING_VERSION = 1.7.2;
|
"$(inherited)",
|
||||||
|
"@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)";
|
||||||
@@ -385,14 +401,16 @@
|
|||||||
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 = 35;
|
CURRENT_PROJECT_VERSION = 38;
|
||||||
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 = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
MARKETING_VERSION = 1.7.5;
|
"$(inherited)",
|
||||||
|
"@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 = "";
|
||||||
|
|||||||
@@ -24,8 +24,10 @@
|
|||||||
<false/>
|
<false/>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Flotilla uses the camera when you enable it in a voice room.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>Flotilla uses the microphone for voice chat in rooms.</string>
|
<string>Flotilla uses the microphone when you enable it in a voice room.</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ 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_2704ecccfd05fcfb1ad8852744422b7c/node_modules/nostr-signer-capacitor-plugin'
|
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_a3c0fb15d5bfa83f24d0070ca2583fc9/node_modules/nostr-signer-capacitor-plugin'
|
||||||
end
|
end
|
||||||
|
|
||||||
target 'Flotilla Chat' do
|
target 'Flotilla Chat' do
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "flotilla",
|
"name": "flotilla",
|
||||||
"version": "1.7.2",
|
"version": "1.8.0",
|
||||||
"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",
|
||||||
@@ -20,9 +18,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/assets": "^3.0.5",
|
"@capacitor/assets": "^3.0.5",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"@sveltejs/kit": "^2.50.1",
|
"@sveltejs/kit": "^2.61.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
||||||
"@tauri-apps/cli": "^2.9.6",
|
"@tailwindcss/postcss": "^4.2.2",
|
||||||
"@types/eslint": "^9.6.1",
|
"@types/eslint": "^9.6.1",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
@@ -30,15 +28,15 @@
|
|||||||
"eslint-config-prettier": "^9.1.2",
|
"eslint-config-prettier": "^9.1.2",
|
||||||
"eslint-plugin-svelte": "^2.46.1",
|
"eslint-plugin-svelte": "^2.46.1",
|
||||||
"globals": "^15.15.0",
|
"globals": "^15.15.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.15",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"prettier-plugin-svelte": "^3.4.1",
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
"svelte": "^5.48.0",
|
"svelte": "^5.55.9",
|
||||||
"svelte-check": "^4.3.5",
|
"svelte-check": "^4.3.5",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^4.2.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.53.1",
|
"typescript-eslint": "^8.53.1",
|
||||||
"vite": "^5.4.21"
|
"vite": "^6.4.2"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -47,47 +45,54 @@
|
|||||||
"@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.2",
|
"@pomade/core": "^0.2.3",
|
||||||
"@poppanator/sveltekit-svg": "^4.2.1",
|
"@poppanator/sveltekit-svg": "^7.0.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@tiptap/core": "^2.27.2",
|
"@tiptap/core": "^2.27.2",
|
||||||
|
"@tiptap/pm": "^2.27.2",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/throttle-debounce": "^5.0.2",
|
"@types/throttle-debounce": "^5.0.2",
|
||||||
"@vite-pwa/assets-generator": "^0.2.6",
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
"@vite-pwa/sveltekit": "^0.6.8",
|
"@vite-pwa/sveltekit": "^1.1.0",
|
||||||
"@welshman/app": "^0.8.12",
|
"@welshman/app": "^0.8.15",
|
||||||
"@welshman/content": "^0.8.12",
|
"@welshman/content": "^0.8.15",
|
||||||
"@welshman/editor": "^0.8.12",
|
"@welshman/editor": "^0.8.15",
|
||||||
"@welshman/feeds": "^0.8.12",
|
"@welshman/feeds": "^0.8.15",
|
||||||
"@welshman/lib": "^0.8.12",
|
"@welshman/lib": "^0.8.15",
|
||||||
"@welshman/net": "^0.8.12",
|
"@welshman/net": "^0.8.15",
|
||||||
"@welshman/router": "^0.8.12",
|
"@welshman/router": "^0.8.15",
|
||||||
"@welshman/signer": "^0.8.12",
|
"@welshman/signer": "^0.8.15",
|
||||||
"@welshman/store": "^0.8.12",
|
"@welshman/store": "^0.8.15",
|
||||||
"@welshman/util": "^0.8.12",
|
"@welshman/util": "^0.8.15",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
"compressorjs-next": "^1.1.2",
|
"compressorjs-next": "^1.1.2",
|
||||||
"daisyui": "^4.12.24",
|
"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",
|
||||||
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
|
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
|
||||||
"nostr-tools": "^2.19.4",
|
"nostr-tools": "^2.19.4",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"qr-scanner": "^1.4.2",
|
"qr-scanner": "^1.4.2",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"throttle-debounce": "^5.0.2",
|
"throttle-debounce": "^5.0.2",
|
||||||
@@ -104,5 +109,6 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"sharp": "0.35.0-rc.0"
|
"sharp": "0.35.0-rc.0"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
"@tailwindcss/postcss": {},
|
||||||
autoprefixer: {},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import dotenv from "dotenv"
|
|||||||
import {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config"
|
import {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config"
|
||||||
|
|
||||||
dotenv.config({path: ".env.local"})
|
dotenv.config({path: ".env.local"})
|
||||||
dotenv.config({path: ".env.template"})
|
dotenv.config({path: ".env"})
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
preset,
|
preset,
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
[toolchain]
|
|
||||||
channel = "1.92.0"
|
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
// If the path has an extension, it's likely a missing static asset, not an SPA route
|
||||||
|
if (path.extname(requestUrl.pathname)) {
|
||||||
|
return context.text("Not found", 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = await getMetadataForRoute(requestUrl)
|
||||||
|
const html = metadata ? injectMeta(metadata) : TEMPLATE_HTML
|
||||||
|
|
||||||
|
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}`)
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "flotilla"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
name = "flotilla_lib"
|
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
tauri-build = { version = "2.5.3", features = [] }
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
tauri = { version = "2.9.5", features = [] }
|
|
||||||
|
|
||||||
[features]
|
|
||||||
default = ["custom-protocol"]
|
|
||||||
custom-protocol = ["tauri/custom-protocol"]
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
fn main() {
|
|
||||||
tauri_build::build()
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
|
||||||
"identifier": "default",
|
|
||||||
"description": "Default desktop capability for the main window",
|
|
||||||
"windows": ["main"],
|
|
||||||
"permissions": ["core:default"]
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
|
||||||
</adaptive-icon>
|
|
||||||
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="ic_launcher_background">#fff</color>
|
|
||||||
</resources>
|
|
||||||
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 668 B |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 926 B |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
@@ -1,2 +0,0 @@
|
|||||||
[toolchain]
|
|
||||||
channel = "1.92.0"
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
|
||||||
pub fn run() {
|
|
||||||
tauri::Builder::default()
|
|
||||||
.run(tauri::generate_context!())
|
|
||||||
.expect("error while running tauri application");
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
flotilla_lib::run();
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
|
||||||
"productName": "Flotilla",
|
|
||||||
"mainBinaryName": "flotilla",
|
|
||||||
"identifier": "social.flotilla.app",
|
|
||||||
"build": {
|
|
||||||
"beforeDevCommand": "pnpm dev",
|
|
||||||
"beforeBuildCommand": "pnpm build",
|
|
||||||
"devUrl": "http://localhost:1847",
|
|
||||||
"frontendDist": "../build"
|
|
||||||
},
|
|
||||||
"app": {
|
|
||||||
"security": {
|
|
||||||
"capabilities": ["default"]
|
|
||||||
},
|
|
||||||
"windows": [
|
|
||||||
{
|
|
||||||
"label": "main",
|
|
||||||
"title": "Flotilla",
|
|
||||||
"width": 1240,
|
|
||||||
"height": 775,
|
|
||||||
"resizable": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"bundle": {
|
|
||||||
"active": false,
|
|
||||||
"targets": "all",
|
|
||||||
"icon": [
|
|
||||||
"icons/32x32.png",
|
|
||||||
"icons/128x128.png",
|
|
||||||
"icons/128x128@2x.png",
|
|
||||||
"icons/icon.icns",
|
|
||||||
"icons/icon.ico"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +1,6 @@
|
|||||||
@import "@welshman/editor/index.css";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@tailwind base;
|
@config "../tailwind.config.js";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
/* Fonts */
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Satoshis";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
src:
|
|
||||||
local(""),
|
|
||||||
url("/fonts/Satoshi Symbol.ttf") format("truetype");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Lato";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
src:
|
|
||||||
local(""),
|
|
||||||
url("/fonts/Lato-Regular.ttf") format("truetype");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Lato";
|
|
||||||
font-style: bold;
|
|
||||||
font-weight: 600;
|
|
||||||
src:
|
|
||||||
local(""),
|
|
||||||
url("/fonts/Lato-Bold.ttf") format("truetype");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Lato";
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 400;
|
|
||||||
src:
|
|
||||||
local(""),
|
|
||||||
url("/fonts/Italic.ttf") format("truetype");
|
|
||||||
}
|
|
||||||
|
|
||||||
/* root */
|
/* root */
|
||||||
|
|
||||||
@@ -52,98 +12,245 @@
|
|||||||
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
|
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme] {
|
@utility pt-sai {
|
||||||
@apply bg-base-300;
|
padding-top: var(--sait);
|
||||||
--base-100: oklch(var(--b1));
|
|
||||||
--base-200: oklch(var(--b2));
|
|
||||||
--base-300: oklch(var(--b3));
|
|
||||||
--base-content: oklch(var(--bc));
|
|
||||||
--primary: oklch(var(--p));
|
|
||||||
--primary-content: oklch(var(--pc));
|
|
||||||
--secondary: oklch(var(--s));
|
|
||||||
--secondary-content: oklch(var(--sc));
|
|
||||||
--neutral: oklch(var(--n));
|
|
||||||
--neutral-content: oklch(var(--nc));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile [data-tip]::before {
|
@utility pr-sai {
|
||||||
display: none !important;
|
padding-right: var(--sair);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* safe area insets */
|
@utility pb-sai {
|
||||||
|
padding-bottom: var(--saib);
|
||||||
|
}
|
||||||
|
|
||||||
@layer components {
|
@utility pl-sai {
|
||||||
.pt-sai {
|
padding-left: var(--sail);
|
||||||
padding-top: var(--sait);
|
}
|
||||||
|
|
||||||
|
@utility px-sai {
|
||||||
|
@apply pl-sai pr-sai;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility py-sai {
|
||||||
|
@apply pt-sai pb-sai;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility p-sai {
|
||||||
|
@apply py-sai px-sai;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility mt-sai {
|
||||||
|
margin-top: var(--sait);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility mr-sai {
|
||||||
|
margin-right: var(--sair);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility mb-sai {
|
||||||
|
margin-bottom: var(--saib);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility ml-sai {
|
||||||
|
margin-left: var(--sail);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility mx-sai {
|
||||||
|
@apply ml-sai mr-sai;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility my-sai {
|
||||||
|
@apply mt-sai mb-sai;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility m-sai {
|
||||||
|
@apply my-sai mx-sai;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility top-sai {
|
||||||
|
top: var(--sait);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility right-sai {
|
||||||
|
right: var(--sair);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility bottom-sai {
|
||||||
|
bottom: var(--saib);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility left-sai {
|
||||||
|
left: var(--sail);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility card2 {
|
||||||
|
@apply rounded-box text-base-content p-4 sm:p-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility column {
|
||||||
|
@apply flex flex-col;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility center {
|
||||||
|
@apply flex items-center justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility row-2 {
|
||||||
|
@apply flex items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility row-3 {
|
||||||
|
@apply flex items-center gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility row-4 {
|
||||||
|
@apply flex items-center gap-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility col-2 {
|
||||||
|
@apply flex flex-col gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility col-3 {
|
||||||
|
@apply flex flex-col gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility col-4 {
|
||||||
|
@apply flex flex-col gap-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility col-8 {
|
||||||
|
@apply flex flex-col gap-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility ellipsize {
|
||||||
|
@apply overflow-hidden text-ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility content-padding-x {
|
||||||
|
@apply px-4 sm:px-8 md:px-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility content-padding-t {
|
||||||
|
@apply pt-4 sm:pt-8 md:pt-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility content-padding-b {
|
||||||
|
@apply pb-4 sm:pb-8 md:pb-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility content-padding-y {
|
||||||
|
@apply pt-4 pb-4 sm:pt-8 sm:pb-8 md:pt-12 md:pb-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility content-sizing {
|
||||||
|
@apply m-auto w-full max-w-3xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility content {
|
||||||
|
@apply m-auto w-full max-w-3xl px-4 pt-4 pb-4 sm:px-8 sm:pt-8 sm:pb-8 md:px-12 md:pt-12 md:pb-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility heading {
|
||||||
|
@apply text-center text-2xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility subheading {
|
||||||
|
@apply text-center text-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility superheading {
|
||||||
|
@apply text-center text-4xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility link {
|
||||||
|
@apply text-primary cursor-pointer underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* content visibility */
|
||||||
|
|
||||||
|
@utility cv {
|
||||||
|
content-visibility: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||||
|
so we've added these compatibility styles to make sure everything still
|
||||||
|
looks the same as it did with Tailwind CSS v3.
|
||||||
|
|
||||||
|
If we ever want to remove these styles, we need to add an explicit border
|
||||||
|
color utility to any element that depends on these defaults.
|
||||||
|
*/
|
||||||
|
@layer base {
|
||||||
|
*,
|
||||||
|
::after,
|
||||||
|
::before,
|
||||||
|
::backdrop,
|
||||||
|
::file-selector-button {
|
||||||
|
border-color: var(--color-gray-200, currentcolor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
/* Fonts */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Satoshis";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src:
|
||||||
|
local(""),
|
||||||
|
url("/fonts/Satoshi Symbol.ttf") format("truetype");
|
||||||
}
|
}
|
||||||
|
|
||||||
.pr-sai {
|
@font-face {
|
||||||
padding-right: var(--sair);
|
font-family: "Lato";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src:
|
||||||
|
local(""),
|
||||||
|
url("/fonts/Lato-Regular.ttf") format("truetype");
|
||||||
}
|
}
|
||||||
|
|
||||||
.pb-sai {
|
@font-face {
|
||||||
padding-bottom: var(--saib);
|
font-family: "Lato";
|
||||||
|
font-style: bold;
|
||||||
|
font-weight: 600;
|
||||||
|
src:
|
||||||
|
local(""),
|
||||||
|
url("/fonts/Lato-Bold.ttf") format("truetype");
|
||||||
}
|
}
|
||||||
|
|
||||||
.pl-sai {
|
@font-face {
|
||||||
padding-left: var(--sail);
|
font-family: "Lato";
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
src:
|
||||||
|
local(""),
|
||||||
|
url("/fonts/Italic.ttf") format("truetype");
|
||||||
}
|
}
|
||||||
|
|
||||||
.px-sai {
|
/* root */
|
||||||
@apply pl-sai pr-sai;
|
|
||||||
|
:root {
|
||||||
|
font-family: Lato;
|
||||||
|
text-size-adjust: 100%;
|
||||||
|
--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));
|
||||||
}
|
}
|
||||||
|
|
||||||
.py-sai {
|
[data-theme] {
|
||||||
@apply pt-sai pb-sai;
|
@apply bg-base-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
.p-sai {
|
.mobile [data-tip]::before {
|
||||||
@apply py-sai px-sai;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mt-sai {
|
/* safe area insets */
|
||||||
margin-top: var(--sait);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mr-sai {
|
|
||||||
margin-right: var(--sair);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mb-sai {
|
|
||||||
margin-bottom: var(--saib);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ml-sai {
|
|
||||||
margin-left: var(--sail);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx-sai {
|
|
||||||
@apply ml-sai mr-sai;
|
|
||||||
}
|
|
||||||
|
|
||||||
.my-sai {
|
|
||||||
@apply mt-sai mb-sai;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-sai {
|
|
||||||
@apply my-sai mx-sai;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-sai {
|
|
||||||
top: var(--sait);
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-sai {
|
|
||||||
right: var(--sair);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom-sai {
|
|
||||||
bottom: var(--saib);
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-sai {
|
|
||||||
left: var(--sail);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* utilities */
|
/* utilities */
|
||||||
@@ -165,110 +272,18 @@
|
|||||||
@apply bg-base-300 text-base-content transition-colors;
|
@apply bg-base-300 text-base-content transition-colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card2 {
|
|
||||||
@apply rounded-box p-4 text-base-content sm:p-6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card2.card2-sm {
|
.card2.card2-sm {
|
||||||
@apply p-2 text-base-content sm:p-4;
|
@apply text-base-content p-2 sm:p-4;
|
||||||
}
|
|
||||||
|
|
||||||
.column {
|
|
||||||
@apply flex flex-col;
|
|
||||||
}
|
|
||||||
|
|
||||||
.center {
|
|
||||||
@apply flex items-center justify-center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-2 {
|
|
||||||
@apply flex items-center gap-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-3 {
|
|
||||||
@apply flex items-center gap-3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-4 {
|
|
||||||
@apply flex items-center gap-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-2 {
|
|
||||||
@apply flex flex-col gap-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-3 {
|
|
||||||
@apply flex flex-col gap-3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-4 {
|
|
||||||
@apply flex flex-col gap-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-8 {
|
|
||||||
@apply flex flex-col gap-8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
@apply justify-start overflow-hidden text-ellipsis whitespace-nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ellipsize {
|
|
||||||
@apply overflow-hidden text-ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-tip]::before {
|
[data-tip]::before {
|
||||||
@apply ellipsize;
|
@apply overflow-hidden text-ellipsis;
|
||||||
}
|
|
||||||
|
|
||||||
.content-padding-x {
|
|
||||||
@apply px-4 sm:px-8 md:px-12;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-padding-t {
|
|
||||||
@apply pt-4 sm:pt-8 md:pt-12;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-padding-b {
|
|
||||||
@apply pb-4 sm:pb-8 md:pb-12;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-padding-y {
|
|
||||||
@apply content-padding-t content-padding-b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-sizing {
|
|
||||||
@apply m-auto w-full max-w-3xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
@apply content-sizing content-padding-x content-padding-y;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heading {
|
|
||||||
@apply text-center text-2xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subheading {
|
|
||||||
@apply text-center text-xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.superheading {
|
|
||||||
@apply text-center text-4xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link {
|
|
||||||
@apply cursor-pointer text-primary underline;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.input input::placeholder {
|
.input input::placeholder {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shadow-top-xl {
|
|
||||||
@apply shadow-[0_20px_25px_-5px_rgb(0,0,0,0.1)_0_8px_10px_-6px_rgb(0,0,0,0.1)];
|
|
||||||
}
|
|
||||||
|
|
||||||
/* tiptap */
|
/* tiptap */
|
||||||
|
|
||||||
.input-editor,
|
.input-editor,
|
||||||
@@ -278,21 +293,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tiptap {
|
.tiptap {
|
||||||
--tiptap-object-bg: var(--neutral);
|
--tiptap-object-bg: var(--color-neutral);
|
||||||
--tiptap-object-fg: var(--neutral-content);
|
--tiptap-object-fg: var(--color-neutral-content);
|
||||||
--tiptap-active-bg: var(--primary);
|
--tiptap-active-bg: var(--color-primary);
|
||||||
--tiptap-active-fg: var(--primary-content);
|
--tiptap-active-fg: var(--color-primary-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-suggestions {
|
.tiptap-suggestions {
|
||||||
--tiptap-object-bg: var(--base-100);
|
--tiptap-object-bg: var(--color-base-100);
|
||||||
--tiptap-object-fg: var(--base-content);
|
--tiptap-object-fg: var(--color-base-content);
|
||||||
--tiptap-active-bg: var(--base-300);
|
--tiptap-active-bg: var(--color-base-300);
|
||||||
--tiptap-active-fg: var(--base-content);
|
--tiptap-active-fg: var(--color-base-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-suggestions__item {
|
.tiptap-suggestions__item {
|
||||||
@apply border-l-2 border-solid border-base-100;
|
@apply border-base-100 border-l-2 border-solid;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-suggestions__selected {
|
.tiptap-suggestions__selected {
|
||||||
@@ -312,13 +327,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.note-editor .tiptap {
|
.note-editor .tiptap {
|
||||||
--tiptap-object-bg: var(--base-200);
|
--tiptap-object-bg: var(--color-base-200);
|
||||||
@apply input input-bordered h-auto min-h-32 rounded-box p-[.65rem] pb-6;
|
@apply input rounded-box block h-auto min-h-32 w-full p-[.65rem] pb-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-editor .tiptap {
|
.input-editor .tiptap {
|
||||||
--tiptap-object-bg: var(--base-200);
|
--tiptap-object-bg: var(--color-base-200);
|
||||||
@apply input input-bordered h-auto p-[.65rem];
|
@apply input block h-auto p-[.65rem];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* link-content, based on tiptap */
|
/* link-content, based on tiptap */
|
||||||
@@ -330,8 +345,8 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
background-color: var(--base-100);
|
background-color: var(--color-base-100);
|
||||||
color: var(--base-content);
|
color: var(--color-base-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* content rendered by welshman/content */
|
/* content rendered by welshman/content */
|
||||||
@@ -347,23 +362,31 @@
|
|||||||
/* date input */
|
/* date input */
|
||||||
|
|
||||||
.picker {
|
.picker {
|
||||||
--date-picker-foreground: var(--base-content);
|
--date-picker-foreground: var(--color-base-content);
|
||||||
--date-picker-background: var(--base-300);
|
--date-picker-background: var(--color-base-300);
|
||||||
--date-picker-highlight-border: var(--primary);
|
--date-picker-highlight-border: var(--color-primary);
|
||||||
--date-picker-selected-color: var(--primary-content);
|
--date-picker-selected-color: var(--color-primary-content);
|
||||||
--date-picker-selected-background: var(--primary);
|
--date-picker-selected-background: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-time-field {
|
.date-time-field {
|
||||||
@apply input input-bordered rounded-lg px-0;
|
@apply input rounded-lg px-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-time-field input {
|
.date-time-field input {
|
||||||
@apply !h-full !w-full !rounded-lg !border-none !bg-inherit !px-4 !text-inherit;
|
@apply h-full! w-full! rounded-lg! border-none! bg-inherit! px-4! text-inherit!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* tippy popover */
|
/* tippy popover */
|
||||||
|
|
||||||
|
.tippy-target {
|
||||||
|
@apply z-tooltip pointer-events-none fixed inset-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tippy-target > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.tippy-box {
|
.tippy-box {
|
||||||
@apply rounded-box shadow-xl;
|
@apply rounded-box shadow-xl;
|
||||||
}
|
}
|
||||||
@@ -371,15 +394,15 @@
|
|||||||
/* emoji picker */
|
/* emoji picker */
|
||||||
|
|
||||||
emoji-picker {
|
emoji-picker {
|
||||||
--background: var(--base-100);
|
--background: var(--color-base-100);
|
||||||
--border-color: var(--base-100);
|
--border-color: var(--color-base-100);
|
||||||
--border-radius: var(--rounded-box);
|
--border-radius: var(--rounded-box);
|
||||||
--button-active-background: var(--base-content);
|
--button-active-background: var(--color-base-content);
|
||||||
--button-hover-background: var(--base-content);
|
--button-hover-background: var(--color-base-content);
|
||||||
--indicator-color: var(--base-content);
|
--indicator-color: var(--color-base-content);
|
||||||
--input-border-color: var(--base-100);
|
--input-border-color: var(--color-base-100);
|
||||||
--input-font-color: var(--base-content);
|
--input-font-color: var(--color-base-content);
|
||||||
--outline-color: var(--base-100);
|
--outline-color: var(--color-base-100);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* progress */
|
/* progress */
|
||||||
@@ -390,52 +413,38 @@ progress[value]::-webkit-progress-value {
|
|||||||
|
|
||||||
/* content width for fixed elements */
|
/* content width for fixed elements */
|
||||||
|
|
||||||
.cw {
|
.left-content {
|
||||||
@apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))];
|
@apply md:left-[calc(18.5rem+var(--sail))];
|
||||||
}
|
}
|
||||||
|
|
||||||
.cw-full {
|
.left-content-full {
|
||||||
@apply w-full md:left-[4rem] md:w-[calc(100%-4rem-var(--sair))];
|
@apply md:left-[calc(3.5rem+var(--sail))];
|
||||||
}
|
|
||||||
|
|
||||||
.cb {
|
|
||||||
@apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)];
|
|
||||||
}
|
|
||||||
|
|
||||||
.ct {
|
|
||||||
@apply top-[calc(var(--sait)+5rem)] md:top-[calc(var(--sait)+3rem)];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keyboard open state adjustments */
|
/* Keyboard open state adjustments */
|
||||||
|
|
||||||
body.keyboard-open .cb {
|
body.keyboard-open {
|
||||||
@apply bottom-sai;
|
--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 cb cw fixed z-compose;
|
@apply z-compose relative mb-14 shrink-0 md:mb-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat__compose-zone {
|
.chat__compose .chat__compose-inner {
|
||||||
@apply cb cw fixed z-compose;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat__compose-zone .chat__compose-inner {
|
|
||||||
@apply min-w-0;
|
@apply min-w-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat__scroll-down {
|
.chat__scroll-down {
|
||||||
@apply pb-sai fixed bottom-28 right-4 z-feature md:bottom-16;
|
@apply pb-sai z-feature fixed right-4 bottom-28 md:bottom-16;
|
||||||
}
|
|
||||||
|
|
||||||
/* content visibility */
|
|
||||||
|
|
||||||
.cv {
|
|
||||||
content-visibility: auto;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,18 @@
|
|||||||
<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 name="og:url" content="{URL}" />
|
<meta property="og:url" content="{URL}" />
|
||||||
<meta name="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta name="og:title" content="{NAME}" />
|
<meta property="og:title" content="{NAME}" />
|
||||||
<meta name="og:description" content="{DESCRIPTION}" />
|
<meta property="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}" />
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import {Room as LiveKitRoom} from "livekit-client"
|
||||||
|
import {derived, writable} from "svelte/store"
|
||||||
|
import {type Room} from "@app/core/state"
|
||||||
|
|
||||||
|
export type VoiceSession = {
|
||||||
|
url: string
|
||||||
|
h: string
|
||||||
|
room: LiveKitRoom
|
||||||
|
cameraOn: boolean
|
||||||
|
screenShareOn: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mic mute state is separate so toggling it does not re-render video tiles. */
|
||||||
|
export const voiceMicMuted = writable(true)
|
||||||
|
|
||||||
|
export type Pubkey = string
|
||||||
|
|
||||||
|
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
|
||||||
|
|
||||||
|
export enum VoiceState {
|
||||||
|
Joining = "joining",
|
||||||
|
Connected = "connected",
|
||||||
|
Disconnected = "disconnected",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
|
||||||
|
|
||||||
|
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
|
||||||
|
|
||||||
|
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
||||||
|
|
||||||
|
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
|
||||||
|
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
|
||||||
|
|
||||||
|
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
|
||||||
|
const pk = pubkeyFromLiveKitIdentity(identity)
|
||||||
|
return pk ? {pubkey: pk, identity} : {identity}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
|
||||||
|
|
||||||
|
export const speakingParticipants = writable<VoiceParticipant[]>([])
|
||||||
|
|
||||||
|
export const participantMediaState = writable(
|
||||||
|
new Map<string, {muted: boolean; cameraOn: boolean}>(),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const mediaStateByIdentity = derived(
|
||||||
|
[participantMediaState, currentVoiceSession, voiceMicMuted],
|
||||||
|
([$media, $session, $micMuted]) =>
|
||||||
|
(identity: string) => {
|
||||||
|
if ($session?.room.localParticipant.identity === identity) {
|
||||||
|
return {muted: $micMuted, cameraOn: $session.cameraOn}
|
||||||
|
}
|
||||||
|
return $media.get(identity) ?? {muted: true, cameraOn: false}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const isParticipantSpeaking = derived(
|
||||||
|
speakingParticipants,
|
||||||
|
$participants => (p: VoiceParticipant) =>
|
||||||
|
$participants.some(sp => participantKey(sp) === participantKey(p)),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const isLocalSpeaking = derived(
|
||||||
|
[currentVoiceSession, speakingParticipants],
|
||||||
|
([$session, $speaking]) => {
|
||||||
|
if (!$session?.room) return false
|
||||||
|
const local = participantFromLiveKitIdentity($session.room.localParticipant.identity)
|
||||||
|
return $speaking.some(sp => participantKey(sp) === participantKey(local))
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import {Track} from "livekit-client"
|
||||||
|
import {MediaQuery} from "svelte/reactivity"
|
||||||
|
import {derived, get, writable} from "svelte/store"
|
||||||
|
import {currentVoiceSession, VoiceState, type VoiceSession, voiceState} from "@app/call/stores"
|
||||||
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
|
export enum VideoCallLayout {
|
||||||
|
Chat = "chat",
|
||||||
|
Video = "video",
|
||||||
|
Split = "split",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isDesktopLayout = new MediaQuery("min-width: 768px", false)
|
||||||
|
|
||||||
|
export enum ViewportSize {
|
||||||
|
Desktop = "desktop",
|
||||||
|
Mobile = "mobile",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoCallViewportSync = {
|
||||||
|
previousLayout: undefined as ViewportSize | undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
|
||||||
|
|
||||||
|
export const resetVideoCallLayout = () => {
|
||||||
|
videoCallViewportSync.previousLayout = undefined
|
||||||
|
videoCallLayout.set(VideoCallLayout.Chat)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
|
||||||
|
|
||||||
|
export const toggleVideoPrimaryTile = (key: string) => {
|
||||||
|
videoPrimaryTileKey.update(k => (k === key ? undefined : key))
|
||||||
|
}
|
||||||
|
|
||||||
|
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
|
||||||
|
|
||||||
|
const countLiveVisualFeeds = (session: VoiceSession): number => {
|
||||||
|
const room = session.room
|
||||||
|
let n = 0
|
||||||
|
const lp = room.localParticipant
|
||||||
|
if (session.cameraOn) {
|
||||||
|
const pub = lp.getTrackPublication(Track.Source.Camera)
|
||||||
|
if (pub?.track) n += 1
|
||||||
|
}
|
||||||
|
if (session.screenShareOn) {
|
||||||
|
const pub = lp.getTrackPublication(Track.Source.ScreenShare)
|
||||||
|
if (pub?.track) n += 1
|
||||||
|
}
|
||||||
|
for (const rp of room.remoteParticipants.values()) {
|
||||||
|
for (const source of VISUAL_SOURCES) {
|
||||||
|
const pub = rp.getTrackPublication(source)
|
||||||
|
if (pub?.isSubscribed && pub.track) n += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
export const triggerVideoFeedCount = () => {
|
||||||
|
currentVoiceSession.update(s => (s ? {...s} : s))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoTileCount = derived([currentVoiceSession, voiceState], ([$session, $state]) => {
|
||||||
|
if ($state !== VoiceState.Connected || !$session) return 0
|
||||||
|
return countLiveVisualFeeds($session)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const toggleCamera = async () => {
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
const cameraOn = !session.cameraOn
|
||||||
|
try {
|
||||||
|
await session.room.localParticipant.setCameraEnabled(cameraOn)
|
||||||
|
currentVoiceSession.set({...session, cameraOn})
|
||||||
|
} catch {
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: cameraOn ? "Could not access camera" : "Could not turn off camera",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toggleScreenShare = async () => {
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
const screenShareOn = !session.screenShareOn
|
||||||
|
try {
|
||||||
|
await session.room.localParticipant.setScreenShareEnabled(screenShareOn)
|
||||||
|
currentVoiceSession.set({...session, screenShareOn})
|
||||||
|
} catch {
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: screenShareOn ? "Could not start screen sharing" : "Could not stop screen sharing",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,85 +4,98 @@
|
|||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
DisconnectReason,
|
DisconnectReason,
|
||||||
|
LocalParticipant,
|
||||||
|
LocalTrackPublication,
|
||||||
|
Participant,
|
||||||
Room as LiveKitRoom,
|
Room as LiveKitRoom,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
Track,
|
Track,
|
||||||
|
TrackPublication,
|
||||||
|
supportsAudioOutputSelection,
|
||||||
type AudioCaptureOptions,
|
type AudioCaptureOptions,
|
||||||
type LocalParticipant,
|
|
||||||
} from "livekit-client"
|
} from "livekit-client"
|
||||||
import {derived, get, writable} from "svelte/store"
|
import {derived, get} from "svelte/store"
|
||||||
import {map, removeUndefined, uniqBy} from "@welshman/lib"
|
import {map, not, nthEq, reject, removeUndefined, uniqBy} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
|
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
|
||||||
import {signer} from "@welshman/app"
|
import {signer} from "@welshman/app"
|
||||||
import {getLivekitEndpoint} from "$lib/livekit"
|
import {getLivekitEndpoint} from "$lib/livekit"
|
||||||
import {AbortError, whenAborted, whenTimeout} from "$lib/util"
|
import {AbortError, whenAborted, whenTimeout} from "$lib/util"
|
||||||
import {deriveLatestEventForUrl, deriveRoom, makeRoomId, type Room} from "@app/core/state"
|
import {
|
||||||
|
currentVoiceRoom,
|
||||||
|
currentVoiceSession,
|
||||||
|
voiceMicMuted,
|
||||||
|
participantFromLiveKitIdentity,
|
||||||
|
participantKey,
|
||||||
|
participantMediaState,
|
||||||
|
speakingParticipants,
|
||||||
|
VoiceState,
|
||||||
|
type VoiceParticipant,
|
||||||
|
voiceState,
|
||||||
|
} from "@app/call/stores"
|
||||||
|
import {resetVideoCallLayout, triggerVideoFeedCount, videoPrimaryTileKey} from "@app/call/video"
|
||||||
|
import {deriveLatestEventForUrl, deriveRoom, makeRoomId} from "@app/core/state"
|
||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
export const LIVEKIT_PARTICIPANTS = 39004
|
export const LIVEKIT_PARTICIPANTS = 39004
|
||||||
|
|
||||||
export {checkRelayHasLivekit} from "$lib/livekit"
|
export {checkRelayHasLivekit} from "$lib/livekit"
|
||||||
|
|
||||||
export type VoiceSession = {
|
export {supportsAudioOutputSelection}
|
||||||
url: string
|
|
||||||
h: string
|
const LIVEKIT_DEFAULT_DEVICE_ID = "default"
|
||||||
room: LiveKitRoom
|
|
||||||
muted: boolean
|
export enum DeviceKind {
|
||||||
|
AudioInput = "audioinput",
|
||||||
|
AudioOutput = "audiooutput",
|
||||||
|
VideoInput = "videoinput",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Pubkey = string
|
export const switchVoiceActiveDevice = async (
|
||||||
|
kind: DeviceKind,
|
||||||
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
|
targetDeviceId: string,
|
||||||
|
): Promise<void> => {
|
||||||
export enum VoiceState {
|
const session = get(currentVoiceSession)
|
||||||
Joining = "joining",
|
if (!session) return
|
||||||
Connected = "connected",
|
const id = targetDeviceId === "" ? LIVEKIT_DEFAULT_DEVICE_ID : targetDeviceId
|
||||||
Disconnected = "disconnected",
|
try {
|
||||||
}
|
await session.room.switchActiveDevice(kind, id)
|
||||||
|
} catch {
|
||||||
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
|
let label: string
|
||||||
|
switch (kind) {
|
||||||
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
|
case DeviceKind.AudioInput:
|
||||||
|
label = "microphone"
|
||||||
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
break
|
||||||
|
case DeviceKind.AudioOutput:
|
||||||
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
|
label = "speaker"
|
||||||
|
break
|
||||||
const addParticipant = (identity: string) => {
|
case DeviceKind.VideoInput:
|
||||||
participantPubkeyMap.update(m => {
|
label = "camera"
|
||||||
const next = new Map(m)
|
break
|
||||||
next.set(identity, pubkeyFromLiveKitIdentity(identity) ?? "")
|
}
|
||||||
return next
|
pushToast({theme: "error", message: `Error changing ${label}`})
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteParticipant = (identity: string) => {
|
const deleteParticipant = (identity: string) => {
|
||||||
participantPubkeyMap.update(m => {
|
participantMediaState.update(m => new Map(reject(nthEq(0, identity), [...m])))
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncParticipantMedia = (participant: Participant) => {
|
||||||
|
const state = {muted: !participant.isMicrophoneEnabled, cameraOn: participant.isCameraEnabled}
|
||||||
|
participantMediaState.update(m => {
|
||||||
|
const prev = m.get(participant.identity)
|
||||||
|
if (prev?.muted === state.muted && prev?.cameraOn === state.cameraOn) return m
|
||||||
const next = new Map(m)
|
const next = new Map(m)
|
||||||
next.delete(identity)
|
next.set(participant.identity, state)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
|
const onParticipantMediaChanged = (_publication: TrackPublication, participant: Participant) => {
|
||||||
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
|
syncParticipantMedia(participant)
|
||||||
|
|
||||||
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
|
|
||||||
const pk = pubkeyFromLiveKitIdentity(identity)
|
|
||||||
return pk ? {pubkey: pk, identity} : {identity}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
|
|
||||||
|
|
||||||
export const speakingParticipants = writable<VoiceParticipant[]>([])
|
|
||||||
|
|
||||||
export const isParticipantSpeaking = derived(
|
|
||||||
speakingParticipants,
|
|
||||||
$participants => (p: VoiceParticipant) =>
|
|
||||||
$participants.some(sp => participantKey(sp) === participantKey(p)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const fetchLivekitToken = async (
|
const fetchLivekitToken = async (
|
||||||
url: string,
|
url: string,
|
||||||
groupId: string,
|
groupId: string,
|
||||||
@@ -116,15 +129,15 @@ export const deriveVoiceParticipants = (url: string, h: string) =>
|
|||||||
// We use the livekit identity list while in a call, and fall back to the list in kind 39004.
|
// We use the livekit identity list while in a call, and fall back to the list in kind 39004.
|
||||||
derived(
|
derived(
|
||||||
[
|
[
|
||||||
participantPubkeyMap,
|
participantMediaState,
|
||||||
currentVoiceRoom,
|
currentVoiceRoom,
|
||||||
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
|
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
|
||||||
],
|
],
|
||||||
([$participantPubkeyMap, $currentVoiceRoom, $publishedParticipantList]) => {
|
([$participantMediaState, $currentVoiceRoom, $publishedParticipantList]) => {
|
||||||
const inCall = $participantPubkeyMap.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h)
|
const inCall = $participantMediaState.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h)
|
||||||
|
|
||||||
if (inCall) {
|
if (inCall) {
|
||||||
const participants = [...$participantPubkeyMap.keys()].map(participantFromLiveKitIdentity)
|
const participants = [...$participantMediaState.keys()].map(participantFromLiveKitIdentity)
|
||||||
return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
|
return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
|
||||||
} else {
|
} else {
|
||||||
const latestEvent = $publishedParticipantList as TrustedEvent | undefined
|
const latestEvent = $publishedParticipantList as TrustedEvent | undefined
|
||||||
@@ -164,7 +177,10 @@ const setUpMicrophone = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoomDisconnected = (reason?: DisconnectReason) => {
|
const onRoomDisconnected = (reason?: DisconnectReason) => {
|
||||||
|
videoPrimaryTileKey.set(undefined)
|
||||||
|
voiceMicMuted.set(true)
|
||||||
currentVoiceSession.set(undefined)
|
currentVoiceSession.set(undefined)
|
||||||
|
resetVideoCallLayout()
|
||||||
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
||||||
voiceState.set(VoiceState.Disconnected)
|
voiceState.set(VoiceState.Disconnected)
|
||||||
const message =
|
const message =
|
||||||
@@ -174,7 +190,7 @@ const onRoomDisconnected = (reason?: DisconnectReason) => {
|
|||||||
pushToast({theme: "error", message})
|
pushToast({theme: "error", message})
|
||||||
}
|
}
|
||||||
speakingParticipants.set([])
|
speakingParticipants.set([])
|
||||||
participantPubkeyMap.set(new Map())
|
participantMediaState.set(new Map())
|
||||||
}
|
}
|
||||||
|
|
||||||
const onTrackSubscribed = (track: Track) => {
|
const onTrackSubscribed = (track: Track) => {
|
||||||
@@ -183,11 +199,16 @@ const onTrackSubscribed = (track: Track) => {
|
|||||||
element.style.display = "none"
|
element.style.display = "none"
|
||||||
document.body.appendChild(element)
|
document.body.appendChild(element)
|
||||||
element.play().catch(() => {})
|
element.play().catch(() => {})
|
||||||
|
} else if (track.kind === Track.Kind.Video) {
|
||||||
|
triggerVideoFeedCount()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onTrackUnsubscribed = (track: Track) => {
|
const onTrackUnsubscribed = (track: Track) => {
|
||||||
track.detach().forEach(el => el.remove())
|
track.detach().forEach(el => el.remove())
|
||||||
|
if (track.kind === Track.Kind.Video) {
|
||||||
|
triggerVideoFeedCount()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
|
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
|
||||||
@@ -199,8 +220,8 @@ const playJoinSound = () => {
|
|||||||
audio.play().catch(() => {})
|
audio.play().catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onParticipantConnected = (participant: {identity: string}) => {
|
const onParticipantConnected = (participant: Participant) => {
|
||||||
addParticipant(participant.identity)
|
syncParticipantMedia(participant)
|
||||||
playJoinSound()
|
playJoinSound()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +229,17 @@ const onParticipantDisconnected = (participant: {identity: string}) => {
|
|||||||
deleteParticipant(participant.identity)
|
deleteParticipant(participant.identity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onLocalTrackUnpublished = (
|
||||||
|
publication: LocalTrackPublication,
|
||||||
|
participant: LocalParticipant,
|
||||||
|
) => {
|
||||||
|
if (publication.source !== Track.Source.ScreenShare) return
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
|
if (!session || participant.identity !== session.room.localParticipant.identity) return
|
||||||
|
if (!session.screenShareOn) return
|
||||||
|
currentVoiceSession.set({...session, screenShareOn: false})
|
||||||
|
}
|
||||||
|
|
||||||
let joinAbortController: AbortController | undefined
|
let joinAbortController: AbortController | undefined
|
||||||
|
|
||||||
export const cancelJoinVoiceRoom = () => {
|
export const cancelJoinVoiceRoom = () => {
|
||||||
@@ -245,12 +277,18 @@ export const joinVoiceRoom = async (
|
|||||||
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.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(5_000, {
|
whenTimeout(15_000, {
|
||||||
message: "Connection timed out. Please check your network and try again.",
|
message: "Connection timed out. Please check your network and try again.",
|
||||||
}),
|
}),
|
||||||
whenAborted(signal),
|
whenAborted(signal),
|
||||||
@@ -260,15 +298,22 @@ export const joinVoiceRoom = async (
|
|||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
|
|
||||||
participantPubkeyMap.set(new Map())
|
participantMediaState.set(new Map())
|
||||||
addParticipant(liveKitRoom.localParticipant.identity)
|
syncParticipantMedia(liveKitRoom.localParticipant)
|
||||||
for (const p of liveKitRoom.remoteParticipants.values()) {
|
for (const p of liveKitRoom.remoteParticipants.values()) {
|
||||||
addParticipant(p.identity)
|
syncParticipantMedia(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
|
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
|
||||||
|
|
||||||
currentVoiceSession.set({url, h, room: liveKitRoom, muted})
|
voiceMicMuted.set(muted)
|
||||||
|
currentVoiceSession.set({
|
||||||
|
url,
|
||||||
|
h,
|
||||||
|
room: liveKitRoom,
|
||||||
|
cameraOn: false,
|
||||||
|
screenShareOn: false,
|
||||||
|
})
|
||||||
voiceState.set(VoiceState.Connected)
|
voiceState.set(VoiceState.Connected)
|
||||||
playJoinSound()
|
playJoinSound()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -287,11 +332,30 @@ export const leaveVoiceRoom = async () => {
|
|||||||
const audio = new Audio("/leave-voice-room.mp3")
|
const audio = new Audio("/leave-voice-room.mp3")
|
||||||
audio.play().catch(() => {})
|
audio.play().catch(() => {})
|
||||||
|
|
||||||
|
if (session.cameraOn) {
|
||||||
|
try {
|
||||||
|
await session.room.localParticipant.setCameraEnabled(false)
|
||||||
|
} catch {
|
||||||
|
pushToast({theme: "error", message: "Error turning off camera."})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.screenShareOn) {
|
||||||
|
try {
|
||||||
|
await session.room.localParticipant.setScreenShareEnabled(false)
|
||||||
|
} catch {
|
||||||
|
pushToast({theme: "error", message: "Error turning off screen sharing."})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
voiceState.set(VoiceState.Disconnected)
|
voiceState.set(VoiceState.Disconnected)
|
||||||
|
videoPrimaryTileKey.set(undefined)
|
||||||
|
voiceMicMuted.set(true)
|
||||||
currentVoiceSession.set(undefined)
|
currentVoiceSession.set(undefined)
|
||||||
|
resetVideoCallLayout()
|
||||||
session.room.disconnect()
|
session.room.disconnect()
|
||||||
speakingParticipants.set([])
|
speakingParticipants.set([])
|
||||||
participantPubkeyMap.set(new Map())
|
participantMediaState.set(new Map())
|
||||||
}
|
}
|
||||||
|
|
||||||
export const rejoinVoiceRoom = async (): Promise<void> => {
|
export const rejoinVoiceRoom = async (): Promise<void> => {
|
||||||
@@ -304,18 +368,17 @@ export const toggleMute = async () => {
|
|||||||
const session = get(currentVoiceSession)
|
const session = get(currentVoiceSession)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
||||||
const muted = !session.muted
|
voiceMicMuted.update(not)
|
||||||
if (muted) {
|
if (get(voiceMicMuted)) {
|
||||||
// Disable and re-enable microphone to trigger permission prompt
|
// Disable and re-enable microphone to trigger permission prompt
|
||||||
session.room.localParticipant.setMicrophoneEnabled(false)
|
session.room.localParticipant.setMicrophoneEnabled(false)
|
||||||
currentVoiceSession.set({...session, muted})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await session.room.localParticipant.setMicrophoneEnabled(true)
|
await session.room.localParticipant.setMicrophoneEnabled(true)
|
||||||
currentVoiceSession.set({...session, muted})
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
voiceMicMuted.set(true)
|
||||||
pushToast({theme: "error", message: "Could not access microphone"})
|
pushToast({theme: "error", message: "Could not access microphone"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
<div class="flex grow flex-wrap justify-end gap-2">
|
||||||
{#if h && showRoom}
|
{#if h && showRoom}
|
||||||
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
||||||
Posted in #<RoomName {h} {url} />
|
Posted in #<RoomName {h} {url} />
|
||||||
|
|||||||
@@ -7,12 +7,13 @@
|
|||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
h?: string
|
h?: string
|
||||||
|
shareToChat?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, h}: Props = $props()
|
const {url, h, shareToChat = false}: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarEventForm {url} {h}>
|
<CalendarEventForm {url} {h} {shareToChat}>
|
||||||
{#snippet header()}
|
{#snippet header()}
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
<ModalTitle>Create an Event</ModalTitle>
|
<ModalTitle>Create an Event</ModalTitle>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import {writable} from "svelte/store"
|
import {writable} from "svelte/store"
|
||||||
import {randomId, HOUR} from "@welshman/lib"
|
import {randomId, HOUR} from "@welshman/lib"
|
||||||
import {makeEvent, EVENT_TIME} from "@welshman/util"
|
import {makeEvent, EVENT_TIME} from "@welshman/util"
|
||||||
import {publishThunk} from "@welshman/app"
|
import {publishThunk, waitForThunkError} from "@welshman/app"
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
import {daysBetween} from "@lib/util"
|
import {daysBetween} from "@lib/util"
|
||||||
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
|
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
|
||||||
@@ -20,24 +20,34 @@
|
|||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
import {PROTECTED} from "@app/core/state"
|
import {PROTECTED} from "@app/core/state"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
|
import {DraftKey} from "@app/util/drafts"
|
||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
import {canEnforceNip70} from "@app/core/commands"
|
import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
|
||||||
|
|
||||||
|
type Values = {
|
||||||
|
d: string
|
||||||
|
title: string
|
||||||
|
content: string | object
|
||||||
|
location: string
|
||||||
|
start?: number
|
||||||
|
end?: number
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
h?: string
|
h?: string
|
||||||
|
shareToChat?: boolean
|
||||||
header: Snippet
|
header: Snippet
|
||||||
initialValues?: {
|
initialValues?: Values
|
||||||
d: string
|
|
||||||
title: string
|
|
||||||
content: string
|
|
||||||
location: string
|
|
||||||
start: number
|
|
||||||
end: number
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, h, header, initialValues}: Props = $props()
|
let {url, h, shareToChat = false, header, initialValues}: Props = $props()
|
||||||
|
|
||||||
|
const draftKey = new DraftKey<Values>(`calendar:${url}:${h ?? ""}`)
|
||||||
|
|
||||||
|
if (!initialValues) {
|
||||||
|
initialValues = draftKey.get()
|
||||||
|
}
|
||||||
|
|
||||||
const shouldProtect = canEnforceNip70(url)
|
const shouldProtect = canEnforceNip70(url)
|
||||||
|
|
||||||
@@ -48,7 +58,7 @@
|
|||||||
const selectFiles = () => editor.then(ed => ed.chain().selectFiles().run())
|
const selectFiles = () => editor.then(ed => ed.chain().selectFiles().run())
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if ($uploading) return
|
if ($uploading || loading) return
|
||||||
|
|
||||||
if (!title) {
|
if (!title) {
|
||||||
return pushToast({
|
return pushToast({
|
||||||
@@ -74,38 +84,68 @@
|
|||||||
const ed = await editor
|
const ed = await editor
|
||||||
const content = ed.getText({blockSeparator: "\n"}).trim()
|
const content = ed.getText({blockSeparator: "\n"}).trim()
|
||||||
const tags = [
|
const tags = [
|
||||||
["d", initialValues?.d || randomId()],
|
["d", d],
|
||||||
["title", title],
|
["title", title],
|
||||||
["location", location || ""],
|
["location", location],
|
||||||
["start", start.toString()],
|
["start", start.toString()],
|
||||||
["end", end.toString()],
|
["end", end.toString()],
|
||||||
...daysBetween(start, end).map(D => ["D", D.toString()]),
|
...daysBetween(start, end).map(D => ["D", D.toString()]),
|
||||||
...ed.storage.nostr.getEditorTags(),
|
...ed.storage.nostr.getEditorTags(),
|
||||||
]
|
]
|
||||||
|
|
||||||
if (await shouldProtect) {
|
loading = true
|
||||||
tags.push(PROTECTED)
|
|
||||||
|
try {
|
||||||
|
const protect = await shouldProtect
|
||||||
|
|
||||||
|
if (protect) {
|
||||||
|
tags.push(PROTECTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (h) {
|
||||||
|
tags.push(["h", h])
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = makeEvent(EVENT_TIME, {content, tags})
|
||||||
|
const calendarThunk = publishThunk({event, relays: [url]})
|
||||||
|
const error = await waitForThunkError(calendarThunk)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return pushToast({theme: "error", message: error})
|
||||||
|
}
|
||||||
|
|
||||||
|
draftKey.clear()
|
||||||
|
history.back()
|
||||||
|
|
||||||
|
if (shareToChat) {
|
||||||
|
publishRoomQuote({url, h, parent: calendarThunk.event, protect})
|
||||||
|
}
|
||||||
|
|
||||||
|
pushToast({message: "Your event has been saved!"})
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (h) {
|
|
||||||
tags.push(["h", h])
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = makeEvent(EVENT_TIME, {content, tags})
|
|
||||||
|
|
||||||
pushToast({message: "Your event has been saved!"})
|
|
||||||
publishThunk({event, relays: [url]})
|
|
||||||
history.back()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = initialValues?.content || ""
|
let loading = $state(false)
|
||||||
const editor = makeEditor({url, submit, uploading, content})
|
|
||||||
|
|
||||||
let title = $state(initialValues?.title || "")
|
const d = $state(initialValues?.d ?? randomId())
|
||||||
let location = $state(initialValues?.location || "")
|
let title = $state(initialValues?.title ?? "")
|
||||||
|
let location = $state(initialValues?.location ?? "")
|
||||||
let start: number | undefined = $state(initialValues?.start)
|
let start: number | undefined = $state(initialValues?.start)
|
||||||
let end: number | undefined = $state(initialValues?.end)
|
let end: number | undefined = $state(initialValues?.end)
|
||||||
let endDirty = Boolean(initialValues?.end)
|
let endDirty = $state(Boolean(initialValues?.end))
|
||||||
|
let content = $state(initialValues?.content ?? "")
|
||||||
|
|
||||||
|
const onChange = (json: object) => {
|
||||||
|
content = json
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = makeEditor({url, submit, uploading, onChange, content})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
draftKey.set({d, title, location, start, end, content})
|
||||||
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!endDirty && start) {
|
if (!endDirty && start) {
|
||||||
@@ -136,10 +176,14 @@
|
|||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<div
|
<div
|
||||||
class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
|
class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
|
||||||
<div class="input-editor flex-grow overflow-hidden">
|
<div class="input-editor grow overflow-hidden">
|
||||||
<EditorContent {editor} />
|
<EditorContent {editor} />
|
||||||
</div>
|
</div>
|
||||||
<Button data-tip="Add an image" class="center btn tooltip" onclick={selectFiles}>
|
<Button
|
||||||
|
data-tip="Add an image"
|
||||||
|
class="center btn tooltip"
|
||||||
|
onclick={selectFiles}
|
||||||
|
disabled={loading}>
|
||||||
{#if $uploading}
|
{#if $uploading}
|
||||||
<span class="loading loading-spinner loading-xs"></span>
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -178,12 +222,12 @@
|
|||||||
</Field>
|
</Field>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button class="btn btn-link" onclick={back}>
|
<Button class="btn btn-link" onclick={back} disabled={loading}>
|
||||||
<Icon icon={AltArrowLeft} />
|
<Icon icon={AltArrowLeft} />
|
||||||
Go back
|
Go back
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" class="btn btn-primary" disabled={$uploading}>
|
<Button type="submit" class="btn btn-primary" disabled={$uploading || loading}>
|
||||||
<Spinner loading={$uploading}>Save Event</Spinner>
|
<Spinner loading={$uploading || loading}>Save Event</Spinner>
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -19,15 +19,17 @@
|
|||||||
const end = $derived(parseInt(meta.end))
|
const end = $derived(parseInt(meta.end))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-grow flex-wrap justify-between gap-2">
|
<div class="flex flex-col justify-between gap-1">
|
||||||
<p class="text-xl">{meta.title || meta.name}</p>
|
<p class="text-lg">{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 items-center gap-2 text-sm">
|
<div class="flex flex-wrap gap-2 text-xs">
|
||||||
<Icon icon={ClockCircle} size={4} />
|
<div class="flex items-center gap-2">
|
||||||
<span class="hidden sm:block">{formatTimestampAsDate(start)}</span>
|
<Icon icon={ClockCircle} size={4} />
|
||||||
|
{formatTimestampAsDate(start)}
|
||||||
|
</div>
|
||||||
{formatTimestampAsTime(start)} — {isSingleDay
|
{formatTimestampAsTime(start)} — {isSingleDay
|
||||||
? formatTimestampAsTime(end)
|
? formatTimestampAsTime(end)
|
||||||
: formatTimestamp(end)}
|
: formatTimestamp(end)}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
{#if meta.location}
|
{#if meta.location}
|
||||||
<span class="flex items-start gap-1">
|
<span class="flex items-start gap-1">
|
||||||
<Icon icon={MapPoint} class="mt-[2px]" size={4} />
|
<Icon icon={MapPoint} class="mt-[2px]" size={4} />
|
||||||
<span class="break-words">{meta.location}</span>
|
<span class="wrap-break-word">{meta.location}</span>
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,8 +53,9 @@
|
|||||||
import ChatComposeEdit from "@app/components/ChatComposeEdit.svelte"
|
import ChatComposeEdit from "@app/components/ChatComposeEdit.svelte"
|
||||||
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
|
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
|
||||||
import ThunkToast from "@app/components/ThunkToast.svelte"
|
import ThunkToast from "@app/components/ThunkToast.svelte"
|
||||||
import {userSettingsValues, deriveChat} from "@app/core/state"
|
import {userSettingsValues, deriveChat, makeChatId} from "@app/core/state"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
|
import {DraftKey} from "@app/util/drafts"
|
||||||
import {makeDelete, prependParent} from "@app/core/commands"
|
import {makeDelete, prependParent} from "@app/core/commands"
|
||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
@@ -65,7 +66,9 @@
|
|||||||
|
|
||||||
const {pubkeys, info}: Props = $props()
|
const {pubkeys, info}: Props = $props()
|
||||||
|
|
||||||
const chat = deriveChat(pubkeys)
|
const chatId = makeChatId(pubkeys)
|
||||||
|
const chat = deriveChat(chatId)
|
||||||
|
const draftKey = new DraftKey<{content?: string | object}>(`dm:${chatId}`)
|
||||||
const others = remove($pubkey!, pubkeys)
|
const others = remove($pubkey!, pubkeys)
|
||||||
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
|
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
|
||||||
|
|
||||||
@@ -196,8 +199,6 @@
|
|||||||
let compose: ChatCompose | undefined = $state()
|
let compose: ChatCompose | undefined = $state()
|
||||||
let parent: TrustedEvent | undefined = $state()
|
let parent: TrustedEvent | undefined = $state()
|
||||||
let eventToEdit: TrustedEvent | undefined = $state()
|
let eventToEdit: TrustedEvent | undefined = $state()
|
||||||
let chatCompose: HTMLElement | undefined = $state()
|
|
||||||
let dynamicPadding: HTMLElement | undefined = $state()
|
|
||||||
|
|
||||||
const elements = $derived.by(() => {
|
const elements = $derived.by(() => {
|
||||||
const elements = []
|
const elements = []
|
||||||
@@ -233,20 +234,6 @@
|
|||||||
for (const pubkey of others) {
|
for (const pubkey of others) {
|
||||||
loadMessagingRelayList(pubkey)
|
loadMessagingRelayList(pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
const observer = new ResizeObserver(() => {
|
|
||||||
if (dynamicPadding && chatCompose) {
|
|
||||||
dynamicPadding.style.minHeight = `${chatCompose.offsetHeight}px`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
observer.observe(chatCompose!)
|
|
||||||
observer.observe(dynamicPadding!)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
observer.unobserve(chatCompose!)
|
|
||||||
observer.unobserve(dynamicPadding!)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -293,8 +280,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</PageBar>
|
</PageBar>
|
||||||
|
|
||||||
<PageContent class="flex flex-col-reverse gap-2 pt-4">
|
<PageContent class="flex flex-col-reverse gap-2 py-4">
|
||||||
<div bind:this={dynamicPadding}></div>
|
|
||||||
{#if missingRelayLists.length > 0}
|
{#if missingRelayLists.length > 0}
|
||||||
<div class="py-12">
|
<div class="py-12">
|
||||||
<div class="card2 col-2 m-auto max-w-md items-center text-center">
|
<div class="card2 col-2 m-auto max-w-md items-center text-center">
|
||||||
@@ -335,9 +321,10 @@
|
|||||||
</Spinner>
|
</Spinner>
|
||||||
{@render info?.()}
|
{@render info?.()}
|
||||||
</p>
|
</p>
|
||||||
|
<div class="h-screen"></div>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|
||||||
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
|
<div class="chat__compose bg-base-200">
|
||||||
<div>
|
<div>
|
||||||
{#if parent}
|
{#if parent}
|
||||||
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
||||||
@@ -352,7 +339,8 @@
|
|||||||
{onSubmit}
|
{onSubmit}
|
||||||
{onEscape}
|
{onEscape}
|
||||||
{onEditPrevious}
|
{onEditPrevious}
|
||||||
content={eventToEdit?.content}
|
initialValues={eventToEdit}
|
||||||
|
draftKey={eventToEdit ? undefined : draftKey}
|
||||||
disabled={Boolean(missingRelayLists.length)} />
|
disabled={Boolean(missingRelayLists.length)} />
|
||||||
{/key}
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,23 +10,40 @@
|
|||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
|
import {type DraftKey} from "@app/util/drafts"
|
||||||
|
|
||||||
|
type Values = {
|
||||||
|
content?: string | object
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
content?: string
|
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
draftKey?: DraftKey<Values>
|
||||||
onEscape?: () => void
|
onEscape?: () => void
|
||||||
onEditPrevious?: () => void
|
onEditPrevious?: () => void
|
||||||
onSubmit: (event: EventContent) => void
|
onSubmit: (event: EventContent) => void
|
||||||
|
initialValues?: Values
|
||||||
}
|
}
|
||||||
|
|
||||||
const {content, disabled = false, onEscape, onEditPrevious, onSubmit}: Props = $props()
|
let {
|
||||||
|
initialValues,
|
||||||
|
disabled = false,
|
||||||
|
draftKey,
|
||||||
|
onEscape,
|
||||||
|
onEditPrevious,
|
||||||
|
onSubmit,
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
if (!initialValues) {
|
||||||
|
initialValues = draftKey?.get()
|
||||||
|
}
|
||||||
|
|
||||||
const autofocus = !isMobile && !disabled
|
const autofocus = !isMobile && !disabled
|
||||||
|
|
||||||
const uploading = writable(false)
|
const uploading = writable(false)
|
||||||
|
|
||||||
const editorClass = $derived(
|
const editorClass = $derived(
|
||||||
cx("chat-editor flex-grow overflow-hidden", {
|
cx("chat-editor grow overflow-hidden", {
|
||||||
"pointer-events-none opacity-50": disabled,
|
"pointer-events-none opacity-50": disabled,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -59,18 +76,29 @@
|
|||||||
|
|
||||||
onSubmit({content, tags})
|
onSubmit({content, tags})
|
||||||
|
|
||||||
|
draftKey?.clear()
|
||||||
ed.chain().clearContent().run()
|
ed.chain().clearContent().run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let content = $state(initialValues?.content ?? "")
|
||||||
|
|
||||||
|
const onChange = (json: object) => {
|
||||||
|
content = json
|
||||||
|
}
|
||||||
|
|
||||||
const editor = makeEditor({
|
const editor = makeEditor({
|
||||||
content,
|
content,
|
||||||
autofocus,
|
|
||||||
submit,
|
submit,
|
||||||
uploading,
|
uploading,
|
||||||
|
onChange,
|
||||||
aggressive: true,
|
aggressive: true,
|
||||||
encryptFiles: true,
|
encryptFiles: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
draftKey?.set({content})
|
||||||
|
})
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const ed = await editor
|
const ed = await editor
|
||||||
ed.view.dom.addEventListener("keydown", handleKeyDown)
|
ed.view.dom.addEventListener("keydown", handleKeyDown)
|
||||||
@@ -95,7 +123,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
<div class={editorClass} aria-disabled={disabled}>
|
<div class={editorClass} aria-disabled={disabled}>
|
||||||
<EditorContent {editor} />
|
<EditorContent {autofocus} {editor} />
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
|
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
<Button class="flex flex-col justify-start gap-1 w-full" onclick={openChat}>
|
<Button class="flex flex-col justify-start gap-1 w-full" onclick={openChat}>
|
||||||
<div
|
<div
|
||||||
class="cursor-pointer border-t border-solid border-base-100 px-6 py-2 transition-colors hover:bg-base-100 {props.class}"
|
class="cursor-pointer border-t border-solid border-base-100 px-3 py-2 transition-colors hover:bg-base-100 {props.class}"
|
||||||
class:bg-base-100={active}>
|
class:bg-base-100={active}>
|
||||||
<div class="flex flex-col justify-start gap-1">
|
<div class="flex flex-col justify-start gap-1">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {assoc} from "@welshman/lib"
|
import {assoc} from "@welshman/lib"
|
||||||
import ChatSquare from "@assets/icons/chat-square.svg?dataurl"
|
|
||||||
import Check from "@assets/icons/check.svg?dataurl"
|
import Check from "@assets/icons/check.svg?dataurl"
|
||||||
import Bell from "@assets/icons/bell.svg?dataurl"
|
import Bell from "@assets/icons/bell.svg?dataurl"
|
||||||
import BellOff from "@assets/icons/bell-off.svg?dataurl"
|
import BellOff from "@assets/icons/bell-off.svg?dataurl"
|
||||||
@@ -8,13 +7,9 @@
|
|||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Modal from "@lib/components/Modal.svelte"
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
import ChatStart from "@app/components/ChatStart.svelte"
|
|
||||||
import {setChecked} from "@app/util/notifications"
|
import {setChecked} from "@app/util/notifications"
|
||||||
import {pushModal} from "@app/util/modal"
|
|
||||||
import {notificationSettings} from "@app/core/state"
|
import {notificationSettings} from "@app/core/state"
|
||||||
|
|
||||||
const startChat = () => pushModal(ChatStart, {}, {replaceState: true})
|
|
||||||
|
|
||||||
const markAsRead = () => {
|
const markAsRead = () => {
|
||||||
setChecked("/chat/*")
|
setChecked("/chat/*")
|
||||||
history.back()
|
history.back()
|
||||||
@@ -28,10 +23,6 @@
|
|||||||
<Modal>
|
<Modal>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<Button class="btn btn-primary" onclick={startChat}>
|
|
||||||
<Icon size={5} icon={ChatSquare} />
|
|
||||||
Start chat
|
|
||||||
</Button>
|
|
||||||
<Button class="btn btn-neutral" onclick={markAsRead}>
|
<Button class="btn btn-neutral" onclick={markAsRead}>
|
||||||
<Icon size={5} icon={Check} />
|
<Icon size={5} icon={Check} />
|
||||||
Mark all read
|
Mark all read
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
<div class="flex grow flex-wrap justify-end gap-2">
|
||||||
{#if h && showRoom}
|
{#if h && showRoom}
|
||||||
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
||||||
Posted in #<RoomName {h} {url} />
|
Posted in #<RoomName {h} {url} />
|
||||||
|
|||||||