Compare commits
159 Commits
1.6.5
...
video-demo
| Author | SHA1 | Date | |
|---|---|---|---|
| ea4e1cde31 | |||
| 4f2e494959 | |||
| fef449be85 | |||
| 945e853e3b | |||
| bad96500d5 | |||
| 148286dc04 | |||
| 3decff3cfc | |||
| b4b8f85e18 | |||
| 6cc21de400 | |||
| 39e851b735 | |||
| 81ff1cafdc | |||
| 008dd246ef | |||
| 50ccfa775f | |||
| 1c8457a4bf | |||
| 8710043a02 | |||
| dc46b42cb6 | |||
| 2f1972e70a | |||
| c5fcf12165 | |||
| 61ed632579 | |||
| 86f4b75c52 | |||
| b26ab916d5 | |||
| c882198206 | |||
| 4aef27ffd5 | |||
| cf4e3f5fc6 | |||
| 57eb919c83 | |||
| 85cfaf2bc9 | |||
| 25a69a8191 | |||
| 6feeb23b1f | |||
| 4b92ffe3c5 | |||
| 823a9c3271 | |||
| fe89df2aa3 | |||
| 97ff8ff802 | |||
| a10a9e7043 | |||
| 4f42abc2ff | |||
| fe042c88b8 | |||
| 55e3a31b61 | |||
| 5760be4313 | |||
| 2fd7556a52 | |||
| e8ed9cd379 | |||
| eeeb3c96d2 | |||
| 2da5dee6bd | |||
| a66193ff45 | |||
| 55131ba7ce | |||
| df6282d2ba | |||
| 6ebe792ce5 | |||
| 6c9bdb2ccd | |||
| bc94c705f3 | |||
| 2b9b4da2cc | |||
| 090070d1f9 | |||
| 16a73f27c9 | |||
| 82245d895c | |||
| 610b8dd171 | |||
| f5b1e91378 | |||
| 1de6d7a874 | |||
| b716f3f792 | |||
| 75053bbbb1 | |||
| f9c7ed4936 | |||
| 1f5be54cb1 | |||
| 0761cdd28f | |||
| 7e2a0e9d5f | |||
| 7ae887561d | |||
| baa1d49b3a | |||
| 58a6be911a | |||
| 368f0b048b | |||
| 10894e17a5 | |||
| ec8a7a40e2 | |||
| ce30820108 | |||
| 147c756cc1 | |||
| c7fb404404 | |||
| 2546146ca8 | |||
| ffa776fd42 | |||
| a59ffb8758 | |||
| 9e74c94871 | |||
| 77294e7f1c | |||
| 57f2f4a619 | |||
| 1df2284ea3 | |||
| 189af077e7 | |||
| 10e4d83bce | |||
| 5d6661f964 | |||
| e6e11bb8f2 | |||
| 0e65e834da | |||
| 19f532c12e | |||
| bfc997ba37 | |||
| 99966a976e | |||
| cd54bc2880 | |||
| ffdd689331 | |||
| af41d81981 | |||
| 10d28ed364 | |||
| b02f4bd53a | |||
| 7ce8e3dbe6 | |||
| 2446d5cdb8 | |||
| d015018a16 | |||
| 6231c75e34 | |||
| 2f3bc6cc6f | |||
| 16c6015919 | |||
| e6b291cc68 | |||
| ae523c1ca6 | |||
| 7c86c1477f | |||
| 71f162f20d | |||
| eeacaca725 | |||
| af52ee25eb | |||
| eef32ca11e | |||
| 1ae821bff8 | |||
| 65483a6ef0 | |||
| 606a9343d9 | |||
| 7dfa6538be | |||
| 476d010ebe | |||
| 96d2efebc8 | |||
| f60f5af424 | |||
| 3da0334083 | |||
| c970038943 | |||
| 4000477bdb | |||
| ba11d53922 | |||
| beef606024 | |||
| 2adf64da55 | |||
| fd3fb8573c | |||
| e0d94d9794 | |||
| 7d049150a0 | |||
| 527ef59adc | |||
| b39775daef | |||
| 4bdb21560a | |||
| 797a9c32aa | |||
| bc864b29f8 | |||
| 482121db5c | |||
| 0fa26c8d0a | |||
| f5c768d6a7 | |||
| c43544734a | |||
| 86d99916f7 | |||
| 135dbc8789 | |||
| fc14de9b0f | |||
| c77197d959 | |||
| 56dddbdd86 | |||
| cbafcf6939 | |||
| 4b156ee699 | |||
| a4e883b09a | |||
| b114a724e2 | |||
| 621c0d839c | |||
| 021c1fc7c4 | |||
| bda91080ab | |||
| a9828be25c | |||
| dde9dbfbfe | |||
| ca7d126a3c | |||
| 7f6450375b | |||
| c9954db3fe | |||
| 3d268f1f9d | |||
| 66a7a2a7af | |||
| 7823e1d803 | |||
| d5e91ce874 | |||
| 6f32c1932f | |||
| cb06c4e954 | |||
| 9188c0a8bc | |||
| 30653fe344 | |||
| 5bb55c453f | |||
| 3024e08ca5 | |||
| aaf1f25167 | |||
| aabbb758a4 | |||
| d824f928b5 | |||
| 445ed27eb8 | |||
| 21f3970ca8 |
@@ -1,5 +1,6 @@
|
||||
--ignore-dir=.svelte-kit
|
||||
--ignore-dir=android
|
||||
--ignore-dir=target
|
||||
--ignore-dir=build
|
||||
--ignore-dir=ios/DerivedData
|
||||
--ignore-dir=ios/App/App/public
|
||||
|
||||
@@ -2,3 +2,11 @@ node_modules
|
||||
android
|
||||
ios
|
||||
build
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Env files (keep .env for build; exclude local overrides)
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
VITE_DEFAULT_PUBKEYS=06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71,266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed,6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e,76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa,7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3
|
||||
VITE_DEFAULT_BLOSSOM_SERVERS=https://blossom.primal.net/
|
||||
VITE_POMADE_SIGNERS=
|
||||
VITE_POMADE_SIGNERS=https://pomade.coracle.social,https://pomade.fiatjaf.com,https://pomade.nostrver.se,https://pomade.scuttle.works
|
||||
VITE_PLATFORM_URL=https://app.flotilla.social
|
||||
VITE_PLATFORM_TERMS=https://flotilla.social/terms
|
||||
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
|
||||
@@ -15,7 +15,8 @@ VITE_PUSH_BRIDGE=wss://npb.coracle.social/
|
||||
VITE_BLOCKED_RELAYS=brb.io,relay.nostr.band,nostr.mutinywallet.com,feeds.nostr.band,nostr.zbd.gg,wot.utxo.one,blastr.f7z.xyz,relay.current.fyi
|
||||
VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
|
||||
VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom
|
||||
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com
|
||||
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_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
|
||||
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
|
||||
VITE_GLITCHTIP_API_KEY=
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
IMAGE_NAME: coracle-social/flotilla
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
@@ -24,9 +25,14 @@ android/app/src/main/assets/public/
|
||||
|
||||
# Web/JavaScript
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
build/
|
||||
.svelte-kit/
|
||||
|
||||
# Rust/Tauri
|
||||
*target/
|
||||
src-tauri/binaries/
|
||||
|
||||
# iOS
|
||||
ios/App/App/public
|
||||
ios/DerivedData
|
||||
|
||||
@@ -157,7 +157,7 @@ src/
|
||||
- Derive all other data inside the component from identifiers
|
||||
- Example: Don't pass `members` prop, derive it from `h` inside component
|
||||
|
||||
**Code Style:**
|
||||
**CRITICAL Code Style Guidelines:**
|
||||
|
||||
- **No `null`** - only use `undefined`
|
||||
- Svelte 5 runes (`$state`, `$derived`, `$effect`) only in UI components
|
||||
@@ -168,6 +168,17 @@ src/
|
||||
- When dynamically building classes, use `cx` from `classnames` rather than embedded ternaries or svelte 4's old `class:` syntax.
|
||||
- When creating forms, use `FieldInline` or `Field` instead of custom elements/tailwindcss
|
||||
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates
|
||||
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
|
||||
- Instead of `getTag(tagName, event.tags)?.[1] || ""`, use `getTagValue(tagName, event.tags)`
|
||||
|
||||
**Human-First Simplicity (Jon Staab Style):**
|
||||
|
||||
- Prefer direct, readable code over layered abstractions.
|
||||
- Do not add indirection (extra helpers, wrappers, stores, or derived state) unless it removes real repeated complexity.
|
||||
- Reuse existing Welshman and Flotilla primitives before introducing new utilities or dependencies.
|
||||
- Favor linear control flow and explicit naming over clever patterns.
|
||||
- Remove defensive checks that do not apply in this runtime model.
|
||||
- When two approaches work, pick the one that feels more human and easier to maintain.
|
||||
|
||||
## Common Tasks
|
||||
|
||||
|
||||
@@ -1,5 +1,58 @@
|
||||
# Changelog
|
||||
|
||||
# 1.7.2
|
||||
|
||||
* Fix race condition in nip 46
|
||||
* Remove duplicate spaces button
|
||||
* Combine discover and space list pages
|
||||
* Fix some chat related bugs
|
||||
* Fix bug with joining spaces
|
||||
|
||||
# 1.7.1
|
||||
|
||||
* Fix pomade registration fallback in case of offline signer
|
||||
|
||||
# 1.7.0
|
||||
|
||||
* Enable email/password login
|
||||
* Add up/edit to direct messages
|
||||
* Fix a number of UI bugs
|
||||
* Improve navigation on mobile
|
||||
* Improve performance and syncing reliability
|
||||
* Add proof of work to DMs
|
||||
* Detect blossom support using supported_nips
|
||||
* Improve notification badges
|
||||
* Add voice rooms (@mplorentz)
|
||||
* Re-design relay onboarding and settings
|
||||
* Add android fallback for push notifications
|
||||
* Fix file uploads on android
|
||||
|
||||
# 1.6.5
|
||||
|
||||
* Attempt to fix permission grant for notifications
|
||||
* Make sync logic more robust
|
||||
* Add unban/unallow support
|
||||
* Improve support for downloading/opening protected images
|
||||
* Add manual send/receive to wallet
|
||||
* Show wallet status when wallet is unreachable
|
||||
* Update nostr signer capacitor plugin
|
||||
* Fix some safe area insets
|
||||
* Update NIP 55 signer plugin (fixes Primal login)
|
||||
* Refine space join dialogs and discover page
|
||||
* Reopen the last DM that was open when navigating back to chat
|
||||
* Get rid of ChatEnable interstitial
|
||||
* Enable auth for relays we're publishing to
|
||||
* Drag and drop space icons
|
||||
* Add better muting support
|
||||
* Add back button to settings menu
|
||||
* Add page titles
|
||||
* Improve scroll to event behavior
|
||||
* Add in-memory search to rooms
|
||||
* Fix editing messages with html tags
|
||||
* Fix DM media detection
|
||||
* Clean up reporting dialogs
|
||||
* Improve room detail
|
||||
|
||||
# 1.6.4
|
||||
|
||||
* Clean up modal design
|
||||
|
||||
@@ -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,24 +1,32 @@
|
||||
FROM node:20-slim
|
||||
# Stage 1: Build
|
||||
# Uses .env from build context for config (logo, branding, etc.)
|
||||
# Optional: docker build --build-arg VITE_BUILD_HASH=$(git rev-parse --short HEAD) -t flotilla .
|
||||
|
||||
FROM node:20-bookworm AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm@latest
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm i
|
||||
|
||||
# Copy the rest of the application
|
||||
# Copy everything (including .env when present) - build.sh will source it
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
ARG VITE_BUILD_HASH
|
||||
ENV VITE_BUILD_HASH=${VITE_BUILD_HASH}
|
||||
|
||||
ENV NODE_OPTIONS=--max_old_space_size=16384
|
||||
RUN pnpm run build
|
||||
|
||||
# Default to serving the build directory
|
||||
CMD ["npx", "serve", "build"]
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy only the built output - no source, no .env, no dev deps
|
||||
COPY --from=builder /app/build ./build
|
||||
|
||||
CMD ["npx", "serve", "-s", "build"]
|
||||
|
||||
@@ -6,21 +6,23 @@ If you would like to be interoperable with Flotilla, please check out this guide
|
||||
|
||||
## Environment
|
||||
|
||||
You can also optionally create an `.env` file and populate it with the following environment variables (see `.env` for examples):
|
||||
You can also optionally create an `.env.local` file and populate it with the following environment variables (see `.env.template` for examples):
|
||||
|
||||
- `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust
|
||||
- `VITE_PLATFORM_URL` - The url where the app will be hosted
|
||||
- `VITE_PLATFORM_NAME` - The name of the app
|
||||
- `VITE_PLATFORM_LOGO` - A logo url for the app
|
||||
- `VITE_PLATFORM_LOGO` - A logo url for the app. Can be a local path or https link. Must be a PNG file.
|
||||
- `VITE_PLATFORM_RELAYS` - A list of comma-separated relay urls that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the first platform relay the home page.
|
||||
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
|
||||
- `VITE_PLATFORM_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.
|
||||
|
||||
## Development
|
||||
|
||||
See [./CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -29,7 +31,7 @@ To run your own Flotilla, it's as simple as:
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm run build
|
||||
npx serve build
|
||||
npx serve -s build
|
||||
```
|
||||
|
||||
Or, if you prefer to use a container:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
android {
|
||||
namespace = "social.flotilla"
|
||||
@@ -7,8 +8,8 @@ android {
|
||||
applicationId "social.flotilla"
|
||||
minSdk rootProject.ext.minSdkVersion
|
||||
targetSdk rootProject.ext.targetSdkVersion
|
||||
versionCode 40
|
||||
versionName "1.6.4"
|
||||
versionCode 44
|
||||
versionName "1.7.2"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
@@ -35,6 +36,10 @@ dependencies {
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation "androidx.work:work-runtime:2.10.3"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
||||
implementation "fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.22.0"
|
||||
implementation project(':capacitor-android')
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
|
||||
@@ -9,6 +9,7 @@ android {
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':aparajita-capacitor-secure-storage')
|
||||
implementation project(':capacitor-community-safe-area')
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-filesystem')
|
||||
|
||||
@@ -42,4 +42,6 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
</manifest>
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
package social.flotilla;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {}
|
||||
import social.flotilla.notifications.AndroidPushFallbackPlugin;
|
||||
|
||||
public class MainActivity extends BridgeActivity {
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
registerPlugin(AndroidPushFallbackPlugin.class);
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package social.flotilla.notifications
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@CapacitorPlugin(name = "AndroidPushFallback")
|
||||
class AndroidPushFallbackPlugin : Plugin() {
|
||||
companion object {
|
||||
const val PREFS_NAME = "CapacitorStorage"
|
||||
const val KEY_STATE = "androidPushFallback.state"
|
||||
const val UNIQUE_PERIODIC_WORK = "androidPushFallback.periodic"
|
||||
const val UNIQUE_IMMEDIATE_WORK = "androidPushFallback.immediate"
|
||||
}
|
||||
|
||||
private fun getPrefs(): SharedPreferences {
|
||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun syncState(call: PluginCall) {
|
||||
val state: JSObject? = call.getObject("state")
|
||||
|
||||
if (state != null) {
|
||||
getPrefs().edit().putString(KEY_STATE, state.toString()).apply()
|
||||
|
||||
if (isEnabled(state.toString())) {
|
||||
scheduleWork()
|
||||
} else {
|
||||
cancelWork()
|
||||
}
|
||||
}
|
||||
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
private fun isEnabled(rawState: String?): Boolean {
|
||||
if (rawState == null || rawState.isEmpty()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return try {
|
||||
val state = JSONObject(rawState)
|
||||
val subscriptions: JSONArray? = state.optJSONArray("subscriptions")
|
||||
subscriptions != null && subscriptions.length() > 0
|
||||
} catch (_: JSONException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleWork() {
|
||||
val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
|
||||
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
|
||||
val periodic = PeriodicWorkRequest.Builder(
|
||||
AndroidPushFallbackWorker::class.java,
|
||||
15,
|
||||
TimeUnit.MINUTES,
|
||||
).setConstraints(constraints).build()
|
||||
|
||||
val immediate = OneTimeWorkRequest.Builder(AndroidPushFallbackWorker::class.java)
|
||||
.setConstraints(constraints)
|
||||
.build()
|
||||
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
UNIQUE_PERIODIC_WORK,
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
periodic,
|
||||
)
|
||||
|
||||
workManager.enqueueUniqueWork(
|
||||
UNIQUE_IMMEDIATE_WORK,
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
immediate,
|
||||
)
|
||||
}
|
||||
|
||||
private fun cancelWork() {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
workManager.cancelUniqueWork(UNIQUE_IMMEDIATE_WORK)
|
||||
workManager.cancelUniqueWork(UNIQUE_PERIODIC_WORK)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,862 @@
|
||||
package social.flotilla.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.util.Log
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.app.ActivityManager
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import fr.acinq.secp256k1.Secp256k1
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import social.flotilla.notifications.AndroidPushFallbackPlugin.Companion.KEY_STATE
|
||||
import social.flotilla.notifications.AndroidPushFallbackPlugin.Companion.PREFS_NAME
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.Arrays
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import android.util.Base64
|
||||
|
||||
class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
|
||||
companion object {
|
||||
private const val TAG = "PushFallback"
|
||||
private const val CHANNEL_ID = "flotilla_fallback"
|
||||
private const val CURSOR_PREFIX = "androidPushFallback.cursor."
|
||||
private const val SOCKET_TIMEOUT_SECONDS = 20L
|
||||
private const val REJECTED = "__REJECTED__"
|
||||
private const val KIND_RELAY_AUTH = 22242
|
||||
private const val KIND_NIP46_RPC = 24133
|
||||
private val SECP = Secp256k1.get()
|
||||
}
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val client: OkHttpClient = OkHttpClient.Builder().build()
|
||||
|
||||
// ---- Socket pool ----
|
||||
|
||||
// Opens each relay URL at most once; caller must invoke closeAll() when done.
|
||||
private inner class SocketPool {
|
||||
private val sockets = ConcurrentHashMap<String, WebSocket>()
|
||||
|
||||
fun open(url: String, listener: WebSocketListener): WebSocket =
|
||||
sockets.getOrPut(url) {
|
||||
client.newWebSocket(Request.Builder().url(url).build(), listener)
|
||||
}
|
||||
|
||||
fun closeAll() {
|
||||
for ((_, ws) in sockets) ws.close(1000, "done")
|
||||
sockets.clear()
|
||||
}
|
||||
}
|
||||
|
||||
override fun doWork(): Result {
|
||||
if (isAppInForeground()) {
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
val pool = SocketPool()
|
||||
try {
|
||||
val rawState = prefs.getString(KEY_STATE, "") ?: ""
|
||||
if (rawState.isEmpty()) return Result.success()
|
||||
|
||||
val state = JSONObject(rawState)
|
||||
val sessionInfo = getSessionInfo(state)
|
||||
val subscriptions = parseSubscriptions(state)
|
||||
if (subscriptions.isEmpty()) return Result.success()
|
||||
|
||||
val activeSince = state.optLong("activeSince", 0L)
|
||||
val seen = mutableSetOf<String>()
|
||||
var latestPair: Pair<String, JSONObject>? = null
|
||||
|
||||
for (sub in subscriptions) {
|
||||
val cursorKey = CURSOR_PREFIX + sub.relay + ":" + sub.key
|
||||
val since = maxOf(prefs.getLong(cursorKey, 0L), activeSince)
|
||||
val result = pollRelay(sub, since, sessionInfo, pool)
|
||||
|
||||
if (result.lastCursor > prefs.getLong(cursorKey, 0L)) {
|
||||
prefs.edit().putLong(cursorKey, result.lastCursor).apply()
|
||||
}
|
||||
|
||||
for (event in result.events) {
|
||||
val id = event.optString("id", "")
|
||||
if (id.isNotEmpty() && seen.add(id)) {
|
||||
val createdAt = event.optLong("created_at", 0L)
|
||||
if (latestPair == null || createdAt > latestPair!!.second.optLong("created_at", 0L)) {
|
||||
latestPair = Pair(sub.relay, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (latestPair != null) {
|
||||
val (relay, event) = latestPair!!
|
||||
postNotification(relay, event)
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Worker failed", e)
|
||||
return Result.success()
|
||||
} finally {
|
||||
pool.closeAll()
|
||||
client.dispatcher.executorService.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isAppInForeground(): Boolean {
|
||||
val am = applicationContext.getSystemService(ActivityManager::class.java) ?: return false
|
||||
val tasks = am.getRunningAppProcesses() ?: return false
|
||||
val pkg = applicationContext.packageName
|
||||
return tasks.any { it.processName == pkg && it.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND }
|
||||
}
|
||||
|
||||
private fun getSessionInfo(state: JSONObject): SessionInfo {
|
||||
val session = state.optJSONObject("session") ?: return SessionInfo("anonymous", "", JSONObject())
|
||||
return SessionInfo(
|
||||
session.optString("method", "anonymous"),
|
||||
session.optString("pubkey", ""),
|
||||
session,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseSubscriptions(state: JSONObject): List<Subscription> {
|
||||
val result = mutableListOf<Subscription>()
|
||||
val arr = state.optJSONArray("subscriptions") ?: return result
|
||||
|
||||
for (i in 0 until arr.length()) {
|
||||
val item = arr.optJSONObject(i) ?: continue
|
||||
val relay = item.optString("relay", "").trim()
|
||||
|
||||
if (!relay.startsWith("wss://") && !relay.startsWith("ws://")) continue
|
||||
|
||||
val filters = item.optJSONArray("filters")
|
||||
if (filters == null || filters.length() == 0) continue
|
||||
|
||||
val key = item.optString("key", "").trim()
|
||||
result.add(Subscription(relay, key, filters, item.optJSONArray("ignore")))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private fun pollRelay(sub: Subscription, since: Long, sessionInfo: SessionInfo, pool: SocketPool): RelayResult {
|
||||
val result = RelayResult()
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
val listener = RelayListener(sub, since, sessionInfo, result, latch, pool)
|
||||
pool.open(sub.relay, listener)
|
||||
|
||||
if (!latch.await(SOCKET_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
|
||||
Log.d(TAG, "Relay ${sub.relay} timed out")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private fun postNotification(relay: String, event: JSONObject) {
|
||||
val context = applicationContext
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||
) return
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
if (manager != null && manager.getNotificationChannel(CHANNEL_ID) == null) {
|
||||
val channel = NotificationChannel(CHANNEL_ID, "Fallback Notifications", NotificationManager.IMPORTANCE_DEFAULT)
|
||||
channel.description = "Notifications delivered by Android background fallback"
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
val id = event.optString("id", "")
|
||||
val encodedRelay = Uri.encode(relay)
|
||||
val url = "https://app.flotilla.social/?relay=$encodedRelay&id=$id"
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
intent.setPackage(context.packageName)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context, 0, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val body = "New activity"
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||
.setContentTitle("Flotilla")
|
||||
.setContentText(body)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setContentIntent(pendingIntent)
|
||||
.build()
|
||||
|
||||
NotificationManagerCompat.from(context).notify(1, notification)
|
||||
}
|
||||
|
||||
private fun matchesFilter(filter: JSONObject, event: JSONObject): Boolean {
|
||||
val kinds = filter.optJSONArray("kinds")
|
||||
if (kinds != null && kinds.length() > 0) {
|
||||
val kind = event.optInt("kind", -1)
|
||||
var found = false
|
||||
for (i in 0 until kinds.length()) {
|
||||
if (kinds.optInt(i, -1) == kind) { found = true; break }
|
||||
}
|
||||
if (!found) return false
|
||||
}
|
||||
|
||||
val tags = event.optJSONArray("tags")
|
||||
val iter = filter.keys()
|
||||
while (iter.hasNext()) {
|
||||
val key = iter.next()
|
||||
if (!key.startsWith("#")) continue
|
||||
val tagName = key.substring(1)
|
||||
val allowed = filter.optJSONArray(key) ?: continue
|
||||
if (allowed.length() == 0) continue
|
||||
|
||||
val allowedValues = mutableSetOf<String>()
|
||||
for (i in 0 until allowed.length()) {
|
||||
val v = allowed.optString(i, "")
|
||||
if (v.isNotEmpty()) allowedValues.add(v)
|
||||
}
|
||||
|
||||
var matched = false
|
||||
if (tags != null) {
|
||||
for (i in 0 until tags.length()) {
|
||||
val tag = tags.optJSONArray(i) ?: continue
|
||||
if (tag.optString(0, "") == tagName && allowedValues.contains(tag.optString(1, ""))) {
|
||||
matched = true; break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!matched) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- Crypto helpers ----
|
||||
|
||||
private fun computeEventId(event: JSONObject): String {
|
||||
return try {
|
||||
val serialized = JSONArray()
|
||||
serialized.put(0)
|
||||
serialized.put(event.optString("pubkey", ""))
|
||||
serialized.put(event.optLong("created_at", 0))
|
||||
serialized.put(event.optInt("kind", 0))
|
||||
serialized.put(event.optJSONArray("tags") ?: JSONArray())
|
||||
serialized.put(event.optString("content", ""))
|
||||
// JSONObject escapes forward slashes (/ -> \/), but NIP-01 event ID hashing
|
||||
// requires unescaped slashes. Replace them before hashing.
|
||||
val serializedStr = serialized.toString().replace("\\/", "/")
|
||||
bytesToHex(sha256(serializedStr.toByteArray(StandardCharsets.UTF_8)))
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private fun deriveXOnlyPubkey(secretHex: String): String {
|
||||
val secret = hexToBytes(secretHex)
|
||||
if (secret.size != 32 || !SECP.secKeyVerify(secret)) return ""
|
||||
val pubkey65 = try { SECP.pubkeyCreate(secret) } catch (_: Exception) { return "" }
|
||||
if (pubkey65.size != 65) return ""
|
||||
return bytesToHex(Arrays.copyOfRange(pubkey65, 1, 33))
|
||||
}
|
||||
|
||||
private fun schnorrSign(secretHex: String, messageHex: String): String {
|
||||
val sk = hexToBytes(secretHex)
|
||||
val msg = hexToBytes(messageHex)
|
||||
if (sk.size != 32 || msg.size != 32 || !SECP.secKeyVerify(sk)) return ""
|
||||
val aux = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
||||
val sig = try { SECP.signSchnorr(msg, sk, aux) } catch (_: Exception) { return "" }
|
||||
if (sig.size != 64) return ""
|
||||
return bytesToHex(sig)
|
||||
}
|
||||
|
||||
private fun sha256(input: ByteArray): ByteArray =
|
||||
try { MessageDigest.getInstance("SHA-256").digest(input) } catch (_: Exception) { ByteArray(32) }
|
||||
|
||||
private fun hexToBytes(hex: String?): ByteArray {
|
||||
var s = hex?.trim()?.lowercase() ?: ""
|
||||
if (s.startsWith("0x")) s = s.substring(2)
|
||||
if (s.length % 2 == 1) s = "0$s"
|
||||
val bytes = ByteArray(s.length / 2)
|
||||
var i = 0
|
||||
while (i < s.length) {
|
||||
val hi = Character.digit(s[i], 16)
|
||||
val lo = Character.digit(s[i + 1], 16)
|
||||
if (hi < 0 || lo < 0) return ByteArray(0)
|
||||
bytes[i / 2] = ((hi shl 4) + lo).toByte()
|
||||
i += 2
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
private fun bytesToHex(bytes: ByteArray): String {
|
||||
val hex = "0123456789abcdef".toCharArray()
|
||||
val chars = CharArray(bytes.size * 2)
|
||||
for (i in bytes.indices) {
|
||||
val v = bytes[i].toInt() and 0xFF
|
||||
chars[i * 2] = hex[v ushr 4]
|
||||
chars[i * 2 + 1] = hex[v and 0x0F]
|
||||
}
|
||||
return String(chars)
|
||||
}
|
||||
|
||||
// ---- NIP-44 encryption (v2: ECDH + HKDF + ChaCha20 + HMAC-SHA256) ----
|
||||
|
||||
private fun nip44ConversationKey(clientSecret: String, theirPubkey: String): ByteArray {
|
||||
val sk = hexToBytes(clientSecret)
|
||||
val pk = hexToBytes("02$theirPubkey")
|
||||
if (sk.size != 32 || pk.size != 33) return ByteArray(0)
|
||||
val shared = try { SECP.pubKeyTweakMul(pk, sk) } catch (_: Exception) { return ByteArray(0) }
|
||||
if (shared.size != 65) return ByteArray(0)
|
||||
val sharedX = Arrays.copyOfRange(shared, 1, 33)
|
||||
return hkdfExtract(sharedX, "nip44-v2".toByteArray(StandardCharsets.UTF_8))
|
||||
}
|
||||
|
||||
private fun hkdfExtract(ikm: ByteArray, salt: ByteArray): ByteArray {
|
||||
val mac = javax.crypto.Mac.getInstance("HmacSHA256")
|
||||
mac.init(javax.crypto.spec.SecretKeySpec(salt, "HmacSHA256"))
|
||||
return mac.doFinal(ikm)
|
||||
}
|
||||
|
||||
private fun hkdfExpand(prk: ByteArray, info: ByteArray, length: Int): ByteArray {
|
||||
val mac = javax.crypto.Mac.getInstance("HmacSHA256")
|
||||
val result = ByteArray(length)
|
||||
var prev = ByteArray(0)
|
||||
var offset = 0
|
||||
var counter = 1
|
||||
while (offset < length) {
|
||||
mac.init(javax.crypto.spec.SecretKeySpec(prk, "HmacSHA256"))
|
||||
mac.update(prev)
|
||||
mac.update(info)
|
||||
mac.update(counter.toByte())
|
||||
prev = mac.doFinal()
|
||||
val toCopy = minOf(prev.size, length - offset)
|
||||
System.arraycopy(prev, 0, result, offset, toCopy)
|
||||
offset += toCopy
|
||||
counter++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun hmacSha256(key: ByteArray, vararg parts: ByteArray): ByteArray {
|
||||
val mac = javax.crypto.Mac.getInstance("HmacSHA256")
|
||||
mac.init(javax.crypto.spec.SecretKeySpec(key, "HmacSHA256"))
|
||||
for (part in parts) mac.update(part)
|
||||
return mac.doFinal()
|
||||
}
|
||||
|
||||
// ChaCha20 block function per RFC 8439
|
||||
private fun chacha20Block(key: ByteArray, counter: Int, nonce: ByteArray): ByteArray {
|
||||
fun Int.rotl(n: Int) = (this shl n) or (this ushr (32 - n))
|
||||
val state = IntArray(16)
|
||||
state[0] = 0x61707865; state[1] = 0x3320646e; state[2] = 0x79622d32; state[3] = 0x6b206574
|
||||
for (i in 0..7) state[4 + i] = (key[i*4].toInt() and 0xFF) or
|
||||
((key[i*4+1].toInt() and 0xFF) shl 8) or
|
||||
((key[i*4+2].toInt() and 0xFF) shl 16) or
|
||||
((key[i*4+3].toInt() and 0xFF) shl 24)
|
||||
state[12] = counter
|
||||
for (i in 0..2) state[13 + i] = (nonce[i*4].toInt() and 0xFF) or
|
||||
((nonce[i*4+1].toInt() and 0xFF) shl 8) or
|
||||
((nonce[i*4+2].toInt() and 0xFF) shl 16) or
|
||||
((nonce[i*4+3].toInt() and 0xFF) shl 24)
|
||||
val working = state.copyOf()
|
||||
repeat(10) {
|
||||
fun quarterRound(a: Int, b: Int, c: Int, d: Int) {
|
||||
working[a] += working[b]; working[d] = (working[d] xor working[a]).rotl(16)
|
||||
working[c] += working[d]; working[b] = (working[b] xor working[c]).rotl(12)
|
||||
working[a] += working[b]; working[d] = (working[d] xor working[a]).rotl(8)
|
||||
working[c] += working[d]; working[b] = (working[b] xor working[c]).rotl(7)
|
||||
}
|
||||
quarterRound(0,4,8,12); quarterRound(1,5,9,13); quarterRound(2,6,10,14); quarterRound(3,7,11,15)
|
||||
quarterRound(0,5,10,15); quarterRound(1,6,11,12); quarterRound(2,7,8,13); quarterRound(3,4,9,14)
|
||||
}
|
||||
val out = ByteArray(64)
|
||||
for (i in 0..15) {
|
||||
val v = working[i] + state[i]
|
||||
out[i*4] = v.toByte()
|
||||
out[i*4+1] = (v ushr 8).toByte()
|
||||
out[i*4+2] = (v ushr 16).toByte()
|
||||
out[i*4+3] = (v ushr 24).toByte()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun chacha20(key: ByteArray, nonce: ByteArray, data: ByteArray): ByteArray {
|
||||
val out = ByteArray(data.size)
|
||||
var counter = 0
|
||||
var offset = 0
|
||||
while (offset < data.size) {
|
||||
val block = chacha20Block(key, counter, nonce)
|
||||
val len = minOf(64, data.size - offset)
|
||||
for (i in 0 until len) out[offset + i] = (data[offset + i].toInt() xor block[i].toInt()).toByte()
|
||||
offset += len
|
||||
counter++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun nip44CalcPaddedLen(len: Int): Int {
|
||||
if (len <= 32) return 32
|
||||
val nextPower = 1 shl (Math.floor(Math.log((len - 1).toDouble()) / Math.log(2.0)).toInt() + 1)
|
||||
val chunk = if (nextPower <= 256) 32 else nextPower / 8
|
||||
return chunk * ((len - 1) / chunk + 1)
|
||||
}
|
||||
|
||||
private fun nip44Pad(plaintext: String): ByteArray {
|
||||
val unpadded = plaintext.toByteArray(StandardCharsets.UTF_8)
|
||||
val len = unpadded.size
|
||||
val padded = ByteArray(2 + nip44CalcPaddedLen(len))
|
||||
padded[0] = (len ushr 8).toByte()
|
||||
padded[1] = len.toByte()
|
||||
System.arraycopy(unpadded, 0, padded, 2, len)
|
||||
return padded
|
||||
}
|
||||
|
||||
private fun nip44Unpad(padded: ByteArray): String {
|
||||
val len = ((padded[0].toInt() and 0xFF) shl 8) or (padded[1].toInt() and 0xFF)
|
||||
if (len == 0 || len > padded.size - 2) return ""
|
||||
return String(padded, 2, len, StandardCharsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun encryptNip44(plaintext: String, conversationKey: ByteArray): String {
|
||||
return try {
|
||||
val nonce = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
||||
val keys = hkdfExpand(conversationKey, nonce, 76)
|
||||
val chachaKey = keys.sliceArray(0 until 32)
|
||||
val chachaNonce = keys.sliceArray(32 until 44)
|
||||
val hmacKey = keys.sliceArray(44 until 76)
|
||||
val padded = nip44Pad(plaintext)
|
||||
val ciphertext = chacha20(chachaKey, chachaNonce, padded)
|
||||
val mac = hmacSha256(hmacKey, nonce, ciphertext)
|
||||
val payload = ByteArray(1 + 32 + ciphertext.size + 32)
|
||||
payload[0] = 2
|
||||
System.arraycopy(nonce, 0, payload, 1, 32)
|
||||
System.arraycopy(ciphertext, 0, payload, 33, ciphertext.size)
|
||||
System.arraycopy(mac, 0, payload, 33 + ciphertext.size, 32)
|
||||
Base64.encodeToString(payload, Base64.NO_WRAP)
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private fun decryptNip44(payload: String, conversationKey: ByteArray): String {
|
||||
return try {
|
||||
if (payload.isEmpty() || payload[0] == '#') return ""
|
||||
val data = Base64.decode(payload, Base64.NO_WRAP)
|
||||
if (data.size < 99 || data[0] != 2.toByte()) return ""
|
||||
val nonce = data.sliceArray(1 until 33)
|
||||
val ciphertext = data.sliceArray(33 until data.size - 32)
|
||||
val mac = data.sliceArray(data.size - 32 until data.size)
|
||||
val keys = hkdfExpand(conversationKey, nonce, 76)
|
||||
val chachaKey = keys.sliceArray(0 until 32)
|
||||
val chachaNonce = keys.sliceArray(32 until 44)
|
||||
val hmacKey = keys.sliceArray(44 until 76)
|
||||
val expectedMac = hmacSha256(hmacKey, nonce, ciphertext)
|
||||
if (!expectedMac.contentEquals(mac)) return ""
|
||||
val padded = chacha20(chachaKey, chachaNonce, ciphertext)
|
||||
nip44Unpad(padded)
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Signing ----
|
||||
|
||||
private fun signWithNip01Secret(secretHex: String, eventJson: String, expectedPubkey: String): String {
|
||||
return try {
|
||||
val secret = hexToBytes(secretHex)
|
||||
if (secret.size != 32) return ""
|
||||
|
||||
val event = JSONObject(eventJson)
|
||||
var pubkey = event.optString("pubkey", expectedPubkey)
|
||||
if (pubkey.isEmpty()) pubkey = deriveXOnlyPubkey(secretHex)
|
||||
if (pubkey.isEmpty()) return ""
|
||||
|
||||
event.put("pubkey", pubkey)
|
||||
val id = computeEventId(event)
|
||||
if (id.isEmpty()) return ""
|
||||
|
||||
val sig = schnorrSign(secretHex, id)
|
||||
if (sig.isEmpty()) return ""
|
||||
|
||||
event.put("id", id)
|
||||
event.put("sig", sig)
|
||||
event.toString()
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private fun signWithNip55ContentResolver(packageName: String, eventJson: String, pubkey: String): String {
|
||||
val uri = Uri.parse("content://$packageName.SIGN_EVENT")
|
||||
var cursor: Cursor? = null
|
||||
return try {
|
||||
cursor = applicationContext.contentResolver.query(uri, arrayOf(eventJson, "", pubkey), "1", null, null)
|
||||
if (cursor == null || !cursor.moveToFirst()) return ""
|
||||
val rejIdx = cursor.getColumnIndex("rejected")
|
||||
if (rejIdx >= 0) {
|
||||
val v = cursor.getString(rejIdx)
|
||||
if (v == "1" || v.equals("true", ignoreCase = true)) return REJECTED
|
||||
}
|
||||
val eventIdx = cursor.getColumnIndex("event")
|
||||
if (eventIdx >= 0) cursor.getString(eventIdx) ?: "" else ""
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
} finally {
|
||||
cursor?.close()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Data types ----
|
||||
|
||||
private data class SessionInfo(
|
||||
val method: String,
|
||||
val pubkey: String,
|
||||
val session: JSONObject,
|
||||
)
|
||||
|
||||
private data class Subscription(
|
||||
val relay: String,
|
||||
val key: String,
|
||||
val filters: JSONArray,
|
||||
val ignore: JSONArray?,
|
||||
)
|
||||
|
||||
private class RelayResult {
|
||||
val events = mutableListOf<JSONObject>()
|
||||
var lastCursor = 0L
|
||||
}
|
||||
|
||||
// ---- Relay listener ----
|
||||
|
||||
private inner class RelayListener(
|
||||
private val sub: Subscription,
|
||||
private val since: Long,
|
||||
private val sessionInfo: SessionInfo,
|
||||
private val result: RelayResult,
|
||||
private val latch: CountDownLatch,
|
||||
private val pool: SocketPool,
|
||||
) : WebSocketListener() {
|
||||
private val subId = UUID.randomUUID().toString().replace("-", "")
|
||||
private var done = false
|
||||
private var authed = false
|
||||
private var authEventId = ""
|
||||
private var nip46InFlight = false
|
||||
private var pendingDone = false
|
||||
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
sendReq(webSocket)
|
||||
}
|
||||
|
||||
private fun sendReq(webSocket: WebSocket) {
|
||||
val req = JSONArray()
|
||||
req.put("REQ")
|
||||
req.put(subId)
|
||||
|
||||
for (i in 0 until sub.filters.length()) {
|
||||
val filter = sub.filters.optJSONObject(i) ?: continue
|
||||
val shaped = JSONObject(filter.toString())
|
||||
if (since > 0) shaped.put("since", since + 1)
|
||||
shaped.put("limit", 1)
|
||||
req.put(shaped)
|
||||
}
|
||||
|
||||
if (req.length() <= 2) { finish(); return }
|
||||
|
||||
send(webSocket, req.toString())
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
try {
|
||||
val message = JSONArray(text)
|
||||
Log.d(TAG, "Received message from ${sub.relay}: $text")
|
||||
when (message.optString(0, "")) {
|
||||
"EVENT" -> {
|
||||
val event = message.optJSONObject(2) ?: return
|
||||
if (!matchesAnyFilter(sub.filters, event)) return
|
||||
if (isIgnored(event)) return
|
||||
result.events.add(event)
|
||||
val createdAt = event.optLong("created_at", 0L)
|
||||
if (createdAt > result.lastCursor) result.lastCursor = createdAt
|
||||
}
|
||||
"AUTH" -> {
|
||||
// Only auth once per connection
|
||||
if (!authed) {
|
||||
authed = true
|
||||
tryAuth(webSocket, message.optString(1, ""))
|
||||
}
|
||||
}
|
||||
"OK" -> {
|
||||
val okId = message.optString(1, "")
|
||||
val accepted = message.optBoolean(2, false)
|
||||
if (accepted && okId == authEventId) sendReq(webSocket)
|
||||
}
|
||||
"EOSE" -> {
|
||||
send(webSocket, JSONArray().apply { put("CLOSE"); put(subId) }.toString())
|
||||
finish()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) = finish()
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) = finish()
|
||||
|
||||
private fun finish() {
|
||||
if (done) return
|
||||
if (nip46InFlight) { pendingDone = true; return }
|
||||
done = true
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
private fun isIgnored(event: JSONObject): Boolean {
|
||||
val ignore = sub.ignore ?: return false
|
||||
for (i in 0 until ignore.length()) {
|
||||
val filter = ignore.optJSONObject(i) ?: continue
|
||||
if (matchesFilter(filter, event)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun matchesAnyFilter(filters: JSONArray, event: JSONObject): Boolean {
|
||||
for (i in 0 until filters.length()) {
|
||||
val filter = filters.optJSONObject(i) ?: continue
|
||||
if (matchesFilter(filter, event)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---- NIP-42 auth ----
|
||||
|
||||
private fun tryAuth(webSocket: WebSocket, challenge: String) {
|
||||
if (challenge.isEmpty()) return
|
||||
when (sessionInfo.method) {
|
||||
"nip01" -> tryNip01Auth(webSocket, challenge)
|
||||
"nip55" -> tryNip55Auth(webSocket, challenge)
|
||||
"nip46" -> tryNip46Auth(webSocket, challenge)
|
||||
// Pomade background auth is not supported: properly delegating to the Pomade signer
|
||||
// from a background worker is complex, usage is rare, and relays that require auth
|
||||
// may still be readable without it.
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildAuthEvent(challenge: String): JSONObject {
|
||||
return JSONObject().apply {
|
||||
put("kind", KIND_RELAY_AUTH)
|
||||
put("pubkey", sessionInfo.pubkey)
|
||||
put("created_at", System.currentTimeMillis() / 1000L)
|
||||
put("content", "")
|
||||
put("id", "")
|
||||
put("sig", "")
|
||||
put("tags", JSONArray().apply {
|
||||
put(JSONArray().apply { put("relay"); put(sub.relay) })
|
||||
put(JSONArray().apply { put("challenge"); put(challenge) })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendAuthMessage(webSocket: WebSocket, signedEventJson: String): Boolean {
|
||||
if (signedEventJson.isEmpty() || signedEventJson == REJECTED) return false
|
||||
return try {
|
||||
val event = JSONObject(signedEventJson)
|
||||
authEventId = event.optString("id", "")
|
||||
send(webSocket, JSONArray().apply { put("AUTH"); put(event) }.toString())
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun send(webSocket: WebSocket, message: String): Boolean {
|
||||
Log.d(TAG, "Sending message to ${webSocket.request().url}: $message")
|
||||
return webSocket.send(message)
|
||||
}
|
||||
|
||||
private fun tryNip01Auth(webSocket: WebSocket, challenge: String): Boolean {
|
||||
val secret = sessionInfo.session.optString("secret", "")
|
||||
if (secret.isEmpty()) return false
|
||||
val signed = signWithNip01Secret(secret, buildAuthEvent(challenge).toString(), sessionInfo.pubkey)
|
||||
return sendAuthMessage(webSocket, signed)
|
||||
}
|
||||
|
||||
private fun tryNip55Auth(webSocket: WebSocket, challenge: String): Boolean {
|
||||
val signerPackage = sessionInfo.session.optString("signer", "")
|
||||
if (signerPackage.isEmpty()) return false
|
||||
val signed = signWithNip55ContentResolver(signerPackage, buildAuthEvent(challenge).toString(), sessionInfo.pubkey)
|
||||
return sendAuthMessage(webSocket, signed)
|
||||
}
|
||||
|
||||
private fun tryNip46Auth(webSocket: WebSocket, challenge: String): Boolean {
|
||||
val handler = sessionInfo.session.optJSONObject("handler") ?: return false
|
||||
val clientSecret = sessionInfo.session.optString("secret", "")
|
||||
val signerPubkey = handler.optString("pubkey", "")
|
||||
val relays = handler.optJSONArray("relays")
|
||||
|
||||
if (clientSecret.isEmpty() || signerPubkey.isEmpty() || relays == null || relays.length() == 0) return false
|
||||
|
||||
val clientPubkey = deriveXOnlyPubkey(clientSecret)
|
||||
if (clientPubkey.isEmpty()) return false
|
||||
|
||||
val authEventJson = buildAuthEvent(challenge).toString()
|
||||
|
||||
nip46InFlight = true
|
||||
var success = false
|
||||
try {
|
||||
for (i in 0 until relays.length()) {
|
||||
val signerRelay = relays.optString(i, "").trim()
|
||||
if (!signerRelay.startsWith("wss://") && !signerRelay.startsWith("ws://")) continue
|
||||
if (tryNip46ViaRelay(webSocket, signerRelay, clientSecret, clientPubkey, signerPubkey, authEventJson)) { success = true; break }
|
||||
}
|
||||
} finally {
|
||||
nip46InFlight = false
|
||||
if (pendingDone) finish()
|
||||
}
|
||||
|
||||
return success
|
||||
}
|
||||
|
||||
private fun tryNip46ViaRelay(
|
||||
relaySocket: WebSocket,
|
||||
signerRelay: String,
|
||||
clientSecret: String,
|
||||
clientPubkey: String,
|
||||
signerPubkey: String,
|
||||
authEventJson: String,
|
||||
): Boolean {
|
||||
val localLatch = CountDownLatch(1)
|
||||
val signedEvent = StringBuilder()
|
||||
val requestId = UUID.randomUUID().toString().replace("-", "")
|
||||
|
||||
val signerSocket = pool.open(signerRelay, object : WebSocketListener() {
|
||||
private var done = false
|
||||
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
try {
|
||||
val rpcEnvelope = JSONObject().apply {
|
||||
put("kind", KIND_NIP46_RPC)
|
||||
put("pubkey", clientPubkey)
|
||||
put("created_at", System.currentTimeMillis() / 1000L)
|
||||
put("content", encryptNip44(
|
||||
JSONObject().apply {
|
||||
put("id", requestId)
|
||||
put("method", "sign_event")
|
||||
put("params", JSONArray().apply { put(authEventJson) })
|
||||
}.toString(),
|
||||
nip44ConversationKey(clientSecret, signerPubkey),
|
||||
))
|
||||
put("id", "")
|
||||
put("sig", "")
|
||||
put("tags", JSONArray().apply { put(JSONArray().apply { put("p"); put(signerPubkey) }) })
|
||||
}
|
||||
|
||||
val signedEnvelope = signWithNip01Secret(clientSecret, rpcEnvelope.toString(), clientPubkey)
|
||||
if (signedEnvelope.isEmpty()) { finish(); return }
|
||||
|
||||
val sentAt = System.currentTimeMillis() / 1000L
|
||||
send(webSocket, JSONArray().apply { put("EVENT"); put(JSONObject(signedEnvelope)) }.toString())
|
||||
send(webSocket, JSONArray().apply {
|
||||
put("REQ")
|
||||
put(requestId)
|
||||
put(JSONObject().apply {
|
||||
put("#p", JSONArray().apply { put(clientPubkey) })
|
||||
put("kinds", JSONArray().apply { put(KIND_NIP46_RPC) })
|
||||
put("since", sentAt)
|
||||
put("limit", 10)
|
||||
})
|
||||
}.toString())
|
||||
} catch (_: Exception) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
try {
|
||||
val message = JSONArray(text)
|
||||
val msgType = message.optString(0, "")
|
||||
Log.d(TAG, "NIP-46 signer message: $msgType from ${webSocket.request().url}")
|
||||
if (msgType != "EVENT") return
|
||||
val event = message.optJSONObject(2) ?: return
|
||||
|
||||
val tags = event.optJSONArray("tags")
|
||||
var hasP = false
|
||||
if (tags != null) {
|
||||
for (i in 0 until tags.length()) {
|
||||
val tag = tags.optJSONArray(i) ?: continue
|
||||
if (tag.optString(0, "") == "p" && tag.optString(1, "") == clientPubkey) { hasP = true; break }
|
||||
}
|
||||
}
|
||||
if (!hasP) { Log.d(TAG, "NIP-46 event missing p tag for client"); return }
|
||||
|
||||
val decryptedContent = decryptNip44(event.optString("content", ""), nip44ConversationKey(clientSecret, signerPubkey))
|
||||
Log.d(TAG, "NIP-46 decrypted response: $decryptedContent")
|
||||
if (decryptedContent.isEmpty()) return
|
||||
val payload = JSONObject(decryptedContent)
|
||||
if (requestId == payload.optString("id", "")) {
|
||||
val result = payload.optString("result", "")
|
||||
if (result.isNotEmpty()) {
|
||||
signedEvent.setLength(0)
|
||||
signedEvent.append(result)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "NIP-46 signer message error", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) = finish()
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) = finish()
|
||||
|
||||
private fun finish() {
|
||||
if (!done) { done = true; localLatch.countDown() }
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
localLatch.await(5, TimeUnit.SECONDS)
|
||||
} catch (_: InterruptedException) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (signedEvent.isEmpty()) return false
|
||||
|
||||
val authEvent = JSONObject(signedEvent.toString())
|
||||
authEventId = authEvent.optString("id", "")
|
||||
val authMessage = JSONArray().apply { put("AUTH"); put(authEvent) }.toString()
|
||||
Log.d(TAG, "NIP-46 sending AUTH to relay ${relaySocket.request().url}: $authMessage")
|
||||
return try {
|
||||
relaySocket.send(authMessage)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "NIP-46 failed to send AUTH", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = '2.2.20'
|
||||
|
||||
repositories {
|
||||
google()
|
||||
@@ -9,6 +10,7 @@ buildscript {
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.13.2'
|
||||
classpath 'com.google.gms:google-services:4.4.4'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':aparajita-capacitor-secure-storage'
|
||||
project(':aparajita-capacitor-secure-storage').projectDir = new File('../node_modules/.pnpm/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage/android')
|
||||
|
||||
include ':capacitor-community-safe-area'
|
||||
project(':capacitor-community-safe-area').projectDir = new File('../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor-community/safe-area/android')
|
||||
|
||||
@@ -27,4 +30,4 @@ include ':capawesome-capacitor-badge'
|
||||
project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge/android')
|
||||
|
||||
include ':nostr-signer-capacitor-plugin'
|
||||
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@8.0.1/node_modules/nostr-signer-capacitor-plugin/android')
|
||||
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_2704ecccfd05fcfb1ad8852744422b7c/node_modules/nostr-signer-capacitor-plugin/android')
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
temp_env=$(declare -p -x)
|
||||
|
||||
if [ -f .env.template ]; then
|
||||
source .env.template
|
||||
if [ -f .env ]; then
|
||||
source .env
|
||||
fi
|
||||
|
||||
# Avoid overwriting env vars provided directly
|
||||
@@ -14,12 +14,13 @@ if [[ -z $VITE_BUILD_HASH ]]; then
|
||||
export VITE_BUILD_HASH=$(git rev-parse --short HEAD)
|
||||
fi
|
||||
|
||||
if [[ $VITE_PLATFORM_LOGO =~ ^https://* ]]; then
|
||||
curl $VITE_PLATFORM_LOGO > static/logo.png
|
||||
if [[ $VITE_PLATFORM_LOGO =~ ^https:// ]]; then
|
||||
curl -fSL "$VITE_PLATFORM_LOGO" -o static/logo.png
|
||||
export VITE_PLATFORM_LOGO=static/logo.png
|
||||
fi
|
||||
|
||||
npx pwa-assets-generator
|
||||
# Ensure generator uses local path (dotenv may have loaded URL from .env)
|
||||
VITE_PLATFORM_LOGO="${VITE_PLATFORM_LOGO}" npx pwa-assets-generator
|
||||
npx vite build
|
||||
|
||||
# Replace index.html variables with stuff from our env
|
||||
|
||||
@@ -4,6 +4,9 @@ const config: CapacitorConfig = {
|
||||
appId: "social.flotilla",
|
||||
appName: "Flotilla",
|
||||
webDir: "build",
|
||||
ios: {
|
||||
scheme: "Flotilla Chat",
|
||||
},
|
||||
android: {
|
||||
adjustMarginsForEdgeToEdge: true,
|
||||
},
|
||||
|
||||
@@ -358,14 +358,14 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 30;
|
||||
CURRENT_PROJECT_VERSION = 35;
|
||||
DEVELOPMENT_TEAM = S26U9DYW3A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.6.4;
|
||||
MARKETING_VERSION = 1.7.2;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -385,14 +385,14 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 30;
|
||||
CURRENT_PROJECT_VERSION = 35;
|
||||
DEVELOPMENT_TEAM = S26U9DYW3A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.6.4;
|
||||
MARKETING_VERSION = 1.7.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
||||
@@ -20,8 +20,16 @@
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Flotilla uses the microphone for voice chat in rooms.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
@@ -47,11 +55,5 @@
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -11,6 +11,7 @@ install! 'cocoapods', :disable_input_output_paths => true
|
||||
def capacitor_pods
|
||||
pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/ios'
|
||||
pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/ios'
|
||||
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 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app'
|
||||
pod 'CapacitorFilesystem', :path => '../../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem'
|
||||
@@ -18,7 +19,7 @@ def capacitor_pods
|
||||
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 '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@0.0.4_@capacitor+core@8.0.1/node_modules/nostr-signer-capacitor-plugin'
|
||||
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_2704ecccfd05fcfb1ad8852744422b7c/node_modules/nostr-signer-capacitor-plugin'
|
||||
end
|
||||
|
||||
target 'Flotilla Chat' do
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { execSync } from 'child_process'
|
||||
|
||||
if (execSync('git status --porcelain', { encoding: 'utf8' }).trim()) {
|
||||
console.error('Error: Git working tree is dirty. Please commit or stash your changes first.')
|
||||
const force = process.argv.includes('--force')
|
||||
|
||||
if (execSync('git status --porcelain', { encoding: 'utf8' }).trim() && !force) {
|
||||
console.error('Error: Git working tree is dirty. Please commit or stash your changes first, or re-run with --force.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -22,7 +23,9 @@ pkg.pnpm.overrides["@welshman/router"] = "link:../welshman/packages/router"
|
||||
pkg.pnpm.overrides["@welshman/signer"] = "link:../welshman/packages/signer"
|
||||
pkg.pnpm.overrides["@welshman/store"] = "link:../welshman/packages/store"
|
||||
pkg.pnpm.overrides["@welshman/util"] = "link:../welshman/packages/util"
|
||||
// pkg.pnpm.overrides["nostr-editor"] = "link:../nostr-editor"
|
||||
// pkg.pnpm.overrides["@pomade/core"] = "link:../pomade/packages/core"
|
||||
// pkg.pnpm.overrides["nostr-signer-capacitor-plugin"] = "link:../nostr-signer-capacitor-plugin"
|
||||
|
||||
fs.writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\n')
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"name": "flotilla",
|
||||
"version": "1.6.4",
|
||||
"version": "1.7.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "./build.sh",
|
||||
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"tauri:info": "tauri info",
|
||||
"tauri:icons": "tauri icon assets/logo.png --output src-tauri/icons",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check src && eslint src",
|
||||
@@ -18,6 +22,7 @@
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@sveltejs/kit": "^2.50.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||
"@tauri-apps/cli": "^2.9.6",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"classnames": "^2.5.1",
|
||||
@@ -37,6 +42,7 @@
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@aparajita/capacitor-secure-storage": "^8.0.0",
|
||||
"@capacitor-community/safe-area": "^8.0.1",
|
||||
"@capacitor/android": "^8.0.1",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
@@ -52,7 +58,7 @@
|
||||
"@getalby/lightning-tools": "^6.1.0",
|
||||
"@getalby/sdk": "^5.1.2",
|
||||
"@noble/curves": "^1.9.7",
|
||||
"@pomade/core": "^0.0.12",
|
||||
"@pomade/core": "^0.2.2",
|
||||
"@poppanator/sveltekit-svg": "^4.2.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@tiptap/core": "^2.27.2",
|
||||
@@ -60,17 +66,17 @@
|
||||
"@types/throttle-debounce": "^5.0.2",
|
||||
"@vite-pwa/assets-generator": "^0.2.6",
|
||||
"@vite-pwa/sveltekit": "^0.6.8",
|
||||
"@welshman/app": "^0.8.4",
|
||||
"@welshman/content": "^0.8.4",
|
||||
"@welshman/editor": "^0.8.4",
|
||||
"@welshman/feeds": "^0.8.4",
|
||||
"@welshman/lib": "^0.8.4",
|
||||
"@welshman/net": "^0.8.4",
|
||||
"@welshman/router": "^0.8.4",
|
||||
"@welshman/signer": "^0.8.4",
|
||||
"@welshman/store": "^0.8.4",
|
||||
"@welshman/util": "^0.8.4",
|
||||
"compressorjs": "^1.2.1",
|
||||
"@welshman/app": "^0.8.12",
|
||||
"@welshman/content": "^0.8.12",
|
||||
"@welshman/editor": "^0.8.12",
|
||||
"@welshman/feeds": "^0.8.12",
|
||||
"@welshman/lib": "^0.8.12",
|
||||
"@welshman/net": "^0.8.12",
|
||||
"@welshman/router": "^0.8.12",
|
||||
"@welshman/signer": "^0.8.12",
|
||||
"@welshman/store": "^0.8.12",
|
||||
"@welshman/util": "^0.8.12",
|
||||
"compressorjs-next": "^1.1.2",
|
||||
"daisyui": "^4.12.24",
|
||||
"date-picker-svelte": "^2.17.0",
|
||||
"dotenv": "^16.6.1",
|
||||
@@ -78,7 +84,8 @@
|
||||
"fuse.js": "^7.1.0",
|
||||
"husky": "^9.1.7",
|
||||
"idb": "^8.0.3",
|
||||
"nostr-signer-capacitor-plugin": "^0.0.4",
|
||||
"livekit-client": "^2.17.2",
|
||||
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
|
||||
"nostr-tools": "^2.19.4",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"qr-scanner": "^1.4.2",
|
||||
@@ -91,7 +98,8 @@
|
||||
"esbuild"
|
||||
],
|
||||
"onlyBuiltDependencies": [
|
||||
"sharp"
|
||||
"sharp",
|
||||
"nostr-signer-capacitor-plugin"
|
||||
],
|
||||
"overrides": {
|
||||
"sharp": "0.35.0-rc.0"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import dotenv from "dotenv"
|
||||
import {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config"
|
||||
|
||||
dotenv.config({path: ".env.local"})
|
||||
dotenv.config({path: ".env"})
|
||||
dotenv.config({path: ".env.template"})
|
||||
|
||||
export default defineConfig({
|
||||
preset,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "1.92.0"
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "flotilla"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "flotilla_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.5.3", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.9.5", features = [] }
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default desktop capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 668 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 926 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "1.92.0"
|
||||
@@ -0,0 +1,6 @@
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
flotilla_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Flotilla",
|
||||
"mainBinaryName": "flotilla",
|
||||
"identifier": "social.flotilla.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"devUrl": "http://localhost:1847",
|
||||
"frontendDist": "../build"
|
||||
},
|
||||
"app": {
|
||||
"security": {
|
||||
"capabilities": ["default"]
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Flotilla",
|
||||
"width": 1240,
|
||||
"height": 775,
|
||||
"resizable": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"active": false,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@
|
||||
--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));
|
||||
--video-call-panel-bg: #181e24;
|
||||
}
|
||||
|
||||
[data-theme] {
|
||||
@@ -274,7 +275,7 @@
|
||||
.input-editor,
|
||||
.chat-editor,
|
||||
.note-editor {
|
||||
@apply -m-1 min-h-12 p-1 text-sm;
|
||||
@apply -m-1 p-1;
|
||||
}
|
||||
|
||||
.tiptap {
|
||||
@@ -300,7 +301,7 @@
|
||||
}
|
||||
|
||||
.tiptap {
|
||||
@apply max-h-[350px] overflow-y-auto p-2 px-4;
|
||||
@apply max-h-[350px] min-h-10 overflow-y-auto p-2 px-4;
|
||||
}
|
||||
|
||||
.tiptap p.is-editor-empty:first-child::before {
|
||||
@@ -394,6 +395,35 @@ progress[value]::-webkit-progress-value {
|
||||
@apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))];
|
||||
}
|
||||
|
||||
.cw-video-call-content {
|
||||
@apply w-full md:left-[calc(18.5rem+18rem)] md:w-[calc(100%-18.5rem-18rem-var(--sair))];
|
||||
}
|
||||
|
||||
/* Voice: desktop split — plain CSS so / in calc is not parsed as Tailwind slash syntax */
|
||||
.cw-split-video {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cw-split-chat {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.cw-split-video {
|
||||
left: 18.5rem;
|
||||
right: auto;
|
||||
width: calc((100vw - 18.5rem - var(--sair)) / 2);
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.cw-split-chat {
|
||||
left: calc(18.5rem + (100vw - 18.5rem - var(--sair)) / 2);
|
||||
right: auto;
|
||||
width: calc((100vw - 18.5rem - var(--sair)) / 2);
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cw-full {
|
||||
@apply w-full md:left-[4rem] md:w-[calc(100%-4rem-var(--sair))];
|
||||
}
|
||||
@@ -402,6 +432,10 @@ progress[value]::-webkit-progress-value {
|
||||
@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 */
|
||||
|
||||
body.keyboard-open .cb {
|
||||
@@ -418,6 +452,33 @@ body.keyboard-open .hide-on-keyboard {
|
||||
@apply cb cw fixed z-compose;
|
||||
}
|
||||
|
||||
.chat__scroll-down {
|
||||
@apply fixed bottom-28 right-4 z-feature md:bottom-16;
|
||||
.chat__compose-zone {
|
||||
@apply cb cw fixed z-compose;
|
||||
}
|
||||
|
||||
.chat__compose-zone .chat__compose-inner {
|
||||
@apply min-w-0;
|
||||
}
|
||||
|
||||
.chat__compose-zone.cw-video-call-content {
|
||||
@apply md:left-[calc(18.5rem+18rem)] md:w-[calc(100%-18.5rem-18rem-var(--sair))];
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.chat__compose-zone.cw-split-chat {
|
||||
left: calc(18.5rem + (100vw - 18.5rem - var(--sair)) / 2);
|
||||
right: auto;
|
||||
width: calc((100vw - 18.5rem - var(--sair)) / 2);
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.chat__scroll-down {
|
||||
@apply pb-sai fixed bottom-28 right-4 z-feature md:bottom-16;
|
||||
}
|
||||
|
||||
/* content visibility */
|
||||
|
||||
.cv {
|
||||
content-visibility: auto;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
import {page} from "$app/stores"
|
||||
import {pubkey} from "@welshman/app"
|
||||
import Dialog from "@lib/components/Dialog.svelte"
|
||||
import Landing from "@app/components/Landing.svelte"
|
||||
import Toast from "@app/components/Toast.svelte"
|
||||
import PrimaryNav from "@app/components/PrimaryNav.svelte"
|
||||
import {modals} from "@app/util/modal"
|
||||
import {modal} from "@app/util/modal"
|
||||
|
||||
interface Props {
|
||||
children: Snippet
|
||||
@@ -20,8 +19,8 @@
|
||||
<PrimaryNav>
|
||||
{@render children?.()}
|
||||
</PrimaryNav>
|
||||
{:else if !$modals[$page.url.hash.slice(1)]}
|
||||
<Dialog children={{component: Landing, props: {}}} />
|
||||
{:else if !$modal}
|
||||
<Dialog noEscape children={{component: Landing, props: {}}} />
|
||||
{/if}
|
||||
</div>
|
||||
<Toast />
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</script>
|
||||
|
||||
<Link
|
||||
class="col-3 card2 bg-alt w-full cursor-pointer shadow-md"
|
||||
class="cv col-3 card2 bg-alt w-full cursor-pointer shadow-md"
|
||||
href={makeCalendarPath(url, getAddress(event))}>
|
||||
<CalendarEventHeader {event} />
|
||||
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
import {onMount} from "svelte"
|
||||
import {goto} from "$app/navigation"
|
||||
import {
|
||||
ago,
|
||||
int,
|
||||
ms,
|
||||
partition,
|
||||
ifLet,
|
||||
spec,
|
||||
nthEq,
|
||||
nthNe,
|
||||
@@ -32,25 +35,27 @@
|
||||
messagingRelayListsByPubkey,
|
||||
} from "@welshman/app"
|
||||
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||
import ArrowLeft from "@assets/icons/arrow-left.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import PageBar from "@lib/components/PageBar.svelte"
|
||||
import PageContent from "@lib/components/PageContent.svelte"
|
||||
import Divider from "@lib/components/Divider.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ProfileName from "@app/components/ProfileName.svelte"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||
import ChatMembers from "@app/components/ChatMembers.svelte"
|
||||
import ChatMessage from "@app/components/ChatMessage.svelte"
|
||||
import ChatCompose from "@app/components/ChatCompose.svelte"
|
||||
import ChatComposeEdit from "@app/components/ChatComposeEdit.svelte"
|
||||
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
|
||||
import ThunkToast from "@app/components/ThunkToast.svelte"
|
||||
import {userSettingsValues, PLATFORM_NAME, deriveChat} from "@app/core/state"
|
||||
import {userSettingsValues, deriveChat} from "@app/core/state"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {prependParent} from "@app/core/commands"
|
||||
import {makeDelete, prependParent} from "@app/core/commands"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
|
||||
type Props = {
|
||||
@@ -62,13 +67,15 @@
|
||||
|
||||
const chat = deriveChat(pubkeys)
|
||||
const others = remove($pubkey!, pubkeys)
|
||||
const missingRelayLists = $derived(pubkeys.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
|
||||
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
|
||||
|
||||
const showMembers = () =>
|
||||
others.length === 1
|
||||
? pushModal(ProfileDetail, {pubkey: others[0]})
|
||||
: pushModal(ChatMembers, {pubkeys: others})
|
||||
|
||||
const back = () => goto("/chat")
|
||||
|
||||
const replyTo = (event: TrustedEvent) => {
|
||||
parent = event
|
||||
compose?.focus()
|
||||
@@ -78,73 +85,117 @@
|
||||
parent = undefined
|
||||
}
|
||||
|
||||
const onSubmit = async (params: EventContent) => {
|
||||
const ptags = remove($pubkey!, pubkeys).map(tagPubkey)
|
||||
|
||||
// Remove p tags since they result in forking the conversation
|
||||
params.tags = params.tags.filter(nthNe(0, "p"))
|
||||
|
||||
// Add our reply quote to content
|
||||
params = prependParent(parent, params)
|
||||
|
||||
const [imetaTags, tags] = partition(nthEq(0, "imeta"), params.tags)
|
||||
const imetas = getTags("imeta", imetaTags).map(tagsFromIMeta)
|
||||
const templates: EventTemplate[] = []
|
||||
const buffer = []
|
||||
|
||||
const addTemplate = (kind: number, content: string, tags: string[][]) => {
|
||||
content = content.trim()
|
||||
|
||||
if (content) {
|
||||
templates.push(makeEvent(kind, {content, tags: [...tags, ...ptags]}))
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of parse(params)) {
|
||||
const imeta = isLink(p)
|
||||
? imetas.find(tags => tags.find(spec(["url", p.value.url.toString()])))
|
||||
: undefined
|
||||
|
||||
if (isLink(p) && imeta) {
|
||||
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
|
||||
addTemplate(
|
||||
DIRECT_MESSAGE_FILE,
|
||||
p.value.url.toString(),
|
||||
imeta.slice(1).filter(nthNe(0, "url")),
|
||||
)
|
||||
} else {
|
||||
buffer.push(p.raw)
|
||||
}
|
||||
}
|
||||
|
||||
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
|
||||
|
||||
// Split the message into multiple pieces so that we can use kind 15 to send images per nip 17
|
||||
// Sleep 1 second between each one to make sure timestamps are distinct
|
||||
const thunks = await Promise.all(
|
||||
Array.from(enumerate(templates)).map(([i, event]) =>
|
||||
sendWrapped({
|
||||
event,
|
||||
recipients: pubkeys,
|
||||
delay: $userSettingsValues.send_delay + ms(i),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
pushToast({
|
||||
timeout: 30_000,
|
||||
children: {
|
||||
component: ThunkToast,
|
||||
props: {thunk: mergeThunks(thunks)},
|
||||
},
|
||||
})
|
||||
|
||||
clearParent()
|
||||
const clearEventToEdit = () => {
|
||||
eventToEdit = undefined
|
||||
}
|
||||
|
||||
const onSubmit = async (params: EventContent) => {
|
||||
try {
|
||||
const ptags = remove($pubkey!, pubkeys).map(tagPubkey)
|
||||
|
||||
// Remove p tags since they result in forking the conversation
|
||||
params.tags = params.tags.filter(nthNe(0, "p"))
|
||||
|
||||
// Add our reply quote to content
|
||||
params = prependParent(parent, params)
|
||||
|
||||
if (eventToEdit) {
|
||||
if (eventToEdit.content === params.content) {
|
||||
return
|
||||
}
|
||||
|
||||
await sendWrapped({
|
||||
event: makeDelete({event: eventToEdit, protect: false}),
|
||||
recipients: pubkeys,
|
||||
pow: 16,
|
||||
})
|
||||
}
|
||||
|
||||
const [imetaTags, tags] = partition(nthEq(0, "imeta"), params.tags)
|
||||
const imetas = getTags("imeta", imetaTags).map(tagsFromIMeta)
|
||||
const templates: EventTemplate[] = []
|
||||
const buffer = []
|
||||
|
||||
const addTemplate = (kind: number, content: string, tags: string[][]) => {
|
||||
content = content.trim()
|
||||
|
||||
if (content) {
|
||||
templates.push(
|
||||
makeEvent(kind, {
|
||||
content,
|
||||
tags: [...tags, ...ptags],
|
||||
created_at: eventToEdit?.created_at,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of parse(params)) {
|
||||
const imeta = isLink(p)
|
||||
? imetas.find(tags => tags.find(spec(["url", p.value.url.toString()])))
|
||||
: undefined
|
||||
|
||||
if (isLink(p) && imeta) {
|
||||
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
|
||||
addTemplate(
|
||||
DIRECT_MESSAGE_FILE,
|
||||
p.value.url.toString(),
|
||||
imeta.slice(1).filter(nthNe(0, "url")),
|
||||
)
|
||||
} else {
|
||||
buffer.push(p.raw)
|
||||
}
|
||||
}
|
||||
|
||||
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
|
||||
|
||||
// Split the message into multiple pieces so that we can use kind 15 to send images per nip 17
|
||||
// Sleep 1 second between each one to make sure timestamps are distinct
|
||||
const thunks = await Promise.all(
|
||||
Array.from(enumerate(templates)).map(([i, event]) =>
|
||||
sendWrapped({
|
||||
event,
|
||||
recipients: pubkeys,
|
||||
delay: $userSettingsValues.send_delay + ms(i),
|
||||
pow: 16,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
pushToast({
|
||||
timeout: 30_000,
|
||||
children: {
|
||||
component: ThunkToast,
|
||||
props: {thunk: mergeThunks(thunks)},
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
clearParent()
|
||||
clearEventToEdit()
|
||||
}
|
||||
}
|
||||
|
||||
const onEscape = () => {
|
||||
clearParent()
|
||||
clearEventToEdit()
|
||||
}
|
||||
|
||||
const canEditEvent = (event: TrustedEvent) =>
|
||||
event.pubkey === $pubkey &&
|
||||
event.kind === DIRECT_MESSAGE &&
|
||||
event.created_at >= ago(500, MINUTE)
|
||||
|
||||
const onEditEvent = (event: TrustedEvent) => {
|
||||
clearParent()
|
||||
eventToEdit = event
|
||||
}
|
||||
|
||||
const onEditPrevious = () => ifLet($chat?.messages.toReversed().find(canEditEvent), onEditEvent)
|
||||
|
||||
let loading = $state(true)
|
||||
let compose: ChatCompose | undefined = $state()
|
||||
let parent: TrustedEvent | undefined = $state()
|
||||
let eventToEdit: TrustedEvent | undefined = $state()
|
||||
let chatCompose: HTMLElement | undefined = $state()
|
||||
let dynamicPadding: HTMLElement | undefined = $state()
|
||||
|
||||
@@ -204,75 +255,59 @@
|
||||
</script>
|
||||
|
||||
<PageBar>
|
||||
{#snippet title()}
|
||||
<Button class="flex flex-col gap-1 sm:flex-row sm:gap-2" onclick={showMembers}>
|
||||
{#if others.length === 0}
|
||||
<div class="row-2">
|
||||
<ProfileCircle pubkey={$pubkey!} size={5} />
|
||||
<ProfileName pubkey={$pubkey!} />
|
||||
</div>
|
||||
{:else if others.length === 1}
|
||||
<div class="row-2">
|
||||
<ProfileCircle pubkey={others[0]} size={5} />
|
||||
<ProfileName pubkey={others[0]} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2">
|
||||
<ProfileCircles pubkeys={others} size={5} />
|
||||
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<ProfileName pubkey={others[0]} />
|
||||
and
|
||||
{#if others.length === 2}
|
||||
<ProfileName pubkey={others[1]} />
|
||||
{:else}
|
||||
{others.length - 1}
|
||||
{others.length > 2 ? "others" : "other"}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex">
|
||||
<Button onclick={back} class="place-self-start pr-3 md:hidden flex items-center">
|
||||
<Icon icon={ArrowLeft} size={7} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
{#if remove($pubkey, missingRelayLists).length > 0}
|
||||
{@const count = remove($pubkey, missingRelayLists).length}
|
||||
{@const label = count > 1 ? "lists are" : "list is"}
|
||||
<div
|
||||
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer"
|
||||
data-tip="{count} messaging {label} not configured.">
|
||||
<Icon icon={Danger} />
|
||||
{count}
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="ellipsize flex items-center gap-4 whitespace-nowrap">
|
||||
<Button class="flex flex-col gap-1 sm:flex-row sm:gap-2" onclick={showMembers}>
|
||||
{#if others.length === 0}
|
||||
<div class="row-2">
|
||||
<ProfileCircle pubkey={$pubkey!} size={5} />
|
||||
<ProfileName pubkey={$pubkey!} />
|
||||
</div>
|
||||
{:else if others.length === 1}
|
||||
<div class="row-2">
|
||||
<ProfileCircle pubkey={others[0]} size={5} />
|
||||
<ProfileName pubkey={others[0]} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2">
|
||||
<ProfileCircles pubkeys={others} size={5} />
|
||||
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<ProfileName pubkey={others[0]} />
|
||||
and
|
||||
{#if others.length === 2}
|
||||
<ProfileName pubkey={others[1]} />
|
||||
{:else}
|
||||
{others.length - 1}
|
||||
{others.length > 2 ? "others" : "other"}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</div>
|
||||
</div>
|
||||
</PageBar>
|
||||
|
||||
<PageContent class="flex flex-col-reverse gap-2 pt-4">
|
||||
<div bind:this={dynamicPadding}></div>
|
||||
{#if missingRelayLists.includes($pubkey!)}
|
||||
{#if missingRelayLists.length > 0}
|
||||
<div class="py-12">
|
||||
<div class="card2 col-2 m-auto max-w-md items-center text-center">
|
||||
<p class="row-2 text-lg text-error">
|
||||
<Icon icon={Danger} />
|
||||
Your messaging relays are not configured.
|
||||
Direct messages are not enabled
|
||||
</p>
|
||||
<p>
|
||||
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please visit
|
||||
your <Link class="link" href="/settings/relays">relay settings page</Link> to receive messages.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if missingRelayLists.length > 0}
|
||||
<div class="py-12">
|
||||
<div class="card2 col-2 m-auto max-w-md items-center text-center">
|
||||
<p class="row-2 text-lg text-error">
|
||||
<Icon icon={Danger} />
|
||||
{missingRelayLists.length} messaging
|
||||
{missingRelayLists.length > 1 ? "lists are" : "list is"} not configured.
|
||||
</p>
|
||||
<p>
|
||||
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please make
|
||||
sure everyone in this conversation has set up their messaging relays.
|
||||
Ask
|
||||
{#each missingRelayLists as pubkey (pubkey)}
|
||||
<ProfileLink {pubkey} />
|
||||
{/each}
|
||||
to enable direct messaging by opening this conversation in their app.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -285,7 +320,9 @@
|
||||
event={$state.snapshot(value as TrustedEvent)}
|
||||
{pubkeys}
|
||||
{showPubkey}
|
||||
{replyTo} />
|
||||
{replyTo}
|
||||
canEdit={canEditEvent}
|
||||
onEdit={onEditEvent} />
|
||||
{/if}
|
||||
{/each}
|
||||
<p class="m-auto flex h-10 max-w-sm flex-col items-center justify-center gap-4 py-20 text-center">
|
||||
@@ -305,6 +342,17 @@
|
||||
{#if parent}
|
||||
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
||||
{/if}
|
||||
{#if eventToEdit}
|
||||
<ChatComposeEdit clear={clearEventToEdit} />
|
||||
{/if}
|
||||
</div>
|
||||
<ChatCompose bind:this={compose} {onSubmit} />
|
||||
{#key eventToEdit}
|
||||
<ChatCompose
|
||||
bind:this={compose}
|
||||
{onSubmit}
|
||||
{onEscape}
|
||||
{onEditPrevious}
|
||||
content={eventToEdit?.content}
|
||||
disabled={Boolean(missingRelayLists.length)} />
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {onDestroy, onMount} from "svelte"
|
||||
import {writable} from "svelte/store"
|
||||
import cx from "classnames"
|
||||
import type {EventContent} from "@welshman/util"
|
||||
import {isMobile, preventDefault} from "@lib/html"
|
||||
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
|
||||
@@ -10,21 +12,44 @@
|
||||
import {makeEditor} from "@app/editor"
|
||||
|
||||
type Props = {
|
||||
content?: string
|
||||
disabled?: boolean
|
||||
onEscape?: () => void
|
||||
onEditPrevious?: () => void
|
||||
onSubmit: (event: EventContent) => void
|
||||
}
|
||||
|
||||
const {onSubmit}: Props = $props()
|
||||
const {content, disabled = false, onEscape, onEditPrevious, onSubmit}: Props = $props()
|
||||
|
||||
const autofocus = !isMobile
|
||||
const autofocus = !isMobile && !disabled
|
||||
|
||||
const uploading = writable(false)
|
||||
|
||||
const editorClass = $derived(
|
||||
cx("chat-editor flex-grow overflow-hidden", {
|
||||
"pointer-events-none opacity-50": disabled,
|
||||
}),
|
||||
)
|
||||
|
||||
export const focus = () => editor.then(ed => ed.chain().focus().run())
|
||||
|
||||
export const canEnterEditPrevious = () =>
|
||||
editor.then(ed => ed.getText({blockSeparator: "\n"}) === "")
|
||||
|
||||
const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onEscape?.()
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp" && (await canEnterEditPrevious())) {
|
||||
onEditPrevious?.()
|
||||
}
|
||||
}
|
||||
|
||||
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
|
||||
|
||||
const submit = async () => {
|
||||
if ($uploading) return
|
||||
if ($uploading || disabled) return
|
||||
|
||||
const ed = await editor
|
||||
const content = ed.getText({blockSeparator: "\n"}).trim()
|
||||
@@ -38,19 +63,30 @@
|
||||
}
|
||||
|
||||
const editor = makeEditor({
|
||||
content,
|
||||
autofocus,
|
||||
submit,
|
||||
uploading,
|
||||
aggressive: true,
|
||||
encryptFiles: true,
|
||||
})
|
||||
|
||||
onMount(async () => {
|
||||
const ed = await editor
|
||||
ed.view.dom.addEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
onDestroy(async () => {
|
||||
const ed = await editor
|
||||
ed?.view?.dom.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
|
||||
<Button
|
||||
data-tip="Add an image"
|
||||
class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200"
|
||||
disabled={$uploading}
|
||||
disabled={$uploading || disabled}
|
||||
onclick={uploadFiles}>
|
||||
{#if $uploading}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
@@ -58,13 +94,13 @@
|
||||
<Icon icon={GallerySend} />
|
||||
{/if}
|
||||
</Button>
|
||||
<div class="chat-editor flex-grow overflow-hidden">
|
||||
<div class={editorClass} aria-disabled={disabled}>
|
||||
<EditorContent {editor} />
|
||||
</div>
|
||||
<Button
|
||||
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
|
||||
class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full"
|
||||
disabled={$uploading}
|
||||
disabled={$uploading || disabled}
|
||||
onclick={submit}>
|
||||
<Icon icon={Plane} />
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import {slide} from "@lib/transition"
|
||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
|
||||
const {
|
||||
clear,
|
||||
}: {
|
||||
clear: () => void
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative flex h-8 items-center justify-between border-l-2 border-solid border-primary bg-base-300 px-2 pr-7 text-xs"
|
||||
transition:slide>
|
||||
<p class="text-primary">Editing message</p>
|
||||
<Button onclick={clear} class="flex items-center">
|
||||
<Icon icon={CloseCircle} />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {goto} from "$app/navigation"
|
||||
import {getRelaysFromList} from "@welshman/util"
|
||||
import {waitForThunkError, setMessagingRelays, userRelayList, setRelays} from "@welshman/app"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import {shouldUnwrap} from "@welshman/app"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
@@ -11,46 +11,53 @@
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import {PLATFORM_NAME} from "@app/core/state"
|
||||
import {clearModals} from "@app/util/modal"
|
||||
import {DEFAULT_RELAYS, DEFAULT_MESSAGING_RELAYS} from "@app/core/state"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
|
||||
const {next} = $props()
|
||||
type Props = {
|
||||
next: () => void
|
||||
}
|
||||
|
||||
const nextUrl = $state.snapshot(next)
|
||||
const {next}: Props = $props()
|
||||
|
||||
let loading = $state(false)
|
||||
|
||||
const submit = async () => {
|
||||
const back = () => history.back()
|
||||
|
||||
const enable = async () => {
|
||||
loading = true
|
||||
|
||||
try {
|
||||
shouldUnwrap.set(true)
|
||||
clearModals()
|
||||
goto(nextUrl)
|
||||
if (getRelaysFromList($userRelayList).length === 0) {
|
||||
const error = await waitForThunkError(await setRelays(DEFAULT_RELAYS.map(r => ["r", r])))
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const error = await waitForThunkError(await setMessagingRelays(DEFAULT_MESSAGING_RELAYS))
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
return
|
||||
}
|
||||
|
||||
await next()
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const back = () => history.back()
|
||||
</script>
|
||||
|
||||
<Modal tag="form" onsubmit={preventDefault(submit)}>
|
||||
<Modal tag="form" onsubmit={preventDefault(enable)}>
|
||||
<ModalBody>
|
||||
<ModalHeader>
|
||||
<ModalTitle>Enable Messages</ModalTitle>
|
||||
<ModalSubtitle>Do you want to enable direct messages?</ModalSubtitle>
|
||||
<ModalTitle>Enable direct messaging?</ModalTitle>
|
||||
</ModalHeader>
|
||||
<p>
|
||||
By default, direct messages are disabled, since loading them requires
|
||||
{PLATFORM_NAME} to download and decrypt a lot of data.
|
||||
</p>
|
||||
<p>
|
||||
If you'd like to enable them, please make sure your signer is set up to to auto-approve
|
||||
requests to decrypt data.
|
||||
</p>
|
||||
<p>Direct messaging isn't currently enabled. Would you like to turn it on?</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
@@ -58,7 +65,7 @@
|
||||
Go back
|
||||
</Button>
|
||||
<Button type="submit" class="btn btn-primary" disabled={loading}>
|
||||
<Spinner {loading}>Enable Messages</Spinner>
|
||||
<Spinner {loading}>Enable direct messaging</Spinner>
|
||||
<Icon icon={AltArrowRight} />
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {pubkey, loadMessagingRelayList} from "@welshman/app"
|
||||
import {fade} from "@lib/transition"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ProfileName from "@app/components/ProfileName.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||
import {makeChatPath} from "@app/util/routes"
|
||||
import {makeChatPath, goToChat} from "@app/util/routes"
|
||||
import {notifications} from "@app/util/notifications"
|
||||
|
||||
interface Props {
|
||||
@@ -24,6 +24,7 @@
|
||||
const others = uniq(remove($pubkey!, props.pubkeys))
|
||||
const active = $derived($page.params.chat === props.id)
|
||||
const path = makeChatPath(props.pubkeys)
|
||||
const openChat = () => goToChat(props.pubkeys)
|
||||
|
||||
onMount(() => {
|
||||
for (const pk of others) {
|
||||
@@ -32,7 +33,7 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<Link class="flex flex-col justify-start gap-1" href={makeChatPath(props.pubkeys)}>
|
||||
<Button class="flex flex-col justify-start gap-1 w-full" onclick={openChat}>
|
||||
<div
|
||||
class="cursor-pointer border-t border-solid border-base-100 px-6 py-2 transition-colors hover:bg-base-100 {props.class}"
|
||||
class:bg-base-100={active}>
|
||||
@@ -71,4 +72,4 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -23,11 +23,13 @@
|
||||
interface Props {
|
||||
event: TrustedEvent
|
||||
replyTo: (event: TrustedEvent) => void
|
||||
canEdit?: (event: TrustedEvent) => boolean
|
||||
onEdit?: (event: TrustedEvent) => void
|
||||
pubkeys: string[]
|
||||
showPubkey?: boolean
|
||||
}
|
||||
|
||||
const {event, replyTo, pubkeys, showPubkey = false}: Props = $props()
|
||||
const {event, replyTo, canEdit, onEdit, pubkeys, showPubkey = false}: Props = $props()
|
||||
|
||||
const isOwn = event.pubkey === $pubkey
|
||||
const profileDisplay = deriveProfileDisplay(event.pubkey)
|
||||
@@ -35,16 +37,21 @@
|
||||
const [_, colorValue] = colors[hash(event.pubkey) % colors.length]
|
||||
|
||||
const reply = () => replyTo(event)
|
||||
const edit = canEdit?.(event) ? () => onEdit?.(event) : undefined
|
||||
|
||||
const deleteReaction = (event: TrustedEvent) =>
|
||||
sendWrapped({event: makeDelete({event, protect: false}), recipients: pubkeys})
|
||||
sendWrapped({event: makeDelete({event, protect: false}), recipients: pubkeys, pow: 16})
|
||||
|
||||
const createReaction = (template: EventContent) =>
|
||||
sendWrapped({event: makeReaction({event, protect: false, ...template}), recipients: pubkeys})
|
||||
sendWrapped({
|
||||
event: makeReaction({event, protect: false, ...template}),
|
||||
recipients: pubkeys,
|
||||
pow: 16,
|
||||
})
|
||||
|
||||
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
|
||||
|
||||
const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys, reply})
|
||||
const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys, reply, edit})
|
||||
|
||||
const togglePopover = () => {
|
||||
if (popoverIsVisible) {
|
||||
@@ -71,7 +78,7 @@
|
||||
<Tippy
|
||||
bind:popover
|
||||
component={ChatMessageMenu}
|
||||
props={{event, pubkeys, popover, replyTo}}
|
||||
props={{event, pubkeys, popover, replyTo, edit}}
|
||||
params={{
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
@@ -93,7 +100,7 @@
|
||||
{/if}
|
||||
<div class="flex min-w-0 flex-col" class:items-end={isOwn}>
|
||||
<TapTarget
|
||||
class="bg-alt chat-bubble mx-1 mb-2 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl"
|
||||
class="bg-alt chat-bubble mx-1 mb-2 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl min-w-[100px]"
|
||||
onTap={showMobileMenu}>
|
||||
{#if showPubkey}
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
sendWrapped({
|
||||
event: makeReaction({event, content: emoji.unicode, protect: false}),
|
||||
recipients: pubkeys,
|
||||
pow: 16,
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
import ChatMessageEmojiButton from "@app/components/ChatMessageEmojiButton.svelte"
|
||||
import EventInfo from "@app/components/EventInfo.svelte"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||
|
||||
const {event, pubkeys, popover, replyTo} = $props()
|
||||
const {event, pubkeys, popover, replyTo, edit} = $props()
|
||||
|
||||
const reply = () => replyTo(event)
|
||||
const onEdit = () => edit?.()
|
||||
|
||||
const showInfo = () => {
|
||||
popover.hide()
|
||||
@@ -24,6 +26,11 @@
|
||||
<Icon size={4} icon={Reply} />
|
||||
</Button>
|
||||
{/if}
|
||||
{#if edit}
|
||||
<Button class="btn join-item btn-xs" onclick={onEdit}>
|
||||
<Icon size={4} icon={Pen} />
|
||||
</Button>
|
||||
{/if}
|
||||
<Button class="btn join-item btn-xs" onclick={showInfo}>
|
||||
<Icon size={4} icon={Code2} />
|
||||
</Button>
|
||||
|
||||