Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c691400630 | |||
| 6acbfb1181 | |||
| 5f7474140f | |||
| c56d6f4c75 | |||
| c874ae50e5 | |||
| 25e7cc97f9 | |||
| 837e4bc537 | |||
| 65fa93d853 | |||
| 28b0276f17 | |||
| 10ac15f8a2 | |||
| a45633e214 | |||
| a42ba5446a | |||
| ccfe1bded5 | |||
| dfedf4e879 | |||
| 0682c404f2 | |||
| 80ece70450 | |||
| f6245c712d | |||
| e0c5f0d4f1 | |||
| b616e2ea33 | |||
| 6fb6995103 | |||
| 47bc0c2382 | |||
| 59a919d888 | |||
| 17d673c288 | |||
| 985fd46243 | |||
| 6d99e296e4 | |||
| 21c34efb6a | |||
| 9c2f923c26 | |||
| 52d2d70838 | |||
| edd8824c5e | |||
| 4f12ad9533 | |||
| 2a5850e67f | |||
| 15341edece | |||
| 30f8b4160e | |||
| 937ca5ecf6 | |||
| ba1757d4f1 | |||
| 5a2b5f43b8 | |||
| 2f487705c3 | |||
| 558d59ce88 | |||
| 1030edd322 | |||
| 981c8fd706 | |||
| 45ade602b5 | |||
| ef8a8682cd | |||
| 112ac4b6d5 | |||
| 3a26d2cb0b | |||
| a678bf42f1 | |||
| dc314a1d1b | |||
| 3af56f6bb1 | |||
| a996664e6c | |||
| 6e865fef06 | |||
| 588bd0f341 | |||
| 69f6abf4b6 | |||
| c8eb4ac31a | |||
| e3e69390ce | |||
| d0b34dfdf8 | |||
| bcdb3dc351 | |||
| a7b0031b8d | |||
| 2c05bc6961 | |||
| c2d0ec92bf | |||
| 407b4dce94 | |||
| 796157384f | |||
| 3446977df6 | |||
| f8016aba99 | |||
| 56d8527ed9 | |||
| 302788bcba | |||
| db075e602a | |||
| 67011d4740 | |||
| a35d867b34 | |||
| 23b59e54d7 | |||
| da2665d2bc |
+1
-1
@@ -9,4 +9,4 @@ build
|
||||
|
||||
# Env files (keep .env for build; exclude local overrides)
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.*.local
|
||||
@@ -1,6 +1,6 @@
|
||||
VITE_DEFAULT_PUBKEYS=06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71,266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed,6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e,76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa,7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3
|
||||
VITE_DEFAULT_BLOSSOM_SERVERS=https://blossom.primal.net/
|
||||
VITE_POMADE_SIGNERS=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,5 +1,4 @@
|
||||
src/assets
|
||||
.claude
|
||||
target
|
||||
build
|
||||
.idea
|
||||
@@ -14,4 +13,4 @@ ios/App/Pods/
|
||||
android/capacitor-cordova-android-plugins
|
||||
android/app/src/androidTest
|
||||
android/app/src/test
|
||||
node_modules
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: coracle-social/flotilla
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
|
||||
+1
-5
@@ -1,6 +1,6 @@
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
@@ -25,10 +25,8 @@ android/app/src/main/assets/public/
|
||||
|
||||
# Web/JavaScript
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
build/
|
||||
.svelte-kit/
|
||||
.next/
|
||||
|
||||
# Rust/Tauri
|
||||
*target/
|
||||
@@ -70,9 +68,7 @@ GoogleService-Info.plist
|
||||
.roo
|
||||
.idea/
|
||||
.vscode/
|
||||
.claude/
|
||||
|
||||
# OS generated
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
package-lock.json
|
||||
|
||||
@@ -169,7 +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):**
|
||||
|
||||
|
||||
@@ -1,89 +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
|
||||
|
||||
@@ -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.
|
||||
+3
-12
@@ -4,8 +4,6 @@
|
||||
|
||||
FROM node:20-bookworm AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl
|
||||
|
||||
RUN npm install -g pnpm@latest
|
||||
|
||||
WORKDIR /app
|
||||
@@ -22,19 +20,12 @@ ENV VITE_BUILD_HASH=${VITE_BUILD_HASH}
|
||||
ENV NODE_OPTIONS=--max_old_space_size=16384
|
||||
RUN pnpm run build
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install production dependencies needed by the Node server runtime
|
||||
RUN npm install -g pnpm@10.33.0
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm i --prod --frozen-lockfile --ignore-scripts
|
||||
|
||||
# Copy only the built output and server source - no app source, no .env, no dev deps
|
||||
# Copy only the built output - no source, no .env, no dev deps
|
||||
COPY --from=builder /app/build ./build
|
||||
COPY --from=builder /app/server.js ./server.js
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
CMD ["npx", "serve", "build"]
|
||||
|
||||
@@ -6,7 +6,7 @@ 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
|
||||
@@ -16,13 +16,11 @@ You can also optionally create an `.env.local` file and populate it with the fol
|
||||
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
|
||||
- `VITE_PLATFORM_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,7 +29,7 @@ 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:
|
||||
|
||||
@@ -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 41
|
||||
versionName "1.6.5"
|
||||
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"
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
temp_env=$(declare -p -x)
|
||||
|
||||
if [ -f .env ]; then
|
||||
source .env
|
||||
if [ -f .env.template ]; then
|
||||
source .env.template
|
||||
fi
|
||||
if [ -f .env.local ]; then
|
||||
source .env.local
|
||||
fi
|
||||
|
||||
# Avoid overwriting env vars provided directly
|
||||
|
||||
@@ -4,9 +4,6 @@ const config: CapacitorConfig = {
|
||||
appId: "social.flotilla",
|
||||
appName: "Flotilla",
|
||||
webDir: "build",
|
||||
ios: {
|
||||
scheme: "Flotilla Chat",
|
||||
},
|
||||
android: {
|
||||
adjustMarginsForEdgeToEdge: true,
|
||||
},
|
||||
|
||||
@@ -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 = 32;
|
||||
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.5;
|
||||
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 = 32;
|
||||
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.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
||||
+6
-10
@@ -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>
|
||||
|
||||
@@ -11,15 +11,12 @@ 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_2704ecccfd05fcfb1ad8852744422b7c/node_modules/nostr-signer-capacitor-plugin'
|
||||
end
|
||||
|
||||
+16
-28
@@ -1,11 +1,10 @@
|
||||
{
|
||||
"name": "flotilla",
|
||||
"version": "1.8.0",
|
||||
"version": "1.6.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "./build.sh",
|
||||
"start": "node server.js",
|
||||
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
@@ -23,7 +22,6 @@
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@sveltejs/kit": "^2.50.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@tauri-apps/cli": "^2.9.6",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
@@ -37,66 +35,57 @@
|
||||
"prettier-plugin-svelte": "^3.4.1",
|
||||
"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": "^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.3",
|
||||
"@pomade/core": "^0.2.1",
|
||||
"@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": "^0.2.6",
|
||||
"@vite-pwa/sveltekit": "^0.6.8",
|
||||
"@welshman/app": "^0.8.15",
|
||||
"@welshman/content": "^0.8.15",
|
||||
"@welshman/editor": "^0.8.15",
|
||||
"@welshman/feeds": "^0.8.15",
|
||||
"@welshman/lib": "^0.8.15",
|
||||
"@welshman/net": "^0.8.15",
|
||||
"@welshman/router": "^0.8.15",
|
||||
"@welshman/signer": "^0.8.15",
|
||||
"@welshman/store": "^0.8.15",
|
||||
"@welshman/util": "^0.8.15",
|
||||
"cheerio": "^1.2.0",
|
||||
"@welshman/app": "^0.8.8",
|
||||
"@welshman/content": "^0.8.8",
|
||||
"@welshman/editor": "^0.8.8",
|
||||
"@welshman/feeds": "^0.8.8",
|
||||
"@welshman/lib": "^0.8.8",
|
||||
"@welshman/net": "^0.8.8",
|
||||
"@welshman/router": "^0.8.8",
|
||||
"@welshman/signer": "^0.8.8",
|
||||
"@welshman/store": "^0.8.8",
|
||||
"@welshman/util": "^0.8.8",
|
||||
"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.15",
|
||||
"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-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",
|
||||
@@ -113,6 +102,5 @@
|
||||
"overrides": {
|
||||
"sharp": "0.35.0-rc.0"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+484
-849
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -1,5 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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,
|
||||
|
||||
@@ -1,280 +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)
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// SPA fallback for routes that don't match static files
|
||||
app.get("*", async context => {
|
||||
const requestUrl = requestUrlFromContext(context)
|
||||
|
||||
// If the path has an extension, it's likely a missing static asset, not an SPA route
|
||||
if (path.extname(requestUrl.pathname)) {
|
||||
return context.text("Not found", 404)
|
||||
}
|
||||
|
||||
const metadata = await getMetadataForRoute(requestUrl)
|
||||
const html = metadata ? injectMeta(metadata) : TEMPLATE_HTML
|
||||
|
||||
return context.html(html, 200, {
|
||||
"Cache-Control": metadata ? "no-store" : "no-cache",
|
||||
})
|
||||
})
|
||||
|
||||
serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
hostname: HOST,
|
||||
port: PORT,
|
||||
},
|
||||
() => {
|
||||
console.log(`Flotilla server running on http://${HOST}:${PORT}`)
|
||||
},
|
||||
)
|
||||
+263
-272
@@ -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,18 +165,110 @@
|
||||
@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,
|
||||
@@ -293,21 +278,21 @@
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -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,16 +390,28 @@ 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 .cb {
|
||||
@apply bottom-sai;
|
||||
}
|
||||
|
||||
body.keyboard-open .hide-on-keyboard {
|
||||
display: none;
|
||||
}
|
||||
@@ -430,13 +419,15 @@ body.keyboard-open .hide-on-keyboard {
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* content visibility */
|
||||
|
||||
.cv {
|
||||
content-visibility: auto;
|
||||
}
|
||||
|
||||
+4
-7
@@ -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}" />
|
||||
|
||||
@@ -1,57 +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
|
||||
muted: boolean
|
||||
cameraOn: boolean
|
||||
screenShareOn: boolean
|
||||
}
|
||||
|
||||
export type Pubkey = string
|
||||
|
||||
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
|
||||
|
||||
export enum VoiceState {
|
||||
Joining = "joining",
|
||||
Connected = "connected",
|
||||
Disconnected = "disconnected",
|
||||
}
|
||||
|
||||
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
|
||||
|
||||
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
|
||||
|
||||
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
||||
|
||||
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
|
||||
|
||||
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
|
||||
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
|
||||
|
||||
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
|
||||
const pk = pubkeyFromLiveKitIdentity(identity)
|
||||
return pk ? {pubkey: pk, identity} : {identity}
|
||||
}
|
||||
|
||||
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
|
||||
|
||||
export const speakingParticipants = writable<VoiceParticipant[]>([])
|
||||
|
||||
export const isParticipantSpeaking = derived(
|
||||
speakingParticipants,
|
||||
$participants => (p: VoiceParticipant) =>
|
||||
$participants.some(sp => participantKey(sp) === participantKey(p)),
|
||||
)
|
||||
|
||||
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))
|
||||
},
|
||||
)
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,373 +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,
|
||||
Room as LiveKitRoom,
|
||||
RoomEvent,
|
||||
Track,
|
||||
supportsAudioOutputSelection,
|
||||
type AudioCaptureOptions,
|
||||
} from "livekit-client"
|
||||
import {derived, get} from "svelte/store"
|
||||
import {map, 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, whenAborted, whenTimeout} from "$lib/util"
|
||||
import {
|
||||
currentVoiceRoom,
|
||||
currentVoiceSession,
|
||||
participantFromLiveKitIdentity,
|
||||
participantKey,
|
||||
participantPubkeyMap,
|
||||
pubkeyFromLiveKitIdentity,
|
||||
speakingParticipants,
|
||||
VoiceState,
|
||||
type VoiceParticipant,
|
||||
voiceState,
|
||||
} from "@app/call/stores"
|
||||
import {resetVideoCallLayout, triggerVideoFeedCount, videoPrimaryTileKey} from "@app/call/video"
|
||||
import {deriveLatestEventForUrl, deriveRoom, makeRoomId} from "@app/core/state"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
|
||||
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 addParticipant = (identity: string) => {
|
||||
participantPubkeyMap.update(m => {
|
||||
const next = new Map(m)
|
||||
next.set(identity, pubkeyFromLiveKitIdentity(identity) ?? "")
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const deleteParticipant = (identity: string) => {
|
||||
participantPubkeyMap.update(m => {
|
||||
const next = new Map(m)
|
||||
next.delete(identity)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
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(
|
||||
[
|
||||
participantPubkeyMap,
|
||||
currentVoiceRoom,
|
||||
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
|
||||
],
|
||||
([$participantPubkeyMap, $currentVoiceRoom, $publishedParticipantList]) => {
|
||||
const inCall = $participantPubkeyMap.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h)
|
||||
|
||||
if (inCall) {
|
||||
const participants = [...$participantPubkeyMap.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,
|
||||
): Promise<boolean> => {
|
||||
if (startMuted) {
|
||||
return true
|
||||
}
|
||||
|
||||
let muted = true
|
||||
let capture: AudioCaptureOptions | undefined = undefined
|
||||
if (preferredMicId) {
|
||||
capture = {deviceId: preferredMicId}
|
||||
}
|
||||
try {
|
||||
await participant.setMicrophoneEnabled(true, capture)
|
||||
muted = false
|
||||
} catch (e) {
|
||||
pushToast({theme: "error", message: "Could not access microphone"})
|
||||
}
|
||||
return muted
|
||||
}
|
||||
|
||||
const onRoomDisconnected = (reason?: DisconnectReason) => {
|
||||
videoPrimaryTileKey.set(undefined)
|
||||
currentVoiceSession.set(undefined)
|
||||
resetVideoCallLayout()
|
||||
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
||||
voiceState.set(VoiceState.Disconnected)
|
||||
const message =
|
||||
reason === DisconnectReason.JOIN_FAILURE
|
||||
? "Could not connect to voice room. Please try again."
|
||||
: "Voice connection lost."
|
||||
pushToast({theme: "error", message})
|
||||
}
|
||||
speakingParticipants.set([])
|
||||
participantPubkeyMap.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: {identity: string}) => {
|
||||
addParticipant(participant.identity)
|
||||
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
|
||||
|
||||
export const cancelJoinVoiceRoom = () => {
|
||||
joinAbortController?.abort()
|
||||
}
|
||||
|
||||
export const joinVoiceRoom = async (
|
||||
url: string,
|
||||
h: string,
|
||||
startMuted = true,
|
||||
preferredMicId?: string,
|
||||
): Promise<void> => {
|
||||
cancelJoinVoiceRoom()
|
||||
|
||||
const session = get(currentVoiceSession)
|
||||
if (session) await leaveVoiceRoom()
|
||||
|
||||
currentVoiceRoom.set(get(deriveRoom(url, h)))
|
||||
voiceState.set(VoiceState.Joining)
|
||||
|
||||
const controller = new AbortController()
|
||||
joinAbortController = controller
|
||||
const signal = controller.signal
|
||||
const isActive = () => joinAbortController === controller
|
||||
|
||||
try {
|
||||
const {server_url, participant_token} = await fetchLivekitToken(url, h, signal)
|
||||
|
||||
if (signal.aborted) throw new AbortError()
|
||||
|
||||
const liveKitRoom = new LiveKitRoom({adaptiveStream: true, dynacast: true})
|
||||
|
||||
liveKitRoom.on(RoomEvent.Disconnected, onRoomDisconnected)
|
||||
liveKitRoom.on(RoomEvent.ParticipantConnected, onParticipantConnected)
|
||||
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
||||
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
||||
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
||||
liveKitRoom.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
|
||||
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
||||
|
||||
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.",
|
||||
}),
|
||||
whenAborted(signal),
|
||||
])
|
||||
} catch (e) {
|
||||
liveKitRoom.disconnect()
|
||||
throw e
|
||||
}
|
||||
|
||||
participantPubkeyMap.set(new Map())
|
||||
addParticipant(liveKitRoom.localParticipant.identity)
|
||||
for (const p of liveKitRoom.remoteParticipants.values()) {
|
||||
addParticipant(p.identity)
|
||||
}
|
||||
|
||||
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
|
||||
|
||||
currentVoiceSession.set({
|
||||
url,
|
||||
h,
|
||||
room: liveKitRoom,
|
||||
muted,
|
||||
cameraOn: false,
|
||||
screenShareOn: false,
|
||||
})
|
||||
voiceState.set(VoiceState.Connected)
|
||||
playJoinSound()
|
||||
} catch (e) {
|
||||
if (isActive()) voiceState.set(VoiceState.Disconnected)
|
||||
if (e instanceof AbortError) return
|
||||
throw e
|
||||
} finally {
|
||||
if (isActive()) joinAbortController = undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const leaveVoiceRoom = async () => {
|
||||
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."})
|
||||
}
|
||||
}
|
||||
|
||||
voiceState.set(VoiceState.Disconnected)
|
||||
videoPrimaryTileKey.set(undefined)
|
||||
currentVoiceSession.set(undefined)
|
||||
resetVideoCallLayout()
|
||||
session.room.disconnect()
|
||||
speakingParticipants.set([])
|
||||
participantPubkeyMap.set(new Map())
|
||||
}
|
||||
|
||||
export const rejoinVoiceRoom = async (): Promise<void> => {
|
||||
const target = get(currentVoiceRoom)
|
||||
if (!target) return
|
||||
return joinVoiceRoom(target.url, target.h)
|
||||
}
|
||||
|
||||
export const toggleMute = async () => {
|
||||
const session = get(currentVoiceSession)
|
||||
if (!session) return
|
||||
|
||||
const muted = !session.muted
|
||||
if (muted) {
|
||||
// Disable and re-enable microphone to trigger permission prompt
|
||||
session.room.localParticipant.setMicrophoneEnabled(false)
|
||||
currentVoiceSession.set({...session, muted})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await session.room.localParticipant.setMicrophoneEnabled(true)
|
||||
currentVoiceSession.set({...session, muted})
|
||||
} catch (e) {
|
||||
pushToast({theme: "error", message: "Could not access microphone"})
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
@@ -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-4">
|
||||
{#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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,72 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {getRelaysFromList} from "@welshman/util"
|
||||
import {waitForThunkError, setMessagingRelays, userRelayList, setRelays} from "@welshman/app"
|
||||
import {preventDefault} from "@lib/html"
|
||||
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"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Spinner from "@lib/components/Spinner.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 ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import {DEFAULT_RELAYS, DEFAULT_MESSAGING_RELAYS} from "@app/core/state"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
|
||||
type Props = {
|
||||
next: () => void
|
||||
}
|
||||
|
||||
const {next}: Props = $props()
|
||||
|
||||
let loading = $state(false)
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const enable = 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()
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal tag="form" onsubmit={preventDefault(enable)}>
|
||||
<ModalBody>
|
||||
<ModalHeader>
|
||||
<ModalTitle>Enable direct messaging?</ModalTitle>
|
||||
</ModalHeader>
|
||||
<p>Direct messaging isn't currently enabled. Would you like to turn it on?</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button type="submit" class="btn btn-primary" disabled={loading}>
|
||||
<Spinner {loading}>Enable direct messaging</Spinner>
|
||||
<Icon icon={AltArrowRight} />
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -42,7 +42,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>
|
||||
|
||||
<ClassifiedForm {url} {h} {shareToChat}>
|
||||
<ClassifiedForm {url} {h}>
|
||||
{#snippet header()}
|
||||
<ModalHeader>
|
||||
<ModalTitle>Create a Classified Listing</ModalTitle>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type {Snippet} from "svelte"
|
||||
import {removeUndefined, randomId, uniq} 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"
|
||||
@@ -20,35 +20,25 @@
|
||||
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
|
||||
topics?: 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 +66,7 @@
|
||||
}
|
||||
|
||||
const tags = [
|
||||
["d", d],
|
||||
["d", initialValues?.d || randomId()],
|
||||
["title", title],
|
||||
["summary", content],
|
||||
["price", String(price), currency],
|
||||
@@ -88,9 +78,7 @@
|
||||
tags.push(["t", topic])
|
||||
}
|
||||
|
||||
const protect = await shouldProtect
|
||||
|
||||
if (protect) {
|
||||
if (await shouldProtect) {
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
|
||||
@@ -117,47 +105,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
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 || [])
|
||||
let topics = $state(uniq(removeUndefined((initialValues?.topics || []).map(normalizeTopic))))
|
||||
</script>
|
||||
|
||||
<Modal tag="form" onsubmit={preventDefault(submit)}>
|
||||
@@ -185,7 +153,7 @@
|
||||
<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}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
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>
|
||||
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
<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..." />
|
||||
</ModalBody>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
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"
|
||||
@@ -45,7 +44,7 @@
|
||||
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
message: getPomadeLoginFailureMessage(messages),
|
||||
message: "Sorry, we were unable to log you in.",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -65,17 +64,10 @@
|
||||
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: getPomadeLoginFailureMessage(res.messages),
|
||||
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 +90,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -15,11 +15,10 @@
|
||||
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 {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 {setChecked} from "@app/util/notifications"
|
||||
import {pushModal, clearModals} from "@app/util/modal"
|
||||
import {deleteDeactivatedPomadeSessions, loginWithPomade} from "@app/util/pomade"
|
||||
|
||||
type Props = {
|
||||
email: string
|
||||
@@ -45,7 +44,7 @@
|
||||
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
message: getPomadeLoginFailureMessage(messages),
|
||||
message: "Sorry, we were unable to log you in.",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -65,17 +64,10 @@
|
||||
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: getPomadeLoginFailureMessage(res.messages),
|
||||
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
|
||||
}
|
||||
@@ -94,7 +86,7 @@
|
||||
<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..." />
|
||||
</ModalBody>
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
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"
|
||||
@@ -47,16 +46,9 @@
|
||||
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: getPomadeLoginFailureMessage(res.messages),
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script lang="ts">
|
||||
import {Capacitor} from "@capacitor/core"
|
||||
import {pubkey} from "@welshman/app"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
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 +14,120 @@
|
||||
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 back = () => history.back()
|
||||
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>
|
||||
<Button class="btn btn-link w-full md:hidden" onclick={back}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
|
||||
@@ -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 rounded-box border-none">
|
||||
{#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>
|
||||
@@ -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},
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {notificationSettings} from "@app/core/state"
|
||||
import {onNotification} from "@app/util/push"
|
||||
import {onNotification} from "@app/util/notifications"
|
||||
|
||||
let audioElement: HTMLAudioElement
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
children,
|
||||
minimal = false,
|
||||
hideProfile = false,
|
||||
noShadow = false,
|
||||
url,
|
||||
...restProps
|
||||
}: {
|
||||
@@ -24,7 +23,6 @@
|
||||
children: Snippet
|
||||
minimal?: boolean
|
||||
hideProfile?: boolean
|
||||
noShadow?: boolean
|
||||
url?: string
|
||||
class?: string
|
||||
} = $props()
|
||||
@@ -36,7 +34,7 @@
|
||||
let muted = $state($isEventMuted(event))
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2 {restProps.class}" class:shadow-md={!noShadow}>
|
||||
<div class="flex flex-col gap-2 shadow-md {restProps.class}">
|
||||
{#if muted}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="row-2 relative">
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, POLL} from "@welshman/util"
|
||||
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
||||
import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte"
|
||||
import NoteContentThread from "@app/components/NoteContentThread.svelte"
|
||||
import NoteContentClassified from "@app/components/NoteContentClassified.svelte"
|
||||
import NoteContentGoal from "@app/components/NoteContentGoal.svelte"
|
||||
import NoteContentPoll from "@app/components/NoteContentPoll.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
|
||||
const props: ComponentProps<typeof Content> = $props()
|
||||
@@ -20,8 +19,6 @@
|
||||
<NoteContentClassified {...props} />
|
||||
{:else if props.event.kind === ZAP_GOAL}
|
||||
<NoteContentGoal {...props} />
|
||||
{:else if props.event.kind === POLL}
|
||||
<NoteContentPoll {...props} />
|
||||
{:else}
|
||||
<Content {...props} />
|
||||
{/if}
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<CalendarEventDate event={props.event} />
|
||||
<div class="flex grow flex-col">
|
||||
<div class="flex flex-grow flex-col">
|
||||
<CalendarEventHeader event={props.event} />
|
||||
<div class="flex py-2 opacity-50">
|
||||
<div class="h-px grow bg-base-content opacity-25"></div>
|
||||
<div class="h-px flex-grow bg-base-content opacity-25"></div>
|
||||
</div>
|
||||
<Content {...props} />
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, POLL} from "@welshman/util"
|
||||
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
||||
import NoteContentMinimalEventTime from "@app/components/NoteContentMinimalEventTime.svelte"
|
||||
import NoteContentMinimalThread from "@app/components/NoteContentMinimalThread.svelte"
|
||||
import NoteContentMinimalClassified from "@app/components/NoteContentMinimalClassified.svelte"
|
||||
import NoteContentMinimalGoal from "@app/components/NoteContentMinimalGoal.svelte"
|
||||
import NoteContentMinimalPoll from "@app/components/NoteContentMinimalPoll.svelte"
|
||||
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
||||
|
||||
const props: ComponentProps<typeof ContentMinimal> = $props()
|
||||
@@ -20,8 +19,6 @@
|
||||
<NoteContentMinimalClassified {...props} />
|
||||
{:else if props.event.kind === ZAP_GOAL}
|
||||
<NoteContentMinimalGoal {...props} />
|
||||
{:else if props.event.kind === POLL}
|
||||
<NoteContentMinimalPoll {...props} />
|
||||
{:else}
|
||||
<ContentMinimal {...props} />
|
||||
{/if}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="flex grow flex-wrap justify-between gap-2">
|
||||
<div class="flex flex-grow flex-wrap justify-between gap-2">
|
||||
<p class="text-sm">{meta.title || meta.name}</p>
|
||||
{#if !isNaN(start) && !isNaN(end)}
|
||||
{@const startDateDisplay = formatTimestampAsDate(start)}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {derived} from "svelte/store"
|
||||
import {POLL_RESPONSE} from "@welshman/util"
|
||||
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
||||
import {deriveEvents} from "@app/core/state"
|
||||
import {getPollResults} from "@app/util/polls"
|
||||
|
||||
const props: ComponentProps<typeof ContentMinimal> = $props()
|
||||
|
||||
const responses = deriveEvents([{kinds: [POLL_RESPONSE], "#e": [props.event.id]}])
|
||||
|
||||
const results = derived(responses, $responses => getPollResults(props.event, $responses))
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-0">
|
||||
<ContentMinimal {...props} />
|
||||
<span class="text-xs opacity-50">{$results.voters} voter{$results.voters === 1 ? "" : "s"}</span>
|
||||
</div>
|
||||
@@ -1,29 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {onMount} from "svelte"
|
||||
import {request} from "@welshman/net"
|
||||
import {POLL_RESPONSE} from "@welshman/util"
|
||||
import PollVotes from "@app/components/PollVotes.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
|
||||
const props: ComponentProps<typeof Content> = $props()
|
||||
|
||||
onMount(() => {
|
||||
if (!props.url) {
|
||||
return
|
||||
}
|
||||
|
||||
request({
|
||||
relays: [props.url],
|
||||
filters: [{kinds: [POLL_RESPONSE], "#e": [props.event.id]}],
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<Content event={props.event} showEntire url={props.url} />
|
||||
|
||||
{#if props.url}
|
||||
<PollVotes url={props.url} event={props.event} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -89,7 +89,7 @@
|
||||
<p>Let's start by confirming your email.</p>
|
||||
<p>
|
||||
For security reasons, you may receive three or more emails with confirmation codes in them.
|
||||
Please paste <strong>all</strong> confirmation codes into the text box below.
|
||||
Please paste <strong>all</strong> confirmation codes into the text box below, on separate lines.
|
||||
</p>
|
||||
<StringMultiInput bind:value={otps} placeholder="Enter your confirmation codes..." />
|
||||
</ModalBody>
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {insertAt, now, randomId, removeAt, removeUndefined} from "@welshman/lib"
|
||||
import {makeEvent, POLL} from "@welshman/util"
|
||||
import {publishThunk, waitForThunkError} from "@welshman/app"
|
||||
import {isMobile, preventDefault} from "@lib/html"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import HamburgerMenu from "@assets/icons/hamburger-menu.svg?dataurl"
|
||||
import PlusCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Field from "@lib/components/Field.svelte"
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import DateTimeInput from "@lib/components/DateTimeInput.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"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {PROTECTED} from "@app/core/state"
|
||||
import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
|
||||
import {DraftKey} from "@app/util/drafts"
|
||||
import type {PollType} from "@app/util/polls"
|
||||
|
||||
type Option = {
|
||||
id: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type Values = {
|
||||
title: string
|
||||
pollType: PollType
|
||||
endsAt?: number
|
||||
options: Option[]
|
||||
}
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
h?: string
|
||||
shareToChat?: boolean
|
||||
}
|
||||
|
||||
const {url, h, shareToChat = false}: Props = $props()
|
||||
const draftKey = new DraftKey<Values>(`poll:${url}:${h ?? ""}`)
|
||||
const initialValues = draftKey.get()
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const addOption = () => {
|
||||
options = [...options, {id: randomId(), value: ""}]
|
||||
}
|
||||
|
||||
const removeOption = (id: string) => {
|
||||
options = options.filter(option => option.id !== id)
|
||||
}
|
||||
|
||||
const updateOption = (id: string, value: string) => {
|
||||
options = options.map(option => (option.id === id ? {...option, value} : option))
|
||||
}
|
||||
|
||||
const reorderOptions = (targetId: string) => {
|
||||
if (!draggedOptionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const sourceIndex = options.findIndex(option => option.id === draggedOptionId)
|
||||
const targetIndex = options.findIndex(option => option.id === targetId)
|
||||
|
||||
if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) {
|
||||
return
|
||||
}
|
||||
|
||||
options = insertAt(targetIndex, options[sourceIndex], removeAt(sourceIndex, options))
|
||||
}
|
||||
|
||||
const onDragStart = (e: DragEvent, id: string) => {
|
||||
draggedOptionId = id
|
||||
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = "move"
|
||||
e.dataTransfer.setData("text/plain", id)
|
||||
}
|
||||
}
|
||||
|
||||
const onDragOver = (e: DragEvent, targetId: string) => {
|
||||
e.preventDefault()
|
||||
reorderOptions(targetId)
|
||||
}
|
||||
|
||||
const onDrop = (e: DragEvent, targetId: string) => {
|
||||
e.preventDefault()
|
||||
reorderOptions(targetId)
|
||||
draggedOptionId = undefined
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
draggedOptionId = undefined
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (loading) return
|
||||
|
||||
if (!title.trim()) {
|
||||
return pushToast({theme: "error", message: "Please provide a title for your poll."})
|
||||
}
|
||||
|
||||
const nonEmptyOptions = removeUndefined(options.map(option => option.value.trim() || undefined))
|
||||
|
||||
if (nonEmptyOptions.length < 2) {
|
||||
return pushToast({theme: "error", message: "Please provide at least two options."})
|
||||
}
|
||||
|
||||
if (endsAt && endsAt <= now()) {
|
||||
return pushToast({theme: "error", message: "End time must be in the future."})
|
||||
}
|
||||
|
||||
const tags: string[][] = [
|
||||
...nonEmptyOptions.map(option => ["option", randomId(), option]),
|
||||
["polltype", pollType],
|
||||
["relay", url],
|
||||
]
|
||||
|
||||
if (endsAt) {
|
||||
tags.push(["endsAt", String(endsAt)])
|
||||
}
|
||||
|
||||
if (h) {
|
||||
tags.push(["h", h])
|
||||
}
|
||||
|
||||
loading = true
|
||||
|
||||
try {
|
||||
const protect = await shouldProtect
|
||||
|
||||
if (protect) {
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
|
||||
const pollThunk = publishThunk({
|
||||
relays: [url],
|
||||
event: makeEvent(POLL, {content: title.trim(), tags}),
|
||||
})
|
||||
|
||||
const error = await waitForThunkError(pollThunk)
|
||||
|
||||
if (error) {
|
||||
return pushToast({theme: "error", message: error})
|
||||
}
|
||||
|
||||
draftKey.clear()
|
||||
history.back()
|
||||
|
||||
if (shareToChat) {
|
||||
publishRoomQuote({url, h, parent: pollThunk.event, protect})
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
let loading = $state(false)
|
||||
|
||||
let draggedOptionId = $state<string | undefined>()
|
||||
let title = $state(initialValues?.title ?? "")
|
||||
let pollType = $state<PollType>(initialValues?.pollType ?? "singlechoice")
|
||||
let endsAt = $state<number | undefined>(initialValues?.endsAt)
|
||||
let options = $state<Option[]>(
|
||||
initialValues?.options ?? [
|
||||
{id: randomId(), value: "Yes"},
|
||||
{id: randomId(), value: "No"},
|
||||
],
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
draftKey.set({title, pollType, endsAt, options})
|
||||
})
|
||||
</script>
|
||||
|
||||
<Modal tag="form" onsubmit={preventDefault(submit)}>
|
||||
<ModalBody>
|
||||
<ModalHeader>
|
||||
<ModalTitle>Create a Poll</ModalTitle>
|
||||
<ModalSubtitle>Ask a question and collect votes right in the feed.</ModalSubtitle>
|
||||
</ModalHeader>
|
||||
<div class="col-8 relative">
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Question*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus={!isMobile}
|
||||
bind:value={title}
|
||||
class="grow"
|
||||
type="text"
|
||||
placeholder="What would you like to ask?" />
|
||||
</label>
|
||||
{/snippet}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Options*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="flex flex-col gap-2" role="list">
|
||||
{#each options as option, index (option.id)}
|
||||
<div
|
||||
class="flex items-center gap-2"
|
||||
draggable="true"
|
||||
role="listitem"
|
||||
ondragstart={e => onDragStart(e, option.id)}
|
||||
ondragover={e => onDragOver(e, option.id)}
|
||||
ondrop={e => onDrop(e, option.id)}
|
||||
ondragend={onDragEnd}>
|
||||
<div class="cursor-move opacity-70" aria-label="Drag handle">
|
||||
<Icon icon={HamburgerMenu} size={4} />
|
||||
</div>
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<input
|
||||
value={option.value}
|
||||
class="grow"
|
||||
type="text"
|
||||
placeholder={`Option ${index + 1}`}
|
||||
oninput={e => updateOption(option.id, e.currentTarget.value)} />
|
||||
</label>
|
||||
<Button class="btn btn-ghost btn-sm" onclick={() => removeOption(option.id)}>
|
||||
<Icon icon={MinusCircle} size={4} />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
<Button class="btn btn-outline btn-sm self-end" onclick={addOption}>
|
||||
<Icon icon={PlusCircle} size={4} />
|
||||
Add option
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Field>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
Poll type
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<select class="select select-bordered w-full max-w-xs" bind:value={pollType}>
|
||||
<option value="singlechoice">Single choice</option>
|
||||
<option value="multiplechoice">Multiple choice</option>
|
||||
</select>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
Ends at
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<DateTimeInput bind:value={endsAt} />
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
</div>
|
||||
</div>
|
||||
</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}>
|
||||
<Spinner {loading}>Create Poll</Spinner>
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
@@ -1,34 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import NoteContent from "@app/components/NoteContent.svelte"
|
||||
import CommentActions from "@app/components/CommentActions.svelte"
|
||||
import RoomLink from "@app/components/RoomLink.svelte"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
import {makePollPath} from "@app/util/routes"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
}
|
||||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const h = getTagValue("h", event.tags)
|
||||
</script>
|
||||
|
||||
<Link
|
||||
class="cv col-2 card2 bg-alt w-full cursor-pointer shadow-md"
|
||||
href={makePollPath(url, event.id)}>
|
||||
<NoteContent {event} {url} />
|
||||
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
|
||||
<span class="whitespace-nowrap py-1 text-sm opacity-75">
|
||||
Posted by <ProfileLink pubkey={event.pubkey} {url} />
|
||||
{#if h}
|
||||
in <RoomLink {url} {h} />
|
||||
{/if}
|
||||
</span>
|
||||
<CommentActions segment="polls" showActivity {url} {event} />
|
||||
</div>
|
||||
</Link>
|
||||
@@ -1,70 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {tweened} from "svelte/motion"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {noop} from "@welshman/lib"
|
||||
import {stopPropagation} from "@lib/html"
|
||||
import {getPollType, isPollClosed} from "@app/util/polls"
|
||||
|
||||
type Props = {
|
||||
event: TrustedEvent
|
||||
option: {id: string; label: string}
|
||||
results: {voters: number; options: {id: string; votes: number}[]}
|
||||
selectedIds: string[]
|
||||
setSingleChoice: (id: string) => void
|
||||
toggleMultipleChoice: (id: string) => void
|
||||
}
|
||||
|
||||
const {event, option, results, selectedIds, setSingleChoice, toggleMultipleChoice}: Props =
|
||||
$props()
|
||||
|
||||
const pollType = getPollType(event)
|
||||
const closed = isPollClosed(event)
|
||||
|
||||
const selected = $derived(
|
||||
pollType === "singlechoice" ? selectedIds[0] === option.id : selectedIds.includes(option.id),
|
||||
)
|
||||
const onselect = () =>
|
||||
pollType === "singlechoice" ? setSingleChoice(option.id) : toggleMultipleChoice(option.id)
|
||||
|
||||
const votes = $derived(results.options.find(r => r.id === option.id)?.votes || 0)
|
||||
const maxVotes = $derived(Math.max(...results.options.map(r => r.votes), 1))
|
||||
|
||||
const tweenedVotes = tweened(votes, {duration: 300})
|
||||
const tweenedMax = tweened(maxVotes, {duration: 300})
|
||||
|
||||
$effect(() => {
|
||||
tweenedVotes.set(votes)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
tweenedMax.set(maxVotes)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2 card2 card2-sm bg-alt">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<label class="flex min-w-0 grow items-center gap-2">
|
||||
{#if !closed}
|
||||
{#if pollType === "singlechoice"}
|
||||
<input
|
||||
name={event.id}
|
||||
type="radio"
|
||||
class="radio radio-primary radio-sm"
|
||||
checked={selected}
|
||||
onclick={stopPropagation(noop)}
|
||||
onchange={onselect} />
|
||||
{:else}
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary checkbox-sm"
|
||||
checked={selected}
|
||||
onclick={stopPropagation(noop)}
|
||||
onchange={onselect} />
|
||||
{/if}
|
||||
{/if}
|
||||
<span class="truncate">{option.label}</span>
|
||||
</label>
|
||||
<span class="whitespace-nowrap text-xs opacity-75">{votes} vote{votes === 1 ? "" : "s"}</span>
|
||||
</div>
|
||||
<progress class="progress progress-primary" value={$tweenedVotes} max={$tweenedMax}></progress>
|
||||
</div>
|
||||
@@ -1,127 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {onDestroy} from "svelte"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {POLL_RESPONSE} from "@welshman/util"
|
||||
import {pubkey, publishThunk, abortThunk} from "@welshman/app"
|
||||
import {formatTimestampRelative} from "@welshman/lib"
|
||||
import {deriveEvents} from "@app/core/state"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {makePollResponse} from "@app/core/commands"
|
||||
import PollOption from "@app/components/PollOption.svelte"
|
||||
import {
|
||||
getPollEndsAt,
|
||||
getPollOptions,
|
||||
getPollResponseSelections,
|
||||
getPollResults,
|
||||
getPollType,
|
||||
isPollClosed,
|
||||
} from "@app/util/polls"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
}
|
||||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const responses = deriveEvents([{kinds: [POLL_RESPONSE], "#e": [event.id]}])
|
||||
|
||||
const pollType = getPollType(event)
|
||||
const options = getPollOptions(event)
|
||||
const closed = isPollClosed(event)
|
||||
const endsAt = getPollEndsAt(event)
|
||||
const publishDelay = pollType === "multiplechoice" ? 10_000 : undefined
|
||||
|
||||
const getOwnResponse = (responses: TrustedEvent[]) => {
|
||||
let latest: TrustedEvent | undefined
|
||||
|
||||
for (const response of responses) {
|
||||
if (response.pubkey !== $pubkey) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!latest || response.created_at > latest.created_at) {
|
||||
latest = response
|
||||
}
|
||||
}
|
||||
|
||||
return latest
|
||||
}
|
||||
|
||||
const publishSelection = (selection: string[]) => {
|
||||
if (activeThunk) {
|
||||
abortThunk(activeThunk)
|
||||
}
|
||||
|
||||
if (selection.length === 0) {
|
||||
activeThunk = undefined
|
||||
return
|
||||
}
|
||||
|
||||
activeThunk = publishThunk({
|
||||
relays: [url],
|
||||
event: makePollResponse({event, selectedIds: selection}),
|
||||
delay: publishDelay,
|
||||
})
|
||||
}
|
||||
|
||||
const publishCurrentSelection = () => {
|
||||
const selection = pollType === "singlechoice" ? selectedIds.slice(0, 1) : selectedIds
|
||||
|
||||
if (selection.length === 0) {
|
||||
return pushToast({theme: "error", message: "Please select at least one option."})
|
||||
}
|
||||
|
||||
publishSelection(selection)
|
||||
}
|
||||
|
||||
const results = $derived(getPollResults(event, $responses))
|
||||
const ownResponse = $derived(getOwnResponse($responses))
|
||||
|
||||
const setSingleChoice = (id: string) => {
|
||||
selectedIds = [id]
|
||||
publishCurrentSelection()
|
||||
}
|
||||
|
||||
const toggleMultipleChoice = (id: string) => {
|
||||
selectedIds = selectedIds.includes(id)
|
||||
? selectedIds.filter(selectedId => selectedId !== id)
|
||||
: [...selectedIds, id]
|
||||
|
||||
publishCurrentSelection()
|
||||
}
|
||||
|
||||
let selectedIds = $state<string[]>([])
|
||||
let activeThunk: ReturnType<typeof publishThunk> | undefined
|
||||
|
||||
$effect(() => {
|
||||
if (ownResponse) {
|
||||
selectedIds = getPollResponseSelections(ownResponse, pollType)
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
if (activeThunk) {
|
||||
abortThunk(activeThunk)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each options as option (option.id)}
|
||||
<PollOption {event} {option} {results} {selectedIds} {setSingleChoice} {toggleMultipleChoice} />
|
||||
{/each}
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="text-sm opacity-75">
|
||||
{pollType === "multiplechoice" ? "Multiple choice" : "Single choice"}
|
||||
{#if endsAt}
|
||||
{#if closed}
|
||||
• Ended {formatTimestampRelative(endsAt)}
|
||||
{:else}
|
||||
• Ends {formatTimestampRelative(endsAt)}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-sm opacity-75">{results.voters} vote{results.voters === 1 ? "" : "s"}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,20 +1,24 @@
|
||||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
import {splitAt} from "@welshman/lib"
|
||||
import {userProfile} from "@welshman/app"
|
||||
import Widget from "@assets/icons/widget.svg?dataurl"
|
||||
import Compass from "@assets/icons/compass.svg?dataurl"
|
||||
import Letter from "@assets/icons/letter.svg?dataurl"
|
||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||
import Widget from "@assets/icons/widget-4.svg?dataurl"
|
||||
import HomeSmile from "@assets/icons/home-smile.svg?dataurl"
|
||||
import SettingsMinimalistic from "@assets/icons/settings-minimalistic.svg?dataurl"
|
||||
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
|
||||
import Settings from "@assets/icons/settings.svg?dataurl"
|
||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||
import Divider from "@lib/components/Divider.svelte"
|
||||
import MenuSettings from "@app/components/MenuSettings.svelte"
|
||||
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
||||
import PrimaryNavSpaces from "@app/components/PrimaryNavSpaces.svelte"
|
||||
import {userSpaceUrls, PLATFORM_RELAYS} from "@app/core/state"
|
||||
import MenuSettings from "@app/components/MenuSettings.svelte"
|
||||
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
|
||||
import {userSpaceUrls, PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/core/state"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {notifications} from "@app/util/notifications"
|
||||
import {goToChat, makeSpacePath} from "@app/util/routes"
|
||||
import {goToLastChat} from "@app/util/routes"
|
||||
|
||||
type Props = {
|
||||
children?: Snippet
|
||||
@@ -22,24 +26,55 @@
|
||||
|
||||
const {children}: Props = $props()
|
||||
|
||||
const chatHandler = () => goToChat()
|
||||
|
||||
const showSettingsMenu = () => pushModal(MenuSettings)
|
||||
|
||||
const anySpaceNotifications = $derived(
|
||||
$userSpaceUrls.some(p => $notifications.has(makeSpacePath(p))),
|
||||
)
|
||||
let windowHeight = $state(0)
|
||||
|
||||
const itemHeight = 56
|
||||
const navPadding = 8 * itemHeight
|
||||
const itemLimit = $derived((windowHeight - navPadding) / itemHeight)
|
||||
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, $userSpaceUrls))
|
||||
const anySpaceNotifications = $derived($userSpaceUrls.some(p => $notifications.has(p)))
|
||||
const otherSpaceNotifications = $derived(secondarySpaceUrls.some(p => $notifications.has(p)))
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerHeight={windowHeight} />
|
||||
|
||||
<div
|
||||
class="ml-sai mt-sai mb-sai relative z-popover isolate hidden w-14 shrink-0 bg-base-200 pt-2 md:block">
|
||||
class="ml-sai mt-sai mb-sai relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block">
|
||||
<div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}>
|
||||
<PrimaryNavSpaces />
|
||||
<div>
|
||||
{#each PLATFORM_RELAYS as url (url)}
|
||||
<PrimaryNavItemSpace {url} />
|
||||
{:else}
|
||||
<PrimaryNavItem title="Home" href="/home" class="tooltip-right">
|
||||
<ImageIcon alt="Home" src={PLATFORM_LOGO} class="rounded-full" size={10} />
|
||||
</PrimaryNavItem>
|
||||
<Divider />
|
||||
{#each primarySpaceUrls as url (url)}
|
||||
<PrimaryNavItemSpace {url} />
|
||||
{/each}
|
||||
<PrimaryNavItem
|
||||
href="/spaces"
|
||||
title="All Spaces"
|
||||
class="tooltip-right"
|
||||
notification={otherSpaceNotifications}>
|
||||
<ImageIcon alt="All Spaces" src={Widget} size={8} />
|
||||
</PrimaryNavItem>
|
||||
<PrimaryNavItem title="Add a Space" href="/discover" class="tooltip-right">
|
||||
<ImageIcon alt="Add a Space" src={Compass} size={8} />
|
||||
</PrimaryNavItem>
|
||||
{/each}
|
||||
</div>
|
||||
{#if PLATFORM_RELAYS.length > 0}
|
||||
<Divider />
|
||||
{/if}
|
||||
<div class="flex flex-col">
|
||||
<PrimaryNavItem title="Settings" href="/settings/profile" prefix="/settings">
|
||||
<div>
|
||||
<PrimaryNavItem
|
||||
title="Settings"
|
||||
href="/settings/profile"
|
||||
prefix="/settings"
|
||||
class="tooltip-right">
|
||||
{#if $userProfile?.picture}
|
||||
<ImageIcon alt="Settings" src={$userProfile?.picture} class="rounded-full" size={10} />
|
||||
{:else}
|
||||
@@ -48,11 +83,12 @@
|
||||
</PrimaryNavItem>
|
||||
<PrimaryNavItem
|
||||
title="Messages"
|
||||
onclick={chatHandler}
|
||||
onclick={goToLastChat}
|
||||
class="tooltip-right"
|
||||
notification={$notifications.has("/chat")}>
|
||||
<ImageIcon alt="Messages" src={Letter} size={8} />
|
||||
</PrimaryNavItem>
|
||||
<PrimaryNavItem title="Search" href="/people">
|
||||
<PrimaryNavItem title="Search" href="/people" class="tooltip-right">
|
||||
<ImageIcon alt="Search" src={Magnifier} size={8} />
|
||||
</PrimaryNavItem>
|
||||
</div>
|
||||
@@ -62,25 +98,25 @@
|
||||
{@render children?.()}
|
||||
|
||||
<!-- a little extra something for ios -->
|
||||
<div class="hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-(--saib) bg-base-100 md:hidden">
|
||||
<div
|
||||
class="bottom-nav hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-[var(--saib)] bg-base-100 md:hidden">
|
||||
</div>
|
||||
<div
|
||||
class="hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
|
||||
class="bottom-nav hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
|
||||
<div class="content-padding-x content-sizing flex justify-between px-2">
|
||||
<div class="flex gap-2 sm:gap-6">
|
||||
<PrimaryNavItem title="Search" href="/people">
|
||||
<ImageIcon alt="Search" src={Magnifier} size={8} />
|
||||
<PrimaryNavItem title="Home" href="/home">
|
||||
<ImageIcon alt="Home" src={HomeSmile} size={8} />
|
||||
</PrimaryNavItem>
|
||||
<PrimaryNavItem
|
||||
title="Messages"
|
||||
href="/chat"
|
||||
onclick={chatHandler}
|
||||
onclick={goToLastChat}
|
||||
notification={$notifications.has("/chat")}>
|
||||
<ImageIcon alt="Messages" src={Letter} size={8} />
|
||||
</PrimaryNavItem>
|
||||
{#if PLATFORM_RELAYS.length !== 1}
|
||||
<PrimaryNavItem title="Spaces" href="/spaces" notification={anySpaceNotifications}>
|
||||
<ImageIcon alt="Spaces" src={Widget} size={8} />
|
||||
<ImageIcon alt="Spaces" src={SettingsMinimalistic} size={8} />
|
||||
</PrimaryNavItem>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {deriveRelayDisplay} from "@welshman/app"
|
||||
import {displayRelayUrl} from "@welshman/util"
|
||||
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
||||
import RelayIcon from "@app/components/RelayIcon.svelte"
|
||||
import {makeSpacePath, goToSpace} from "@app/util/routes"
|
||||
@@ -12,17 +12,12 @@
|
||||
const {url}: Props = $props()
|
||||
|
||||
const onClick = () => goToSpace(url)
|
||||
|
||||
const path = makeSpacePath(url)
|
||||
|
||||
const display = $derived(deriveRelayDisplay(url))
|
||||
</script>
|
||||
|
||||
<PrimaryNavItem
|
||||
href={path}
|
||||
onclick={onClick}
|
||||
title={$display}
|
||||
title={displayRelayUrl(url)}
|
||||
class="tooltip-right"
|
||||
notification={$notifications.has(path)}>
|
||||
notification={$notifications.has(makeSpacePath(url))}>
|
||||
<RelayIcon {url} size={10} class="rounded-full" />
|
||||
</PrimaryNavItem>
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {splitAt} from "@welshman/lib"
|
||||
import Widget from "@assets/icons/widget-4.svg?dataurl"
|
||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||
import Divider from "@lib/components/Divider.svelte"
|
||||
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
||||
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
|
||||
import {userSpaceUrls, PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/core/state"
|
||||
import {notifications} from "@app/util/notifications"
|
||||
|
||||
let windowHeight = $state(0)
|
||||
|
||||
const itemHeight = 56
|
||||
const navPadding = 8 * itemHeight
|
||||
const itemLimit = $derived(Math.max(0, (windowHeight - navPadding) / itemHeight))
|
||||
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, $userSpaceUrls))
|
||||
const otherSpaceNotifications = $derived(secondarySpaceUrls.some(p => $notifications.has(p)))
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerHeight={windowHeight} />
|
||||
|
||||
<div>
|
||||
{#each PLATFORM_RELAYS as url (url)}
|
||||
<PrimaryNavItemSpace {url} />
|
||||
{:else}
|
||||
<PrimaryNavItem title="Home" href="/home">
|
||||
<ImageIcon alt="Home" src={PLATFORM_LOGO} class="rounded-full" size={10} />
|
||||
</PrimaryNavItem>
|
||||
<Divider />
|
||||
{#each primarySpaceUrls as url (url)}
|
||||
<PrimaryNavItemSpace {url} />
|
||||
{/each}
|
||||
<PrimaryNavItem
|
||||
href="/spaces"
|
||||
title="All Spaces"
|
||||
prefix="no-highlight"
|
||||
notification={otherSpaceNotifications}>
|
||||
<ImageIcon alt="All Spaces" src={Widget} size={8} />
|
||||
</PrimaryNavItem>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -17,43 +17,28 @@
|
||||
url?: string
|
||||
showPubkey?: boolean
|
||||
avatarSize?: number
|
||||
inert?: boolean
|
||||
}
|
||||
|
||||
const {pubkey, url, showPubkey, inert, avatarSize = 10}: Props = $props()
|
||||
const {pubkey, url, showPubkey, avatarSize = 10}: Props = $props()
|
||||
|
||||
const relays = removeUndefined([url])
|
||||
const profileDisplay = deriveProfileDisplay(pubkey, relays)
|
||||
const handle = deriveHandleForPubkey(pubkey)
|
||||
|
||||
const openProfile = () => {
|
||||
pushModal(ProfileDetail, {pubkey, url})
|
||||
}
|
||||
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
|
||||
|
||||
const copyPubkey = () => clip(nip19.npubEncode(pubkey))
|
||||
</script>
|
||||
|
||||
<div class="flex max-w-full items-start gap-3">
|
||||
{#if inert}
|
||||
<span class="py-1">
|
||||
<ProfileCircle {pubkey} size={avatarSize} />
|
||||
</span>
|
||||
{:else}
|
||||
<Button onclick={openProfile} class="py-1">
|
||||
<ProfileCircle {pubkey} size={avatarSize} />
|
||||
</Button>
|
||||
{/if}
|
||||
<Button onclick={openProfile} class="py-1">
|
||||
<ProfileCircle {pubkey} size={avatarSize} />
|
||||
</Button>
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if inert}
|
||||
<span class="text-bold overflow-hidden text-ellipsis">
|
||||
{$profileDisplay}
|
||||
</span>
|
||||
{:else}
|
||||
<Button onclick={openProfile} class="text-bold overflow-hidden text-ellipsis">
|
||||
{$profileDisplay}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button onclick={openProfile} class="text-bold overflow-hidden text-ellipsis">
|
||||
{$profileDisplay}
|
||||
</Button>
|
||||
<WotScore {pubkey} />
|
||||
</div>
|
||||
{#if $handle}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user