Compare commits

..

23 Commits

Author SHA1 Message Date
triesap 5a69d305af Remove lightning vitest coverage 2026-02-17 20:01:03 +00:00
triesap ce62cafd59 Replace lightning toolkit with wallet adapter 2026-02-17 19:54:16 +00:00
Jon Staab 6e865fef06 tweak how at works 2026-02-17 10:45:50 -08:00
Jon Staab 588bd0f341 Fix scroll to event behavior 2026-02-17 10:45:50 -08:00
Jon Staab 69f6abf4b6 Pin scroll position to at'd event until user scrolls 2026-02-17 10:45:50 -08:00
Jon Staab c8eb4ac31a Simplify goToEvent 2026-02-17 10:45:50 -08:00
Jon Staab e3e69390ce Add forward scrolling to makeMakeFeed 2026-02-17 10:45:49 -08:00
Jon Staab d0b34dfdf8 Make createScroller honor reverse param 2026-02-17 10:45:49 -08:00
Jon Staab bcdb3dc351 Fix duplicate ids in chat 2026-02-17 10:45:49 -08:00
Jon Staab a7b0031b8d Use compressorjs-next 2026-02-17 10:45:49 -08:00
Jon Staab 2c05bc6961 Refactor SpaceSearch into its own component 2026-02-17 10:45:49 -08:00
Ben c2d0ec92bf Space search 2026-02-17 10:45:49 -08:00
Jon Staab 407b4dce94 Fix editing messages with html tags 2026-02-17 10:45:49 -08:00
Jon Staab 796157384f Fix DM media detection 2026-02-17 10:45:49 -08:00
Jon Staab 3446977df6 Make hover target for menu button more reasonable 2026-02-17 10:45:49 -08:00
Jon Staab f8016aba99 Watch tracker in feed utils 2026-02-17 10:45:49 -08:00
Jon Staab 56d8527ed9 Revert makeFeed changes 2026-02-17 10:45:49 -08:00
Jon Staab 302788bcba Clean up report item design, bad/restore user actions, space description input, add feed to home page 2026-02-17 10:45:49 -08:00
Jon Staab db075e602a Tweak wallet page 2026-02-17 10:45:49 -08:00
Jon Staab 67011d4740 Fix makeFeed (maybe) 2026-02-17 10:45:49 -08:00
Jon Staab a35d867b34 Tweak room detail 2026-02-17 10:45:49 -08:00
Jon Staab 23b59e54d7 Fix scroll to bottom button safe insets 2026-02-17 10:45:49 -08:00
Jon Staab da2665d2bc Disable wallet on ios 2026-02-17 10:45:49 -08:00
324 changed files with 8349 additions and 16793 deletions
-1
View File
@@ -1,6 +1,5 @@
--ignore-dir=.svelte-kit
--ignore-dir=android
--ignore-dir=target
--ignore-dir=build
--ignore-dir=ios/DerivedData
--ignore-dir=ios/App/App/public
-7
View File
@@ -2,10 +2,3 @@ node_modules
android
ios
build
# Git
.gitignore
# Env files (keep .env for build; exclude local overrides)
.env.local
.env.*.local
+2 -4
View File
@@ -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=https://pomade.coracle.social,https://pomade.fiatjaf.com,https://pomade.nostrver.se,https://pomade.scuttle.works
VITE_POMADE_SIGNERS=
VITE_PLATFORM_URL=https://app.flotilla.social
VITE_PLATFORM_TERMS=https://flotilla.social/terms
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
@@ -15,10 +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_SEARCH_RELAYS=relay.ditto.pub,antiprimal.net,relay.vertexlab.io
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com
VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
VITE_THUMBNAIL_URL=https://vthumbs.coracle.social
VITE_GLITCHTIP_API_KEY=
GLITCHTIP_AUTH_TOKEN=
+1 -4
View File
@@ -1,6 +1,4 @@
src/assets
.claude
target
build
.idea
.gradle
@@ -14,5 +12,4 @@ ios/App/Pods/
android/capacitor-cordova-android-plugins
android/app/src/androidTest
android/app/src/test
node_modules
.svelte-kit
@@ -1,17 +1,12 @@
name: Container Image Build and Publish
name: Docker
on:
push:
branches: [master]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: gitea.coracle.social
IMAGE_NAME: coracle/flotilla
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-image:
@@ -28,8 +23,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: hodlbod
password: ${{ secrets.PACKAGE_TOKEN }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
@@ -37,7 +32,6 @@ jobs:
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
- name: Set up Docker Buildx
@@ -51,7 +45,6 @@ jobs:
with:
context: .
push: true
target: production
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+1 -12
View File
@@ -1,16 +1,10 @@
# Env
.env.local
.env.*.local
.env
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Playwright
/test-results/
/playwright-report/
/playwright/.cache/
# Generated assets
static/favicon.ico
static/pwa-64x64.png
@@ -30,11 +24,8 @@ android/app/src/main/assets/public/
# Web/JavaScript
node_modules/
.pnpm-store/
build/
build-server/
.svelte-kit/
.next/
# iOS
ios/App/App/public
@@ -72,9 +63,7 @@ GoogleService-Info.plist
.roo
.idea/
.vscode/
.claude/
# OS generated
.DS_Store
Thumbs.db
package-lock.json
-10
View File
@@ -169,16 +169,6 @@ src/
- 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
-110
View File
@@ -1,115 +1,5 @@
# Changelog
# 1.8.0
* Fix relay badge overflow
* Suppress programmatic scroll when user is scrolling
* Fix vertical alignment of emoji and overflow buttons in shared event action row
* Use type=email for signup/login email inputs, validate password
* Improve toggle switch placement on settings screens
* Fix relay auth privacy toggle
* Improve field layout
* Add progress bar to signup flow
* Bundle emojis properly
* Rework hosting page
* Fix padding on pages on small screens
* Add richer link preview support
* Fix pasting into event summary
* Publish fewer join/claim requests
* Fix new messages not rendering in safari
* Avoid capturing stale cleanup function in chat
* Hide keyboard on app resume
* Add email rendering support
* Fix bunker login
* Fix undefined chat draft key
* Allow sharing to chat without a message
* Make sure to show date on calendar events when embedded
* Improve space search
# 1.7.4
* Fix safe area inset for FAB
# 1.7.3
* Add native share support for space invites
* Stop sending duplicate requests per room
* Add more robust thumbnail url generation
* Make space reordering discoverable with smoother drag animation
* Improve relay member list
* Add room mentions and clickable room/relay refs
* Support native clipboard image paste on mobile
* publish kind 9 quote after room content creation for cross-client interoperability
* Improve feed pagination logic and performance
* Support Aegis URL scheme for NIP-46 login
* Various UI and bug fixes
* Raise message size limit in chat
* Fix realtime updates for room members and admins
* Add video to calls
* Remove follow graph building
* Add start chat FAB
* Add drafts
* Redesign toast notifications
* Remove room/space leave indications
* Hide report badge for non-admin users
* Add polls
* Add search to recent activity page
* Fix notification badge on mobile nav
* Change audio devices in call
# 1.7.2
* 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
-56
View File
@@ -1,56 +0,0 @@
## 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.
+19 -26
View File
@@ -1,31 +1,24 @@
# Build and run the Flotilla web server.
#
# docker build -t flotilla .
# docker run -p 3000:3000 flotilla
#
# Pass --build-arg VITE_BUILD_HASH=$(git rev-parse --short HEAD) to stamp the build.
# A .env in the build context is picked up by build.sh for branding config.
FROM node:20-slim
# https://pnpm.io/docker#example-3-build-on-cicd
FROM node:24-slim AS builder
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
# Install pnpm
RUN npm install -g pnpm@latest
# Set working directory
WORKDIR /app
ENV NODE_OPTIONS=--max_old_space_size=16384
# Copy package files
COPY package.json pnpm-lock.yaml ./
RUN pnpm i --frozen-lockfile
COPY . .
ARG VITE_BUILD_HASH
RUN pnpm run build
RUN pnpm run build:server
FROM node:24-slim AS production
ENV NODE_ENV=production
WORKDIR /app
COPY --from=builder /app/build /app/build
COPY --from=builder /app/build-server/server.js /app/server.js
EXPOSE 3000
USER node
CMD ["node", "server.js"]
# Install dependencies
RUN pnpm i
# Copy the rest of the application
COPY . .
# Build the application
ENV NODE_OPTIONS=--max_old_space_size=16384
RUN pnpm run build
# Default to serving the build directory
CMD ["npx", "serve", "build"]
+6 -8
View File
@@ -6,23 +6,21 @@ If you would like to be interoperable with Flotilla, please check out this guide
## Environment
You can also optionally create an `.env.local` file and populate it with the following environment variables (see `.env.template` for examples):
You can also optionally create an `.env` file and populate it with the following environment variables (see `.env` 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. Can be a local path or https link. Must be a PNG file.
- `VITE_PLATFORM_LOGO` - A logo url for the app
- `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](AGENTS.md).
## Deployment
@@ -31,18 +29,18 @@ To run your own Flotilla, it's as simple as:
```sh
pnpm install
pnpm run build
pnpm run start
npx serve build
```
Or, if you prefer to use a container:
```sh
docker run -d -p 3000:3000 gitea.coracle.social/coracle/flotilla:latest
podman run -d -p 3000:3000 ghcr.io/coracle-social/flotilla:latest
```
Alternatively, you can copy the build files into a directory of your choice and serve it yourself:
```sh
mkdir ./mount
docker run -v ./mount:/app/mount gitea.coracle.social/coracle/flotilla:latest bash -c 'cp -r build/* mount'
podman run -v ./mount:/app/mount ghcr.io/coracle-social/flotilla:latest bash -c 'cp -r build/* mount'
```
+2 -7
View File
@@ -1,5 +1,4 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
android {
namespace = "social.flotilla"
@@ -8,8 +7,8 @@ android {
applicationId "social.flotilla"
minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion
versionCode 47
versionName "1.8.0"
versionCode 40
versionName "1.6.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -36,10 +35,6 @@ 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"
-3
View File
@@ -9,15 +9,12 @@ 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-clipboard')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-keyboard')
implementation project(':capacitor-preferences')
implementation project(':capacitor-push-notifications')
implementation project(':capacitor-share')
implementation project(':capawesome-capacitor-android-dark-mode-support')
implementation project(':capawesome-capacitor-badge')
implementation project(':nostr-signer-capacitor-plugin')
-5
View File
@@ -42,9 +42,4 @@
<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" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
</manifest>
@@ -1,15 +1,5 @@
package social.flotilla;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
import social.flotilla.notifications.AndroidPushFallbackPlugin;
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(AndroidPushFallbackPlugin.class);
super.onCreate(savedInstanceState);
}
}
public class MainActivity extends BridgeActivity {}
@@ -1,101 +0,0 @@
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.OutOfQuotaPolicy
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)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.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)
}
}
@@ -1,861 +0,0 @@
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 = 30L
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 {
Log.i(TAG, "doWork() started")
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>()
val newEvents = mutableListOf<Pair<String, JSONObject>>()
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)) {
newEvents.add(Pair(sub.relay, event))
}
}
}
for ((relay, event) in newEvents) {
postNotification(relay, event)
}
return Result.success()
} catch (e: Exception) {
Log.e(TAG, "Worker failed", e)
return Result.retry()
} 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()
val notificationId = id.hashCode().let { if (it == 0) 1 else it }
NotificationManagerCompat.from(context).notify(notificationId, notification)
}
private fun matchesFilter(filter: JSONObject, event: JSONObject): Boolean {
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
}
}
}
}
-2
View File
@@ -1,7 +1,6 @@
// 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()
@@ -10,7 +9,6 @@ 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
+1 -10
View File
@@ -2,18 +2,12 @@
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')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app/android')
include ':capacitor-clipboard'
project(':capacitor-clipboard').projectDir = new File('../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/clipboard/android')
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem/android')
@@ -26,9 +20,6 @@ project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@
include ':capacitor-push-notifications'
project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications/android')
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/share/android')
include ':capawesome-capacitor-android-dark-mode-support'
project(':capawesome-capacitor-android-dark-mode-support').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-android-dark-mode-support@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-android-dark-mode-support/android')
@@ -36,4 +27,4 @@ include ':capawesome-capacitor-badge'
project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge/android')
include ':nostr-signer-capacitor-plugin'
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_a3c0fb15d5bfa83f24d0070ca2583fc9/node_modules/nostr-signer-capacitor-plugin/android')
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@8.0.1/node_modules/nostr-signer-capacitor-plugin/android')
+5 -6
View File
@@ -2,8 +2,8 @@
temp_env=$(declare -p -x)
if [ -f .env ]; then
source .env
if [ -f .env.template ]; then
source .env.template
fi
# Avoid overwriting env vars provided directly
@@ -14,13 +14,12 @@ if [[ -z $VITE_BUILD_HASH ]]; then
export VITE_BUILD_HASH=$(git rev-parse --short HEAD)
fi
if [[ $VITE_PLATFORM_LOGO =~ ^https:// ]]; then
curl -fSL "$VITE_PLATFORM_LOGO" -o static/logo.png
if [[ $VITE_PLATFORM_LOGO =~ ^https://* ]]; then
curl $VITE_PLATFORM_LOGO > static/logo.png
export VITE_PLATFORM_LOGO=static/logo.png
fi
# Ensure generator uses local path (dotenv may have loaded URL from .env)
VITE_PLATFORM_LOGO="${VITE_PLATFORM_LOGO}" npx pwa-assets-generator
npx pwa-assets-generator
npx vite build
# Replace index.html variables with stuff from our env
-3
View File
@@ -4,9 +4,6 @@ const config: CapacitorConfig = {
appId: "social.flotilla",
appName: "Flotilla",
webDir: "build",
ios: {
scheme: "Flotilla Chat",
},
android: {
adjustMarginsForEdgeToEdge: true,
},
-12
View File
@@ -1,12 +0,0 @@
import {expect, test} from "@playwright/test"
test("boots the SPA on the home page", async ({page}) => {
const response = await page.goto("/")
expect(response?.ok()).toBeTruthy()
// adapter-static serves an empty shell that hydrates client-side, so the presence of
// rendered text proves the Svelte app actually mounted (not just that a file was served).
// TODO: tighten this to assert concrete onboarding UI once the markup is settled.
await expect(page.locator("body")).toContainText(/\S/, {timeout: 15_000})
})
-29
View File
@@ -1,29 +0,0 @@
import type {SignedEvent} from "@welshman/util"
import type {RelayMockConfig} from "../../src/lib/test/relayMocks"
import relay1Events from "./fixtures/relay1.json"
// Fake relay urls used by tests. Each maps to a json fixture under ./fixtures/ and an entry in
// EVENTS_BY_RELAY below. To add a relay: drop a `<name>.json` file in ./fixtures/, import it, add a
// url here, and wire it into EVENTS_BY_RELAY.
export const FIXTURE_RELAYS = {
relay1: "wss://relay1.test/",
} as const
// The events each fake relay serves. The json files hold static, pre-signed events: schnorr
// signatures are non-deterministic, so events are signed once and committed verbatim (they pass
// verifyEvent, which netContext.isEventValid enforces). Regenerate with @welshman/signer:
// await Nip01Signer.fromSecret(secret).sign(makeEvent(kind, {content, created_at}))
const EVENTS_BY_RELAY: Record<string, SignedEvent[]> = {
[FIXTURE_RELAYS.relay1]: relay1Events as SignedEvent[],
}
// Build a RelayMockConfig populating the given fixture relays (all of them when none are passed).
// Any relay not included returns nothing, keeping tests offline.
export const relayFixtures = (...urls: string[]): RelayMockConfig => {
const selected = urls.length > 0 ? urls : Object.keys(EVENTS_BY_RELAY)
return {
relays: Object.fromEntries(selected.map(url => [url, EVENTS_BY_RELAY[url] ?? []])),
}
}
-29
View File
@@ -1,29 +0,0 @@
[
{
"kind": 0,
"content": "{\"name\":\"Alice\"}",
"tags": [],
"created_at": 1700000000,
"pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"id": "9b3d138641b38364945b20d800268006c2cb7d974bb4b1d63a9f90f5ab974b90",
"sig": "de6b86274e7bcf6c02aa881ada1feee9e01ba320691711d4975916b3cd231ab43cf469a47c5db99503ed72707d5db85fede1ad3763c4fbd7c998d04f00eda6bc"
},
{
"kind": 1,
"content": "hello from the fixture relay",
"tags": [],
"created_at": 1700000000,
"pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"id": "b9874875bfa8d830c5c9ef3673104360cf21b94848a311febfaf52f0a652b1a9",
"sig": "85df94a2e9884ac3d280145492d5191cde2948d49a824c443a1f5d2143633eff1e1789fa7e8843b6efc3dd2dc0d7e33322edb628125d8e35de8ddca1d06ca970"
},
{
"kind": 1,
"content": "reply from bob",
"tags": [],
"created_at": 1700000001,
"pubkey": "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5",
"id": "171dcbdd63d474ba46da609e8b0104cbcf4801fbb581b6c343d9426280f9e1be",
"sig": "eecf26e616a6b70dc67c7eae16fc4fe159314647ba1d4581332257de7f070410aa9a9e41f571b9403ff145d16c9b32766846fce08516201263e25cf08c1ed8f1"
}
]
-30
View File
@@ -1,30 +0,0 @@
import type {Page} from "@playwright/test"
import type {RelayMockConfig} from "../../src/lib/test/relayMocks"
// Must match RELAY_MOCKS_KEY in src/lib/test/relayMocks.ts.
const RELAY_MOCKS_KEY = "__RELAY_MOCKS__"
// Hard safety net: intercept every real websocket so a test can never reach the network, even if
// some code path opens a socket directly (e.g. relay AUTH) rather than going through the adapter
// layer. We never call route.connectToServer(), so the socket connects to Playwright's in-process
// mock and simply receives nothing.
export const blockWebsockets = (page: Page) => page.routeWebSocket(/^wss?:\/\//, () => {})
// Inject the relay-mock config the app reads on startup. addInitScript runs before any page script
// on every navigation, so this must be called before page.goto().
export const injectRelayConfig = (page: Page, config: RelayMockConfig) =>
page.addInitScript(
([key, value]) => {
Object.assign(window, {[key]: value})
},
[RELAY_MOCKS_KEY, config] as const,
)
// Full network isolation plus optional fixtures, in one call. With no config, every relay returns
// nothing (requirement 1). Pass {relays: {url: events}} to populate specific relays (requirement 2).
export const setupRelayMocks = async (page: Page, config: RelayMockConfig = {}) => {
await blockWebsockets(page)
await injectRelayConfig(page, config)
}
export type {RelayMockConfig}
+11 -29
View File
@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objectVersion = 48;
objects = {
/* Begin PBXBuildFile section */
@@ -131,9 +131,8 @@
504EC2FC1FED79650016851F /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 920;
LastUpgradeCheck = 2630;
LastUpgradeCheck = 920;
TargetAttributes = {
504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2;
@@ -258,7 +257,6 @@
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
@@ -266,10 +264,8 @@
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
@@ -279,10 +275,8 @@
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = S26U9DYW3A;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
@@ -301,7 +295,6 @@
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
@@ -321,7 +314,6 @@
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
@@ -329,10 +321,8 @@
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
@@ -342,10 +332,8 @@
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = S26U9DYW3A;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@@ -357,9 +345,7 @@
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
VALIDATE_PRODUCT = YES;
};
name = Release;
@@ -372,16 +358,14 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 38;
CURRENT_PROJECT_VERSION = 31;
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.8.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.6.4;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -401,16 +385,14 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 38;
CURRENT_PROJECT_VERSION = 31;
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.8.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.6.4;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
+6 -10
View File
@@ -20,18 +20,8 @@
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Flotilla uses the camera when you enable it in a voice room.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Flotilla uses the microphone when you enable it in a voice room.</string>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
@@ -57,5 +47,11 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
</dict>
</plist>
+1 -4
View File
@@ -11,17 +11,14 @@ 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 'CapacitorClipboard', :path => '../../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/clipboard'
pod 'CapacitorFilesystem', :path => '../../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem'
pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/keyboard'
pod 'CapacitorPreferences', :path => '../../node_modules/.pnpm/@capacitor+preferences@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/preferences'
pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications'
pod 'CapacitorShare', :path => '../../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/share'
pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge'
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_a3c0fb15d5bfa83f24d0070ca2583fc9/node_modules/nostr-signer-capacitor-plugin'
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@8.0.1/node_modules/nostr-signer-capacitor-plugin'
end
target 'Flotilla Chat' do
+3 -7
View File
@@ -1,18 +1,16 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
import { execSync } from 'child_process'
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.')
if (execSync('git status --porcelain', { encoding: 'utf8' }).trim()) {
console.error('Error: Git working tree is dirty. Please commit or stash your changes first.')
process.exit(1)
}
const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
pkg.pnpm = pkg.pnpm || {}
pkg.pnpm.overrides = pkg.pnpm.overrides || {}
pkg.pnpm.overrides["@welshman/app"] = "link:../welshman/packages/app"
pkg.pnpm.overrides["@welshman/content"] = "link:../welshman/packages/content"
@@ -24,9 +22,7 @@ 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')
+36 -43
View File
@@ -1,18 +1,14 @@
{
"name": "flotilla",
"version": "1.8.0",
"version": "1.6.4",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "./build.sh",
"build:server": "vite build --config vite.config.server.ts",
"start": "node server.js",
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
"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",
"test": "playwright test",
"test:ui": "playwright test --ui",
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
"format:all": "prettier --write src",
"prepare": "husky"
@@ -20,87 +16,84 @@
"devDependencies": {
"@capacitor/assets": "^3.0.5",
"@eslint/js": "^9.39.2",
"@playwright/test": "^1.49.1",
"@sveltejs/kit": "^2.61.1",
"@sveltejs/vite-plugin-svelte": "^5.1.1",
"@tailwindcss/postcss": "^4.2.2",
"@sveltejs/kit": "^2.50.1",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@types/eslint": "^9.6.1",
"@types/node": "^25.9.1",
"autoprefixer": "^10.4.23",
"classnames": "^2.5.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^9.1.2",
"eslint-plugin-svelte": "^2.46.1",
"globals": "^15.15.0",
"postcss": "^8.5.15",
"postcss": "^8.5.6",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.55.9",
"svelte": "^5.48.0",
"svelte-check": "^4.3.5",
"tailwindcss": "^4.2.2",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"typescript-eslint": "^8.53.1",
"vite": "^6.4.2"
"vite": "^5.4.21"
},
"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",
"@capacitor/cli": "^8.0.1",
"@capacitor/clipboard": "^8.0.1",
"@capacitor/core": "^8.0.1",
"@capacitor/filesystem": "^8.1.0",
"@capacitor/ios": "^8.0.1",
"@capacitor/keyboard": "^8.0.0",
"@capacitor/preferences": "^8.0.0",
"@capacitor/push-notifications": "^8.0.0",
"@capacitor/share": "^8.0.1",
"@capawesome/capacitor-android-dark-mode-support": "^8.0.0",
"@capawesome/capacitor-badge": "^8.0.0",
"@getalby/lightning-tools": "^6.1.0",
"@getalby/sdk": "^5.1.2",
"@hono/node-server": "^2.0.0",
"@noble/curves": "^1.9.7",
"@pomade/core": "^0.2.5",
"@poppanator/sveltekit-svg": "^7.0.0",
"@noble/hashes": "^2.0.1",
"@pomade/core": "^0.0.12",
"@poppanator/sveltekit-svg": "^4.2.1",
"@sveltejs/adapter-static": "^3.0.10",
"@tiptap/core": "^2.27.2",
"@tiptap/pm": "^2.27.2",
"@types/qrcode": "^1.5.6",
"@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^1.0.2",
"@vite-pwa/sveltekit": "^1.1.0",
"@welshman/app": "^0.8.16",
"@welshman/content": "^0.8.16",
"@welshman/editor": "^0.8.16",
"@welshman/feeds": "^0.8.16",
"@welshman/lib": "^0.8.16",
"@welshman/net": "^0.8.16",
"@welshman/router": "^0.8.16",
"@welshman/signer": "^0.8.16",
"@welshman/store": "^0.8.16",
"@welshman/util": "^0.8.16",
"cheerio": "^1.2.0",
"@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-next": "^1.1.2",
"daisyui": "^5.5.19",
"daisyui": "^4.12.24",
"date-picker-svelte": "^2.17.0",
"dotenv": "^16.6.1",
"emoji-picker-element": "^1.28.1",
"emoji-picker-element-data": "^1.8.0",
"fuse.js": "^7.1.0",
"hono": "^4.12.23",
"husky": "^9.1.7",
"idb": "^8.0.3",
"livekit-client": "^2.17.2",
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
"nostr-signer-capacitor-plugin": "^0.0.4",
"nostr-tools": "^2.19.4",
"prettier-plugin-tailwindcss": "^0.7.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"throttle-debounce": "^5.0.2",
"tippy.js": "^6.3.7"
},
"packageManager": "pnpm@11.5.1"
"pnpm": {
"ignoredBuiltDependencies": [
"esbuild"
],
"onlyBuiltDependencies": [
"sharp"
],
"overrides": {
"sharp": "0.35.0-rc.0"
}
}
}
-27
View File
@@ -1,27 +0,0 @@
import {defineConfig, devices} from "@playwright/test"
// E2E tests live in ./e2e and run against the dev server (port 1847 from vite.config.ts).
// Run with `pnpm test:e2e` (after `pnpm exec playwright install` to fetch browsers).
export default defineConfig({
testDir: "e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: "html",
use: {
baseURL: "http://localhost:1847",
trace: "on-first-retry",
},
// Boots the SvelteKit dev server before the suite and reuses one if already running locally.
webServer: {
command: "pnpm dev",
url: "http://localhost:1847",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
projects: [
{name: "chromium", use: {...devices["Desktop Chrome"]}},
{name: "firefox", use: {...devices["Desktop Firefox"]}},
{name: "webkit", use: {...devices["Desktop Safari"]}},
],
})
+2669 -3237
View File
File diff suppressed because it is too large Load Diff
-19
View File
@@ -1,19 +0,0 @@
allowBuilds:
nostr-signer-capacitor-plugin: true
cbor-extract: false
esbuild: false
sharp: true
minimumReleaseAgeExclude:
- '@pomade/core'
- '@welshman/app'
- '@welshman/content'
- '@welshman/editor'
- '@welshman/feeds'
- '@welshman/lib'
- '@welshman/net'
- '@welshman/router'
- '@welshman/signer'
- '@welshman/store'
- '@welshman/util'
overrides:
sharp: 0.35.0-rc.0
+2 -1
View File
@@ -1,5 +1,6 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
tailwindcss: {},
autoprefixer: {},
},
}
+1 -1
View File
@@ -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,
-282
View File
@@ -1,282 +0,0 @@
import path from "node:path"
import {promises as fs} from "node:fs"
import {fileURLToPath} from "node:url"
import "dotenv/config"
import {serve} from "@hono/node-server"
import {serveStatic} from "@hono/node-server/serve-static"
import {loadRelay} from "@welshman/app"
import {displayRelayUrl, normalizeRelayUrl} from "@welshman/util"
import {load} from "cheerio"
import {Hono} from "hono"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const BUILD_DIR = path.join(__dirname, "build")
const INDEX_PATH = path.join(BUILD_DIR, "index.html")
const PORT = parseInt(process.env.PORT || "", 10) || 3000
const HOST = process.env.HOST || "0.0.0.0"
let TEMPLATE_HTML = ""
try {
TEMPLATE_HTML = await fs.readFile(INDEX_PATH, "utf8")
} catch (error) {
console.error(`Unable to read ${INDEX_PATH}. Run "pnpm run build" first.`)
process.exit(1)
}
const PLATFORM_NAME = process.env.VITE_PLATFORM_NAME
const PLATFORM_DESCRIPTION = process.env.VITE_PLATFORM_DESCRIPTION
// Match client-side decode logic
const decodeRelay = url => {
try {
return normalizeRelayUrl(decodeURIComponent(url))
} catch {
return undefined
}
}
const requestUrlFromContext = context => {
const requestUrl = new URL(context.req.url)
const forwardedProto = context.req.header("x-forwarded-proto")?.split(",")[0]?.trim()
const forwardedHost = context.req.header("x-forwarded-host")?.split(",")[0]?.trim()
if (forwardedProto === "http" || forwardedProto === "https") {
requestUrl.protocol = `${forwardedProto}:`
}
if (forwardedHost) {
requestUrl.host = forwardedHost
}
return requestUrl
}
const fetchRelayMeta = async relayUrl => {
if (!relayUrl) return undefined
try {
return await loadRelay(normalizeRelayUrl(relayUrl))
} catch (err) {
console.error(`Failed to fetch relay metadata for ${relayUrl}:`, err)
return undefined
}
}
const buildDefaultImage = requestUrl => {
return new URL("/maskable-icon-512x512.png", requestUrl.origin).toString()
}
const getMetadataForInvite = async (url, match) => {
const relayParam = url.searchParams.get("r")
if (!relayParam) return undefined
const relayMetadata = await fetchRelayMeta(relayParam)
if (!relayMetadata) return undefined
const relayDisplay = displayRelayUrl(relayParam)
const spaceName = relayMetadata.name
const relayDescription = relayMetadata.description
const title = spaceName
? `Invite to ${spaceName} on ${PLATFORM_NAME}`
: `Invite to a Space on ${PLATFORM_NAME}`
const parts = []
if (spaceName) {
parts.push(`You are invited to join ${spaceName} on ${PLATFORM_NAME}.`)
} else {
parts.push(`You are invited to join a space on ${PLATFORM_NAME}.`)
}
if (relayDisplay) parts.push(`Relay: ${relayDisplay}.`)
if (relayDescription) parts.push(relayDescription)
else parts.push(PLATFORM_DESCRIPTION)
const description = parts.join(" ")
const image =
relayMetadata.icon ||
relayMetadata.picture ||
relayMetadata.image ||
buildDefaultImage(url)
return {
title,
description,
image,
url: url.toString(),
site: url.origin,
}
}
const getMetadataForSpace = async (url, match) => {
const relayParam = decodeRelay(match[1])
if (!relayParam) return undefined
const relayMetadata = await fetchRelayMeta(relayParam)
if (!relayMetadata) return undefined
const spaceName = relayMetadata.name || displayRelayUrl(relayParam)
return {
title: `${spaceName} on ${PLATFORM_NAME}`,
description: relayMetadata.description || PLATFORM_DESCRIPTION,
image:
relayMetadata.icon ||
relayMetadata.picture ||
relayMetadata.image ||
buildDefaultImage(url),
url: url.toString(),
site: url.origin,
}
}
const getMetadataForSpaceSection = async (url, match) => {
const spaceMeta = await getMetadataForSpace(url, match)
if (!spaceMeta) return undefined
const section = match[2]
const sectionName = section.charAt(0).toUpperCase() + section.slice(1)
spaceMeta.title = `${sectionName} on ${spaceMeta.title}`
return spaceMeta
}
const getMetadataForSpaceItem = async (url, match) => {
const spaceMeta = await getMetadataForSpace(url, match)
if (!spaceMeta) return undefined
const section = match[2]
let itemType = "Item"
if (section === "calendar") itemType = "Event"
if (section === "threads") itemType = "Thread"
if (section === "polls") itemType = "Poll"
if (section === "goals") itemType = "Goal"
if (section === "classifieds") itemType = "Listing"
spaceMeta.title = `${itemType} on ${spaceMeta.title}`
return spaceMeta
}
const getMetadataForRoom = async (url, match) => {
const spaceMeta = await getMetadataForSpace(url, match)
if (!spaceMeta) return undefined
// Room metadata requires fetching from Nostr, which can be added later.
spaceMeta.title = `Room on ${spaceMeta.title}`
return spaceMeta
}
const routes = [
[/^\/join\/?$/, getMetadataForInvite],
[/^\/spaces\/([^/]+)\/(calendar|chat|threads|polls|goals|classifieds|recent)\/?$/, getMetadataForSpaceSection],
[/^\/spaces\/([^/]+)\/(calendar|threads|polls|goals|classifieds)\/([^/]+)\/?$/, getMetadataForSpaceItem],
[/^\/spaces\/([^/]+)\/([^/]+)\/?$/, getMetadataForRoom],
[/^\/spaces\/([^/]+)\/?$/, getMetadataForSpace],
]
const getMetadataForRoute = async url => {
for (const [regex, getMetadata] of routes) {
const match = url.pathname.match(regex)
if (match) {
try {
return await getMetadata(url, match)
} catch (err) {
console.error(`Error generating metadata for route ${url.pathname}:`, err)
return undefined
}
}
}
return undefined
}
const injectMeta = metadata => {
const $ = load(TEMPLATE_HTML)
if (metadata.title) {
$("title").text(metadata.title)
$('meta[property="og:title"]').attr("content", metadata.title)
$('meta[name="twitter:title"]').attr("content", metadata.title)
}
if (metadata.description) {
$('meta[name="description"]').attr("content", metadata.description)
$('meta[property="og:description"]').attr("content", metadata.description)
$('meta[name="twitter:description"]').attr("content", metadata.description)
}
if (metadata.image) {
$('meta[property="og:image"]').attr("content", metadata.image)
$('meta[name="twitter:image"]').attr("content", metadata.image)
}
if (metadata.url) {
$('meta[property="og:url"]').attr("content", metadata.url)
$('meta[name="twitter:site"]').attr("content", metadata.site)
$('meta[name="twitter:url"]').attr("content", metadata.url)
$('link[rel="canonical"]').attr("href", metadata.url)
}
return $.html()
}
const app = new Hono()
// Only allow GET and HEAD requests
app.use("*", async (context, next) => {
const method = context.req.method
if (method !== "GET" && method !== "HEAD") {
return context.text("Method Not Allowed", 405, {Allow: "GET, HEAD"})
}
await next()
})
// Serve static assets with appropriate caching
app.use(
"*",
serveStatic({
root: BUILD_DIR,
onFound: (filePath, context) => {
const isImmutable = filePath.split(path.sep).join("/").includes("/_app/immutable/")
const cacheControl =
path.basename(filePath) === "index.html"
? "no-cache"
: isImmutable
? "public, max-age=31536000, immutable"
: "public, max-age=3600"
context.header("Cache-Control", cacheControl)
// Immutable assets are content-hashed by Vite, so the filename is itself a
// stable content identifier. Exposing it as an ETag lets clients that
// revalidate explicitly (e.g. emoji-picker-element checks its data source
// on every load) skip re-downloading large files when nothing changed.
if (isImmutable) {
context.header("ETag", `"${path.basename(filePath)}"`)
}
},
}),
)
// SPA fallback for routes that don't match static files
app.get("*", async context => {
const requestUrl = requestUrlFromContext(context)
const metadata = await getMetadataForRoute(requestUrl)
const html = metadata ? injectMeta(metadata) : TEMPLATE_HTML
return context.html(html, 200, {
"Cache-Control": metadata ? "no-store" : "no-cache",
})
})
serve(
{
fetch: app.fetch,
hostname: HOST,
port: PORT,
},
() => {
console.log(`Flotilla server running on http://${HOST}:${PORT}`)
},
)
+257 -280
View File
@@ -1,6 +1,46 @@
@import "tailwindcss";
@import "@welshman/editor/index.css";
@config "../tailwind.config.js";
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Fonts */
@font-face {
font-family: "Satoshis";
font-style: normal;
font-weight: 400;
src:
local(""),
url("/fonts/Satoshi Symbol.ttf") format("truetype");
}
@font-face {
font-family: "Lato";
font-style: normal;
font-weight: 400;
src:
local(""),
url("/fonts/Lato-Regular.ttf") format("truetype");
}
@font-face {
font-family: "Lato";
font-style: bold;
font-weight: 600;
src:
local(""),
url("/fonts/Lato-Bold.ttf") format("truetype");
}
@font-face {
font-family: "Lato";
font-style: italic;
font-weight: 400;
src:
local(""),
url("/fonts/Italic.ttf") format("truetype");
}
/* root */
@@ -12,245 +52,98 @@
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
}
@utility pt-sai {
padding-top: var(--sait);
[data-theme] {
@apply bg-base-300;
--base-100: oklch(var(--b1));
--base-200: oklch(var(--b2));
--base-300: oklch(var(--b3));
--base-content: oklch(var(--bc));
--primary: oklch(var(--p));
--primary-content: oklch(var(--pc));
--secondary: oklch(var(--s));
--secondary-content: oklch(var(--sc));
--neutral: oklch(var(--n));
--neutral-content: oklch(var(--nc));
}
@utility pr-sai {
padding-right: var(--sair);
.mobile [data-tip]::before {
display: none !important;
}
@utility pb-sai {
padding-bottom: var(--saib);
}
/* safe area insets */
@utility pl-sai {
padding-left: var(--sail);
}
@utility px-sai {
@apply pl-sai pr-sai;
}
@utility py-sai {
@apply pt-sai pb-sai;
}
@utility p-sai {
@apply py-sai px-sai;
}
@utility mt-sai {
margin-top: var(--sait);
}
@utility mr-sai {
margin-right: var(--sair);
}
@utility mb-sai {
margin-bottom: var(--saib);
}
@utility ml-sai {
margin-left: var(--sail);
}
@utility mx-sai {
@apply ml-sai mr-sai;
}
@utility my-sai {
@apply mt-sai mb-sai;
}
@utility m-sai {
@apply my-sai mx-sai;
}
@utility top-sai {
top: var(--sait);
}
@utility right-sai {
right: var(--sair);
}
@utility bottom-sai {
bottom: var(--saib);
}
@utility left-sai {
left: var(--sail);
}
@utility card2 {
@apply rounded-box text-base-content p-4 sm:p-6;
}
@utility column {
@apply flex flex-col;
}
@utility center {
@apply flex items-center justify-center;
}
@utility row-2 {
@apply flex items-center gap-2;
}
@utility row-3 {
@apply flex items-center gap-3;
}
@utility row-4 {
@apply flex items-center gap-4;
}
@utility col-2 {
@apply flex flex-col gap-2;
}
@utility col-3 {
@apply flex flex-col gap-3;
}
@utility col-4 {
@apply flex flex-col gap-4;
}
@utility col-8 {
@apply flex flex-col gap-8;
}
@utility ellipsize {
@apply overflow-hidden text-ellipsis;
}
@utility content-padding-x {
@apply px-4 sm:px-8 md:px-12;
}
@utility content-padding-t {
@apply pt-4 sm:pt-8 md:pt-12;
}
@utility content-padding-b {
@apply pb-4 sm:pb-8 md:pb-12;
}
@utility content-padding-y {
@apply pt-4 pb-4 sm:pt-8 sm:pb-8 md:pt-12 md:pb-12;
}
@utility content-sizing {
@apply m-auto w-full max-w-3xl;
}
@utility content {
@apply m-auto w-full max-w-3xl px-4 pt-4 pb-4 sm:px-8 sm:pt-8 sm:pb-8 md:px-12 md:pt-12 md:pb-12;
}
@utility heading {
@apply text-center text-2xl;
}
@utility subheading {
@apply text-center text-xl;
}
@utility superheading {
@apply text-center text-4xl;
}
@utility link {
@apply text-primary cursor-pointer underline;
}
/* content visibility */
@utility cv {
content-visibility: auto;
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@layer utilities {
/* Fonts */
@font-face {
font-family: "Satoshis";
font-style: normal;
font-weight: 400;
src:
local(""),
url("/fonts/Satoshi Symbol.ttf") format("truetype");
@layer components {
.pt-sai {
padding-top: var(--sait);
}
@font-face {
font-family: "Lato";
font-style: normal;
font-weight: 400;
src:
local(""),
url("/fonts/Lato-Regular.ttf") format("truetype");
.pr-sai {
padding-right: var(--sair);
}
@font-face {
font-family: "Lato";
font-style: bold;
font-weight: 600;
src:
local(""),
url("/fonts/Lato-Bold.ttf") format("truetype");
.pb-sai {
padding-bottom: var(--saib);
}
@font-face {
font-family: "Lato";
font-style: italic;
font-weight: 400;
src:
local(""),
url("/fonts/Italic.ttf") format("truetype");
.pl-sai {
padding-left: var(--sail);
}
/* root */
:root {
font-family: Lato;
text-size-adjust: 100%;
--sait: var(--safe-area-inset-top, env(safe-area-inset-top));
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
--sail: var(--safe-area-inset-left, env(safe-area-inset-left));
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
.px-sai {
@apply pl-sai pr-sai;
}
[data-theme] {
@apply bg-base-300;
.py-sai {
@apply pt-sai pb-sai;
}
.mobile [data-tip]::before {
display: none !important;
.p-sai {
@apply py-sai px-sai;
}
/* safe area insets */
.mt-sai {
margin-top: var(--sait);
}
.mr-sai {
margin-right: var(--sair);
}
.mb-sai {
margin-bottom: var(--saib);
}
.ml-sai {
margin-left: var(--sail);
}
.mx-sai {
@apply ml-sai mr-sai;
}
.my-sai {
@apply mt-sai mb-sai;
}
.m-sai {
@apply my-sai mx-sai;
}
.top-sai {
top: var(--sait);
}
.right-sai {
right: var(--sair);
}
.bottom-sai {
bottom: var(--saib);
}
.left-sai {
left: var(--sail);
}
}
/* utilities */
@@ -272,42 +165,134 @@
@apply bg-base-300 text-base-content transition-colors;
}
.card2 {
@apply rounded-box p-4 text-base-content sm:p-6;
}
.card2.card2-sm {
@apply text-base-content p-2 sm:p-4;
@apply p-2 text-base-content sm:p-4;
}
.column {
@apply flex flex-col;
}
.center {
@apply flex items-center justify-center;
}
.row-2 {
@apply flex items-center gap-2;
}
.row-3 {
@apply flex items-center gap-3;
}
.row-4 {
@apply flex items-center gap-4;
}
.col-2 {
@apply flex flex-col gap-2;
}
.col-3 {
@apply flex flex-col gap-3;
}
.col-4 {
@apply flex flex-col gap-4;
}
.col-8 {
@apply flex flex-col gap-8;
}
.badge {
@apply justify-start overflow-hidden text-ellipsis whitespace-nowrap;
}
.ellipsize {
@apply overflow-hidden text-ellipsis;
}
[data-tip]::before {
@apply overflow-hidden text-ellipsis;
@apply ellipsize;
}
.content-padding-x {
@apply px-4 sm:px-8 md:px-12;
}
.content-padding-t {
@apply pt-4 sm:pt-8 md:pt-12;
}
.content-padding-b {
@apply pb-4 sm:pb-8 md:pb-12;
}
.content-padding-y {
@apply content-padding-t content-padding-b;
}
.content-sizing {
@apply m-auto w-full max-w-3xl;
}
.content {
@apply content-sizing content-padding-x content-padding-y;
}
.heading {
@apply text-center text-2xl;
}
.subheading {
@apply text-center text-xl;
}
.superheading {
@apply text-center text-4xl;
}
.link {
@apply cursor-pointer text-primary underline;
}
.input input::placeholder {
opacity: 0.5;
}
.shadow-top-xl {
@apply shadow-[0_20px_25px_-5px_rgb(0,0,0,0.1)_0_8px_10px_-6px_rgb(0,0,0,0.1)];
}
/* tiptap */
.input-editor,
.chat-editor,
.note-editor {
@apply -m-1 p-1;
@apply -m-1 min-h-12 p-1 text-sm;
}
.tiptap {
--tiptap-object-bg: var(--color-neutral);
--tiptap-object-fg: var(--color-neutral-content);
--tiptap-active-bg: var(--color-primary);
--tiptap-active-fg: var(--color-primary-content);
--tiptap-object-bg: var(--neutral);
--tiptap-object-fg: var(--neutral-content);
--tiptap-active-bg: var(--primary);
--tiptap-active-fg: var(--primary-content);
}
.tiptap-suggestions {
--tiptap-object-bg: var(--color-base-100);
--tiptap-object-fg: var(--color-base-content);
--tiptap-active-bg: var(--color-base-300);
--tiptap-active-fg: var(--color-base-content);
--tiptap-object-bg: var(--base-100);
--tiptap-object-fg: var(--base-content);
--tiptap-active-bg: var(--base-300);
--tiptap-active-fg: var(--base-content);
}
.tiptap-suggestions__item {
@apply border-base-100 border-l-2 border-solid;
@apply border-l-2 border-solid border-base-100;
}
.tiptap-suggestions__selected {
@@ -315,7 +300,7 @@
}
.tiptap {
@apply max-h-[350px] min-h-10 overflow-y-auto p-2 px-4;
@apply max-h-[350px] overflow-y-auto p-2 px-4;
}
.tiptap p.is-editor-empty:first-child::before {
@@ -327,13 +312,13 @@
}
.note-editor .tiptap {
--tiptap-object-bg: var(--color-base-200);
@apply input rounded-box block h-auto min-h-32 w-full p-[.65rem] pb-6;
--tiptap-object-bg: var(--base-200);
@apply input input-bordered h-auto min-h-32 rounded-box p-[.65rem] pb-6;
}
.input-editor .tiptap {
--tiptap-object-bg: var(--color-base-200);
@apply input block h-auto p-[.65rem];
--tiptap-object-bg: var(--base-200);
@apply input input-bordered h-auto p-[.65rem];
}
/* link-content, based on tiptap */
@@ -345,8 +330,8 @@
white-space: nowrap;
border-radius: 3px;
padding: 0 0.25rem;
background-color: var(--color-base-100);
color: var(--color-base-content);
background-color: var(--base-100);
color: var(--base-content);
}
/* content rendered by welshman/content */
@@ -362,31 +347,23 @@
/* date input */
.picker {
--date-picker-foreground: var(--color-base-content);
--date-picker-background: var(--color-base-300);
--date-picker-highlight-border: var(--color-primary);
--date-picker-selected-color: var(--color-primary-content);
--date-picker-selected-background: var(--color-primary);
--date-picker-foreground: var(--base-content);
--date-picker-background: var(--base-300);
--date-picker-highlight-border: var(--primary);
--date-picker-selected-color: var(--primary-content);
--date-picker-selected-background: var(--primary);
}
.date-time-field {
@apply input rounded-lg px-0;
@apply input input-bordered rounded-lg px-0;
}
.date-time-field input {
@apply h-full! w-full! rounded-lg! border-none! bg-inherit! px-4! text-inherit!;
@apply !h-full !w-full !rounded-lg !border-none !bg-inherit !px-4 !text-inherit;
}
/* tippy popover */
.tippy-target {
@apply z-tooltip pointer-events-none fixed inset-0;
}
.tippy-target > * {
pointer-events: auto;
}
.tippy-box {
@apply rounded-box shadow-xl;
}
@@ -394,15 +371,15 @@
/* emoji picker */
emoji-picker {
--background: var(--color-base-100);
--border-color: var(--color-base-100);
--background: var(--base-100);
--border-color: var(--base-100);
--border-radius: var(--rounded-box);
--button-active-background: var(--color-base-content);
--button-hover-background: var(--color-base-content);
--indicator-color: var(--color-base-content);
--input-border-color: var(--color-base-100);
--input-font-color: var(--color-base-content);
--outline-color: var(--color-base-100);
--button-active-background: var(--base-content);
--button-hover-background: var(--base-content);
--indicator-color: var(--base-content);
--input-border-color: var(--base-100);
--input-font-color: var(--base-content);
--outline-color: var(--base-100);
}
/* progress */
@@ -413,38 +390,38 @@ progress[value]::-webkit-progress-value {
/* content width for fixed elements */
.left-content {
@apply md:left-[calc(18.5rem+var(--sail))];
.cw {
@apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))];
}
.left-content-full {
@apply md:left-[calc(3.5rem+var(--sail))];
.cw-full {
@apply w-full md:left-[4rem] md:w-[calc(100%-4rem-var(--sair))];
}
.cb {
@apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)];
}
.ct {
@apply top-[calc(var(--sait)+5rem)] md:top-[calc(var(--sait)+3rem)];
}
/* Keyboard open state adjustments */
body.keyboard-open {
--saib: 0px;
body.keyboard-open .cb {
@apply bottom-sai;
}
body.keyboard-open .hide-on-keyboard {
display: none;
}
body.keyboard-open .chat__compose {
margin-bottom: 0;
}
/* chat view */
.chat__compose {
@apply z-compose relative mb-14 shrink-0 md:mb-0;
}
.chat__compose .chat__compose-inner {
@apply min-w-0;
@apply cb cw fixed z-compose;
}
.chat__scroll-down {
@apply pb-sai z-feature fixed right-4 bottom-28 md:bottom-16;
@apply pb-sai fixed bottom-28 right-4 z-feature md:bottom-16;
}
+4 -7
View File
@@ -2,18 +2,15 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{NAME}</title>
<link rel="canonical" href="{URL}" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="theme-color" content="{ACCENT}" />
<meta name="description" content="{DESCRIPTION}" />
<meta property="og:url" content="{URL}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="{NAME}" />
<meta property="og:description" content="{DESCRIPTION}" />
<meta property="og:image" content="" />
<meta name="og:url" content="{URL}" />
<meta name="og:type" content="website" />
<meta name="og:title" content="{NAME}" />
<meta name="og:description" content="{DESCRIPTION}" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:site" content="{URL}" />
<meta name="twitter:title" content="{NAME}" />
-75
View File
@@ -1,75 +0,0 @@
import {Room as LiveKitRoom} from "livekit-client"
import {derived, writable} from "svelte/store"
import {type Room} from "@app/core/state"
export type VoiceSession = {
url: string
h: string
room: LiveKitRoom
cameraOn: boolean
screenShareOn: boolean
}
/** Mic mute state is separate so toggling it does not re-render video tiles. */
export const voiceMicMuted = writable(true)
export type Pubkey = string
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
export type ParticipantMediaState = {
muted: boolean
cameraOn: boolean
}
export enum VoiceState {
Joining = "joining",
Connected = "connected",
Disconnected = "disconnected",
}
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
export const currentVoiceRoom = writable<Room | undefined>(undefined)
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
const pk = pubkeyFromLiveKitIdentity(identity)
return pk ? {pubkey: pk, identity} : {identity}
}
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
export const speakingParticipants = writable<VoiceParticipant[]>([])
export const participantMediaState = writable(new Map<string, ParticipantMediaState>())
export const mediaStateByIdentity = derived(
[participantMediaState, currentVoiceSession, voiceMicMuted],
([$media, $session, $micMuted]) =>
(identity: string) => {
if ($session?.room.localParticipant.identity === identity) {
return {muted: $micMuted, cameraOn: $session.cameraOn}
}
return $media.get(identity) ?? {muted: true, cameraOn: false}
},
)
export const isParticipantSpeaking = derived(
speakingParticipants,
$participants => (p: VoiceParticipant) =>
$participants.some(sp => participantKey(sp) === participantKey(p)),
)
export const isLocalSpeaking = derived(
[currentVoiceSession, speakingParticipants],
([$session, $speaking]) => {
if (!$session?.room) return false
const local = participantFromLiveKitIdentity($session.room.localParticipant.identity)
return $speaking.some(sp => participantKey(sp) === participantKey(local))
},
)
-99
View File
@@ -1,99 +0,0 @@
import {Track} from "livekit-client"
import {MediaQuery} from "svelte/reactivity"
import {derived, get, writable} from "svelte/store"
import {currentVoiceSession, VoiceState, type VoiceSession, voiceState} from "@app/call/stores"
import {pushToast} from "@app/util/toast"
export enum VideoCallLayout {
Chat = "chat",
Video = "video",
Split = "split",
}
export const isDesktopLayout = new MediaQuery("min-width: 768px", false)
export enum ViewportSize {
Desktop = "desktop",
Mobile = "mobile",
}
export const videoCallViewportSync = {
previousLayout: undefined as ViewportSize | undefined,
}
export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
export const resetVideoCallLayout = () => {
videoCallViewportSync.previousLayout = undefined
videoCallLayout.set(VideoCallLayout.Chat)
}
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
export const toggleVideoPrimaryTile = (key: string) => {
videoPrimaryTileKey.update(k => (k === key ? undefined : key))
}
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
const countLiveVisualFeeds = (session: VoiceSession): number => {
const room = session.room
let n = 0
const lp = room.localParticipant
if (session.cameraOn) {
const pub = lp.getTrackPublication(Track.Source.Camera)
if (pub?.track) n += 1
}
if (session.screenShareOn) {
const pub = lp.getTrackPublication(Track.Source.ScreenShare)
if (pub?.track) n += 1
}
for (const rp of room.remoteParticipants.values()) {
for (const source of VISUAL_SOURCES) {
const pub = rp.getTrackPublication(source)
if (pub?.isSubscribed && pub.track) n += 1
}
}
return n
}
export const triggerVideoFeedCount = () => {
currentVoiceSession.update(s => (s ? {...s} : s))
}
export const videoTileCount = derived([currentVoiceSession, voiceState], ([$session, $state]) => {
if ($state !== VoiceState.Connected || !$session) return 0
return countLiveVisualFeeds($session)
})
export const toggleCamera = async () => {
const session = get(currentVoiceSession)
if (!session) return
const cameraOn = !session.cameraOn
try {
await session.room.localParticipant.setCameraEnabled(cameraOn)
currentVoiceSession.set({...session, cameraOn})
} catch {
pushToast({
theme: "error",
message: cameraOn ? "Could not access camera" : "Could not turn off camera",
})
}
}
export const toggleScreenShare = async () => {
const session = get(currentVoiceSession)
if (!session) return
const screenShareOn = !session.screenShareOn
try {
await session.room.localParticipant.setScreenShareEnabled(screenShareOn)
currentVoiceSession.set({...session, screenShareOn})
} catch {
pushToast({
theme: "error",
message: screenShareOn ? "Could not start screen sharing" : "Could not stop screen sharing",
})
}
}
-547
View File
@@ -1,547 +0,0 @@
/**
* Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox
* (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS.
*/
import {
DisconnectReason,
LocalParticipant,
LocalTrackPublication,
Participant,
Room as LiveKitRoom,
RoomEvent,
Track,
TrackPublication,
supportsAudioOutputSelection,
type AudioCaptureOptions,
} from "livekit-client"
import {derived, get} from "svelte/store"
import {map, not, nthEq, reject, removeUndefined, uniqBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
import {signer} from "@welshman/app"
import {getLivekitEndpoint} from "$lib/livekit"
import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util"
import {
currentVoiceRoom,
currentVoiceSession,
voiceMicMuted,
participantFromLiveKitIdentity,
participantKey,
participantMediaState,
speakingParticipants,
VoiceState,
type ParticipantMediaState,
type VoiceParticipant,
voiceState,
} from "@app/call/stores"
import {resetVideoCallLayout, triggerVideoFeedCount, videoPrimaryTileKey} from "@app/call/video"
import {deriveLatestEventForUrl, deriveRoom, makeRoomId} from "@app/core/state"
import {pushToast} from "@app/util/toast"
export const LIVEKIT_PARTICIPANTS = 39004
export {checkRelayHasLivekit} from "$lib/livekit"
export {supportsAudioOutputSelection}
const LIVEKIT_DEFAULT_DEVICE_ID = "default"
export enum DeviceKind {
AudioInput = "audioinput",
AudioOutput = "audiooutput",
VideoInput = "videoinput",
}
export const switchVoiceActiveDevice = async (
kind: DeviceKind,
targetDeviceId: string,
): Promise<void> => {
const session = get(currentVoiceSession)
if (!session) return
const id = targetDeviceId === "" ? LIVEKIT_DEFAULT_DEVICE_ID : targetDeviceId
try {
await session.room.switchActiveDevice(kind, id)
} catch {
let label: string
switch (kind) {
case DeviceKind.AudioInput:
label = "microphone"
break
case DeviceKind.AudioOutput:
label = "speaker"
break
case DeviceKind.VideoInput:
label = "camera"
break
}
pushToast({theme: "error", message: `Error changing ${label}`})
}
}
const participantMediaFrom = (participant: Participant): ParticipantMediaState => ({
muted: !participant.isMicrophoneEnabled,
cameraOn: participant.isCameraEnabled,
})
const deleteParticipant = (identity: string) => {
participantMediaState.update(m => new Map(reject(nthEq(0, identity), [...m])))
}
const syncParticipantMedia = (participant: Participant) => {
const state = participantMediaFrom(participant)
participantMediaState.update(m => {
const prev = m.get(participant.identity)
if (prev?.muted === state.muted && prev?.cameraOn === state.cameraOn) return m
const next = new Map(m)
next.set(participant.identity, state)
return next
})
}
// LiveKit does not emit ParticipantConnected/Disconnected during reconnect.
const resyncAfterReconnect = (room: LiveKitRoom) => {
if (room !== activeRoom) return
const next = new Map<string, ParticipantMediaState>()
for (const p of [room.localParticipant, ...room.remoteParticipants.values()]) {
next.set(p.identity, participantMediaFrom(p))
}
participantMediaState.set(next)
const session = get(currentVoiceSession)
if (!session) return
const {localParticipant} = room
voiceMicMuted.set(!localParticipant.isMicrophoneEnabled)
currentVoiceSession.set({
...session,
cameraOn: localParticipant.isCameraEnabled,
screenShareOn: localParticipant.isScreenShareEnabled,
})
triggerVideoFeedCount()
}
const onParticipantMediaChanged = (_publication: TrackPublication, participant: Participant) => {
syncParticipantMedia(participant)
}
const fetchLivekitToken = async (
url: string,
groupId: string,
signal?: AbortSignal,
): Promise<{server_url: string; participant_token: string}> => {
const endpoint = getLivekitEndpoint(url, groupId)
const $signer = signer.get()
if (!$signer) throw new Error("No signer available")
if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
const template = await makeHttpAuth(endpoint, "GET")
const signedEvent = await $signer.sign(template)
const authHeader = makeHttpAuthHeader(signedEvent)
const response = await fetch(endpoint, {
headers: {Authorization: authHeader},
signal,
})
if (!response.ok) {
const text = await response.text()
throw new Error(`Token request failed (${response.status}): ${text}`)
}
return response.json()
}
export const deriveVoiceParticipants = (url: string, h: string) =>
// We use the livekit identity list while in a call, and fall back to the list in kind 39004.
derived(
[
participantMediaState,
currentVoiceRoom,
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
],
([$participantMediaState, $currentVoiceRoom, $publishedParticipantList]) => {
const inCall = $participantMediaState.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h)
if (inCall) {
const participants = [...$participantMediaState.keys()].map(participantFromLiveKitIdentity)
return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
} else {
const latestEvent = $publishedParticipantList as TrustedEvent | undefined
if (!latestEvent) return []
const participants = removeUndefined(
map(
(tag: string[]) => (tag[1] ? {pubkey: tag[1], identity: tag[1]} : undefined),
getTags("participant", latestEvent.tags),
),
)
return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
}
},
)
const setUpMicrophone = async (
startMuted: boolean,
preferredMicId: string | undefined,
participant: LocalParticipant,
signal?: AbortSignal,
settleSignal?: AbortSignal,
): Promise<boolean> => {
if (startMuted) {
return true
}
let muted = true
let capture: AudioCaptureOptions | undefined = undefined
if (preferredMicId) {
capture = {deviceId: preferredMicId}
}
try {
await Promise.race([
participant.setMicrophoneEnabled(true, capture),
whenTimeout(15_000, {message: "Microphone access timed out.", signal: settleSignal}),
whenAborted(signal),
])
muted = false
} catch (e) {
// Timeout or microphone rejection: join muted, the call is still usable. A
// genuine abort is surfaced to the caller so it can tear down the room.
if (e instanceof AbortError) throw e
if (!(e instanceof TimeoutError)) {
pushToast({theme: "error", message: "Could not access microphone"})
}
}
return muted
}
// The room whose events are allowed to mutate shared state. Abandoned rooms
// (after switching calls or an engine reconnect give-up) must not clobber it.
let activeRoom: LiveKitRoom | undefined
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined
let reconnectAttempt = 0
const clearReconnectSchedule = () => {
if (reconnectTimeout !== undefined) {
clearTimeout(reconnectTimeout)
reconnectTimeout = undefined
}
reconnectAttempt = 0
}
const attemptReconnect = async () => {
const target = get(currentVoiceRoom)
if (!target) return
try {
await joinVoiceRoom(target.url, target.h)
} catch {
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
pushToast({theme: "error", message: "Voice connection lost."})
clearReconnectSchedule()
return
}
scheduleReconnect()
}
}
const scheduleReconnect = () => {
if (reconnectTimeout !== undefined) return
if (!get(currentVoiceRoom)) return
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
pushToast({theme: "error", message: "Voice connection lost."})
return
}
const delay = RECONNECT_DELAYS[reconnectAttempt]!
reconnectAttempt++
reconnectTimeout = setTimeout(() => {
reconnectTimeout = undefined
void attemptReconnect()
}, delay)
}
const makeOnRoomReconnected = (room: LiveKitRoom) => () => {
if (room !== activeRoom) return
resyncAfterReconnect(room)
}
const makeOnRoomDisconnected = (room: LiveKitRoom) => (reason?: DisconnectReason) => {
// Ignore disconnects from rooms that are no longer the active session.
if (room !== activeRoom) return
activeRoom = undefined
room.removeAllListeners()
videoPrimaryTileKey.set(undefined)
voiceMicMuted.set(true)
currentVoiceSession.set(undefined)
resetVideoCallLayout()
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
voiceState.set(VoiceState.Disconnected)
if (reason === DisconnectReason.JOIN_FAILURE) {
pushToast({theme: "error", message: "Could not connect to voice room. Please try again."})
} else if (get(currentVoiceRoom)) {
clearReconnectSchedule()
scheduleReconnect()
} else {
pushToast({theme: "error", message: "Voice connection lost."})
}
}
speakingParticipants.set([])
participantMediaState.set(new Map())
}
const onTrackSubscribed = (track: Track) => {
if (track.kind === Track.Kind.Audio) {
const element = track.attach()
element.style.display = "none"
document.body.appendChild(element)
element.play().catch(() => {})
} else if (track.kind === Track.Kind.Video) {
triggerVideoFeedCount()
}
}
const onTrackUnsubscribed = (track: Track) => {
track.detach().forEach(el => el.remove())
if (track.kind === Track.Kind.Video) {
triggerVideoFeedCount()
}
}
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
speakingParticipants.set(participants.map(p => participantFromLiveKitIdentity(p.identity)))
}
const playJoinSound = () => {
const audio = new Audio("/join-voice-room.mp3")
audio.play().catch(() => {})
}
const onParticipantConnected = (participant: Participant) => {
syncParticipantMedia(participant)
playJoinSound()
}
const onParticipantDisconnected = (participant: {identity: string}) => {
deleteParticipant(participant.identity)
}
const onLocalTrackUnpublished = (
publication: LocalTrackPublication,
participant: LocalParticipant,
) => {
if (publication.source !== Track.Source.ScreenShare) return
const session = get(currentVoiceSession)
if (!session || participant.identity !== session.room.localParticipant.identity) return
if (!session.screenShareOn) return
currentVoiceSession.set({...session, screenShareOn: false})
}
let joinAbortController: AbortController | undefined
const abortJoinVoiceRoom = () => {
joinAbortController?.abort()
}
export const cancelJoinVoiceRoom = () => {
clearReconnectSchedule()
abortJoinVoiceRoom()
}
export const joinVoiceRoom = async (
url: string,
h: string,
startMuted = true,
preferredMicId?: string,
): Promise<void> => {
abortJoinVoiceRoom()
currentVoiceRoom.set(get(deriveRoom(url, h)))
voiceState.set(VoiceState.Joining)
const controller = new AbortController()
joinAbortController = controller
const signal = controller.signal
const isActive = () => joinAbortController === controller
// Self-cleaning controller: aborted in finally so whenTimeout/whenAborted
// helpers clear their timers/listeners once the races below have settled.
const settle = new AbortController()
try {
// Tear down any existing session before joining. Bound it so a slow leave
// (camera/screenshare renegotiation can take ~15s) cannot block this join.
if (get(currentVoiceSession)) {
await Promise.race([
leaveVoiceRoom(),
whenTimeout(15_000, {message: "Leaving previous call timed out.", signal: settle.signal}),
whenAborted(signal),
]).catch(e => {
if (e instanceof AbortError) throw e
})
// leaveVoiceRoom flips voiceState to Disconnected; re-assert Joining.
voiceState.set(VoiceState.Joining)
}
if (signal.aborted) throw new AbortError()
const {server_url, participant_token} = await Promise.race([
fetchLivekitToken(url, h, signal),
whenTimeout(15_000, {
message: "Connection timed out. Please check your network and try again.",
signal: settle.signal,
}),
whenAborted(signal),
])
if (signal.aborted) throw new AbortError()
const liveKitRoom = new LiveKitRoom({adaptiveStream: true, dynacast: true})
activeRoom = liveKitRoom
liveKitRoom.on(RoomEvent.Disconnected, makeOnRoomDisconnected(liveKitRoom))
liveKitRoom.on(RoomEvent.Reconnected, makeOnRoomReconnected(liveKitRoom))
liveKitRoom.on(RoomEvent.ParticipantConnected, onParticipantConnected)
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
liveKitRoom.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
liveKitRoom.on(RoomEvent.TrackMuted, onParticipantMediaChanged)
liveKitRoom.on(RoomEvent.TrackUnmuted, onParticipantMediaChanged)
liveKitRoom.on(RoomEvent.TrackPublished, onParticipantMediaChanged)
liveKitRoom.on(RoomEvent.TrackUnpublished, onParticipantMediaChanged)
liveKitRoom.on(RoomEvent.LocalTrackPublished, onParticipantMediaChanged)
try {
await Promise.race([
liveKitRoom.connect(server_url, participant_token, {maxRetries: 0}),
whenTimeout(15_000, {
message: "Connection timed out. Please check your network and try again.",
signal: settle.signal,
}),
whenAborted(signal),
])
} catch (e) {
if (activeRoom === liveKitRoom) activeRoom = undefined
liveKitRoom.removeAllListeners()
liveKitRoom.disconnect()
throw e
}
participantMediaState.set(new Map())
syncParticipantMedia(liveKitRoom.localParticipant)
for (const p of liveKitRoom.remoteParticipants.values()) {
syncParticipantMedia(p)
}
// Bounded against timeout/abort inside setUpMicrophone: a stuck permission
// prompt resolves to muted rather than hanging the join forever.
const muted = await setUpMicrophone(
startMuted,
preferredMicId,
liveKitRoom.localParticipant,
signal,
settle.signal,
)
// A cancel during the mic step must tear down the connected room rather
// than leaking it.
if (signal.aborted) {
if (activeRoom === liveKitRoom) activeRoom = undefined
liveKitRoom.removeAllListeners()
liveKitRoom.disconnect()
throw new AbortError()
}
voiceMicMuted.set(muted)
currentVoiceSession.set({
url,
h,
room: liveKitRoom,
cameraOn: false,
screenShareOn: false,
})
voiceState.set(VoiceState.Connected)
clearReconnectSchedule()
playJoinSound()
} catch (e) {
if (isActive()) voiceState.set(VoiceState.Disconnected)
if (e instanceof AbortError) {
clearReconnectSchedule()
return
}
throw e
} finally {
settle.abort()
if (isActive()) joinAbortController = undefined
}
}
export const leaveVoiceRoom = async () => {
clearReconnectSchedule()
const session = get(currentVoiceSession)
if (!session) return
const audio = new Audio("/leave-voice-room.mp3")
audio.play().catch(() => {})
if (session.cameraOn) {
try {
await session.room.localParticipant.setCameraEnabled(false)
} catch {
pushToast({theme: "error", message: "Error turning off camera."})
}
}
if (session.screenShareOn) {
try {
await session.room.localParticipant.setScreenShareEnabled(false)
} catch {
pushToast({theme: "error", message: "Error turning off screen sharing."})
}
}
// Always tear down this room's connection and listeners.
if (activeRoom === session.room) activeRoom = undefined
session.room.removeAllListeners()
session.room.disconnect()
// Only reset shared UI state if this session is still current. A slow leave
// that was superseded by a new join (bounded by a timeout in joinVoiceRoom)
// must not clobber the freshly-joined session when it finally completes.
if (get(currentVoiceSession) === session) {
voiceState.set(VoiceState.Disconnected)
videoPrimaryTileKey.set(undefined)
voiceMicMuted.set(true)
currentVoiceSession.set(undefined)
resetVideoCallLayout()
speakingParticipants.set([])
participantMediaState.set(new Map())
}
}
export const toggleMute = async () => {
const session = get(currentVoiceSession)
if (!session) return
voiceMicMuted.update(not)
if (get(voiceMicMuted)) {
// Disable and re-enable microphone to trigger permission prompt
session.room.localParticipant.setMicrophoneEnabled(false)
return
}
try {
await session.room.localParticipant.setMicrophoneEnabled(true)
} catch (e) {
voiceMicMuted.set(true)
pushToast({theme: "error", message: "Could not access microphone"})
}
}
+4 -3
View File
@@ -1,11 +1,12 @@
<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 {modal} from "@app/util/modal"
import {modals} from "@app/util/modal"
interface Props {
children: Snippet
@@ -19,8 +20,8 @@
<PrimaryNav>
{@render children?.()}
</PrimaryNav>
{:else if !$modal}
<Dialog noEscape children={{component: Landing, props: {}}} />
{:else if !$modals[$page.url.hash.slice(1)]}
<Dialog children={{component: Landing, props: {}}} />
{/if}
</div>
<Toast />
@@ -38,7 +38,7 @@
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex grow flex-wrap justify-end gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<RoomName {h} {url} />
@@ -7,13 +7,12 @@
type Props = {
url: string
h?: string
shareToChat?: boolean
}
const {url, h, shareToChat = false}: Props = $props()
const {url, h}: Props = $props()
</script>
<CalendarEventForm {url} {h} {shareToChat}>
<CalendarEventForm {url} {h}>
{#snippet header()}
<ModalHeader>
<ModalTitle>Create an Event</ModalTitle>
+36 -80
View File
@@ -3,7 +3,7 @@
import {writable} from "svelte/store"
import {randomId, HOUR} from "@welshman/lib"
import {makeEvent, EVENT_TIME} from "@welshman/util"
import {publishThunk, waitForThunkError} from "@welshman/app"
import {publishThunk} from "@welshman/app"
import {preventDefault} from "@lib/html"
import {daysBetween} from "@lib/util"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
@@ -20,34 +20,24 @@
import EditorContent from "@app/editor/EditorContent.svelte"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {pushToast} from "@app/util/toast"
import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
type Values = {
d: string
title: string
content: string | object
location: string
start?: number
end?: number
}
import {canEnforceNip70} from "@app/core/commands"
type Props = {
url: string
h?: string
shareToChat?: boolean
header: Snippet
initialValues?: Values
initialValues?: {
d: string
title: string
content: string
location: string
start: number
end: number
}
}
let {url, h, shareToChat = false, header, initialValues}: Props = $props()
const draftKey = new DraftKey<Values>(`calendar:${url}:${h ?? ""}`)
if (!initialValues) {
initialValues = draftKey.get()
}
const {url, h, header, initialValues}: Props = $props()
const shouldProtect = canEnforceNip70(url)
@@ -58,7 +48,7 @@
const selectFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const submit = async () => {
if ($uploading || loading) return
if ($uploading) return
if (!title) {
return pushToast({
@@ -84,68 +74,38 @@
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = [
["d", d],
["d", initialValues?.d || randomId()],
["title", title],
["location", location],
["location", location || ""],
["start", start.toString()],
["end", end.toString()],
...daysBetween(start, end).map(D => ["D", D.toString()]),
...ed.storage.nostr.getEditorTags(),
]
loading = true
try {
const protect = await shouldProtect
if (protect) {
tags.push(PROTECTED)
}
if (h) {
tags.push(["h", h])
}
const event = makeEvent(EVENT_TIME, {content, tags})
const calendarThunk = publishThunk({event, relays: [url]})
const error = await waitForThunkError(calendarThunk)
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
history.back()
if (shareToChat) {
publishRoomQuote({url, h, parent: calendarThunk.event, protect})
}
pushToast({message: "Your event has been saved!"})
} finally {
loading = false
if (await shouldProtect) {
tags.push(PROTECTED)
}
if (h) {
tags.push(["h", h])
}
const event = makeEvent(EVENT_TIME, {content, tags})
pushToast({message: "Your event has been saved!"})
publishThunk({event, relays: [url]})
history.back()
}
let loading = $state(false)
const content = initialValues?.content || ""
const editor = makeEditor({url, submit, uploading, content})
const d = $state(initialValues?.d ?? randomId())
let title = $state(initialValues?.title ?? "")
let location = $state(initialValues?.location ?? "")
let title = $state(initialValues?.title || "")
let location = $state(initialValues?.location || "")
let start: number | undefined = $state(initialValues?.start)
let end: number | undefined = $state(initialValues?.end)
let endDirty = $state(Boolean(initialValues?.end))
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({url, submit, uploading, onChange, content})
$effect(() => {
draftKey.set({d, title, location, start, end, content})
})
let endDirty = Boolean(initialValues?.end)
$effect(() => {
if (!endDirty && start) {
@@ -176,14 +136,10 @@
{#snippet input()}
<div
class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
<div class="input-editor grow overflow-hidden">
<div class="input-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
<Button
data-tip="Add an image"
class="center btn tooltip"
onclick={selectFiles}
disabled={loading}>
<Button data-tip="Add an image" class="center btn tooltip" onclick={selectFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
@@ -222,12 +178,12 @@
</Field>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={$uploading || loading}>
<Spinner loading={$uploading || loading}>Save Event</Spinner>
<Button type="submit" class="btn btn-primary" disabled={$uploading}>
<Spinner loading={$uploading}>Save Event</Spinner>
</Button>
</ModalFooter>
</Modal>
@@ -19,17 +19,15 @@
const end = $derived(parseInt(meta.end))
</script>
<div class="flex flex-col justify-between gap-1">
<p class="text-lg">{meta.title || meta.name}</p>
<div class="flex flex-grow flex-wrap justify-between gap-2">
<p class="text-xl">{meta.title || meta.name}</p>
{#if !isNaN(start) && !isNaN(end)}
{@const startDateDisplay = formatTimestampAsDate(start)}
{@const endDateDisplay = formatTimestampAsDate(end)}
{@const isSingleDay = startDateDisplay === endDateDisplay}
<div class="flex flex-wrap gap-2 text-xs">
<div class="flex items-center gap-2">
<Icon icon={ClockCircle} size={4} />
{formatTimestampAsDate(start)}
</div>
<div class="flex items-center gap-2 text-sm">
<Icon icon={ClockCircle} size={4} />
<span class="hidden sm:block">{formatTimestampAsDate(start)}</span>
{formatTimestampAsTime(start)}{isSingleDay
? formatTimestampAsTime(end)
: formatTimestamp(end)}
+1 -1
View File
@@ -19,7 +19,7 @@
</script>
<Link
class="cv col-3 card2 bg-alt w-full cursor-pointer shadow-md"
class="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 -1
View File
@@ -23,7 +23,7 @@
{#if meta.location}
<span class="flex items-start gap-1">
<Icon icon={MapPoint} class="mt-[2px]" size={4} />
<span class="wrap-break-word">{meta.location}</span>
<span class="break-words">{meta.location}</span>
</span>
{/if}
</div>
+136 -172
View File
@@ -1,13 +1,10 @@
<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,
@@ -35,28 +32,25 @@
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, deriveChat, makeChatId} from "@app/core/state"
import {userSettingsValues, PLATFORM_NAME, deriveChat} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {DraftKey} from "@app/util/drafts"
import {makeDelete, prependParent} from "@app/core/commands"
import {prependParent} from "@app/core/commands"
import {pushToast} from "@app/util/toast"
type Props = {
@@ -66,19 +60,15 @@
const {pubkeys, info}: Props = $props()
const chatId = makeChatId(pubkeys)
const chat = deriveChat(chatId)
const draftKey = new DraftKey<{content?: string | object}>(`dm:${chatId}`)
const chat = deriveChat(pubkeys)
const others = remove($pubkey!, pubkeys)
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
const missingRelayLists = $derived(pubkeys.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()
@@ -88,117 +78,75 @@
parent = undefined
}
const clearEventToEdit = () => {
eventToEdit = undefined
}
const onSubmit = async (params: EventContent) => {
try {
const ptags = remove($pubkey!, pubkeys).map(tagPubkey)
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"))
// 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)
// Add our reply quote to content
params = prependParent(parent, params)
if (eventToEdit) {
if (eventToEdit.content === params.content) {
return
}
const [imetaTags, tags] = partition(nthEq(0, "imeta"), params.tags)
const imetas = getTags("imeta", imetaTags).map(tagsFromIMeta)
const templates: EventTemplate[] = []
const buffer = []
await sendWrapped({
event: makeDelete({event: eventToEdit, protect: false}),
recipients: pubkeys,
pow: 16,
})
const addTemplate = (kind: number, content: string, tags: string[][]) => {
content = content.trim()
if (content) {
templates.push(makeEvent(kind, {content, tags: [...tags, ...ptags]}))
}
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 = () => {
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()
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()
const elements = $derived.by(() => {
const elements = []
@@ -234,6 +182,20 @@
for (const pubkey of others) {
loadMessagingRelayList(pubkey)
}
const observer = new ResizeObserver(() => {
if (dynamicPadding && chatCompose) {
dynamicPadding.style.minHeight = `${chatCompose.offsetHeight}px`
}
})
observer.observe(chatCompose!)
observer.observe(dynamicPadding!)
return () => {
observer.unobserve(chatCompose!)
observer.unobserve(dynamicPadding!)
}
})
setTimeout(() => {
@@ -242,58 +204,75 @@
</script>
<PageBar>
<div class="flex">
<Button onclick={back} class="place-self-start pr-3 md:hidden flex items-center">
<Icon icon={ArrowLeft} size={7} />
{#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}
</Button>
<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>
{/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>
</div>
</div>
{/if}
{/snippet}
</PageBar>
<PageContent class="flex flex-col-reverse gap-2 py-2 !mb-0">
{#if missingRelayLists.length > 0}
<PageContent class="flex flex-col-reverse gap-2 pt-4">
<div bind:this={dynamicPadding}></div>
{#if missingRelayLists.includes($pubkey!)}
<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} />
Direct messages are not enabled
Your messaging relays are not configured.
</p>
<p>
Ask
{#each missingRelayLists as pubkey (pubkey)}
<ProfileLink {pubkey} />
{/each}
to enable direct messaging by opening this conversation in their app.
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.
</p>
</div>
</div>
@@ -306,9 +285,7 @@
event={$state.snapshot(value as TrustedEvent)}
{pubkeys}
{showPubkey}
{replyTo}
canEdit={canEditEvent}
onEdit={onEditEvent} />
{replyTo} />
{/if}
{/each}
<p class="m-auto flex h-10 max-w-sm flex-col items-center justify-center gap-4 py-20 text-center">
@@ -321,26 +298,13 @@
</Spinner>
{@render info?.()}
</p>
<div class="h-screen"></div>
</PageContent>
<div class="chat__compose bg-base-200">
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
<div>
{#if parent}
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if eventToEdit}
<ChatComposeEdit clear={clearEventToEdit} />
{/if}
</div>
{#key eventToEdit}
<ChatCompose
bind:this={compose}
{onSubmit}
{onEscape}
{onEditPrevious}
initialValues={eventToEdit}
draftKey={eventToEdit ? undefined : draftKey}
disabled={Boolean(missingRelayLists.length)} />
{/key}
<ChatCompose bind:this={compose} {onSubmit} />
</div>
+8 -72
View File
@@ -1,7 +1,5 @@
<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,63 +8,23 @@
import Button from "@lib/components/Button.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
import {type DraftKey} from "@app/util/drafts"
type Values = {
content?: string | object
}
type Props = {
disabled?: boolean
draftKey?: DraftKey<Values>
onEscape?: () => void
onEditPrevious?: () => void
onSubmit: (event: EventContent) => void
initialValues?: Values
}
let {
initialValues,
disabled = false,
draftKey,
onEscape,
onEditPrevious,
onSubmit,
}: Props = $props()
const {onSubmit}: Props = $props()
if (!initialValues) {
initialValues = draftKey?.get()
}
const autofocus = !isMobile && !disabled
const autofocus = !isMobile
const uploading = writable(false)
const editorClass = $derived(
cx("chat-editor 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 || disabled) return
if ($uploading) return
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
@@ -76,45 +34,23 @@
onSubmit({content, tags})
draftKey?.clear()
ed.chain().clearContent().run()
}
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({
content,
autofocus,
submit,
uploading,
onChange,
aggressive: true,
encryptFiles: true,
})
$effect(() => {
draftKey?.set({content})
})
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}
disabled={$uploading}
onclick={uploadFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
@@ -122,13 +58,13 @@
<Icon icon={GallerySend} />
{/if}
</Button>
<div class={editorClass} aria-disabled={disabled}>
<EditorContent {autofocus} {editor} />
<div class="chat-editor flex-grow overflow-hidden">
<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}
disabled={$uploading}
onclick={submit}>
<Icon icon={Plane} />
</Button>
-21
View File
@@ -1,21 +0,0 @@
<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>
+25 -32
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import {getRelaysFromList} from "@welshman/util"
import {waitForThunkError, setMessagingRelays, userRelayList, setRelays} from "@welshman/app"
import {goto} from "$app/navigation"
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,53 +11,46 @@
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 {DEFAULT_RELAYS, DEFAULT_MESSAGING_RELAYS} from "@app/core/state"
import {pushToast} from "@app/util/toast"
import {PLATFORM_NAME} from "@app/core/state"
import {clearModals} from "@app/util/modal"
type Props = {
next: () => void
}
const {next} = $props()
const {next}: Props = $props()
const nextUrl = $state.snapshot(next)
let loading = $state(false)
const back = () => history.back()
const enable = async () => {
const submit = async () => {
loading = true
try {
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()
shouldUnwrap.set(true)
clearModals()
goto(nextUrl)
} finally {
loading = false
}
}
const back = () => history.back()
</script>
<Modal tag="form" onsubmit={preventDefault(enable)}>
<Modal tag="form" onsubmit={preventDefault(submit)}>
<ModalBody>
<ModalHeader>
<ModalTitle>Enable direct messaging?</ModalTitle>
<ModalTitle>Enable Messages</ModalTitle>
<ModalSubtitle>Do you want to enable direct messages?</ModalSubtitle>
</ModalHeader>
<p>Direct messaging isn't currently enabled. Would you like to turn it on?</p>
<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>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
@@ -65,7 +58,7 @@
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Enable direct messaging</Spinner>
<Spinner {loading}>Enable Messages</Spinner>
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
+5 -6
View File
@@ -5,11 +5,11 @@
import type {TrustedEvent} from "@welshman/util"
import {pubkey, loadMessagingRelayList} from "@welshman/app"
import {fade} from "@lib/transition"
import Button from "@lib/components/Button.svelte"
import Link from "@lib/components/Link.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {makeChatPath, goToChat} from "@app/util/routes"
import {makeChatPath} from "@app/util/routes"
import {notifications} from "@app/util/notifications"
interface Props {
@@ -24,7 +24,6 @@
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) {
@@ -33,9 +32,9 @@
})
</script>
<Button class="flex flex-col justify-start gap-1 w-full" onclick={openChat}>
<Link class="flex flex-col justify-start gap-1" href={makeChatPath(props.pubkeys)}>
<div
class="cursor-pointer border-t border-solid border-base-100 px-3 py-2 transition-colors hover:bg-base-100 {props.class}"
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}>
<div class="flex flex-col justify-start gap-1">
<div class="flex items-center justify-between gap-2">
@@ -72,4 +71,4 @@
</p>
</div>
</div>
</Button>
</Link>
+9
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import {assoc} from "@welshman/lib"
import ChatSquare from "@assets/icons/chat-square.svg?dataurl"
import Check from "@assets/icons/check.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
import BellOff from "@assets/icons/bell-off.svg?dataurl"
@@ -7,9 +8,13 @@
import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ChatStart from "@app/components/ChatStart.svelte"
import {setChecked} from "@app/util/notifications"
import {pushModal} from "@app/util/modal"
import {notificationSettings} from "@app/core/state"
const startChat = () => pushModal(ChatStart, {}, {replaceState: true})
const markAsRead = () => {
setChecked("/chat/*")
history.back()
@@ -23,6 +28,10 @@
<Modal>
<ModalBody>
<div class="flex flex-col gap-2">
<Button class="btn btn-primary" onclick={startChat}>
<Icon size={5} icon={ChatSquare} />
Start chat
</Button>
<Button class="btn btn-neutral" onclick={markAsRead}>
<Icon size={5} icon={Check} />
Mark all read
+6 -13
View File
@@ -23,13 +23,11 @@
interface Props {
event: TrustedEvent
replyTo: (event: TrustedEvent) => void
canEdit?: (event: TrustedEvent) => boolean
onEdit?: (event: TrustedEvent) => void
pubkeys: string[]
showPubkey?: boolean
}
const {event, replyTo, canEdit, onEdit, pubkeys, showPubkey = false}: Props = $props()
const {event, replyTo, pubkeys, showPubkey = false}: Props = $props()
const isOwn = event.pubkey === $pubkey
const profileDisplay = deriveProfileDisplay(event.pubkey)
@@ -37,21 +35,16 @@
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, pow: 16})
sendWrapped({event: makeDelete({event, protect: false}), recipients: pubkeys})
const createReaction = (template: EventContent) =>
sendWrapped({
event: makeReaction({event, protect: false, ...template}),
recipients: pubkeys,
pow: 16,
})
sendWrapped({event: makeReaction({event, protect: false, ...template}), recipients: pubkeys})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys, reply, edit})
const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys, reply})
const togglePopover = () => {
if (popoverIsVisible) {
@@ -78,7 +71,7 @@
<Tippy
bind:popover
component={ChatMessageMenu}
props={{event, pubkeys, popover, replyTo, edit}}
props={{event, pubkeys, popover, replyTo}}
params={{
interactive: true,
trigger: "manual",
@@ -100,7 +93,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 min-w-[100px]"
class="bg-alt chat-bubble mx-1 mb-2 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl"
onTap={showMobileMenu}>
{#if showPubkey}
<div class="flex items-center gap-2">
@@ -18,7 +18,6 @@
sendWrapped({
event: makeReaction({event, content: emoji.unicode, protect: false}),
recipients: pubkeys,
pow: 16,
})
</script>
+1 -8
View File
@@ -4,14 +4,12 @@
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, edit} = $props()
const {event, pubkeys, popover, replyTo} = $props()
const reply = () => replyTo(event)
const onEdit = () => edit?.()
const showInfo = () => {
popover.hide()
@@ -26,11 +24,6 @@
<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>
@@ -3,7 +3,6 @@
import type {TrustedEvent} from "@welshman/util"
import {sendWrapped} from "@welshman/app"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Copy from "@assets/icons/copy.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
@@ -21,17 +20,15 @@
pubkeys: string[]
event: TrustedEvent
reply: () => void
edit?: () => void
}
const {event, pubkeys, reply, edit}: Props = $props()
const {event, pubkeys, reply}: Props = $props()
const onEmoji = ((event: TrustedEvent, pubkeys: string[], emoji: NativeEmoji) => {
history.back()
sendWrapped({
event: makeReaction({event, content: emoji.unicode, protect: false}),
recipients: pubkeys,
pow: 16,
})
}).bind(undefined, event, pubkeys)
@@ -42,11 +39,6 @@
reply()
}
const sendEdit = () => {
history.back()
edit?.()
}
const copyText = () => {
history.back()
clip(event.content)
@@ -70,12 +62,6 @@
<Icon size={4} icon={Reply} />
Send Reply
</Button>
{#if edit}
<Button class="btn btn-neutral w-full" onclick={sendEdit}>
<Icon size={4} icon={Pen} />
Edit Message
</Button>
{/if}
<Button class="btn btn-primary w-full" onclick={showEmojiPicker}>
<Icon size={4} icon={SmileCircle} />
Send Reaction
+3 -2
View File
@@ -2,6 +2,7 @@
import * as nip19 from "nostr-tools/nip19"
import {onMount} from "svelte"
import {writable} from "svelte/store"
import {goto} from "$app/navigation"
import {tryCatch, uniq} from "@welshman/lib"
import {fromNostrURI} from "@welshman/util"
import {loadMessagingRelayList} from "@welshman/app"
@@ -18,11 +19,11 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
import {goToChat} from "@app/util/routes"
import {makeChatPath} from "@app/util/routes"
const back = () => history.back()
const onSubmit = () => goToChat(pubkeys)
const onSubmit = () => goto(makeChatPath(pubkeys))
const addPubkey = (pubkey: string) => {
pubkeys = uniq([...pubkeys, pubkey])
+2 -12
View File
@@ -1,10 +1,8 @@
<script lang="ts">
import {uniq} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {getTagValue, getTagValues, getAddress} from "@welshman/util"
import {getTagValue, getAddress} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Pen2 from "@assets/icons/pen-2.svg?dataurl"
import {normalizeTopic} from "@lib/util"
import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -29,7 +27,6 @@
const {url, event, showRoom, showActivity}: Props = $props()
const h = getTagValue("h", event.tags)
const topics = getTagValues("t", event.tags)
const path = makeClassifiedPath(url, getAddress(event))
const shouldProtect = canEnforceNip70(url)
@@ -42,19 +39,12 @@
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex grow flex-wrap justify-end gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<RoomName {h} {url} />
</Link>
{/if}
<div class="flex min-w-0 flex-wrap gap-2">
{#each uniq(topics) as topic (topic)}
<button type="button" class="btn btn-xs rounded-full font-normal">
#{normalizeTopic(topic)}
</button>
{/each}
</div>
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event}>
<ClassifiedStatus {event} />
+2 -3
View File
@@ -7,13 +7,12 @@
type Props = {
url: string
h?: string
shareToChat?: boolean
}
const {url, h, shareToChat = false}: Props = $props()
const {url, h}: Props = $props()
</script>
<ClassifiedForm {url} {h} {shareToChat}>
<ClassifiedForm {url} {h}>
{#snippet header()}
<ModalHeader>
<ModalTitle>Create a Classified Listing</ModalTitle>
+1 -2
View File
@@ -18,8 +18,7 @@
const {d, title, status} = fromPairs(event.tags)
const [_, price = 0, currency = "SAT"] = getTag("price", event.tags) || []
const images = getTagValues("image", event.tags)
const topics = getTagValues("t", event.tags)
const initialValues = {d, title, status, content, price: Number(price), currency, images, topics}
const initialValues = {d, title, status, content, price: Number(price), currency, images}
</script>
<ClassifiedForm {url} {initialValues}>
+25 -73
View File
@@ -1,10 +1,9 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {removeUndefined, randomId, uniq} from "@welshman/lib"
import {randomId} from "@welshman/lib"
import {makeEvent, CLASSIFIED} from "@welshman/util"
import {publishThunk, waitForThunkError} from "@welshman/app"
import {publishThunk} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html"
import {normalizeTopic} from "@lib/util"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
@@ -15,40 +14,28 @@
import ModalBody from "@lib/components/ModalBody.svelte"
import ImagesInput from "@lib/components/ImagesInput.svelte"
import CurrencyInput from "@app/components/CurrencyInput.svelte"
import TopicMultiSelect from "@app/components/TopicMultiSelect.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {canEnforceNip70, publishRoomQuote, uploadFile} from "@app/core/commands"
type Values = {
d: string
title: string
content: string | object
price: number
currency: string
images: (string | File)[]
status: string
topics: string[]
}
import {canEnforceNip70, uploadFile} from "@app/core/commands"
type Props = {
url: string
h?: string
shareToChat?: boolean
header: Snippet
initialValues?: Values
initialValues?: {
d?: string
title?: string
content?: string
price?: number
currency?: string
images?: string[]
status?: string
}
}
let {url, h, shareToChat = false, header, initialValues}: Props = $props()
const draftKey = new DraftKey<Values>(`classified:${url}:${h ?? ""}`)
if (!initialValues) {
initialValues = draftKey.get()
}
const {url, h, header, initialValues}: Props = $props()
const shouldProtect = canEnforceNip70(url)
@@ -76,7 +63,7 @@
}
const tags = [
["d", d],
["d", initialValues?.d || randomId()],
["title", title],
["summary", content],
["price", String(price), currency],
@@ -84,13 +71,7 @@
...ed.storage.nostr.getEditorTags(),
]
for (const topic of topics) {
tags.push(["t", topic])
}
const protect = await shouldProtect
if (protect) {
if (await shouldProtect) {
tags.push(PROTECTED)
}
@@ -117,47 +98,26 @@
}
}
const classifiedThunk = publishThunk({
publishThunk({
relays: [url],
event: makeEvent(CLASSIFIED, {content, tags}),
})
const error = await waitForThunkError(classifiedThunk)
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
history.back()
if (shareToChat) {
publishRoomQuote({url, h, parent: classifiedThunk.event, protect})
}
} finally {
loading = false
}
}
const content = initialValues?.content || ""
const editor = makeEditor({url, submit, content})
let loading = $state(false)
const d = $state(initialValues?.d ?? randomId())
let title = $state(initialValues?.title ?? "")
let status = $state(initialValues?.status ?? "active")
let price = $state(initialValues?.price ?? 0)
let currency = $state(initialValues?.currency ?? "SAT")
let images = $state(initialValues?.images ?? [])
let topics = $state(uniq(removeUndefined(initialValues?.topics?.map(normalizeTopic) ?? [])))
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({url, submit, onChange, content})
$effect(() => {
draftKey.set({d, title, status, price, currency, images, topics, content})
})
let title = $state(initialValues?.title || "")
let status = $state(initialValues?.status || "active")
let price = $state(Number(initialValues?.price || 0))
let currency = $state(initialValues?.currency || "SAT")
let images = $state<(string | File)[]>(initialValues?.images || [])
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
@@ -185,19 +145,11 @@
<p>Description*</p>
{/snippet}
{#snippet input()}
<div class="note-editor grow overflow-hidden">
<div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Topics</p>
{/snippet}
{#snippet input()}
<TopicMultiSelect bind:value={topics} />
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Price*</p>
+2 -2
View File
@@ -20,12 +20,12 @@
const title = getTagValue("title", event.tags)
const h = getTagValue("h", event.tags)
const images = new Set(getTagValues("image", event.tags))
const images = getTagValues("image", event.tags)
const [_, price = 0, currency = "SAT"] = getTag("price", event.tags) || []
</script>
<Link
class="cv col-2 card2 bg-alt w-full cursor-pointer shadow-xl"
class="col-2 card2 bg-alt w-full cursor-pointer shadow-xl"
href={makeClassifiedPath(url, getAddress(event))}>
{#if title}
<div class="flex w-full items-center justify-between gap-2">
+1 -1
View File
@@ -28,7 +28,7 @@
</script>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex grow flex-wrap justify-end gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2">
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
+4 -14
View File
@@ -4,7 +4,6 @@
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
import Revote from "@assets/icons/revote.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import {pushModal} from "@app/util/modal"
@@ -12,7 +11,6 @@
import ThreadCreate from "@app/components/ThreadCreate.svelte"
import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte"
import GoalCreate from "@app/components/GoalCreate.svelte"
import PollCreate from "@app/components/PollCreate.svelte"
type Props = {
url: string
@@ -22,15 +20,13 @@
const {url, h, onClick}: Props = $props()
const createGoal = () => pushModal(GoalCreate, {url, h, shareToChat: true})
const createGoal = () => pushModal(GoalCreate, {url, h})
const createCalendarEvent = () => pushModal(CalendarEventCreate, {url, h, shareToChat: true})
const createCalendarEvent = () => pushModal(CalendarEventCreate, {url, h})
const createThread = () => pushModal(ThreadCreate, {url, h, shareToChat: true})
const createThread = () => pushModal(ThreadCreate, {url, h})
const createClassified = () => pushModal(ClassifiedCreate, {url, h, shareToChat: true})
const createPoll = () => pushModal(PollCreate, {url, h, shareToChat: true})
const createClassified = () => pushModal(ClassifiedCreate, {url, h})
let ul: Element
@@ -64,10 +60,4 @@
Create Thread
</Button>
</li>
<li>
<Button onclick={createPoll}>
<Icon size={4} icon={Revote} />
Ask a Question
</Button>
</li>
</ul>
+1 -5
View File
@@ -6,7 +6,6 @@
truncate,
renderAsHtml,
isText,
isEmail,
isEmoji,
isTopic,
isCode,
@@ -27,7 +26,6 @@
import Button from "@lib/components/Button.svelte"
import ContentToken from "@app/components/ContentToken.svelte"
import ContentEmoji from "@app/components/ContentEmoji.svelte"
import ContentEmail from "@app/components/ContentEmail.svelte"
import ContentCode from "@app/components/ContentCode.svelte"
import ContentLinkInline from "@app/components/ContentLinkInline.svelte"
import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte"
@@ -152,7 +150,7 @@
</div>
{:else}
<div
class="overflow-hidden text-ellipsis wrap-break-word"
class="overflow-hidden text-ellipsis break-words"
style={expandBlock ? "mask-image: linear-gradient(0deg, transparent 0px, black 100px)" : ""}>
{#each shortContent as parsed, i}
{#if isNewline(parsed) && !isBlock(i - 1)}
@@ -161,8 +159,6 @@
<ContentTopic value={parsed.value} />
{:else if isEmoji(parsed)}
<ContentEmoji value={parsed.value} />
{:else if isEmail(parsed)}
<ContentEmail value={parsed.value} />
{:else if isCode(parsed)}
<ContentCode
value={parsed.value}
-12
View File
@@ -1,12 +0,0 @@
<script lang="ts">
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
export let value: string
</script>
<Link external href="mailto:{value}">
<Icon icon={LinkRound} size={3} />
{value}
</Link>
+41 -69
View File
@@ -1,44 +1,27 @@
<script lang="ts">
import {call, ellipsize, displayUrl, postJson} from "@welshman/lib"
import {isRelayUrl, getTagValue} from "@welshman/util"
import {Capacitor} from "@capacitor/core"
import {preventDefault, stopPropagation} from "@lib/html"
import Link from "@lib/components/Link.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import ContentLinkUrl from "@app/components/ContentLinkUrl.svelte"
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
import {pushModal} from "@app/util/modal"
import {
dufflepud,
IMAGE_CONTENT_TYPES,
PLATFORM_URL,
VIDEO_CONTENT_TYPES,
THUMBNAIL_URL,
isRoomId,
} from "@app/core/state"
import {dufflepud, PLATFORM_URL, IMAGE_CONTENT_TYPES, VIDEO_CONTENT_TYPES} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
const {value, event} = $props()
let hideImage = $state(false)
const url = value.url.toString()
const isRoomOrRelay = isRoomId(url) || isRelayUrl(url)
const fileType = getTagValue("file-type", event.tags) || ""
const [href, external] = call(() => {
if (isRelayUrl(url)) return [makeSpacePath(url), false]
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
return [url, true]
})
const fileType = getTagValue("file-type", event.tags) || ""
const getVideoPoster = (videoUrl: string): string | undefined => {
if (Capacitor.getPlatform() === "android" && THUMBNAIL_URL) {
return `${THUMBNAIL_URL}/thumbnail?url=${encodeURIComponent(videoUrl)}`
}
return undefined
}
const loadPreview = async () => {
const json = await postJson(dufflepud("link/preview"), {url})
@@ -56,52 +39,41 @@
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
</script>
{#if isRoomOrRelay}
<div>
<ContentLinkUrl {url} class="link-content whitespace-nowrap" />
<Link {external} {href} class="my-2 block">
<div class="overflow-hidden rounded-box">
{#if url.match(/\.(mov|webm|mp4)$/) || VIDEO_CONTENT_TYPES.includes(fileType)}
<video controls src={url} class="max-h-96 rounded-box object-contain object-center">
<track kind="captions" />
</video>
{:else if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)}
<button type="button" onclick={stopPropagation(preventDefault(expand))}>
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-box" />
</button>
{:else}
{#await loadPreview()}
<div class="center my-12 w-full">
<span class="loading loading-spinner"></span>
</div>
{:then preview}
<div class="bg-alt flex max-w-xl flex-col leading-normal">
{#if preview.image && !hideImage}
<img
alt=""
onerror={onError}
src={preview.image}
class="bg-alt max-h-72 rounded-t-box object-contain object-center" />
{/if}
<div class="flex flex-col gap-2 p-4">
<strong class="overflow-hidden text-ellipsis whitespace-nowrap"
>{preview.title || displayUrl(url)}</strong>
<p>{ellipsize(preview.description, 140)}</p>
</div>
</div>
{:catch}
<p class="bg-alt p-12 text-center leading-normal">
Unable to load a preview for {url}
</p>
{/await}
{/if}
</div>
{:else}
<Link {external} {href} class="my-2 block">
<div class="overflow-hidden rounded-box">
{#if url.match(/\.(mov|webm|mp4)$/) || VIDEO_CONTENT_TYPES.includes(fileType)}
<video
controls
src={url}
poster={getVideoPoster(url)}
preload="metadata"
class="max-h-96 rounded-box object-contain object-center">
<track kind="captions" />
</video>
{:else if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)}
<button type="button" onclick={stopPropagation(preventDefault(expand))}>
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-box" />
</button>
{:else}
{#await loadPreview()}
<div class="center my-12 w-full">
<span class="loading loading-spinner"></span>
</div>
{:then preview}
<div class="bg-alt flex max-w-xl flex-col leading-normal">
{#if preview.image && !hideImage}
<img
alt=""
onerror={onError}
src={preview.image}
class="bg-alt max-h-72 rounded-t-box object-contain object-center" />
{/if}
<div class="flex flex-col gap-2 p-4">
<strong class="overflow-hidden text-ellipsis whitespace-nowrap"
>{preview.title || displayUrl(url)}</strong>
<p>{ellipsize(preview.description, 140)}</p>
</div>
</div>
{:catch}
<p class="bg-alt p-12 text-center leading-normal">
Unable to load a preview for {url}
</p>
{/await}
{/if}
</div>
</Link>
{/if}
</Link>
@@ -1,6 +1,6 @@
<script lang="ts">
import {onMount, onDestroy} from "svelte"
import {displayUrl, once} from "@welshman/lib"
import {displayUrl} from "@welshman/lib"
import {
getTags,
getBlob,
@@ -26,24 +26,8 @@
const key = getTagValue("decryption-key", meta)
const nonce = getTagValue("decryption-nonce", meta)
const algorithm = getTagValue("encryption-algorithm", meta)
const mime = getTagValue("m", meta)
const fileName =
getTagValue("filename", meta) ||
getTagValue("name", meta) ||
decodeURIComponent(new URL(url).pathname.split("/").filter(Boolean).at(-1) || "image")
const revokeSrc = () => {
if (src.startsWith("blob:")) {
URL.revokeObjectURL(src)
}
}
const setBlobSrc = (data: Blob | Uint8Array<ArrayBuffer>, type?: string) => {
revokeSrc()
src = URL.createObjectURL(new File([data], fileName, type ? {type} : undefined))
}
const onError = once(async () => {
const onError = async () => {
// If the image failed to load, try authenticating
if (hash && $signer) {
const server = new URL(url).origin
@@ -52,15 +36,14 @@
const res = await getBlob(server, hash, {authEvent})
if (res.status === 200) {
const blob = await res.blob()
setBlobSrc(blob, blob.type || undefined)
src = URL.createObjectURL(await res.blob())
} else {
hasError = true
}
} else {
hasError = true
}
})
}
let hasError = $state(false)
let src = $state("")
@@ -74,7 +57,7 @@
const ciphertext = new Uint8Array(await response.arrayBuffer())
const decryptedData = await decryptFile({ciphertext, key, nonce, algorithm})
setBlobSrc(new Uint8Array(decryptedData), mime)
src = URL.createObjectURL(new Blob([new Uint8Array(decryptedData)]))
}
} else {
src = url
@@ -82,7 +65,7 @@
})
onDestroy(() => {
revokeSrc()
URL.revokeObjectURL(src)
})
</script>
+15 -5
View File
@@ -1,18 +1,25 @@
<script lang="ts">
import {displayUrl} from "@welshman/lib"
import {getTagValue} from "@welshman/util"
import {call, displayUrl} from "@welshman/lib"
import {isRelayUrl, getTagValue} from "@welshman/util"
import {preventDefault, stopPropagation} from "@lib/html"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import ContentLinkUrl from "@app/components/ContentLinkUrl.svelte"
import {pushModal} from "@app/util/modal"
import {IMAGE_CONTENT_TYPES} from "@app/core/state"
import {PLATFORM_URL, IMAGE_CONTENT_TYPES} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
const {value, event} = $props()
const url = value.url.toString()
const fileType = getTagValue("file-type", event.tags) || ""
const [href, external] = call(() => {
if (isRelayUrl(url)) return [makeSpacePath(url), false]
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
return [url, true]
})
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
</script>
@@ -27,5 +34,8 @@
{displayUrl(url)}
</a>
{:else}
<ContentLinkUrl {url} class="link-content whitespace-nowrap" />
<Link {external} {href} class="link-content whitespace-nowrap">
<Icon icon={LinkRound} size={3} class="inline-block" />
{displayUrl(url)}
</Link>
{/if}
-59
View File
@@ -1,59 +0,0 @@
<script lang="ts">
import {call, displayUrl} from "@welshman/lib"
import {displayRelayUrl, isRelayUrl, normalizeRelayUrl} from "@welshman/util"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import {PLATFORM_URL, displayRoom, isRoomId, splitRoomId} from "@app/core/state"
import {makeRoomPath, makeSpacePath} from "@app/util/routes"
const {
url,
class: className = "",
}: {
url: string
class?: string
} = $props()
const roomReference = call(() => {
if (!isRoomId(url)) {
return undefined
}
const [roomUrl, h] = splitRoomId(url)
if (!roomUrl || !h || !isRelayUrl(roomUrl)) {
return undefined
}
return {url: normalizeRelayUrl(roomUrl), h}
})
const relayReference = call(() => {
if (roomReference || !isRelayUrl(url)) {
return undefined
}
return normalizeRelayUrl(url)
})
const [href, external] = call(() => {
if (roomReference) return [makeRoomPath(roomReference.url, roomReference.h), false]
if (relayReference) return [makeSpacePath(relayReference), false]
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
return [url, true]
})
</script>
<Link {external} {href} class={className}>
{#if roomReference}
~<span class="text-primary">{displayRelayUrl(roomReference.url)}</span> /
{displayRoom(roomReference.url, roomReference.h)}
{:else if relayReference}
<span class="text-primary">{displayRelayUrl(relayReference)}</span>
{:else}
<Icon icon={LinkRound} size={3} class="inline-block" />
{displayUrl(url)}
{/if}
</Link>
+1 -5
View File
@@ -7,7 +7,6 @@
renderAsHtml,
isText,
isEmoji,
isEmail,
isTopic,
isCode,
isCashu,
@@ -25,7 +24,6 @@
import Button from "@lib/components/Button.svelte"
import ContentToken from "@app/components/ContentToken.svelte"
import ContentEmoji from "@app/components/ContentEmoji.svelte"
import ContentEmail from "@app/components/ContentEmail.svelte"
import ContentCode from "@app/components/ContentCode.svelte"
import ContentLinkInline from "@app/components/ContentLinkInline.svelte"
import ContentNewline from "@app/components/ContentNewline.svelte"
@@ -103,7 +101,7 @@
</p>
</div>
{:else}
<div class="overflow-hidden text-ellipsis wrap-break-word">
<div class="overflow-hidden text-ellipsis break-words">
{#each shortContent as parsed, i}
{#if isNewline(parsed)}
<ContentNewline value={parsed.value} />
@@ -111,8 +109,6 @@
<ContentTopic value={parsed.value} />
{:else if isEmoji(parsed)}
<ContentEmoji value={parsed.value} />
{:else if isEmail(parsed)}
<ContentEmail value={parsed.value} />
{:else if isCode(parsed)}
<ContentCode
value={parsed.value}
+2 -2
View File
@@ -45,11 +45,11 @@
{#if $quote.kind === MESSAGE}
<div
class="border-l-2 border-solid border-l-primary py-1 pl-2 opacity-90"
style="background-color: color-mix(in srgb, var(--color-primary) 10%, var(--color-base-300) 90%);">
style="background-color: color-mix(in srgb, var(--primary) 10%, var(--base-300) 90%);">
<NoteContentMinimal trimParent {url} event={$quote} />
</div>
{:else}
<NoteCard noShadow event={$quote} {url} class="bg-alt rounded-box p-4">
<NoteCard event={$quote} {url} class="bg-alt rounded-box p-4">
<NoteContentMinimal {url} event={$quote} />
</NoteCard>
{/if}
-4
View File
@@ -96,10 +96,6 @@
params={{
trigger: "manual",
interactive: true,
placement: "bottom",
getReferenceClientRect: () => wrapper!.getBoundingClientRect(),
onShow: (instance: Instance) => {
instance.popper.style.width = `${wrapper!.getBoundingClientRect().width + 8}px`
},
}} />
</button>
+2 -3
View File
@@ -42,7 +42,7 @@
let popover: Instance | undefined = $state()
</script>
<div class="join items-center rounded-full">
<Button class="join rounded-full">
{#if ENABLE_ZAPS && !hideZap}
<ZapButton {url} {event} class="btn join-item btn-neutral btn-xs">
<Icon icon={Bolt} size={4} />
@@ -52,7 +52,6 @@
<Icon icon={SmileCircle} size={4} />
</EmojiButton>
<Tippy
class="flex"
bind:popover
component={EventMenu}
props={{url, noun, event, customActions, onClick: hidePopover}}
@@ -61,4 +60,4 @@
<Icon icon={MenuDots} size={4} />
</Button>
</Tippy>
</div>
</Button>
+1 -1
View File
@@ -26,7 +26,7 @@
<Icon icon={Reply} />
<span>{$replies.length} {$replies.length === 1 ? "reply" : "replies"}</span>
</div>
<div class="btn btn-neutral btn-xs relative rounded-full">
<div class="btn btn-neutral btn-xs relative hidden rounded-full sm:flex">
{#if gt(lastActive, $checked)}
<div class="h-2 w-2 rounded-full bg-primary"></div>
{/if}
+2 -2
View File
@@ -101,7 +101,7 @@
{/if}
<div class="relative">
<pre class="card2 card2-sm bg-alt overflow-auto text-xs"><code>{json}</code></pre>
<p class="absolute right-2 top-2 flex grow items-center justify-between">
<p class="absolute right-2 top-2 flex flex-grow items-center justify-between">
<Button onclick={copyJson} class="btn btn-neutral btn-sm flex items-center">
<Icon icon={Copy} /> Copy
</Button>
@@ -109,6 +109,6 @@
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-primary grow" onclick={() => history.back()}>Got it</Button>
<Button class="btn btn-primary flex-grow" onclick={() => history.back()}>Got it</Button>
</ModalFooter>
</Modal>
+8 -28
View File
@@ -10,19 +10,13 @@
import {publishComment, canEnforceNip70} from "@app/core/commands"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {pushToast} from "@app/util/toast"
type Values = {
content?: string | object
}
const {url, event, onClose, onSubmit} = $props()
const draftKey = new DraftKey<Values>(`reply:${event.id}`)
const initialValues = draftKey.get()
const shouldProtect = canEnforceNip70(url)
const uploading = writable(false)
const autofocus = !isMobile
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
@@ -44,23 +38,13 @@
})
}
draftKey.clear()
onSubmit(publishComment({event, content, tags, relays: [url]}))
}
const editor = makeEditor({url, submit, uploading, autofocus: !isMobile})
let form: HTMLElement
let spacer: HTMLElement
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({url, submit, uploading, content, onChange})
$effect(() => {
draftKey.set({content})
})
onMount(() => {
setTimeout(() => {
@@ -68,7 +52,7 @@
})
const observer = new ResizeObserver(() => {
spacer!.style.minHeight = `${form!.offsetHeight + 60}px`
spacer!.style.minHeight = `${form!.offsetHeight}px`
})
observer.observe(form!)
@@ -80,15 +64,11 @@
</script>
<div bind:this={spacer}></div>
<form
in:fly
bind:this={form}
onsubmit={preventDefault(submit)}
class="left-content bottom-sai right-sai fixed z-feature mb-14 md:mb-0 w-full md:w-auto pr-2">
<form in:fly bind:this={form} onsubmit={preventDefault(submit)} class="cb cw fixed z-feature pt-3">
<div class="card2 mx-2 my-2 bg-alt shadow-md">
<div class="relative">
<div class="note-editor grow overflow-hidden">
<EditorContent {autofocus} {editor} />
<div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
<Button
data-tip="Add an image"
+1 -1
View File
@@ -30,7 +30,7 @@
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex grow flex-wrap justify-end gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<RoomName {h} {url} />
+34 -90
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import {writable} from "svelte/store"
import {makeEvent, ZAP_GOAL} from "@welshman/util"
import {publishThunk, waitForThunkError} from "@welshman/app"
import {publishThunk} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import Bolt from "@assets/icons/bolt.svg?dataurl"
@@ -10,7 +10,6 @@
import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
@@ -21,29 +20,14 @@
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
type Values = {
title: string
content: string | object
amount: number
}
import {canEnforceNip70} from "@app/core/commands"
type Props = {
url: string
h?: string
initialValues?: Values
shareToChat?: boolean
}
let {url, h, initialValues, shareToChat = false}: Props = $props()
const draftKey = new DraftKey<Values>(`goal:${url}:${h ?? ""}`)
if (!initialValues) {
initialValues = draftKey.get()
}
const {url, h}: Props = $props()
const shouldProtect = canEnforceNip70(url)
@@ -54,9 +38,9 @@
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => {
if ($uploading || loading) return
if ($uploading) return
if (!title) {
if (!content) {
return pushToast({
theme: "error",
message: "Please provide a title for your funding goal.",
@@ -64,9 +48,9 @@
}
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
const summary = ed.getText({blockSeparator: "\n"}).trim()
if (!content.trim()) {
if (!summary.trim()) {
return pushToast({
theme: "error",
message: "Please provide details about your funding goal.",
@@ -75,68 +59,31 @@
const tags = [
...ed.storage.nostr.getEditorTags(),
["summary", content],
["summary", summary],
["amount", String(amount)],
["relays", url],
]
loading = true
try {
const protect = await shouldProtect
if (protect) {
tags.push(PROTECTED)
}
if (h) {
tags.push(["h", h])
}
const goalThunk = publishThunk({
relays: [url],
event: makeEvent(ZAP_GOAL, {content: title, tags}),
})
const error = await waitForThunkError(goalThunk)
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
history.back()
if (shareToChat) {
publishRoomQuote({url, h, parent: goalThunk.event, protect})
}
} finally {
loading = false
if (await shouldProtect) {
tags.push(PROTECTED)
}
if (h) {
tags.push(["h", h])
}
publishThunk({
relays: [url],
event: makeEvent(ZAP_GOAL, {content, tags}),
})
history.back()
}
let loading = $state(false)
const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"})
let title = $state(initialValues?.title ?? "")
let amount = $state(initialValues?.amount ?? 1000)
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({
url,
submit,
uploading,
onChange,
placeholder: "What's on your mind?",
content,
})
$effect(() => {
draftKey.update({title, content, amount})
})
let content = $state("")
let amount = $state(1000)
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
@@ -155,7 +102,7 @@
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus={!isMobile}
bind:value={title}
bind:value={content}
class="grow"
type="text"
placeholder="What do funds go towards?" />
@@ -168,7 +115,7 @@
<p>Details*</p>
{/snippet}
{#snippet input()}
<div class="note-editor grow overflow-hidden">
<div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
{/snippet}
@@ -176,8 +123,7 @@
<Button
data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2"
onclick={selectFiles}
disabled={loading}>
onclick={selectFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
@@ -191,17 +137,17 @@
Goal Amount (sats)*
{/snippet}
{#snippet input()}
<div class="flex grow justify-end">
<label class="input input-bordered flex w-auto items-center gap-2">
<div class="flex flex-grow justify-end">
<label class="input input-bordered flex items-center gap-2">
<Icon icon={Bolt} />
<input bind:value={amount} type="number" class="w-28 grow" />
<p class="shrink-0 opacity-50">sats</p>
<input bind:value={amount} type="number" class="w-28" />
<p class="opacity-50">sats</p>
</label>
</div>
{/snippet}
</FieldInline>
<input
class="range range-primary -mt-2 w-full"
class="range range-primary -mt-2"
type="range"
min="1000"
max="100000"
@@ -211,12 +157,10 @@
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={$uploading || loading}>
<Spinner {loading}>Create Goal</Spinner>
</Button>
<Button type="submit" class="btn btn-primary">Create Goal</Button>
</ModalFooter>
</Modal>
+1 -3
View File
@@ -20,9 +20,7 @@
const h = getTagValue("h", event.tags)
</script>
<Link
class="cv col-2 card2 bg-alt w-full cursor-pointer shadow-md"
href={makeGoalPath(url, event.id)}>
<Link class="col-2 card2 bg-alt w-full cursor-pointer shadow-md" href={makeGoalPath(url, event.id)}>
<p class="text-2xl">{event.content}</p>
<Content
event={{content: summary, tags: event.tags}}
-17
View File
@@ -1,17 +0,0 @@
<script lang="ts">
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import IconPicker from "@app/components/IconPicker.svelte"
type Props = {
onSelect: (iconUrl: string) => void
}
const {onSelect}: Props = $props()
</script>
<Modal>
<ModalBody>
<IconPicker {onSelect} />
</ModalBody>
</Modal>
+1 -1
View File
@@ -23,7 +23,7 @@
<ModalTitle>Unable to Zap</ModalTitle>
</ModalHeader>
<p>
Zapping <ProfileLink {pubkey} class="text-primary!" /> isn't possible right now because
Zapping <ProfileLink {pubkey} class="!text-primary" /> isn't possible right now because
{#if $zapper}
their zap receiver isn't correctly set up.
{:else}
@@ -97,10 +97,10 @@
tabindex="-1"
onmousedown={stopPropagation(onClear)}
ontouchstart={stopPropagation(onClear)}>
<Icon icon={CloseCircle} class="scale-150 bg-base-300!" />
<Icon icon={CloseCircle} class="scale-150 !bg-base-300" />
</span>
{:else}
<Icon icon={AddCircle} class="scale-150 bg-base-300!" />
<Icon icon={AddCircle} class="scale-150 !bg-base-300" />
{/if}
</div>
{#if !url}
+1 -7
View File
@@ -15,7 +15,6 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProgressBar from "@app/components/ProgressBar.svelte"
import {pushToast} from "@app/util/toast"
import {PLATFORM_NAME} from "@app/core/state"
@@ -23,11 +22,9 @@
secret: string
next: () => unknown
submitText?: string
step?: number
totalSteps?: number
}
const {secret, next, submitText = "Continue", step, totalSteps}: Props = $props()
const {secret, next, submitText = "Continue"}: Props = $props()
const back = () => history.back()
@@ -153,9 +150,6 @@
</Button>
</div>
</ModalBody>
{#if step && totalSteps}
<ProgressBar current={step} total={totalSteps} />
{/if}
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
+22 -7
View File
@@ -15,10 +15,10 @@
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import StringMultiInput from "@lib/components/StringMultiInput.svelte"
import KeyDownload from "@app/components/KeyDownload.svelte"
import {pushToast} from "@app/util/toast"
import {pushModal, clearModals} from "@app/util/modal"
import {POMADE_SIGNERS} from "@app/core/state"
type Props = {
peersByPrefix: Map<string, string>
@@ -32,6 +32,18 @@
} = $session as SessionPomade
const confirmRecovery = async () => {
const otps = input
.split(/\n/)
.map(x => x.trim())
.filter(x => x.match(/^[0-9]{8}$/))
if (otps.length < 2) {
return pushToast({
theme: "error",
message: "Failed to recover, not enough valid recovery codes were provided.",
})
}
const request = await Client.recoverWithChallenge(email, peersByPrefix, otps)
if (!request.ok) {
@@ -39,7 +51,7 @@
return pushToast({
theme: "error",
message: `Failed to recover: ${request.messages[0]?.res?.message.toLowerCase()}`,
message: `Failed to recover: ${request.messages[0]?.payload.message.toLowerCase()}`,
})
}
@@ -50,7 +62,7 @@
return pushToast({
theme: "error",
message: `Failed to recover: ${result.messages[0]?.res?.message.toLowerCase()}`,
message: `Failed to recover: ${result.messages[0]?.payload.message.toLowerCase()}`,
})
}
@@ -70,7 +82,7 @@
const back = () => history.back()
let loading = $state(false)
let otps = $state<string[]>([])
let input = $state("")
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
@@ -82,16 +94,19 @@
<p>Your recovery codes have been sent!</p>
<p>
For security reasons, you may receive three or more emails with recovery codes in them. Please
paste <strong>all</strong> recovery codes into the text box below.
paste <strong>all</strong> recovery codes into the text box below, on separate lines.
</p>
<StringMultiInput bind:value={otps} placeholder="Enter your recovery codes..." />
<textarea
rows={POMADE_SIGNERS.length + 1}
class="textarea textarea-bordered leading-4"
bind:value={input}></textarea>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading || otps.length < 2}>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Confirm recovery</Spinner>
<Icon icon={AltArrowRight} />
</Button>
+2 -1
View File
@@ -3,7 +3,7 @@
import {Capacitor} from "@capacitor/core"
import {getNip07, getNip55, Nip55Signer} from "@welshman/signer"
import {addSession, type Session, makeNip07Session, makeNip55Session} from "@welshman/app"
import Widget from "@assets/icons/widget-4.svg?dataurl"
import Widget from "@assets/icons/widget-2.svg?dataurl"
import Letter from "@assets/icons/letter.svg?dataurl"
import Cpu from "@assets/icons/cpu-bolt.svg?dataurl"
import Compass from "@assets/icons/compass-big.svg?dataurl"
@@ -34,6 +34,7 @@
const onSuccess = async (session: Session) => {
addSession(session)
pushToast({message: "Successfully logged in!"})
setChecked("*")
clearModals()
}
-11
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import {Capacitor} from "@capacitor/core"
import {onMount, onDestroy} from "svelte"
import type {Nip46ResponseWithResult} from "@welshman/signer"
import {Nip46Broker} from "@welshman/signer"
@@ -78,7 +77,6 @@
controller.stop()
loginWithNip46(pubkey, clientSecret, signerPubkey, relays)
setChecked("*")
} else {
return pushToast({
theme: "error",
@@ -104,16 +102,10 @@
mode = "connect"
}
const openSigner = () => {
controller.launchSigner()
}
const selectBunker = () => {
mode = "bunker"
}
const isIos = Capacitor.getPlatform() === "ios"
let mode: string = $state("bunker")
$effect(() => {
@@ -145,9 +137,6 @@
<BunkerUrl {controller} />
<Button class="btn {$bunker ? 'btn-neutral' : 'btn-primary'}" onclick={selectConnect}
>Log in with a QR code instead</Button>
{#if isIos}
<Button class="btn btn-neutral" onclick={openSigner}>Open in Signer</Button>
{/if}
{/if}
</ModalBody>
<ModalFooter>
+17 -31
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import {uniq} from "@welshman/lib"
import {Client} from "@pomade/core"
import {loginWithPomade} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
@@ -17,9 +17,6 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import LogInOTP from "@app/components/LogInOTP.svelte"
import LogInSelect from "@app/components/LogInSelect.svelte"
import {deleteDeactivatedPomadeSessions, loginWithPomade} from "@app/util/pomade"
import {getPomadeLoginFailureMessage, POMADE_NETWORK_ERROR_MESSAGE} from "@app/util/pomadeErrors"
import {pushModal, clearModals} from "@app/util/modal"
import {setChecked} from "@app/util/notifications"
import {pushToast} from "@app/util/toast"
@@ -40,42 +37,31 @@
try {
const {ok, options, messages, clientSecret} = await Client.loginWithPassword(email, password)
if (!ok || options.length === 0) {
if (!ok) {
console.error(messages)
return pushToast({
theme: "error",
message: getPomadeLoginFailureMessage(messages),
message: "Sorry, we were unable to log you in.",
})
}
if (uniq(options.map(o => o.pubkey)).length > 1) {
pushModal(LogInSelect, {email, options, clientSecret})
const [client, peers] = options[0]!
const {clientOptions, ...res} = await Client.selectLogin(clientSecret, client, peers)
if (res.ok && clientOptions) {
loginWithPomade(clientOptions.group.group_pk.slice(2), email, clientOptions)
pushToast({message: "Successfully logged in!"})
setChecked("*")
clearModals()
} else {
const {client, peers} = options[0]
const {clientOptions, ...res} = await Client.selectLogin(clientSecret, client, peers)
console.error(res.messages)
if (res.ok && clientOptions) {
loginWithPomade(clientOptions, email)
deleteDeactivatedPomadeSessions()
setChecked("*")
clearModals()
} else {
console.error(res.messages)
pushToast({
theme: "error",
message: getPomadeLoginFailureMessage(res.messages),
})
}
pushToast({
theme: "error",
message: "Sorry, we were unable to log you in.",
})
}
} catch (error) {
console.error("Login error:", error)
pushToast({
theme: "error",
message: POMADE_NETWORK_ERROR_MESSAGE,
})
} finally {
loading = false
}
@@ -98,7 +84,7 @@
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Letter} />
<input type="email" bind:value={email} />
<input bind:value={email} />
</label>
{/snippet}
</FieldInline>
+1
View File
@@ -57,6 +57,7 @@
}
loginWithNip01(secret)
pushToast({message: "Successfully logged in!"})
setChecked("*")
clearModals()
} catch (e) {
+2 -12
View File
@@ -15,7 +15,6 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import LogInOTPConfirm from "@app/components/LogInOTPConfirm.svelte"
import {POMADE_NETWORK_ERROR_MESSAGE} from "@app/util/pomadeErrors"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
@@ -36,20 +35,11 @@
if (ok) {
pushModal(LogInOTPConfirm, {email, peersByPrefix})
} else {
console.error("Pomade challenge request failed during OTP login")
pushToast({
theme: "error",
message: POMADE_NETWORK_ERROR_MESSAGE,
message: "Sorry, we were unable to request a login code.",
})
}
} catch (error) {
console.error(error)
pushToast({
theme: "error",
message: POMADE_NETWORK_ERROR_MESSAGE,
})
} finally {
loading = false
}
@@ -71,7 +61,7 @@
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Letter} />
<input type="email" bind:value={email} />
<input bind:value={email} />
</label>
{/snippet}
</FieldInline>
+37 -36
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import {uniq} from "@welshman/lib"
import {Client} from "@pomade/core"
import {loginWithPomade} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
@@ -13,13 +13,10 @@
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import StringMultiInput from "@lib/components/StringMultiInput.svelte"
import LogInSelect from "@app/components/LogInSelect.svelte"
import {pushModal, clearModals} from "@app/util/modal"
import {clearModals} from "@app/util/modal"
import {setChecked} from "@app/util/notifications"
import {deleteDeactivatedPomadeSessions, loginWithPomade} from "@app/util/pomade"
import {getPomadeLoginFailureMessage, POMADE_NETWORK_ERROR_MESSAGE} from "@app/util/pomadeErrors"
import {pushToast} from "@app/util/toast"
import {POMADE_SIGNERS} from "@app/core/state"
type Props = {
email: string
@@ -31,6 +28,18 @@
const back = () => history.back()
const onSubmit = async () => {
const otps = input
.split(/\n/)
.map(x => x.trim())
.filter(x => x.match(/^[0-9]{8}$/))
if (otps.length < 2) {
return pushToast({
theme: "error",
message: "Failed to recover, not enough valid recovery codes were provided.",
})
}
loading = true
try {
@@ -40,48 +49,37 @@
otps,
)
if (!ok || options.length === 0) {
if (!ok) {
console.error(messages)
return pushToast({
theme: "error",
message: getPomadeLoginFailureMessage(messages),
message: "Sorry, we were unable to log you in.",
})
}
if (uniq(options.map(o => o.pubkey)).length > 1) {
pushModal(LogInSelect, {email, options, clientSecret})
const [client, peers] = options[0]!
const {clientOptions, ...res} = await Client.selectLogin(clientSecret, client, peers)
if (res.ok && clientOptions) {
loginWithPomade(clientOptions.group.group_pk.slice(2), email, clientOptions)
pushToast({message: "Successfully logged in!"})
setChecked("*")
clearModals()
} else {
const {client, peers} = options[0]
const {clientOptions, ...res} = await Client.selectLogin(clientSecret, client, peers)
console.error(res.messages)
if (res.ok && clientOptions) {
loginWithPomade(clientOptions, email)
deleteDeactivatedPomadeSessions()
setChecked("*")
clearModals()
} else {
console.error(res.messages)
pushToast({
theme: "error",
message: getPomadeLoginFailureMessage(res.messages),
})
}
pushToast({
theme: "error",
message: "Sorry, we were unable to log you in.",
})
}
} catch (error) {
console.error("Login error:", error)
pushToast({
theme: "error",
message: POMADE_NETWORK_ERROR_MESSAGE,
})
} finally {
loading = false
}
}
let otps = $state<string[]>([])
let input = $state("")
let loading = $state(false)
</script>
@@ -94,16 +92,19 @@
<p>Your login codes have been sent!</p>
<p>
For security reasons, you may receive three or more emails with login codes in them. Please
paste <strong>all</strong> login codes into the text box below.
paste <strong>all</strong> login codes into the text box below, on separate lines.
</p>
<StringMultiInput bind:value={otps} placeholder="Enter your login codes..." />
<textarea
rows={POMADE_SIGNERS.length + 1}
class="textarea textarea-bordered leading-4"
bind:value={input}></textarea>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading || otps.length < 3}>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Log In</Spinner>
<Icon icon={AltArrowRight} />
</Button>
-91
View File
@@ -1,91 +0,0 @@
<script lang="ts">
import type {AccountOption} from "@pomade/core"
import {Client} from "@pomade/core"
import {uniqBy} from "@welshman/lib"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Modal from "@lib/components/Modal.svelte"
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 Profile from "@app/components/Profile.svelte"
import {deleteDeactivatedPomadeSessions, loginWithPomade} from "@app/util/pomade"
import {getPomadeLoginFailureMessage, POMADE_NETWORK_ERROR_MESSAGE} from "@app/util/pomadeErrors"
import {setChecked} from "@app/util/notifications"
import {clearModals} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
interface Props {
email: string
options: AccountOption[]
clientSecret: string
}
const {email, options, clientSecret}: Props = $props()
let loading = $state(false)
const back = () => history.back()
const selectAccount = async ({client, peers}: AccountOption) => {
loading = true
try {
const {clientOptions, ...res} = await Client.selectLogin(clientSecret, client, peers)
if (res.ok && clientOptions) {
loginWithPomade(clientOptions, email)
deleteDeactivatedPomadeSessions()
setChecked("*")
clearModals()
} else {
console.error(res.messages)
pushToast({
theme: "error",
message: getPomadeLoginFailureMessage(res.messages),
})
}
} catch (error) {
console.error("Login error:", error)
pushToast({
theme: "error",
message: POMADE_NETWORK_ERROR_MESSAGE,
})
} finally {
loading = false
}
}
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Select Account</ModalTitle>
<ModalSubtitle
>Multiple accounts are associated with {email}. Please select one to continue.</ModalSubtitle>
</ModalHeader>
<div class="flex flex-col gap-2">
{#each uniqBy(o => o.pubkey, options) as option (option.pubkey)}
<Button
onclick={() => selectAccount(option)}
disabled={loading}
class="card2 bg-alt flex w-full items-center p-3 text-left">
<Profile pubkey={option.pubkey} />
</Button>
{/each}
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Spinner {loading} />
</ModalFooter>
</Modal>
+9 -2
View File
@@ -9,7 +9,8 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {logout} from "@app/util/logout"
import {Push} from "@app/util/notifications"
import {kv, db} from "@app/core/storage"
const back = () => history.back()
@@ -17,7 +18,13 @@
loading = true
try {
await logout()
await Push.disable()
await kv.clear()
await db.clear()
localStorage.clear()
window.location.href = "/"
} catch (e) {
console.error(e)
loading = false
+15
View File
@@ -0,0 +1,15 @@
<script lang="ts">
import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte"
type Props = {
urls: string[]
}
const {urls}: Props = $props()
</script>
<div class="column menu gap-2">
{#each urls as url (url)}
<MenuSpacesItem {url} />
{/each}
</div>
+103 -54
View File
@@ -1,9 +1,11 @@
<script lang="ts">
import {Capacitor} from "@capacitor/core"
import {pubkey} from "@welshman/app"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Server from "@assets/icons/server.svg?dataurl"
import GalleryMinimalistic from "@assets/icons/gallery-minimalistic.svg?dataurl"
import Shield from "@assets/icons/shield-minimalistic.svg?dataurl"
import Moon from "@assets/icons/moon.svg?dataurl"
import Settings from "@assets/icons/settings-minimalistic.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import Exit from "@assets/icons/logout-3.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
import Wallet from "@assets/icons/wallet.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
@@ -11,69 +13,116 @@
import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import Profile from "@app/components/Profile.svelte"
import CardButton from "@lib/components/CardButton.svelte"
import LogOut from "@app/components/LogOut.svelte"
import {PLATFORM_NAME} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {theme} from "@app/util/theme"
const logout = () => pushModal(LogOut)
const toggleTheme = () => theme.set($theme === "dark" ? "light" : "dark")
</script>
<Modal>
<ModalBody>
<div class="flex flex-col gap-8 items-center py-12 max-w-[16rem] m-auto w-full">
{#if $pubkey}
<Link replaceState href="/settings/profile">
<Profile inert pubkey={$pubkey} />
<div class="flex flex-col gap-2">
<Link replaceState href="/settings/profile">
<CardButton class="btn-neutral">
{#snippet icon()}
<div><Icon icon={UserRounded} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Profile</div>
{/snippet}
{#snippet info()}
<div>Customize your user profile</div>
{/snippet}
</CardButton>
</Link>
<Link replaceState href="/settings/alerts">
<CardButton class="btn-neutral">
{#snippet icon()}
<div><Icon icon={Bell} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Alerts</div>
{/snippet}
{#snippet info()}
<div>Set up email digests and push notifications</div>
{/snippet}
</CardButton>
</Link>
{#if Capacitor.getPlatform() !== "ios"}
<Link replaceState href="/settings/wallet">
<CardButton class="btn-neutral">
{#snippet icon()}
<div><Icon icon={Wallet} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Wallet</div>
{/snippet}
{#snippet info()}
<div>Connect a bitcoin wallet for sending social tips</div>
{/snippet}
</CardButton>
</Link>
{/if}
<div class="grid grid-cols-3 gap-3 w-full">
<Link
replaceState
href="/settings/alerts"
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-2 text-center">
<Icon icon={Bell} size={5} />
Alerts
</Link>
{#if Capacitor.getPlatform() !== "ios"}
<Link
replaceState
href="/settings/wallet"
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-2 text-center">
<Icon icon={Wallet} size={5} />
Wallet
</Link>
{/if}
<Link
replaceState
href="/settings/relays"
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-2 text-center">
<Icon icon={Server} size={5} />
Relays
</Link>
<Link
replaceState
href="/settings/content"
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-2 text-center">
<Icon icon={GalleryMinimalistic} size={5} />
Content
</Link>
<Link
replaceState
href="/settings/privacy"
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-2 text-center">
<Icon icon={Shield} size={5} />
Privacy
</Link>
</div>
<div class="flex gap-3 items-center opacity-75 text-sm">
<Button onclick={toggleTheme}>Theme</Button>
/
<Link replaceState href="/settings/about">About</Link>
/
<Button onclick={logout}>Log Out</Button>
</div>
<Link replaceState href="/settings/relays">
<CardButton class="btn-neutral">
{#snippet icon()}
<div><Icon icon={Server} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Relays</div>
{/snippet}
{#snippet info()}
<div>Control how {PLATFORM_NAME} talks to the network</div>
{/snippet}
</CardButton>
</Link>
<Link replaceState href="/settings/content">
<CardButton class="btn-neutral">
{#snippet icon()}
<div><Icon icon={Settings} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Settings</div>
{/snippet}
{#snippet info()}
<div>Get into the details about how {PLATFORM_NAME} works</div>
{/snippet}
</CardButton>
</Link>
<Button onclick={toggleTheme}>
<CardButton class="btn-neutral">
{#snippet icon()}
<div><Icon icon={Moon} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Theme</div>
{/snippet}
{#snippet info()}
<div>Switch between light and dark mode</div>
{/snippet}
</CardButton>
</Button>
<Link replaceState href="/settings/about">
<CardButton class="btn-neutral">
{#snippet icon()}
<div><Icon icon={Code2} size={7} /></div>
{/snippet}
{#snippet title()}
<div>About</div>
{/snippet}
{#snippet info()}
<div>Learn about {PLATFORM_NAME} and support the developer</div>
{/snippet}
</CardButton>
</Link>
<Button onclick={logout} class="btn btn-neutral">
<Icon icon={Exit} /> Log Out
</Button>
</div>
</ModalBody>
</Modal>
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts">
import Link from "@lib/components/Link.svelte"
import CardButton from "@lib/components/CardButton.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import RelayName from "@app/components/RelayName.svelte"
import RelayDescription from "@app/components/RelayDescription.svelte"
import {makeSpacePath} from "@app/util/routes"
import {notifications} from "@app/util/notifications"
const {url} = $props()
const path = makeSpacePath(url)
</script>
<Link replaceState href={path}>
<CardButton class="btn-neutral shadow-md bg-alt">
{#snippet icon()}
<RelayIcon {url} size={12} class="rounded-full" />
{/snippet}
{#snippet title()}
<div class="flex gap-1">
<RelayName {url} />
{#if $notifications.has(path)}
<div class="relative top-1 h-2 w-2 rounded-full bg-primary"></div>
{/if}
</div>
{/snippet}
{#snippet info()}
<div><RelayDescription {url} /></div>
{/snippet}
</CardButton>
</Link>
+13 -22
View File
@@ -2,47 +2,38 @@
import {onMount, mount, unmount} from "svelte"
import Drawer from "@lib/components/Drawer.svelte"
import Dialog from "@lib/components/Dialog.svelte"
import {modal, modalStack, popModal} from "@app/util/modal"
import {modal, clearModals} from "@app/util/modal"
const closeModal = () => {
const closeModals = () => {
if ($modal && !$modal.options.noEscape) {
popModal()
clearModals()
}
}
const onKeyDown = (e: any) => {
if (e.code === "Escape" && e.target === document.body) {
closeModal()
closeModals()
}
}
let element: HTMLElement
const instances: Record<string, any> = {}
let instance: any | undefined
onMount(() => {
return modalStack.subscribe($modalStack => {
const ids = $modalStack.map(({id}) => id)
for (const [id, instance] of Object.entries(instances)) {
if (!ids.includes(id)) {
unmount(instance, {outro: true})
delete instances[id]
}
return modal.subscribe($modal => {
if (instance) {
unmount(instance, {outro: true})
instance = undefined
}
for (const item of $modalStack) {
if (instances[item.id]) {
continue
}
const {options, component, props} = item
if ($modal) {
const {options, component, props} = $modal
const wrapper = options.drawer ? Drawer : Dialog
instances[item.id] = mount(wrapper as any, {
instance = mount(wrapper as any, {
target: element,
props: {
onClose: closeModal,
noEscape: options.noEscape,
onClose: closeModals,
fullscreen: options.fullscreen,
children: {component, props},
},

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