Compare commits

...

56 Commits

Author SHA1 Message Date
Jon Staab 1ced5689c3 Bump version 2025-05-12 15:19:38 -07:00
Jon Staab 263a803875 Add custom emoji parsing and display 2025-05-12 15:10:24 -07:00
Jon Staab 58afb8fa0c Bump editor 2025-05-12 11:17:13 -07:00
Jon Staab 4aaa19ea1b Apply theme to body so popovers get themed too, make selected popover item more clear 2025-05-12 10:03:29 -07:00
Jon Staab 2f9010cd13 Ignore unnecessary error 2025-05-12 09:01:13 -07:00
Jon Staab 12fcdfcd4f Add light theme and secondary color 2025-05-12 08:48:54 -07:00
Jon Staab 317ab57ed2 Use env instead of env.local 2025-05-12 08:27:46 -07:00
Jon Staab 52ef67740a Move default env to env.template, fix notifier relay/pubkey 2025-05-12 08:27:07 -07:00
Jon Staab 68ebd32e15 Bump welshman 2025-05-09 12:41:02 -07:00
Jon Staab e94aa3c119 Bump version, fix new messages thing 2025-05-09 12:26:05 -07:00
Jon Staab 4d10fe7cc0 Handle broken supported_nips 2025-05-08 11:16:02 -07:00
Jon Staab 841928783b Re-introduce safe inset areas 2025-05-08 11:05:27 -07:00
Jon Staab 6e5e1a0846 Remove safe area inset stuff to re-apply later 2025-05-08 09:11:10 -07:00
Jon Staab d57f4747a6 Tweak errors so that actionable links are rendered 2025-05-07 15:04:35 -07:00
Jon Staab 94a0077b09 Use non-singleton broker 2025-05-07 13:53:58 -07:00
Jon Staab f2eb04adff Bump version 2025-05-07 09:12:17 -07:00
Jon Staab d4d5979a35 Fix missing room images and room overflow in nav 2025-05-07 09:11:00 -07:00
Jon Staab dde6e54657 Add build in production script 2025-05-06 18:26:48 -07:00
Jon Staab 698a7513b8 Tweak some gradle stuff 2025-05-06 18:07:30 -07:00
Jon Staab ea3f5a6779 Bump version 2025-05-06 17:06:18 -07:00
Jon Staab f5fce8e2e7 Bump welshman and signer plugin 2025-05-06 10:34:14 -07:00
Jon Staab 46b5c01c49 Allow use of cleartext relays on native 2025-05-06 09:50:05 -07:00
Jon Staab dd069329ee Add timezone and locale to alerts 2025-05-05 15:39:07 -07:00
Jon Staab c1b52b66ff Use lib version of date functions 2025-05-05 10:11:02 -07:00
Jon Staab 5873e8aa60 Fix modal stuff 2025-04-29 15:20:40 -07:00
Jon Staab c582082816 Fix link detail for authenticated images 2025-04-29 12:30:01 -07:00
Jon Staab 6ddba63ff9 Use space as blossom server if supported 2025-04-29 12:26:29 -07:00
Jon Staab 5a7750a91b Use user blossom server list for settings, add InputList 2025-04-29 11:04:39 -07:00
Jon Staab 8c71b7d9b9 Update welshman 2025-04-29 09:56:52 -07:00
Jon Staab b5a28c71ad Support auth-protected images 2025-04-28 15:46:48 -07:00
Jon Staab ccdd18a863 Fill in default email for alerts 2025-04-28 12:28:39 -07:00
Jon Staab 2244ecad9b Update alerts to use new anchor 2025-04-28 09:48:09 -07:00
Jon Staab da2457da9f Use new relay getters 2025-04-25 10:41:38 -07:00
Jon Staab c18b29e7d6 Update welshman stuff, fix bug in makeFeed 2025-04-24 12:35:41 -07:00
Jon Staab 3a954201ce Tweak boot, stop saving alert events 2025-04-23 11:05:28 -07:00
Jon Staab c8bc8ee8bf Fix thunk indicator 2025-04-16 14:19:09 -07:00
Jon Staab 8c3e52ce8c Update storage adapters 2025-04-16 14:08:58 -07:00
Jon Staab 303b8967e9 Remove aliases, their time has not yet come 2025-04-16 10:36:21 -07:00
Jon Staab f3debe6c02 Use new ALIAS kind 2025-04-15 15:45:48 -07:00
Jon Staab 374ca7f265 Add per-url aliases 2025-04-15 15:07:54 -07:00
Jon Staab 91689e5b90 Optionally protect profiles 2025-04-15 09:36:59 -07:00
Jon Staab a64eaba45c Fix modal flash 2025-04-14 17:13:16 -07:00
Jon Staab 394a1e7d30 Update to new thunk stuff 2025-04-14 16:50:48 -07:00
Jon Staab d5b1fab1e7 Tweak data loading 2025-04-11 14:44:27 -07:00
Jon Staab 10a1e6e640 Update welshman session stuff 2025-04-11 11:51:15 -07:00
Jon Staab 84af4d2d8e Update welshman stuff again 2025-04-11 09:27:19 -07:00
Jon Staab acddff79f0 Improve loading a bit 2025-04-11 08:41:50 -07:00
Jon Staab 489707b9b2 Switch to pnpm, use new welshman stuff 2025-04-09 15:32:35 -07:00
Jon Staab 33902dbefe Make calendar window smaller to avoid tag limits 2025-04-03 15:56:37 -07:00
Jon Staab 1b318a7a52 Fix reactions on mobile 2025-04-03 15:40:52 -07:00
Jon Staab b6a4b38d14 Make relays configurable 2025-04-03 15:35:56 -07:00
Jon Staab a3eb6d52c0 Fix nip46 signer connect 2025-03-24 12:40:47 -07:00
Jon Staab d2c537d275 Refactor login, pass bunker to alerts 2025-03-20 13:00:07 -07:00
Jon Staab 9eefd6600d Add handler for alerts 2025-03-20 09:38:57 -07:00
Jon Staab ad034b1641 Tweak layout css 2025-03-19 11:22:57 -07:00
Jon Staab d94860014c Fix chat spacing 2025-03-19 09:56:00 -07:00
131 changed files with 11698 additions and 17085 deletions
+5
View File
@@ -7,6 +7,11 @@ VITE_PLATFORM_NAME=Flotilla
VITE_PLATFORM_LOGO=static/flotilla.png VITE_PLATFORM_LOGO=static/flotilla.png
VITE_PLATFORM_RELAY= VITE_PLATFORM_RELAY=
VITE_PLATFORM_ACCENT="#7161FF" VITE_PLATFORM_ACCENT="#7161FF"
VITE_PLATFORM_SECONDARY="#EB5E28"
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities." VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities."
VITE_INDEXER_RELAYS=wss://purplepag.es/,wss://relay.damus.io/,wss://relay.nostr.band/
VITE_SIGNER_RELAYS=wss://relay.nsec.app/,wss://bucket.coracle.social/
VITE_NOTIFIER_PUBKEY=27b7c2ed89ef78322114225ea3ebf5f72c7767c2528d4d0c1854d039c00085df
VITE_NOTIFIER_RELAY=wss://anchor.coracle.social/
VITE_GLITCHTIP_API_KEY= VITE_GLITCHTIP_API_KEY=
GLITCHTIP_AUTH_TOKEN= GLITCHTIP_AUTH_TOKEN=
+1 -1
View File
@@ -1,5 +1,5 @@
# Env # Env
.env.local .env
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*
+2 -2
View File
@@ -1,2 +1,2 @@
npm run lint pnpm run lint
npm run check pnpm run check
+31
View File
@@ -1,8 +1,39 @@
# Changelog # Changelog
# 1.0.3
* Add light theme
* Use correct alerts server
* Ignore relay errors for claims
* Fix inline code blocks
* Add custom emoji parsing and display
# 1.0.2
* Fix add relay button
* Fix safe inset areas
* Better rendering for errors from relays
* Improve remote signer login
# 1.0.1
* Fix relay images in nav
* Fix relay nav overflow
# 1.0.0 # 1.0.0
* Add alerts via Anchor * Add alerts via Anchor
* Fix nip46 signer connect
* Allow use of cleartext relays on native builds
* Fix some modal state bugs caused by svelte 5
* Detect blossom support on community relays
* Use user blossom server list in settings
* Fix some feed bugs
* Improve thunk indicator
* Update storage adapters
* Fix modal flash
* Switch to pnpm
* Improve calendar windowing
# 0.2.14 # 0.2.14
+9 -9
View File
@@ -2,19 +2,19 @@
A discord-like nostr client based on the idea of "relays as groups". A discord-like nostr client based on the idea of "relays as groups".
If you would like to be interoperable with Flotilla, please check out this draft NIP: https://github.com/coracle-social/nips/blob/relay-chat/xx.md If you would like to be interoperable with Flotilla, please check out this guide: https://habla.news/u/hodlbod@coracle.social/1741286140797
# Deploy # Deploy
To run your own Flotilla, it's as simple as: To run your own Flotilla, it's as simple as:
- `npm install` - `pnpm install`
- `npm run build` - `pnpm run build`
- `npx serve build` - `npx serve build`
## Environment ## Environment
You can also optionally create an `.env.local` file and populate it with the following environment variables (see `.env` 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_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. This is only used for build-time population of meta tags. - `VITE_PLATFORM_URL` - The url where the app will be hosted. This is only used for build-time population of meta tags.
@@ -38,7 +38,7 @@ First, create an `A` record with your DNS provider pointing to the IP of your se
Next install `nginx`, `git`, and `certbot`. If you're on a debian- or ubuntu-based distro, run `sudo apt-get update && sudo apt-get install nginx git certbot python3-certbot-nginx`. Next install `nginx`, `git`, and `certbot`. If you're on a debian- or ubuntu-based distro, run `sudo apt-get update && sudo apt-get install nginx git certbot python3-certbot-nginx`.
Now, create a new user where your code will be stored, clone the repository, fill in your `.env.local` file, and build the app. Now, create a new user where your code will be stored, clone the repository, fill in your `.env` file, and build the app.
```sh ```sh
# Replace with your password # Replace with your password
@@ -65,12 +65,12 @@ git clone https://github.com/coracle-social/flotilla.git
cd ~/flotilla cd ~/flotilla
nvm install nvm install
nvm use nvm use
npm i pnpm i
# Optionally create and populate .env.local to suit your use case # Optionally create and populate .env to suit your use case
# Build the app # Build the app
NODE_OPTIONS=--max_old_space_size=16384 npm run build NODE_OPTIONS=--max_old_space_size=16384 pnpm run build
# Exit back to root # Exit back to root
exit exit
@@ -108,4 +108,4 @@ Now, visit your domain. You should be all set up!
# Development # Development
Run `npm run dev` to get a dev server, and `npm run check:watch` to watch for typescript errors. When you're ready to commit, run `npm run format && npm run lint` and fix any errors that come up. Run `pnpm run dev` to get a dev server, and `pnpm run check:watch` to watch for typescript errors. When you're ready to commit, run `pnpm run format && pnpm run lint` and fix any errors that come up.
+4 -4
View File
@@ -5,10 +5,10 @@ android {
compileSdk rootProject.ext.compileSdkVersion compileSdk rootProject.ext.compileSdkVersion
defaultConfig { defaultConfig {
applicationId "social.flotilla" applicationId "social.flotilla"
minSdkVersion rootProject.ext.minSdkVersion minSdk rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdk rootProject.ext.targetSdkVersion
versionCode 13 versionCode 17
versionName "0.2.13" versionName "1.0.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+2 -1
View File
@@ -6,7 +6,8 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme"> android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<activity <activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
android:name=".MainActivity" android:name=".MainActivity"
+4 -4
View File
@@ -1,12 +1,12 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN // DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android' include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/android/capacitor')
include ':capacitor-app' include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app/android')
include ':capacitor-keyboard' include ':capacitor-keyboard'
project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android') project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard/android')
include ':nostr-signer-capacitor-plugin' include ':nostr-signer-capacitor-plugin'
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/nostr-signer-capacitor-plugin/android') project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin/android')
+4 -2
View File
@@ -3,7 +3,9 @@ ext {
compileSdkVersion = 35 compileSdkVersion = 35
targetSdkVersion = 35 targetSdkVersion = 35
androidxActivityVersion = '1.9.2' androidxActivityVersion = '1.9.2'
androidxAppCompatVersion = '1.7.0' //https://github.com/ionic-team/capacitor/issues/7866
// androidxAppCompatVersion = '1.7.0'
androidxAppCompatVersion = '1.6.1'
androidxCoordinatorLayoutVersion = '1.2.0' androidxCoordinatorLayoutVersion = '1.2.0'
androidxCoreVersion = '1.15.0' androidxCoreVersion = '1.15.0'
androidxFragmentVersion = '1.8.4' androidxFragmentVersion = '1.8.4'
@@ -13,4 +15,4 @@ ext {
androidxJunitVersion = '1.2.1' androidxJunitVersion = '1.2.1'
androidxEspressoCoreVersion = '3.6.1' androidxEspressoCoreVersion = '3.6.1'
cordovaAndroidVersion = '10.1.1' cordovaAndroidVersion = '10.1.1'
} }
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Fetch tags and set to env vars
git fetch --prune --unshallow --tags
git describe --tags --abbrev=0
export VITE_BUILD_VERSION=$RENDER_GIT_COMMIT
export VITE_BUILD_HASH=$RENDER_GIT_COMMIT
# Remove link overrides
node remove-pnpm-overrides.js package.json
# When CI=true as it is on render.com, removing link overrides breaks the lockfile
pnpm i --no-frozen-lockfile
# Rebuild sharp
pnpm rebuild
# The build runs out of memory at times
NODE_OPTIONS=--max_old_space_size=16384 pnpm run build
+4 -4
View File
@@ -2,12 +2,12 @@
temp_env=$(declare -p -x) temp_env=$(declare -p -x)
if [ -f .env ]; then if [ -f .env.template ]; then
source .env source .env.template
fi fi
if [ -f .env.local ]; then if [ -f .env ]; then
source .env.local source .env
fi fi
# Avoid overwriting env vars provided directly # Avoid overwriting env vars provided directly
+1 -1
View File
@@ -18,7 +18,7 @@ const config: CapacitorConfig = {
}, },
// Use this for live reload https://capacitorjs.com/docs/guides/live-reload // Use this for live reload https://capacitorjs.com/docs/guides/live-reload
// server: { // server: {
// url: "http://192.168.1.250:1847", // url: "http://192.168.1.115:1847",
// cleartext: true // cleartext: true
// }, // },
}; };
+4 -4
View File
@@ -351,14 +351,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7; CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = S26U9DYW3A; DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.2.13; MARKETING_VERSION = 1.0.3;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -376,14 +376,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7; CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = S26U9DYW3A; DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.2.13; MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
+6 -6
View File
@@ -1,4 +1,4 @@
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers' require_relative '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '14.0' platform :ios, '14.0'
use_frameworks! use_frameworks!
@@ -9,11 +9,11 @@ use_frameworks!
install! 'cocoapods', :disable_input_output_paths => true install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios' pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios'
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app' pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app'
pod 'CapacitorKeyboard', :path => '../../node_modules/@capacitor/keyboard' pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard'
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/nostr-signer-capacitor-plugin' pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin'
end end
target 'Flotilla Chat' do target 'Flotilla Chat' do
-15449
View File
File diff suppressed because it is too large Load Diff
+37 -12
View File
@@ -1,6 +1,6 @@
{ {
"name": "flotilla", "name": "flotilla",
"version": "0.2.13", "version": "1.0.3",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -51,16 +51,18 @@
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@vite-pwa/assets-generator": "^0.2.6", "@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.6", "@vite-pwa/sveltekit": "^0.6.6",
"@welshman/app": "^0.0.43", "@welshman/app": "^0.2.5",
"@welshman/content": "^0.1.0", "@welshman/content": "^0.2.2",
"@welshman/dvm": "^0.0.15", "@welshman/dvm": "^0.2.0",
"@welshman/editor": "^0.1.0", "@welshman/editor": "^0.2.4",
"@welshman/feeds": "^0.1.0", "@welshman/feeds": "^0.2.2",
"@welshman/lib": "^0.1.0", "@welshman/lib": "^0.2.2",
"@welshman/net": "^0.0.49", "@welshman/net": "^0.2.3",
"@welshman/signer": "^0.1.0", "@welshman/relay": "^0.2.0",
"@welshman/store": "^0.1.0", "@welshman/router": "^0.2.0",
"@welshman/util": "^0.1.0", "@welshman/signer": "^0.2.3",
"@welshman/store": "^0.2.0",
"@welshman/util": "^0.2.3",
"daisyui": "^4.12.10", "daisyui": "^4.12.10",
"date-picker-svelte": "^2.13.0", "date-picker-svelte": "^2.13.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
@@ -68,9 +70,32 @@
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"husky": "^9.1.6", "husky": "^9.1.6",
"idb": "^8.0.0", "idb": "^8.0.0",
"nostr-signer-capacitor-plugin": "coracle-social/nostr-signer-capacitor-plugin#9fbe4f8", "nostr-signer-capacitor-plugin": "^0.0.4",
"nostr-tools": "^2.7.2", "nostr-tools": "^2.7.2",
"prettier-plugin-tailwindcss": "^0.6.5", "prettier-plugin-tailwindcss": "^0.6.5",
"qrcode": "^1.5.4" "qrcode": "^1.5.4"
},
"pnpm": {
"overrides": {
"@welshman/lib": "link:../welshman/packages/lib",
"@welshman/util": "link:../welshman/packages/util",
"@welshman/app": "link:../welshman/packages/app",
"@welshman/content": "link:../welshman/packages/content",
"@welshman/dvm": "link:../welshman/packages/dvm",
"@welshman/feeds": "link:../welshman/packages/feeds",
"@welshman/net": "link:../welshman/packages/net",
"@welshman/relay": "link:../welshman/packages/relay",
"@welshman/router": "link:../welshman/packages/router",
"@welshman/signer": "link:../welshman/packages/signer",
"@welshman/store": "link:../welshman/packages/store",
"@welshman/editor": "link:../welshman/packages/editor"
},
"ignoredBuiltDependencies": [
"@sentry/cli",
"esbuild"
],
"onlyBuiltDependencies": [
"sharp"
]
} }
} }
+9190
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,8 +1,8 @@
import dotenv from "dotenv" import dotenv from "dotenv"
import {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config" import {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config"
dotenv.config({path: ".env.local"})
dotenv.config({path: ".env"}) dotenv.config({path: ".env"})
dotenv.config({path: ".env.template"})
export default defineConfig({ export default defineConfig({
preset, preset,
+20
View File
@@ -0,0 +1,20 @@
// This script is necessary for installing stuff on a host, since our links don't exist there.
import fs from "fs"
const pkgName = process.argv[2]
if (!pkgName?.endsWith("package.json")) {
console.log("File passed was not a package.json file")
process.exit(1)
}
const pkg = JSON.parse(fs.readFileSync(pkgName, "utf8"))
if (pkg.pnpm && pkg.pnpm.overrides) {
delete pkg.pnpm.overrides
fs.writeFileSync(pkgName, JSON.stringify(pkg, null, 2) + "\n")
console.log("Removed pnpm.overrides from package.json")
} else {
console.log("No pnpm.overrides found in package.json")
}
+95 -49
View File
@@ -46,6 +46,14 @@
:root { :root {
font-family: Lato; font-family: Lato;
--sait: env(safe-area-inset-top);
--saib: env(safe-area-inset-bottom);
--sail: env(safe-area-inset-left);
--sair: env(safe-area-inset-right);
}
[data-theme] {
@apply bg-base-300;
--base-100: oklch(var(--b1)); --base-100: oklch(var(--b1));
--base-200: oklch(var(--b2)); --base-200: oklch(var(--b2));
--base-300: oklch(var(--b3)); --base-300: oklch(var(--b3));
@@ -56,56 +64,80 @@
--secondary-content: oklch(var(--sc)); --secondary-content: oklch(var(--sc));
} }
:root, /* safe area insets */
body,
html {
@apply bg-base-300;
}
/* ios */ @layer components {
.pt-sai {
padding-top: var(--sait);
}
.sait { .pr-sai {
padding-top: env(safe-area-inset-top); padding-right: var(--sair);
} }
.sair { .pb-sai {
padding-right: env(safe-area-inset-right); padding-bottom: var(--saib);
} }
.saib { .pl-sai {
padding-bottom: env(safe-area-inset-bottom); padding-left: var(--sail);
} }
.sail { .px-sai {
padding-left: env(safe-area-inset-left); @apply pl-sai pr-sai;
} }
.saix { .py-sai {
@apply sail sair; @apply pt-sai pb-sai;
} }
.saiy { .p-sai {
@apply sait saib; @apply py-sai px-sai;
} }
.sai { .mt-sai {
@apply saiy saix; padding-top: var(--sait);
} }
.top-sai { .mr-sai {
top: env(safe-area-inset-top); padding-right: var(--sair);
} }
.right-sai { .mb-sai {
right: env(safe-area-inset-right); padding-bottom: var(--saib);
} }
.bottom-sai { .ml-sai {
bottom: env(safe-area-inset-bottom); padding-left: var(--sail);
} }
.left-sai { .mx-sai {
left: env(safe-area-inset-left); @apply ml-sai mr-sai;
}
.my-sai {
@apply mt-sai mb-sai;
}
.m-sai {
@apply my-sai mx-sai;
}
.top-sai {
top: var(--sait);
}
.right-sai {
right: var(--sair);
}
.bottom-sai {
bottom: var(--saib);
}
.left-sai {
left: var(--sail);
}
} }
/* utilities */ /* utilities */
@@ -259,6 +291,14 @@ html {
--tiptap-active-fg: var(--base-content); --tiptap-active-fg: var(--base-content);
} }
.tiptap-suggestions__item {
@apply border-l-2 border-solid border-base-100;
}
.tiptap-suggestions__selected {
@apply border-primary;
}
.tiptap { .tiptap {
@apply max-h-[350px] overflow-y-auto p-2 px-4; @apply max-h-[350px] overflow-y-auto p-2 px-4;
} }
@@ -294,6 +334,16 @@ html {
color: var(--base-content); color: var(--base-content);
} }
/* content rendered by welshman/content */
.welshman-content a {
@apply link;
}
.welshman-content-error a {
@apply underline;
}
/* date input */ /* date input */
.picker { .picker {
@@ -335,23 +385,19 @@ progress[value]::-webkit-progress-value {
/* content width for fixed elements */ /* content width for fixed elements */
.cw { .cw {
@apply w-full md:w-[calc(100%-18.5rem)]; @apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))];
}
.cb {
@apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)];
} }
/* chat view */ /* chat view */
.chat__page-bar {
@apply sait cw !fixed top-2;
}
.chat__messages {
@apply saib cw fixed top-12 flex h-[calc(100%-6rem)] flex-col-reverse overflow-y-auto overflow-x-hidden md:h-[calc(100%-3rem)];
}
.chat__compose { .chat__compose {
@apply saib cw fixed bottom-14 md:bottom-0; @apply cb cw fixed;
} }
.chat__scroll-down { .chat__scroll-down {
@apply saib fixed bottom-28 right-4 md:bottom-16; @apply fixed bottom-28 right-4 md:bottom-16;
} }
+86 -126
View File
@@ -1,6 +1,8 @@
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import {get} from "svelte/store" import {get} from "svelte/store"
import {ctx, randomId, uniq, equals} from "@welshman/lib" import {randomId, poll, uniq, equals, TIMEZONE, LOCALE} from "@welshman/lib"
import type {Feed} from "@welshman/feeds"
import type {TrustedEvent, EventContent} from "@welshman/util"
import { import {
DELETE, DELETE,
REPORT, REPORT,
@@ -28,32 +30,29 @@ import {
getRelayTags, getRelayTags,
getRelayTagValues, getRelayTagValues,
toNostrURI, toNostrURI,
unionFilters, getRelaysFromList,
RelayMode,
} from "@welshman/util" } from "@welshman/util"
import type {TrustedEvent, Filter, EventContent, EventTemplate} from "@welshman/util" import {Pool, PublishStatus, AuthStatus, SocketStatus} from "@welshman/net"
import {PublishStatus, AuthStatus, SocketStatus} from "@welshman/net" import {Router} from "@welshman/router"
import {Nip59, makeSecret, stamp, Nip46Broker} from "@welshman/signer"
import { import {
pubkey, pubkey,
signer, signer,
repository, repository,
publishThunk, publishThunk,
publishThunks,
profilesByPubkey, profilesByPubkey,
relaySelectionsByPubkey, relaySelectionsByPubkey,
getWriteRelayUrls,
tagEvent, tagEvent,
tagEventForReaction, tagEventForReaction,
getRelayUrls,
userRelaySelections, userRelaySelections,
userInboxRelaySelections, userInboxRelaySelections,
nip44EncryptToSelf, nip44EncryptToSelf,
loadRelay, loadRelay,
addSession,
clearStorage, clearStorage,
dropSession, dropSession,
tagEventForComment, tagEventForComment,
tagEventForQuote, tagEventForQuote,
thunkIsComplete,
} from "@welshman/app" } from "@welshman/app"
import type {Thunk} from "@welshman/app" import type {Thunk} from "@welshman/app"
import { import {
@@ -61,19 +60,17 @@ import {
PROTECTED, PROTECTED,
userMembership, userMembership,
INDEXER_RELAYS, INDEXER_RELAYS,
NIP46_PERMS,
ALERT, ALERT,
NOTIFIER_PUBKEY, NOTIFIER_PUBKEY,
NOTIFIER_RELAY, NOTIFIER_RELAY,
userRoomsByUrl, userRoomsByUrl,
} from "@app/state" } from "@app/state"
import {loadUserData} from "@app/requests"
// Utils // Utils
export const getPubkeyHints = (pubkey: string) => { export const getPubkeyHints = (pubkey: string) => {
const selections = relaySelectionsByPubkey.get().get(pubkey) const selections = relaySelectionsByPubkey.get().get(pubkey)
const relays = selections ? getWriteRelayUrls(selections) : [] const relays = selections ? getRelaysFromList(selections, RelayMode.Write) : []
const hints = relays.length ? relays : INDEXER_RELAYS const hints = relays.length ? relays : INDEXER_RELAYS
return hints return hints
@@ -86,14 +83,20 @@ export const getPubkeyPetname = (pubkey: string) => {
return display return display
} }
export const getThunkError = async (thunk: Thunk) => { export const getThunkError = (thunk: Thunk) =>
const result = await thunk.result new Promise<string>(resolve => {
const [{status, message}] = Object.values(result) as any thunk.subscribe($thunk => {
for (const [relay, status] of Object.entries($thunk.status)) {
if (status === PublishStatus.Failure) {
resolve($thunk.details[relay])
}
}
if (status !== PublishStatus.Success) { if (thunkIsComplete($thunk)) {
return message resolve("")
} }
} })
})
export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => { export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => {
if (parent) { if (parent) {
@@ -101,7 +104,7 @@ export const prependParent = (parent: TrustedEvent | undefined, {content, tags}:
id: parent.id, id: parent.id,
kind: parent.kind, kind: parent.kind,
author: parent.pubkey, author: parent.pubkey,
relays: ctx.app.router.Event(parent).limit(3).getUrls(), relays: Router.get().Event(parent).limit(3).getUrls(),
}) })
tags = [...tags, tagEventForQuote(parent)] tags = [...tags, tagEventForQuote(parent)]
@@ -111,38 +114,6 @@ export const prependParent = (parent: TrustedEvent | undefined, {content, tags}:
return {content, tags} return {content, tags}
} }
// Log in
export const loginWithNip46 = async ({
relays,
signerPubkey,
clientSecret = makeSecret(),
connectSecret = "",
}: {
relays: string[]
signerPubkey: string
clientSecret?: string
connectSecret?: string
}) => {
const broker = Nip46Broker.get({relays, clientSecret, signerPubkey})
const result = await broker.connect(connectSecret, NIP46_PERMS)
// TODO: remove ack result
if (!["ack", connectSecret].includes(result)) return false
const pubkey = await broker.getPublicKey()
if (!pubkey) return false
await loadUserData(pubkey)
const handler = {relays, pubkey: signerPubkey}
addSession({method: "nip46", pubkey, secret: clientSecret, handler})
return true
}
// Log out // Log out
export const logout = async () => { export const logout = async () => {
@@ -203,7 +174,7 @@ export const nip29 = {
export const addSpaceMembership = async (url: string) => { export const addSpaceMembership = async (url: string) => {
const list = get(userMembership) || makeList({kind: GROUPS}) const list = get(userMembership) || makeList({kind: GROUPS})
const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf) const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf)
const relays = uniq([...ctx.app.router.FromUser().getUrls(), ...getRelayTagValues(event.tags)]) const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays}) return publishThunk({event, relays})
} }
@@ -212,11 +183,7 @@ export const removeSpaceMembership = async (url: string) => {
const list = get(userMembership) || makeList({kind: GROUPS}) const list = get(userMembership) || makeList({kind: GROUPS})
const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf) const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([ const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
url,
...ctx.app.router.FromUser().getUrls(),
...getRelayTagValues(event.tags),
])
return publishThunk({event, relays}) return publishThunk({event, relays})
} }
@@ -228,7 +195,7 @@ export const addRoomMembership = async (url: string, room: string, name: string)
["group", room, url, name], ["group", room, url, name],
] ]
const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf) const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf)
const relays = uniq([...ctx.app.router.FromUser().getUrls(), ...getRelayTagValues(event.tags)]) const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays}) return publishThunk({event, relays})
} }
@@ -237,11 +204,7 @@ export const removeRoomMembership = async (url: string, room: string) => {
const list = get(userMembership) || makeList({kind: GROUPS}) const list = get(userMembership) || makeList({kind: GROUPS})
const pred = (t: string[]) => equals(["group", room, url], t.slice(0, 3)) const pred = (t: string[]) => equals(["group", room, url], t.slice(0, 3))
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf) const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([ const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
url,
...ctx.app.router.FromUser().getUrls(),
...getRelayTagValues(event.tags),
])
return publishThunk({event, relays}) return publishThunk({event, relays})
} }
@@ -263,7 +226,7 @@ export const setRelayPolicy = (url: string, read: boolean, write: boolean) => {
relays: [ relays: [
url, url,
...INDEXER_RELAYS, ...INDEXER_RELAYS,
...ctx.app.router.FromUser().getUrls(), ...Router.get().FromUser().getUrls(),
...userRoomsByUrl.get().keys(), ...userRoomsByUrl.get().keys(),
], ],
}) })
@@ -273,7 +236,7 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
const list = get(userInboxRelaySelections) || makeList({kind: INBOX_RELAYS}) const list = get(userInboxRelaySelections) || makeList({kind: INBOX_RELAYS})
// Only update inbox policies if they already exist or we're adding them // Only update inbox policies if they already exist or we're adding them
if (enabled || getRelayUrls(list).includes(url)) { if (enabled || getRelaysFromList(list).includes(url)) {
const tags = getRelayTags(getListTags(list)).filter(t => normalizeRelayUrl(t[1]) !== url) const tags = getRelayTags(getListTags(list)).filter(t => normalizeRelayUrl(t[1]) !== url)
if (enabled) { if (enabled) {
@@ -284,7 +247,7 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
event: createEvent(list.kind, {tags}), event: createEvent(list.kind, {tags}),
relays: [ relays: [
...INDEXER_RELAYS, ...INDEXER_RELAYS,
...ctx.app.router.FromUser().getUrls(), ...Router.get().FromUser().getUrls(),
...userRoomsByUrl.get().keys(), ...userRoomsByUrl.get().keys(),
], ],
}) })
@@ -294,28 +257,31 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
// Relay access // Relay access
export const checkRelayAccess = async (url: string, claim = "") => { export const checkRelayAccess = async (url: string, claim = "") => {
const connection = ctx.net.pool.get(url) const socket = Pool.get().get(url)
await connection.auth.attempt(5000) await socket.auth.attemptAuth(e => signer.get()?.sign(e))
const thunk = publishThunk({ const thunk = publishThunk({
event: createEvent(AUTH_JOIN, {tags: [["claim", claim]]}), event: createEvent(AUTH_JOIN, {tags: [["claim", claim]]}),
relays: [url], relays: [url],
}) })
const result = await thunk.result const error = await getThunkError(thunk)
if (result[url].status === PublishStatus.Failure) { if (error) {
const message = const message =
connection.auth.message?.replace(/^.*: /, "") || socket.auth.details?.replace(/^\w+: /, "") ||
result[url].message?.replace(/^.*: /, "") || error?.replace(/^\w+: /, "") ||
"join request rejected" "join request rejected"
// If it's a strict NIP 29 relay don't worry about requesting access // If it's a strict NIP 29 relay don't worry about requesting access
// TODO: remove this if relay29 ever gets less strict // TODO: remove this if relay29 ever gets less strict
if (message !== "missing group (`h`) tag") { if (message === "missing group (`h`) tag") return
return `Failed to join relay (${message})`
} // Ignore messages about the relay ignoring ours
if (error?.startsWith("mute: ")) return
return message
} }
} }
@@ -328,26 +294,30 @@ export const checkRelayProfile = async (url: string) => {
} }
export const checkRelayConnection = async (url: string) => { export const checkRelayConnection = async (url: string) => {
const connection = ctx.net.pool.get(url) const socket = Pool.get().get(url)
await connection.socket.open() socket.attemptToOpen()
await connection.socket.wait(3000)
if (connection.socket.status !== SocketStatus.Open) { await poll({
signal: AbortSignal.timeout(3000),
condition: () => socket.status === SocketStatus.Open,
})
if (socket.status !== SocketStatus.Open) {
return `Failed to connect` return `Failed to connect`
} }
} }
export const checkRelayAuth = async (url: string, timeout = 3000) => { export const checkRelayAuth = async (url: string, timeout = 3000) => {
const connection = ctx.net.pool.get(url) const socket = Pool.get().get(url)
const okStatuses = [AuthStatus.None, AuthStatus.Ok] const okStatuses = [AuthStatus.None, AuthStatus.Ok]
await connection.auth.attempt(timeout) await socket.auth.attemptAuth(e => signer.get()?.sign(e))
// Only raise an error if it's not a timeout. // Only raise an error if it's not a timeout.
// If it is, odds are the problem is with our signer, not the relay // If it is, odds are the problem is with our signer, not the relay
if (!okStatuses.includes(connection.auth.status) && connection.auth.message) { if (!okStatuses.includes(socket.auth.status) && socket.auth.details) {
return `Failed to authenticate (${connection.auth.message})` return `Failed to authenticate (${socket.auth.details})`
} }
} }
@@ -369,28 +339,6 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
// Actions // Actions
export const sendWrapped = async ({
template,
pubkeys,
delay,
}: {
template: EventTemplate
pubkeys: string[]
delay?: number
}) => {
const nip59 = Nip59.fromSigner(signer.get()!)
return publishThunks(
await Promise.all(
uniq(pubkeys).map(async recipient => ({
event: await nip59.wrap(recipient, stamp(template)),
relays: ctx.app.router.PubkeyInbox(recipient).getUrls(),
delay,
})),
),
)
}
export const makeDelete = ({event}: {event: TrustedEvent}) => { export const makeDelete = ({event}: {event: TrustedEvent}) => {
const tags = [["k", String(event.kind)], ...tagEvent(event)] const tags = [["k", String(event.kind)], ...tagEvent(event)]
const groupTag = getTag("h", event.tags) const groupTag = getTag("h", event.tags)
@@ -432,10 +380,12 @@ export const publishReport = ({
export type ReactionParams = { export type ReactionParams = {
event: TrustedEvent event: TrustedEvent
content: string content: string
tags?: string[][]
} }
export const makeReaction = ({event, content}: ReactionParams) => { export const makeReaction = ({content, event, tags: paramTags = []}: ReactionParams) => {
const tags = tagEventForReaction(event) const tags = [...paramTags, ...tagEventForReaction(event)]
const groupTag = getTag("h", event.tags) const groupTag = getTag("h", event.tags)
if (groupTag) { if (groupTag) {
@@ -462,33 +412,43 @@ export const publishComment = ({relays, ...params}: CommentParams & {relays: str
publishThunk({event: makeComment(params), relays}) publishThunk({event: makeComment(params), relays})
export type AlertParams = { export type AlertParams = {
feed: Feed
cron: string cron: string
email: string email: string
relay: string bunker: string
handler: string secret: string
filters: Filter[] description: string
} }
export const makeAlert = async ({cron, email, handler, relay, filters}: AlertParams) => export const makeAlert = async ({cron, email, feed, bunker, secret, description}: AlertParams) => {
createEvent(ALERT, { const tags = [
content: await signer ["feed", JSON.stringify(feed)],
.get() ["cron", cron],
.nip44.encrypt( ["email", email],
NOTIFIER_PUBKEY, ["locale", LOCALE],
JSON.stringify([ ["timezone", TIMEZONE],
["cron", cron], ["description", description],
["email", email], ["channel", "email"],
["relay", relay], [
["handler", handler], "handler",
["channel", "email"], "31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1737058597050",
...unionFilters(filters).map(filter => ["filter", JSON.stringify(filter)]), "wss://relay.nostr.band/",
]), "web",
), ],
]
if (bunker) {
tags.push(["nip46", secret, bunker])
}
return createEvent(ALERT, {
content: await signer.get().nip44.encrypt(NOTIFIER_PUBKEY, JSON.stringify(tags)),
tags: [ tags: [
["d", randomId()], ["d", randomId()],
["p", NOTIFIER_PUBKEY], ["p", NOTIFIER_PUBKEY],
], ],
}) })
}
export const publishAlert = async (params: AlertParams) => export const publishAlert = async (params: AlertParams) =>
publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]}) publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]})
+141 -70
View File
@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import {Capacitor} from "@capacitor/core"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import {randomInt} from "@welshman/lib" import {randomInt, displayList, TIMEZONE, identity} from "@welshman/lib"
import {displayRelayUrl, THREAD, MESSAGE, EVENT_TIME, COMMENT} from "@welshman/util" import {displayRelayUrl, getTagValue, THREAD, MESSAGE, EVENT_TIME, COMMENT} from "@welshman/util"
import type {Filter} from "@welshman/util" import type {Filter} from "@welshman/util"
import type {Nip46ResponseWithResult} from "@welshman/signer"
import {makeIntersectionFeed, makeRelayFeed, feedFromFilters} from "@welshman/feeds"
import {pubkey} from "@welshman/app" import {pubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -11,20 +12,21 @@
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import {getMembershipUrls, getMembershipRoomsByUrl, userMembership} from "@app/state" import InfoBunker from "@app/components/InfoBunker.svelte"
import BunkerConnect, {BunkerConnectController} from "@app/components/BunkerConnect.svelte"
import {
GENERAL,
alerts,
getMembershipUrls,
getMembershipRoomsByUrl,
userMembership,
} from "@app/state"
import {loadAlertStatuses} from "@app/requests" import {loadAlertStatuses} from "@app/requests"
import {publishAlert} from "@app/commands" import {publishAlert} from "@app/commands"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
const handler = Capacitor.isNativePlatform() const timezoneOffset = parseInt(TIMEZONE.slice(3)) / 100
? "https://app.flotilla.social"
: window.location.origin
const timezone = new Date()
.toString()
.match(/GMT[^\s]+/)![0]
.slice(3)
const timezoneOffset = parseInt(timezone) / 100
const minute = randomInt(0, 59) const minute = randomInt(0, 59)
const hour = (17 - timezoneOffset) % 24 const hour = (17 - timezoneOffset) % 24
const WEEKLY = `0 ${minute} ${hour} * * 1` const WEEKLY = `0 ${minute} ${hour} * * 1`
@@ -32,14 +34,38 @@
let loading = false let loading = false
let cron = WEEKLY let cron = WEEKLY
let email = "" let email = $alerts.map(a => getTagValue("email", a.tags)).filter(identity)[0] || ""
let relay = "" let relay = ""
let bunker = ""
let secret = ""
let notifyThreads = true let notifyThreads = true
let notifyCalendar = true let notifyCalendar = true
let notifyChat = false let notifyChat = false
let showBunker = false
const back = () => history.back() const back = () => history.back()
const controller = new BunkerConnectController({
onNostrConnect: (response: Nip46ResponseWithResult) => {
bunker = controller.broker.getBunkerUrl()
secret = controller.broker.params.clientSecret
showBunker = false
},
})
const connectBunker = () => {
showBunker = true
}
const hideBunker = () => {
showBunker = false
}
const clearBunker = () => {
bunker = ""
secret = ""
}
const submit = async () => { const submit = async () => {
if (!email.includes("@")) { if (!email.includes("@")) {
return pushToast({ return pushToast({
@@ -63,25 +89,37 @@
} }
const filters: Filter[] = [] const filters: Filter[] = []
const display: string[] = []
if (notifyThreads) { if (notifyThreads) {
display.push("threads")
filters.push({kinds: [THREAD]}) filters.push({kinds: [THREAD]})
filters.push({kinds: [COMMENT], "#k": [String(THREAD)]}) filters.push({kinds: [COMMENT], "#k": [String(THREAD)]})
} }
if (notifyCalendar) { if (notifyCalendar) {
display.push("calendar events")
filters.push({kinds: [EVENT_TIME]}) filters.push({kinds: [EVENT_TIME]})
filters.push({kinds: [COMMENT], "#k": [String(EVENT_TIME)]}) filters.push({kinds: [COMMENT], "#k": [String(EVENT_TIME)]})
} }
if (notifyChat) { if (notifyChat) {
filters.push({kinds: [MESSAGE], "#h": getMembershipRoomsByUrl(relay, $userMembership)}) display.push("chat")
filters.push({
kinds: [MESSAGE],
"#h": [GENERAL, ...getMembershipRoomsByUrl(relay, $userMembership)],
})
} }
loading = true loading = true
try { try {
await publishAlert({cron, email, relay, handler, filters}) const cadence = cron?.endsWith("1") ? "Weekly" : "Daily"
const description = `${cadence} alert for ${displayList(display)} on ${displayRelayUrl(relay)}, sent via email.`
const feed = makeIntersectionFeed(feedFromFilters(filters), makeRelayFeed(relay))
const thunk = await publishAlert({cron, email, feed, bunker, secret, description})
await thunk.result
await loadAlertStatuses($pubkey!) await loadAlertStatuses($pubkey!)
pushToast({message: "Your alert has been successfully created!"}) pushToast({message: "Your alert has been successfully created!"})
@@ -98,67 +136,100 @@
Add an Alert Add an Alert
{/snippet} {/snippet}
</ModalHeader> </ModalHeader>
<FieldInline> {#if showBunker}
{#snippet label()} <div class="card2 flex flex-col items-center gap-4 bg-base-300">
<p>Email Address*</p> <p>Scan using a nostr signer, or click to copy.</p>
{/snippet} <BunkerConnect {controller} />
{#snippet input()} <Button class="btn btn-neutral btn-sm" onclick={hideBunker}>Cancel</Button>
<label class="input input-bordered flex w-full items-center gap-2"> </div>
<input placeholder="email@example.com" bind:value={email} /> {:else}
</label> <FieldInline>
{/snippet} {#snippet label()}
</FieldInline> <p>Email Address*</p>
<FieldInline> {/snippet}
{#snippet label()} {#snippet input()}
<p>Frequency*</p> <label class="input input-bordered flex w-full items-center gap-2">
{/snippet} <input placeholder="email@example.com" bind:value={email} />
{#snippet input()} </label>
<select bind:value={cron} class="select select-bordered"> {/snippet}
<option value={WEEKLY}>Weekly</option> </FieldInline>
<option value={DAILY}>Daily</option> <FieldInline>
</select> {#snippet label()}
{/snippet} <p>Frequency*</p>
</FieldInline> {/snippet}
<FieldInline> {#snippet input()}
{#snippet label()} <select bind:value={cron} class="select select-bordered">
<p>Space*</p> <option value={WEEKLY}>Weekly</option>
{/snippet} <option value={DAILY}>Daily</option>
{#snippet input()} </select>
<select bind:value={relay} class="select select-bordered"> {/snippet}
<option value="" disabled selected>Choose a space URL</option> </FieldInline>
{#each getMembershipUrls($userMembership) as url (url)} <FieldInline>
<option value={url}>{displayRelayUrl(url)}</option> {#snippet label()}
{/each} <p>Space*</p>
</select> {/snippet}
{/snippet} {#snippet input()}
</FieldInline> <select bind:value={relay} class="select select-bordered">
<FieldInline> <option value="" disabled selected>Choose a space URL</option>
{#snippet label()} {#each getMembershipUrls($userMembership) as url (url)}
<p>Notifications*</p> <option value={url}>{displayRelayUrl(url)}</option>
{/snippet} {/each}
{#snippet input()} </select>
<div class="flex items-center justify-end gap-4"> {/snippet}
<span class="flex gap-3"> </FieldInline>
<input type="checkbox" class="checkbox" bind:checked={notifyThreads} /> <FieldInline>
Threads {#snippet label()}
</span> <p>Notifications*</p>
<span class="flex gap-3"> {/snippet}
<input type="checkbox" class="checkbox" bind:checked={notifyCalendar} /> {#snippet input()}
Calendar <div class="flex items-center justify-end gap-4">
</span> <span class="flex gap-3">
<span class="flex gap-3"> <input type="checkbox" class="checkbox" bind:checked={notifyThreads} />
<input type="checkbox" class="checkbox" bind:checked={notifyChat} /> Threads
Chat </span>
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyCalendar} />
Calendar
</span>
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyChat} />
Chat
</span>
</div>
{/snippet}
</FieldInline>
<div class="card2 flex flex-col gap-3 bg-base-300">
<div class="flex items-center justify-between">
<strong>Connect a Bunker</strong>
<span class="flex items-center gap-2 text-sm" class:text-primary={bunker}>
{#if bunker}
<Icon icon="check-circle" size={5} />
Connected
{:else}
<Icon icon="close-circle" size={5} />
Not Connected
{/if}
</span> </span>
</div> </div>
{/snippet} <p class="text-sm">
</FieldInline> Required for receiving alerts about spaces with access controls. You can get one from your
<Button class="text-primary" onclick={() => pushModal(InfoBunker)}>remote signer app</Button
>.
</p>
{#if bunker}
<Button class="btn btn-neutral btn-sm flex-grow" onclick={clearBunker}>Disconnect</Button>
{:else}
<Button class="btn btn-primary btn-sm w-full flex-grow" onclick={connectBunker}
>Connect</Button>
{/if}
</div>
{/if}
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back}> <Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" /> <Icon icon="alt-arrow-left" />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary" disabled={loading}> <Button type="submit" class="btn btn-primary" disabled={loading || showBunker}>
<Spinner {loading}>Confirm</Spinner> <Spinner {loading}>Confirm</Spinner>
<Icon icon="alt-arrow-right" /> <Icon icon="alt-arrow-right" />
</Button> </Button>
+16 -32
View File
@@ -1,22 +1,12 @@
<script lang="ts"> <script lang="ts">
import {parseJson, nthEq} from "@welshman/lib" import {parseJson, nthEq} from "@welshman/lib"
import { import {displayFeeds} from "@welshman/feeds"
getAddress, import {getAddress, getTagValue, getTagValues} from "@welshman/util"
getTagValue,
getTagValues,
displayRelayUrl,
EVENT_TIME,
MESSAGE,
THREAD,
} from "@welshman/util"
import {displayList} from "@lib/util"
import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import AlertDelete from "@app/components/AlertDelete.svelte" import AlertDelete from "@app/components/AlertDelete.svelte"
import type {Alert} from "@app/state" import type {Alert} from "@app/state"
import {alertStatuses} from "@app/state" import {alertStatuses} from "@app/state"
import {makeSpacePath} from "@app/routes"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
type Props = { type Props = {
@@ -29,31 +19,25 @@
const status = $derived($alertStatuses.find(s => s.event.tags.some(nthEq(1, address)))) const status = $derived($alertStatuses.find(s => s.event.tags.some(nthEq(1, address))))
const cron = $derived(getTagValue("cron", alert.tags)) const cron = $derived(getTagValue("cron", alert.tags))
const channel = $derived(getTagValue("channel", alert.tags)) const channel = $derived(getTagValue("channel", alert.tags))
const relay = $derived(getTagValue("relay", alert.tags)!) const feeds = $derived(getTagValues("feed", alert.tags))
const filters = $derived(getTagValues("filter", alert.tags).map(parseJson)) const description = $derived(
const types = $derived.by(() => { getTagValue("description", alert.tags) ||
const t: string[] = [] [
`${cron?.endsWith("1") ? "Weekly" : "Daily"} alert for events`,
if (filters.some(f => f.kinds?.includes(THREAD))) t.push("threads") displayFeeds(feeds.map(parseJson)),
if (filters.some(f => f.kinds?.includes(EVENT_TIME))) t.push("calendar events") `sent via ${channel}.`,
if (filters.some(f => f.kinds?.includes(MESSAGE))) t.push("chat") ].join(" "),
)
return t
})
const startDelete = () => pushModal(AlertDelete, {alert}) const startDelete = () => pushModal(AlertDelete, {alert})
</script> </script>
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<Button class="py-1" onclick={startDelete}> <div class="flex items-start gap-4">
<Icon icon="trash-bin-2" /> <Button class="py-1" onclick={startDelete}>
</Button> <Icon icon="trash-bin-2" />
<div class="flex-inline gap-1"> </Button>
{cron?.endsWith("1") ? "Weekly" : "Daily"} alert for <div class="flex-inline gap-1">{description}</div>
{displayList(types)} on
<Link class="link" href={makeSpacePath(relay)}>
{displayRelayUrl(relay)}
</Link>, sent via {channel}.
</div> </div>
{#if status} {#if status}
{@const statusText = getTagValue("status", status.tags) || "error"} {@const statusText = getTagValue("status", status.tags) || "error"}
+8
View File
@@ -1,12 +1,20 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte"
import {pubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import AlertAdd from "@app/components/AlertAdd.svelte" import AlertAdd from "@app/components/AlertAdd.svelte"
import AlertItem from "@app/components/AlertItem.svelte" import AlertItem from "@app/components/AlertItem.svelte"
import {loadAlertStatuses, loadAlerts} from "@app/requests"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {alerts} from "@app/state" import {alerts} from "@app/state"
const startAlert = () => pushModal(AlertAdd) const startAlert = () => pushModal(AlertAdd)
onMount(() => {
loadAlertStatuses($pubkey!)
loadAlerts($pubkey!)
})
</script> </script>
<div class="card2 bg-alt flex flex-col gap-6 shadow-xl"> <div class="card2 bg-alt flex flex-col gap-6 shadow-xl">
+79
View File
@@ -0,0 +1,79 @@
<script module lang="ts">
import type {Nip46ResponseWithResult} from "@welshman/signer"
import {Nip46Broker, makeSecret} from "@welshman/signer"
import {NIP46_PERMS, PLATFORM_URL, PLATFORM_NAME, PLATFORM_LOGO, SIGNER_RELAYS} from "@app/state"
export class BunkerConnectController {
url = $state("")
bunker = $state("")
loading = $state(false)
clientSecret = makeSecret()
abortController = new AbortController()
broker = new Nip46Broker({clientSecret: this.clientSecret, relays: SIGNER_RELAYS})
onNostrConnect: (response: Nip46ResponseWithResult) => void
constructor({onNostrConnect}: {onNostrConnect: (response: Nip46ResponseWithResult) => void}) {
this.onNostrConnect = onNostrConnect
}
async start() {
this.url = await this.broker.makeNostrconnectUrl({
perms: NIP46_PERMS,
url: PLATFORM_URL,
name: PLATFORM_NAME,
image: PLATFORM_LOGO,
})
let response
try {
response = await this.broker.waitForNostrconnect(this.url, this.abortController.signal)
} catch (errorResponse: any) {
if (errorResponse?.error) {
pushToast({
theme: "error",
message: `Received error from signer: ${errorResponse.error}`,
})
} else if (errorResponse) {
console.error(errorResponse)
}
}
if (response) {
this.loading = true
this.onNostrConnect(response)
}
}
stop() {
this.broker.cleanup()
this.abortController.abort()
}
}
</script>
<script lang="ts">
import {onMount, onDestroy} from "svelte"
import {slideAndFade} from "@lib/transition"
import QRCode from "@app/components/QRCode.svelte"
import {pushToast} from "@app/toast"
type Props = {
controller: BunkerConnectController
}
const {controller}: Props = $props()
onMount(() => {
controller.start()
})
onDestroy(() => {
controller.stop()
})
</script>
{#if controller.url}
<div class="flex justify-center" out:slideAndFade>
<QRCode code={controller.url} />
</div>
{/if}
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts">
import {pushModal} from "@app/modal"
import InfoBunker from "@app/components/InfoBunker.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte"
type Props = {
bunker: string
loading: boolean
}
let {loading, bunker = $bindable("")}: Props = $props()
</script>
<Field>
{#snippet label()}
<p>Bunker Link*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="cpu" />
<input disabled={loading} bind:value={bunker} class="grow" placeholder="bunker://" />
</label>
{/snippet}
{#snippet info()}
<p>
A login link provided by a nostr signing app.
<Button class="link" onclick={() => pushModal(InfoBunker)}>What is a bunker link?</Button>
</p>
{/snippet}
</Field>
+5 -10
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {pubkey} from "@welshman/app" import {pubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -26,20 +26,15 @@
const editEvent = () => pushModal(CalendarEventEdit, {url, event}) const editEvent = () => pushModal(CalendarEventEdit, {url, event})
const onReactionClick = (content: string, events: TrustedEvent[]) => { const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
const reaction = events.find(e => e.pubkey === $pubkey)
if (reaction) { const createReaction = (template: EventContent) =>
publishDelete({relays: [url], event: reaction}) publishReaction({...template, event, relays: [url]})
} else {
publishReaction({event, content, relays: [url]})
}
}
</script> </script>
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2"> <div class="flex flex-grow flex-wrap justify-end gap-2">
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-left" /> <ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} /> <ThunkStatusOrDeleted {event} />
{#if showActivity} {#if showActivity}
<EventActivity {url} {path} {event} /> <EventActivity {url} {path} {event} />
+1 -2
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import {fromPairs} from "@welshman/lib" import {fromPairs, LOCALE, secondsToDate} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {LOCALE, secondsToDate} from "@welshman/app"
type Props = { type Props = {
event: TrustedEvent event: TrustedEvent
+11 -11
View File
@@ -36,7 +36,9 @@
const back = () => history.back() const back = () => history.back()
const submit = () => { const selectFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const submit = async () => {
if ($uploading) return if ($uploading) return
if (!title) { if (!title) {
@@ -60,16 +62,17 @@
}) })
} }
const ed = await editor
const event = createEvent(EVENT_TIME, { const event = createEvent(EVENT_TIME, {
content: editor.getText({blockSeparator: "\n"}).trim(), content: ed.getText({blockSeparator: "\n"}).trim(),
tags: [ tags: [
["d", initialValues?.d || randomId()], ["d", initialValues?.d || randomId()],
["title", title], ["title", title],
["location", location], ["location", location || ""],
["start", start.toString()], ["start", start.toString()],
["end", end.toString()], ["end", end.toString()],
...daysBetween(start, end).map(D => ["D", D.toString()]), ...daysBetween(start, end).map(D => ["D", D.toString()]),
...editor.storage.nostr.getEditorTags(), ...ed.storage.nostr.getEditorTags(),
tagRoom(GENERAL, url), tagRoom(GENERAL, url),
PROTECTED, PROTECTED,
], ],
@@ -81,10 +84,10 @@
} }
const content = initialValues?.content || "" const content = initialValues?.content || ""
const editor = makeEditor({submit, uploading, content}) const editor = makeEditor({url, submit, uploading, content})
let title = $state(initialValues?.title) let title = $state(initialValues?.title || "")
let location = $state(initialValues?.location) let location = $state(initialValues?.location || "")
let start: number | undefined = $state(initialValues?.start) let start: number | undefined = $state(initialValues?.start)
let end: number | undefined = $state(initialValues?.end) let end: number | undefined = $state(initialValues?.end)
let endDirty = Boolean(initialValues?.end) let endDirty = Boolean(initialValues?.end)
@@ -119,10 +122,7 @@
<div class="input-editor flex-grow overflow-hidden"> <div class="input-editor flex-grow overflow-hidden">
<EditorContent {editor} /> <EditorContent {editor} />
</div> </div>
<Button <Button data-tip="Add an image" class="center btn tooltip" onclick={selectFiles}>
data-tip="Add an image"
class="center btn tooltip"
onclick={() => editor.chain().selectFiles().run()}>
{#if $uploading} {#if $uploading}
<span class="loading loading-spinner loading-xs"></span> <span class="loading loading-spinner loading-xs"></span>
{:else} {:else}
@@ -1,8 +1,12 @@
<script lang="ts"> <script lang="ts">
import {fromPairs} from "@welshman/lib" import {
fromPairs,
formatTimestamp,
formatTimestampAsDate,
formatTimestampAsTime,
} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import {formatTimestamp, formatTimestampAsDate, formatTimestampAsTime} from "@welshman/app"
type Props = { type Props = {
event: TrustedEvent event: TrustedEvent
+1 -1
View File
@@ -18,7 +18,7 @@
<CalendarEventHeader {event} /> <CalendarEventHeader {event} />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row"> <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"> <span class="whitespace-nowrap py-1 text-sm opacity-75">
Posted by <ProfileLink pubkey={event.pubkey} /> Posted by <ProfileLink pubkey={event.pubkey} {url} />
</span> </span>
<CalendarEventActions showActivity {url} {event} /> <CalendarEventActions showActivity {url} {event} />
</div> </div>
+3 -2
View File
@@ -6,16 +6,17 @@
type Props = { type Props = {
event: TrustedEvent event: TrustedEvent
url: string
} }
const {event}: Props = $props() const {event, url}: Props = $props()
const meta = $derived(fromPairs(event.tags) as Record<string, string>) const meta = $derived(fromPairs(event.tags) as Record<string, string>)
</script> </script>
<div class="flex min-w-0 flex-col gap-1 text-sm opacity-75"> <div class="flex min-w-0 flex-col gap-1 text-sm opacity-75">
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<Icon icon="user-circle" size={4} /> <Icon icon="user-circle" size={4} />
Posted by <ProfileLink pubkey={event.pubkey} /> Posted by <ProfileLink pubkey={event.pubkey} {url} />
</span> </span>
{#if meta.location} {#if meta.location}
<span class="flex items-start gap-1"> <span class="flex items-start gap-1">
+13 -10
View File
@@ -1,39 +1,42 @@
<script lang="ts"> <script lang="ts">
import {writable} from "svelte/store" import {writable} from "svelte/store"
import type {EventContent} from "@welshman/util"
import {isMobile, preventDefault} from "@lib/html" import {isMobile, preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import EditorContent from "@app/editor/EditorContent.svelte" import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
interface Props { type Props = {
onSubmit: any url?: string
onSubmit: (event: EventContent) => void
} }
const {onSubmit}: Props = $props() const {onSubmit, url}: Props = $props()
const autofocus = !isMobile const autofocus = !isMobile
const uploading = writable(false) const uploading = writable(false)
export const focus = () => editor.chain().focus().run() export const focus = () => editor.then(ed => ed.chain().focus().run())
const uploadFiles = () => editor.chain().selectFiles().run() const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const submit = () => { const submit = async () => {
if ($uploading) return if ($uploading) return
const content = editor.getText({blockSeparator: "\n"}).trim() const ed = await editor
const tags = editor.storage.nostr.getEditorTags() const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = ed.storage.nostr.getEditorTags()
if (!content) return if (!content) return
onSubmit({content, tags}) onSubmit({content, tags})
editor.chain().clearContent().run() ed.chain().clearContent().run()
} }
const editor = makeEditor({autofocus, submit, uploading, aggressive: true}) const editor = makeEditor({url, autofocus, submit, uploading, aggressive: true})
</script> </script>
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}> <form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
+20 -28
View File
@@ -1,15 +1,7 @@
<script lang="ts"> <script lang="ts">
import {hash} from "@welshman/lib" import {hash, now, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib"
import {now} from "@welshman/lib" import type {TrustedEvent, EventContent} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import {thunks, deriveProfile, deriveProfileDisplay} from "@welshman/app"
import {
thunks,
pubkey,
deriveProfile,
deriveProfileDisplay,
formatTimestampAsDate,
formatTimestampAsTime,
} from "@welshman/app"
import {isMobile} from "@lib/html" import {isMobile} from "@lib/html"
import TapTarget from "@lib/components/TapTarget.svelte" import TapTarget from "@lib/components/TapTarget.svelte"
import Avatar from "@lib/components/Avatar.svelte" import Avatar from "@lib/components/Avatar.svelte"
@@ -27,10 +19,10 @@
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
interface Props { interface Props {
url: any url: string
room: any room: string
event: TrustedEvent event: TrustedEvent
replyTo?: any replyTo?: (event: TrustedEvent) => void
showPubkey?: boolean showPubkey?: boolean
inert?: boolean inert?: boolean
} }
@@ -39,25 +31,20 @@
const thunk = $thunks[event.id] const thunk = $thunks[event.id]
const today = formatTimestampAsDate(now()) const today = formatTimestampAsDate(now())
const profile = deriveProfile(event.pubkey) const profile = deriveProfile(event.pubkey, [url])
const profileDisplay = deriveProfileDisplay(event.pubkey) const profileDisplay = deriveProfileDisplay(event.pubkey, [url])
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length] const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
const reply = () => replyTo(event) const reply = () => replyTo!(event)
const onTap = () => pushModal(ChannelMessageMenuMobile, {url, event, reply}) const onTap = () => pushModal(ChannelMessageMenuMobile, {url, event, reply})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey}) const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
const onReactionClick = (content: string, events: TrustedEvent[]) => { const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
const reaction = events.find(e => e.pubkey === $pubkey)
if (reaction) { const createReaction = (template: EventContent) =>
publishDelete({relays: [url], event: reaction}) publishReaction({...template, event, relays: [url]})
} else {
publishReaction({event, content, relays: [url]})
}
}
</script> </script>
<TapTarget <TapTarget
@@ -89,7 +76,7 @@
</div> </div>
{/if} {/if}
<div class="text-sm"> <div class="text-sm">
<Content {event} relays={[url]} /> <Content {event} {url} />
{#if thunk} {#if thunk}
<ThunkStatus {thunk} class="mt-2" /> <ThunkStatus {thunk} class="mt-2" />
{/if} {/if}
@@ -97,7 +84,12 @@
</div> </div>
</div> </div>
<div class="row-2 ml-10 mt-1"> <div class="row-2 ml-10 mt-1">
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-right" /> <ReactionSummary
{url}
{event}
{deleteReaction}
{createReaction}
reactionClass="tooltip-right" />
</div> </div>
{#if !isMobile} {#if !isMobile}
<button <button
+82 -63
View File
@@ -1,20 +1,21 @@
<script lang="ts"> <script lang="ts">
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {onMount} from "svelte" import {onMount} from "svelte"
import {int, nthNe, MINUTE, sortBy, remove} from "@welshman/lib" import {int, nthNe, MINUTE, sortBy, remove, formatTimestampAsDate} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {createEvent, DIRECT_MESSAGE, INBOX_RELAYS} from "@welshman/util" import {createEvent, DIRECT_MESSAGE, INBOX_RELAYS} from "@welshman/util"
import { import {
pubkey, pubkey,
tagPubkey, tagPubkey,
formatTimestampAsDate, sendWrapped,
loadUsingOutbox,
inboxRelaySelectionsByPubkey, inboxRelaySelectionsByPubkey,
load,
} from "@welshman/app" } from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import PageBar from "@lib/components/PageBar.svelte" import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte" import Divider from "@lib/components/Divider.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ProfileName from "@app/components/ProfileName.svelte" import ProfileName from "@app/components/ProfileName.svelte"
@@ -23,19 +24,24 @@
import ProfileDetail from "@app/components/ProfileDetail.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ProfileList from "@app/components/ProfileList.svelte" import ProfileList from "@app/components/ProfileList.svelte"
import ChatMessage from "@app/components/ChatMessage.svelte" import ChatMessage from "@app/components/ChatMessage.svelte"
import ChatCompose from "@app/components/ChannelCompose.svelte" import ChatCompose from "@app/components/ChatCompose.svelte"
import ChatComposeParent from "@app/components/ChannelComposeParent.svelte" import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
import {userSettingValues, deriveChat, splitChatId, PLATFORM_NAME} from "@app/state" import {
INDEXER_RELAYS,
userSettingValues,
deriveChat,
splitChatId,
PLATFORM_NAME,
} from "@app/state"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {sendWrapped, prependParent} from "@app/commands" import {prependParent} from "@app/commands"
const { type Props = {
id,
info,
}: {
id: string id: string
info?: Snippet info?: Snippet
} = $props() }
const {id, info}: Props = $props()
const chat = deriveChat(id) const chat = deriveChat(id)
const pubkeys = splitChatId(id) const pubkeys = splitChatId(id)
@@ -105,16 +111,26 @@
onMount(() => { onMount(() => {
// Don't use loadInboxRelaySelection because we want to force reload // Don't use loadInboxRelaySelection because we want to force reload
load({filters: [{kinds: [INBOX_RELAYS], authors: others}]}) for (const pubkey of others) {
loadUsingOutbox({
pubkey,
kind: INBOX_RELAYS,
relays: INDEXER_RELAYS,
})
}
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
dynamicPadding!.style.minHeight = `${chatCompose!.offsetHeight}px` if (dynamicPadding && chatCompose) {
dynamicPadding.style.minHeight = `${chatCompose.offsetHeight}px`
}
}) })
observer.observe(chatCompose!) observer.observe(chatCompose!)
observer.observe(dynamicPadding!)
return () => { return () => {
observer.unobserve(chatCompose!) observer.unobserve(chatCompose!)
observer.unobserve(dynamicPadding!)
} }
}) })
@@ -123,56 +139,59 @@
}, 5000) }, 5000)
</script> </script>
{#if others.length > 0} <PageBar>
<PageBar class="chat__page-bar"> {#snippet title()}
{#snippet title()} <div class="flex flex-col gap-1 sm:flex-row sm:gap-2">
<div class="flex flex-col gap-1 sm:flex-row sm:gap-2"> {#if others.length === 0}
{#if others.length === 1} <div class="row-2">
{@const pubkey = others[0]} <ProfileCircle pubkey={$pubkey!} size={5} />
{@const onClick = () => pushModal(ProfileDetail, {pubkey})} <ProfileName pubkey={$pubkey!} />
<Button onclick={onClick} class="row-2"> </div>
<ProfileCircle {pubkey} size={5} /> {:else if others.length === 1}
<ProfileName {pubkey} /> {@const pubkey = others[0]}
</Button> {@const onClick = () => pushModal(ProfileDetail, {pubkey})}
{:else} <Button onclick={onClick} class="row-2">
<div class="flex items-center gap-2"> <ProfileCircle {pubkey} size={5} />
<ProfileCircles pubkeys={others} size={5} /> <ProfileName {pubkey} />
<p class="overflow-hidden text-ellipsis whitespace-nowrap"> </Button>
<ProfileName pubkey={others[0]} /> {:else}
and <div class="flex items-center gap-2">
{#if others.length === 2} <ProfileCircles pubkeys={others} size={5} />
<ProfileName pubkey={others[1]} /> <p class="overflow-hidden text-ellipsis whitespace-nowrap">
{:else} <ProfileName pubkey={others[0]} />
{others.length - 1} and
{others.length > 2 ? "others" : "other"} {#if others.length === 2}
{/if} <ProfileName pubkey={others[1]} />
</p> {:else}
</div> {others.length - 1}
{#if others.length > 2} {others.length > 2 ? "others" : "other"}
<Button onclick={showMembers} class="btn btn-link hidden sm:block" {/if}
>Show all members</Button> </p>
{/if} </div>
{#if others.length > 2}
<Button onclick={showMembers} class="btn btn-link hidden sm:block"
>Show all members</Button>
{/if} {/if}
</div> {/if}
{/snippet} </div>
{#snippet action()} {/snippet}
<div> {#snippet action()}
{#if remove($pubkey, missingInboxes).length > 0} <div>
{@const count = remove($pubkey, missingInboxes).length} {#if remove($pubkey, missingInboxes).length > 0}
{@const label = count > 1 ? "inboxes are" : "inbox is"} {@const count = remove($pubkey, missingInboxes).length}
<div {@const label = count > 1 ? "inboxes are" : "inbox is"}
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer" <div
data-tip="{count} {label} not configured."> class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer"
<Icon icon="danger" /> data-tip="{count} {label} not configured.">
{count} <Icon icon="danger" />
</div> {count}
{/if} </div>
</div> {/if}
{/snippet} </div>
</PageBar> {/snippet}
{/if} </PageBar>
<div class="chat__messages scroll-container"> <PageContent class="flex flex-col-reverse pt-4">
<div bind:this={dynamicPadding}></div> <div bind:this={dynamicPadding}></div>
{#if missingInboxes.includes($pubkey!)} {#if missingInboxes.includes($pubkey!)}
<div class="py-12"> <div class="py-12">
@@ -223,7 +242,7 @@
</Spinner> </Spinner>
{@render info?.()} {@render info?.()}
</p> </p>
</div> </PageContent>
<div class="chat__compose bg-base-200" bind:this={chatCompose}> <div class="chat__compose bg-base-200" bind:this={chatCompose}>
<div> <div>
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import {writable} from "svelte/store"
import type {EventContent} from "@welshman/util"
import {isMobile, preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
type Props = {
url?: string
onSubmit: (event: EventContent) => void
}
const {onSubmit, url}: Props = $props()
const autofocus = !isMobile
const uploading = writable(false)
export const focus = () => editor.then(ed => ed.chain().focus().run())
const submit = async () => {
if ($uploading) return
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = ed.storage.nostr.getEditorTags()
if (!content) return
onSubmit({content, tags})
ed.chain().clearContent().run()
}
const editor = makeEditor({
url,
autofocus,
submit,
uploading,
aggressive: true,
disableFileUpload: true,
})
</script>
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
<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}
onclick={submit}>
<Icon icon="plain" />
</Button>
</form>
@@ -0,0 +1,35 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {displayProfileByPubkey} from "@welshman/app"
import {slide} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
const {
verb,
event,
clear,
}: {
verb: string
event: TrustedEvent
clear: () => void
} = $props()
</script>
<div
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8 text-xs"
transition:slide>
<p class="text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p>
{#key event.id}
<NoteContent
{event}
hideMediaAtDepth={0}
minLength={100}
maxLength={300}
expandMode="disabled" />
{/key}
<Button class="absolute right-2 top-2 cursor-pointer" onclick={clear}>
<Icon icon="close-circle" />
</Button>
</div>
+3 -1
View File
@@ -13,6 +13,8 @@
const {next} = $props() const {next} = $props()
const nextUrl = $state.snapshot(next)
let loading = $state(false) let loading = $state(false)
const enableChat = async () => { const enableChat = async () => {
@@ -23,7 +25,7 @@
} }
clearModals() clearModals()
goto(next) goto(nextUrl)
} }
const submit = async () => { const submit = async () => {
+1 -1
View File
@@ -40,7 +40,7 @@
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2"> <div class="flex min-w-0 items-center gap-2">
{#if others.length === 0} {#if others.length === 0}
<ProfileCircle pubkey={$pubkey} size={5} /> <ProfileCircle pubkey={$pubkey!} size={5} />
Note to self Note to self
{:else if others.length === 1} {:else if others.length === 1}
<ProfileCircle pubkey={others[0]} size={5} /> <ProfileCircle pubkey={others[0]} size={5} />
+10 -17
View File
@@ -1,14 +1,8 @@
<script lang="ts"> <script lang="ts">
import {type Instance} from "tippy.js" import {type Instance} from "tippy.js"
import {hash} from "@welshman/lib" import {hash, formatTimestampAsTime} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import { import {thunks, pubkey, deriveProfile, deriveProfileDisplay, sendWrapped} from "@welshman/app"
thunks,
deriveProfile,
deriveProfileDisplay,
formatTimestampAsTime,
pubkey,
} from "@welshman/app"
import {isMobile} from "@lib/html" import {isMobile} from "@lib/html"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -22,7 +16,7 @@
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte" import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte" import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte"
import {colors} from "@app/state" import {colors} from "@app/state"
import {makeDelete, makeReaction, sendWrapped} from "@app/commands" import {makeDelete, makeReaction} from "@app/commands"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
interface Props { interface Props {
@@ -42,12 +36,11 @@
const reply = () => replyTo(event) const reply = () => replyTo(event)
const onReactionClick = async (content: string, events: TrustedEvent[]) => { const deleteReaction = (event: TrustedEvent) =>
const reaction = events.find(e => e.pubkey === $pubkey) sendWrapped({template: makeDelete({event}), pubkeys})
const template = reaction ? makeDelete({event: reaction}) : makeReaction({event, content})
await sendWrapped({template, pubkeys}) const createReaction = (template: EventContent) =>
} sendWrapped({template: makeReaction({event, ...template}), pubkeys})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey}) const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
@@ -125,8 +118,8 @@
<Content showEntire {event} /> <Content showEntire {event} />
</div> </div>
</TapTarget> </TapTarget>
<div class="row-2 z-feature -mt-1 ml-4"> <div class="row-2 z-feature -mt-4 ml-4">
<ReactionSummary {event} {onReactionClick} noTooltip /> <ReactionSummary {event} {deleteReaction} {createReaction} noTooltip />
</div> </div>
</div> </div>
</div> </div>
@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared" import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {sendWrapped} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte" import EmojiButton from "@lib/components/EmojiButton.svelte"
import {makeReaction, sendWrapped} from "@app/commands" import {makeReaction} from "@app/commands"
interface Props { interface Props {
event: TrustedEvent event: TrustedEvent
@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared" import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {sendWrapped} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte" import EmojiPicker from "@lib/components/EmojiPicker.svelte"
import EventInfo from "@app/components/EventInfo.svelte" import EventInfo from "@app/components/EventInfo.svelte"
import {makeReaction, sendWrapped} from "@app/commands" import {makeReaction} from "@app/commands"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {clip} from "@app/toast" import {clip} from "@app/toast"
@@ -17,10 +18,10 @@
const {event, pubkeys, reply}: Props = $props() const {event, pubkeys, reply}: Props = $props()
const onEmoji = ((event: TrustedEvent, emoji: NativeEmoji) => { const onEmoji = ((event: TrustedEvent, pubkeys: string[], emoji: NativeEmoji) => {
history.back() history.back()
sendWrapped({template: makeReaction({event, content: emoji.unicode}), pubkeys}) sendWrapped({template: makeReaction({event, content: emoji.unicode}), pubkeys})
}).bind(undefined, event) }).bind(undefined, event, pubkeys)
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true}) const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
+9 -5
View File
@@ -6,6 +6,7 @@
truncate, truncate,
renderAsHtml, renderAsHtml,
isText, isText,
isEmoji,
isTopic, isTopic,
isCode, isCode,
isCashu, isCashu,
@@ -22,6 +23,7 @@
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ContentToken from "@app/components/ContentToken.svelte" import ContentToken from "@app/components/ContentToken.svelte"
import ContentEmoji from "@app/components/ContentEmoji.svelte"
import ContentCode from "@app/components/ContentCode.svelte" import ContentCode from "@app/components/ContentCode.svelte"
import ContentLinkInline from "@app/components/ContentLinkInline.svelte" import ContentLinkInline from "@app/components/ContentLinkInline.svelte"
import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte" import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte"
@@ -38,8 +40,8 @@
showEntire?: boolean showEntire?: boolean
hideMediaAtDepth?: number hideMediaAtDepth?: number
expandMode?: string expandMode?: string
relays?: string[]
depth?: number depth?: number
url?: string
} }
let { let {
@@ -49,8 +51,8 @@
showEntire = $bindable(false), showEntire = $bindable(false),
hideMediaAtDepth = 1, hideMediaAtDepth = 1,
expandMode = "block", expandMode = "block",
relays = [],
depth = 0, depth = 0,
url,
}: Props = $props() }: Props = $props()
const fullContent = parse(event) const fullContent = parse(event)
@@ -133,6 +135,8 @@
<ContentNewline value={parsed.value} /> <ContentNewline value={parsed.value} />
{:else if isTopic(parsed)} {:else if isTopic(parsed)}
<ContentTopic value={parsed.value} /> <ContentTopic value={parsed.value} />
{:else if isEmoji(parsed)}
<ContentEmoji value={parsed.value} />
{:else if isCode(parsed)} {:else if isCode(parsed)}
<ContentCode <ContentCode
value={parsed.value} value={parsed.value}
@@ -141,15 +145,15 @@
<ContentToken value={parsed.value} /> <ContentToken value={parsed.value} />
{:else if isLink(parsed)} {:else if isLink(parsed)}
{#if isBlock(i)} {#if isBlock(i)}
<ContentLinkBlock value={parsed.value} /> <ContentLinkBlock value={parsed.value} {event} />
{:else} {:else}
<ContentLinkInline value={parsed.value} /> <ContentLinkInline value={parsed.value} />
{/if} {/if}
{:else if isProfile(parsed)} {:else if isProfile(parsed)}
<ContentMention value={parsed.value} /> <ContentMention value={parsed.value} {url} />
{:else if isEvent(parsed) || isAddress(parsed)} {:else if isEvent(parsed) || isAddress(parsed)}
{#if isBlock(i)} {#if isBlock(i)}
<ContentQuote {depth} {relays} {hideMediaAtDepth} value={parsed.value} {event} /> <ContentQuote {depth} {url} {hideMediaAtDepth} value={parsed.value} {event} />
{:else} {:else}
<Link <Link
external external
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import type {ParsedEmojiValue} from "@welshman/content"
import {imgproxy} from "@app/state"
export let value: ParsedEmojiValue
const alt = `:${value.name}:`
</script>
{#if value.url}
<img
{alt}
src={imgproxy(value.url, {w: 24, h: 24})}
class="-mt-0.5 inline h-[1em] min-w-[1em] align-middle" />
{:else}
{alt}
{/if}
+4 -3
View File
@@ -4,9 +4,10 @@
import {preventDefault, stopPropagation} from "@lib/html" import {preventDefault, stopPropagation} from "@lib/html"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte" import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
const {value} = $props() const {value, event} = $props()
let hideImage = $state(false) let hideImage = $state(false)
@@ -26,7 +27,7 @@
hideImage = true hideImage = true
} }
const expand = () => pushModal(ContentLinkDetail, {url}, {fullscreen: true}) const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
</script> </script>
<Link external href={url} class="my-2 block"> <Link external href={url} class="my-2 block">
@@ -37,7 +38,7 @@
</video> </video>
{:else if url.match(/\.(jpe?g|png|gif|webp)$/)} {:else if url.match(/\.(jpe?g|png|gif|webp)$/)}
<button type="button" onclick={stopPropagation(preventDefault(expand))}> <button type="button" onclick={stopPropagation(preventDefault(expand))}>
<img alt="Link preview" src={imgproxy(url)} class="m-auto max-h-96 rounded-box" /> <ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-box" />
</button> </button>
{:else} {:else}
{#await loadPreview()} {#await loadPreview()}
@@ -0,0 +1,49 @@
<script lang="ts">
import {onDestroy} from "svelte"
import {now} from "@welshman/lib"
import {BLOSSOM_AUTH, makeEvent, getTags, getTagValue, tagsFromIMeta} from "@welshman/util"
import {signer} from "@welshman/app"
import {imgproxy} from "@app/state"
const {value, event, ...props} = $props()
const url = value.url.toString()
// If we fail to fetch the image, try authenticating if we have a blossom hash
const onerror = async () => {
const meta = getTags("imeta", event.tags)
.map(tagsFromIMeta)
.find(meta => getTagValue("url", meta) === url)
const hash = meta ? getTagValue("x", meta) : undefined
if (hash && $signer) {
const event = await signer.get().sign(
makeEvent(BLOSSOM_AUTH, {
tags: [
["t", "get"],
["x", hash],
["expiration", String(now() + 30)],
],
}),
)
const res = await fetch(url, {
headers: {
Authorization: `Nostr ${btoa(JSON.stringify(event))}`,
},
})
if (res.status === 200) {
src = URL.createObjectURL(await res.blob())
}
}
}
let src = $state(imgproxy(url))
onDestroy(() => {
URL.revokeObjectURL(src)
})
</script>
<img alt="" {src} {onerror} {...props} />
+3 -3
View File
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import {imgproxy} from "@app/state"
const {url} = $props() const {value, event} = $props()
const back = () => history.back() const back = () => history.back()
</script> </script>
<Button class="m-auto h-screen w-screen cursor-pointer p-4" onclick={back}> <Button class="m-auto h-screen w-screen cursor-pointer p-4" onclick={back}>
<img alt="" src={imgproxy(url)} class="m-auto max-h-full max-w-full rounded-box" /> <ContentLinkBlockImage {value} {event} class="m-auto max-h-full max-w-full rounded-box" />
</Button> </Button>
+10 -3
View File
@@ -1,15 +1,22 @@
<script lang="ts"> <script lang="ts">
import {removeNil} from "@welshman/lib"
import type {ProfilePointer} from "@welshman/content"
import {displayProfile} from "@welshman/util" import {displayProfile} from "@welshman/util"
import {deriveProfile} from "@welshman/app" import {deriveProfile} from "@welshman/app"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
const {value} = $props() type Props = {
value: ProfilePointer
url?: string
}
const profile = deriveProfile(value.pubkey) const {value, url}: Props = $props()
const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey}) const profile = deriveProfile(value.pubkey, removeNil([url]))
const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey, url})
</script> </script>
<Button onclick={openProfile} class="link-content"> <Button onclick={openProfile} class="link-content">
+24 -33
View File
@@ -1,9 +1,12 @@
<script lang="ts"> <script lang="ts">
import {nip19} from "nostr-tools" import * as nip19 from "nostr-tools/nip19"
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {ctx, nthEq} from "@welshman/lib" import {nthEq} from "@welshman/lib"
import {Router} from "@welshman/router"
import {tracker, repository} from "@welshman/app" import {tracker, repository} from "@welshman/app"
import type {TrustedEvent} from "@welshman/util"
import {Address, DIRECT_MESSAGE, MESSAGE, THREAD, EVENT_TIME} from "@welshman/util" import {Address, DIRECT_MESSAGE, MESSAGE, THREAD, EVENT_TIME} from "@welshman/util"
import {scrollToEvent} from "@lib/html"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import NoteCard from "@app/components/NoteCard.svelte" import NoteCard from "@app/components/NoteCard.svelte"
@@ -11,47 +14,35 @@
import {deriveEvent, entityLink, ROOM} from "@app/state" import {deriveEvent, entityLink, ROOM} from "@app/state"
import {makeThreadPath, makeCalendarPath, makeRoomPath} from "@app/routes" import {makeThreadPath, makeCalendarPath, makeRoomPath} from "@app/routes"
const {value, event, depth, hideMediaAtDepth, relays = []} = $props() type Props = {
value: any
hideMediaAtDepth: number
event: TrustedEvent
depth: number
url?: string
}
const {id, identifier, kind, pubkey, relays: relayHints = []} = value const {value, event, depth, hideMediaAtDepth, url}: Props = $props()
const {id, identifier, kind, pubkey, relays = []} = value
const idOrAddress = id || new Address(kind, pubkey, identifier).toString() const idOrAddress = id || new Address(kind, pubkey, identifier).toString()
const mergedRelays = [ const mergedRelays = Router.get().Quote(event, idOrAddress, relays).getUrls()
...relays,
...ctx.app.router.Quote(event, idOrAddress, relayHints).getUrls(), if (url) {
] mergedRelays.push(url)
}
const quote = deriveEvent(idOrAddress, mergedRelays) const quote = deriveEvent(idOrAddress, mergedRelays)
const entity = id const entity = id
? nip19.neventEncode({id, relays: mergedRelays}) ? nip19.neventEncode({id, relays: mergedRelays})
: new Address(kind, pubkey, identifier, mergedRelays).toNaddr() : new Address(kind, pubkey, identifier, mergedRelays).toNaddr()
const scrollToEvent = (id: string) => {
const element = document.querySelector(`[data-event="${id}"]`) as any
if (element) {
element.scrollIntoView({behavior: "smooth"})
element.style =
"filter: brightness(1.5); transition-property: all; transition-duration: 400ms;"
setTimeout(() => {
element.style = "transition-property: all; transition-duration: 300ms;"
}, 800)
setTimeout(() => {
element.style = ""
}, 800 + 400)
}
return Boolean(element)
}
const openMessage = (url: string, room: string, id: string) => { const openMessage = (url: string, room: string, id: string) => {
const event = repository.getEvent(id) const event = repository.getEvent(id)
if (event) { if (event) {
goto(makeRoomPath(url, room)) goto(makeRoomPath(url, room))
scrollToEvent(id)
// TODO: if the event doesn't immediately load, this won't work. Scroll up until it's found
setTimeout(() => scrollToEvent(id), 300)
} }
return Boolean(event) return Boolean(event)
@@ -104,8 +95,8 @@
<Button class="my-2 block max-w-full text-left" {onclick}> <Button class="my-2 block max-w-full text-left" {onclick}>
{#if $quote} {#if $quote}
<NoteCard event={$quote} class="bg-alt rounded-box p-4"> <NoteCard event={$quote} {url} class="bg-alt rounded-box p-4">
<NoteContent {hideMediaAtDepth} {relays} event={$quote} depth={depth + 1} /> <NoteContent {hideMediaAtDepth} {url} event={$quote} depth={depth + 1} />
</NoteCard> </NoteCard>
{:else} {:else}
<div class="rounded-box p-4"> <div class="rounded-box p-4">
+3 -2
View File
@@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {max} from "@welshman/lib" import {max, formatTimestampRelative} from "@welshman/lib"
import {COMMENT} from "@welshman/util" import {COMMENT} from "@welshman/util"
import {load} from "@welshman/net"
import {deriveEvents} from "@welshman/store" import {deriveEvents} from "@welshman/store"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {formatTimestampRelative, repository, load} from "@welshman/app" import {repository} from "@welshman/app"
import {notifications} from "@app/notifications" import {notifications} from "@app/notifications"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
+3 -3
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import {nip19} from "nostr-tools" import * as nip19 from "nostr-tools/nip19"
import {ctx} from "@welshman/lib" import {Router} from "@welshman/router"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import FieldInline from "@lib/components/FieldInline.svelte" import FieldInline from "@lib/components/FieldInline.svelte"
@@ -15,7 +15,7 @@
const {url, event}: Props = $props() const {url, event}: Props = $props()
const relays = url ? [url] : ctx.app.router.Event(event).getUrls() const relays = url ? [url] : Router.get().Event(event).getUrls()
const nevent1 = nip19.neventEncode({...event, relays}) const nevent1 = nip19.neventEncode({...event, relays})
const npub1 = nip19.npubEncode(event.pubkey) const npub1 = nip19.npubEncode(event.pubkey)
const json = JSON.stringify(event, null, 2) const json = JSON.stringify(event, null, 2)
+50 -24
View File
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte"
import {writable} from "svelte/store" import {writable} from "svelte/store"
import {isMobile, preventDefault} from "@lib/html" import {isMobile, preventDefault} from "@lib/html"
import {fly, slideAndFade} from "@lib/transition" import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
@@ -15,11 +16,14 @@
const uploading = writable(false) const uploading = writable(false)
const submit = () => { const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => {
if ($uploading) return if ($uploading) return
const content = editor.getText({blockSeparator: "\n"}).trim() const ed = await editor
const tags = [...editor.storage.nostr.getEditorTags(), tagRoom(GENERAL, url), PROTECTED] const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = [...ed.storage.nostr.getEditorTags(), tagRoom(GENERAL, url), PROTECTED]
if (!content) { if (!content) {
return pushToast({ return pushToast({
@@ -31,31 +35,53 @@
onSubmit(publishComment({event, content, tags, relays: [url]})) onSubmit(publishComment({event, content, tags, relays: [url]}))
} }
const editor = makeEditor({submit, uploading, autofocus: !isMobile}) const editor = makeEditor({url, submit, uploading, autofocus: !isMobile})
let form: HTMLElement
let spacer: HTMLElement
onMount(() => {
setTimeout(() => {
spacer.scrollIntoView({block: "end", behavior: "smooth"})
})
const observer = new ResizeObserver(() => {
spacer!.style.minHeight = `${form!.offsetHeight}px`
})
observer.observe(form!)
return () => {
observer.unobserve(form!)
}
})
</script> </script>
<div bind:this={spacer}></div>
<form <form
in:fly in:fly
out:slideAndFade bind:this={form}
onsubmit={preventDefault(submit)} onsubmit={preventDefault(submit)}
class="card2 sticky bottom-2 z-feature mx-2 mt-4 bg-neutral"> class="cb cw fixed z-feature -mx-2 pt-3">
<div class="relative"> <div class="card2 mx-2 my-2 bg-neutral">
<div class="note-editor flex-grow overflow-hidden"> <div class="relative">
<EditorContent {editor} /> <div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
<Button
data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2"
onclick={selectFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="paperclip" size={3} />
{/if}
</Button>
</div> </div>
<Button <ModalFooter>
data-tip="Add an image" <Button class="btn btn-link" onclick={onClose}>Cancel</Button>
class="tooltip tooltip-left absolute bottom-1 right-2" <Button type="submit" class="btn btn-primary">Post Reply</Button>
onclick={editor.commands.selectFiles}> </ModalFooter>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="paperclip" size={3} />
{/if}
</Button>
</div> </div>
<ModalFooter>
<Button class="btn btn-link" onclick={onClose}>Cancel</Button>
<Button type="submit" class="btn btn-primary">Post Reply</Button>
</ModalFooter>
</form> </form>
+1 -1
View File
@@ -42,7 +42,7 @@
<div class="column gap-2"> <div class="column gap-2">
<div class="flex justify-between"> <div class="flex justify-between">
<div> <div>
<Profile pubkey={report.pubkey} /> <Profile pubkey={report.pubkey} {url} />
<span>Reported this event as "{reason}"</span> <span>Reported this event as "{reason}"</span>
</div> </div>
{#if report.pubkey === $pubkey} {#if report.pubkey === $pubkey}
+4 -4
View File
@@ -2,7 +2,7 @@
import {onMount} from "svelte" import {onMount} from "svelte"
import {Capacitor} from "@capacitor/core" import {Capacitor} from "@capacitor/core"
import {getNip07, getNip55, Nip55Signer} from "@welshman/signer" import {getNip07, getNip55, Nip55Signer} from "@welshman/signer"
import {addSession, type Session} from "@welshman/app" import {addSession, type Session, makeNip07Session, makeNip55Session} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -24,7 +24,7 @@
const signUp = () => pushModal(SignUp) const signUp = () => pushModal(SignUp)
const onSuccess = async (session: Session, relays: string[] = []) => { const onSuccess = async (session: Session, relays: string[] = []) => {
await loadUserData(session.pubkey, {relays}) await loadUserData(session.pubkey, relays)
addSession(session) addSession(session)
pushToast({message: "Successfully logged in!"}) pushToast({message: "Successfully logged in!"})
@@ -39,7 +39,7 @@
const pubkey = await getNip07()?.getPublicKey() const pubkey = await getNip07()?.getPublicKey()
if (pubkey) { if (pubkey) {
await onSuccess({method: "nip07", pubkey}) await onSuccess(makeNip07Session(pubkey))
} else { } else {
pushToast({ pushToast({
theme: "error", theme: "error",
@@ -59,7 +59,7 @@
const pubkey = await signer.getPubkey() const pubkey = await signer.getPubkey()
if (pubkey) { if (pubkey) {
await onSuccess({method: "nip55", pubkey, signer: app.packageName}) await onSuccess(makeNip55Session(pubkey, app.packageName))
} else { } else {
pushToast({ pushToast({
theme: "error", theme: "error",
+46 -107
View File
@@ -1,38 +1,39 @@
<script lang="ts"> <script lang="ts">
import {onMount, onDestroy} from "svelte" import type {Nip46ResponseWithResult} from "@welshman/signer"
import {Nip46Broker, getPubkey, makeSecret} from "@welshman/signer" import {Nip46Broker, makeSecret} from "@welshman/signer"
import {addSession} from "@welshman/app" import {loginWithNip01, loginWithNip46} from "@welshman/app"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import {slideAndFade} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import QRCode from "@app/components/QRCode.svelte" import BunkerConnect, {BunkerConnectController} from "@app/components/BunkerConnect.svelte"
import InfoBunker from "@app/components/InfoBunker.svelte" import BunkerUrl from "@app/components/BunkerUrl.svelte"
import {loginWithNip46} from "@app/commands"
import {loadUserData} from "@app/requests" import {loadUserData} from "@app/requests"
import {pushModal, clearModals} from "@app/modal" import {clearModals} from "@app/modal"
import {setChecked} from "@app/notifications" import {setChecked} from "@app/notifications"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {NIP46_PERMS, PLATFORM_URL, PLATFORM_NAME, PLATFORM_LOGO, SIGNER_RELAYS} from "@app/state" import {SIGNER_RELAYS, NIP46_PERMS} from "@app/state"
const clientSecret = makeSecret()
const abortController = new AbortController()
const broker = Nip46Broker.get({clientSecret, relays: SIGNER_RELAYS})
const back = () => history.back() const back = () => history.back()
const onSubmit = async () => { const controller = new BunkerConnectController({
const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(bunker) onNostrConnect: async (response: Nip46ResponseWithResult) => {
const pubkey = await controller.broker.getPublicKey()
if (loading) { await loadUserData(pubkey)
return
} loginWithNip46(pubkey, controller.clientSecret, response.event.pubkey, SIGNER_RELAYS)
setChecked("*")
clearModals()
},
})
const onSubmit = async () => {
if (controller.loading) return
const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(controller.bunker)
if (!signerPubkey || relays.length === 0) { if (!signerPubkey || relays.length === 0) {
return pushToast({ return pushToast({
@@ -41,13 +42,22 @@
}) })
} }
loading = true controller.loading = true
try { try {
const success = await loginWithNip46({connectSecret, clientSecret, signerPubkey, relays}) const {clientSecret} = controller
const broker = new Nip46Broker({relays, clientSecret, signerPubkey})
const result = await broker.connect(connectSecret, NIP46_PERMS)
const pubkey = await broker.getPublicKey()
if (success) { // TODO: remove ack result
abortController.abort() if (pubkey && ["ack", connectSecret].includes(result)) {
broker.cleanup()
controller.stop()
await loadUserData(pubkey)
loginWithNip46(pubkey, clientSecret, signerPubkey, relays)
} else { } else {
return pushToast({ return pushToast({
theme: "error", theme: "error",
@@ -55,72 +65,18 @@
}) })
} }
} finally { } finally {
loading = false controller.loading = false
} }
clearModals() clearModals()
} }
let url = $state("")
let bunker = $state("")
let loading = $state(false)
$effect(() => { $effect(() => {
// For testing and for play store reviewers // For testing and for play store reviewers
if (bunker === "reviewkey") { if (controller.bunker === "reviewkey") {
const secret = makeSecret() loginWithNip01(makeSecret())
addSession({method: "nip01", secret, pubkey: getPubkey(secret)})
} }
}) })
onMount(async () => {
url = await broker.makeNostrconnectUrl({
perms: NIP46_PERMS,
url: PLATFORM_URL,
name: PLATFORM_NAME,
image: PLATFORM_LOGO,
})
let response
try {
response = await broker.waitForNostrconnect(url, abortController)
} catch (errorResponse: any) {
if (errorResponse?.error) {
pushToast({
theme: "error",
message: `Received error from signer: ${errorResponse.error}`,
})
} else if (errorResponse) {
console.error(errorResponse)
}
}
if (response) {
loading = true
const userPubkey = await broker.getPublicKey()
await loadUserData(userPubkey)
addSession({
method: "nip46",
pubkey: userPubkey,
secret: clientSecret,
handler: {
pubkey: response.event.pubkey,
relays: SIGNER_RELAYS,
},
})
setChecked("*")
clearModals()
}
})
onDestroy(() => {
abortController.abort()
})
</script> </script>
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}> <form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
@@ -132,35 +88,18 @@
<div>Connect your signer by scanning the QR code below or pasting a bunker link.</div> <div>Connect your signer by scanning the QR code below or pasting a bunker link.</div>
{/snippet} {/snippet}
</ModalHeader> </ModalHeader>
{#if !loading && url} <BunkerConnect {controller} />
<div class="flex justify-center" out:slideAndFade> <BunkerUrl loading={controller.loading} bind:bunker={controller.bunker} />
<QRCode code={url} />
</div>
{/if}
<Field>
{#snippet label()}
<p>Bunker Link*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="cpu" />
<input disabled={loading} bind:value={bunker} class="grow" placeholder="bunker://" />
</label>
{/snippet}
{#snippet info()}
<p>
A login link provided by a nostr signing app.
<Button class="link" onclick={() => pushModal(InfoBunker)}>What is a bunker link?</Button>
</p>
{/snippet}
</Field>
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}> <Button class="btn btn-link" onclick={back} disabled={controller.loading}>
<Icon icon="alt-arrow-left" /> <Icon icon="alt-arrow-left" />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary" disabled={loading || !bunker}> <Button
<Spinner {loading}>Next</Spinner> type="submit"
class="btn btn-primary"
disabled={controller.loading || !controller.bunker}>
<Spinner loading={controller.loading}>Next</Spinner>
<Icon icon="alt-arrow-right" /> <Icon icon="alt-arrow-right" />
</Button> </Button>
</ModalFooter> </ModalFooter>
+8 -13
View File
@@ -3,7 +3,7 @@
import {postJson, stripProtocol} from "@welshman/lib" import {postJson, stripProtocol} from "@welshman/lib"
import {Nip46Broker, makeSecret} from "@welshman/signer" import {Nip46Broker, makeSecret} from "@welshman/signer"
import {normalizeRelayUrl} from "@welshman/util" import {normalizeRelayUrl} from "@welshman/util"
import {addSession} from "@welshman/app" import {addSession, makeNip46Session} from "@welshman/app"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -34,7 +34,7 @@
? [normalizeRelayUrl("ws://" + stripProtocol(BURROW_URL))] ? [normalizeRelayUrl("ws://" + stripProtocol(BURROW_URL))]
: [normalizeRelayUrl(BURROW_URL)] : [normalizeRelayUrl(BURROW_URL)]
const broker = Nip46Broker.get({clientSecret, relays}) const broker = new Nip46Broker({clientSecret, relays})
const back = () => history.back() const back = () => history.back()
@@ -68,7 +68,7 @@
let response let response
try { try {
response = await broker.waitForNostrconnect(url, abortController) response = await broker.waitForNostrconnect(url, abortController.signal)
} catch (errorResponse: any) { } catch (errorResponse: any) {
if (errorResponse?.error) { if (errorResponse?.error) {
pushToast({ pushToast({
@@ -83,18 +83,13 @@
if (response) { if (response) {
loading = true loading = true
const userPubkey = await broker.getPublicKey() const pubkey = await broker.getPublicKey()
const session = makeNip46Session(pubkey, clientSecret, response.event.pubkey, relays)
await loadUserData(userPubkey) await loadUserData(pubkey)
addSession({
email,
method: "nip46",
pubkey: userPubkey,
secret: clientSecret,
handler: {pubkey: response.event.pubkey, relays},
})
addSession({...session, email})
broker.cleanup()
setChecked("*") setChecked("*")
clearModals() clearModals()
} }
+15
View File
@@ -0,0 +1,15 @@
<script lang="ts">
import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte"
type Props = {
urls: string[]
}
const {urls}: Props = $props()
</script>
<div class="column menu gap-2">
{#each urls as url (url)}
<MenuSpacesItem {url} />
{/each}
</div>
+2 -4
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {displayRelayUrl, GROUP_META} from "@welshman/util" import {displayRelayUrl} from "@welshman/util"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -22,7 +22,6 @@
deriveOtherRooms, deriveOtherRooms,
} from "@app/state" } from "@app/state"
import {notifications} from "@app/notifications" import {notifications} from "@app/notifications"
import {pullConservatively} from "@app/requests"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {makeSpacePath} from "@app/routes" import {makeSpacePath} from "@app/routes"
@@ -44,7 +43,7 @@
const showMembers = () => const showMembers = () =>
pushModal( pushModal(
ProfileList, ProfileList,
{pubkeys: members, title: `Members of`, subtitle: displayRelayUrl(url)}, {url, pubkeys: members, title: `Members of`, subtitle: displayRelayUrl(url)},
{replaceState}, {replaceState},
) )
@@ -66,7 +65,6 @@
onMount(() => { onMount(() => {
replaceState = Boolean(element?.closest(".drawer")) replaceState = Boolean(element?.closest(".drawer"))
pullConservatively({relays: [url], filters: [{kinds: [GROUP_META]}]})
}) })
</script> </script>
+3 -10
View File
@@ -10,26 +10,19 @@
} }
} }
let modal: any = $state.raw()
const hash = $derived($page.url.hash.slice(1)) const hash = $derived($page.url.hash.slice(1))
const hashIsValid = $derived(Boolean($modals[hash])) const modal = $derived($modals[hash])
$effect(() => {
if ($modals[hash]) {
modal = $modals[hash]
}
})
</script> </script>
<svelte:window onkeydown={onKeyDown} /> <svelte:window onkeydown={onKeyDown} />
{#if hashIsValid && modal?.options?.drawer} {#if modal?.options?.drawer}
<Drawer onClose={clearModals} {...modal.options}> <Drawer onClose={clearModals} {...modal.options}>
{#key modal.id} {#key modal.id}
<modal.component {...modal.props} /> <modal.component {...modal.props} />
{/key} {/key}
</Drawer> </Drawer>
{:else if hashIsValid && modal} {:else if modal}
<Dialog onClose={clearModals} {...modal.options}> <Dialog onClose={clearModals} {...modal.options}>
{#key modal.id} {#key modal.id}
<modal.component {...modal.props} /> <modal.component {...modal.props} />
+9 -6
View File
@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames" import cx from "classnames"
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {nip19} from "nostr-tools" import * as nip19 from "nostr-tools/nip19"
import {ctx} from "@welshman/lib" import {formatTimestamp} from "@welshman/lib"
import {getListTags, getPubkeyTagValues} from "@welshman/util" import {getListTags, getPubkeyTagValues} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {formatTimestamp, userMutes} from "@welshman/app" import {Router} from "@welshman/router"
import {userMutes} from "@welshman/app"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -18,16 +19,18 @@
children, children,
minimal = false, minimal = false,
hideProfile = false, hideProfile = false,
url,
...restProps ...restProps
}: { }: {
event: TrustedEvent event: TrustedEvent
children: Snippet children: Snippet
minimal?: boolean minimal?: boolean
hideProfile?: boolean hideProfile?: boolean
url?: string
class?: string class?: string
} = $props() } = $props()
const relays = ctx.app.router.Event(event).getUrls() const relays = Router.get().Event(event).getUrls()
const nevent = nip19.neventEncode({id: event.id, relays}) const nevent = nip19.neventEncode({id: event.id, relays})
const ignoreMute = () => { const ignoreMute = () => {
@@ -50,9 +53,9 @@
<div class="flex justify-between gap-2"> <div class="flex justify-between gap-2">
{#if !hideProfile} {#if !hideProfile}
{#if minimal} {#if minimal}
@<ProfileName pubkey={event.pubkey} /> @<ProfileName pubkey={event.pubkey} {url} />
{:else} {:else}
<Profile pubkey={event.pubkey} /> <Profile pubkey={event.pubkey} {url} />
{/if} {/if}
{/if} {/if}
<Link <Link
+6 -12
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared" import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte" import EmojiButton from "@lib/components/EmojiButton.svelte"
import NoteContent from "@app/components/NoteContent.svelte" import NoteContent from "@app/components/NoteContent.svelte"
@@ -11,24 +10,19 @@
const {url, event} = $props() const {url, event} = $props()
const onReactionClick = (content: string, events: TrustedEvent[]) => { const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
const reaction = events.find(e => e.pubkey === $pubkey)
if (reaction) { const createReaction = (template: EventContent) =>
publishDelete({relays: [url], event: reaction}) publishReaction({...template, event, relays: [url]})
} else {
publishReaction({event, content, relays: [url]})
}
}
const onEmoji = (emoji: NativeEmoji) => const onEmoji = (emoji: NativeEmoji) =>
publishReaction({event, content: emoji.unicode, relays: [url]}) publishReaction({event, content: emoji.unicode, relays: [url]})
</script> </script>
<NoteCard {event} class="card2 bg-alt"> <NoteCard {event} {url} class="card2 bg-alt">
<NoteContent {event} expandMode="inline" /> <NoteContent {event} expandMode="inline" />
<div class="flex w-full justify-between gap-2"> <div class="flex w-full justify-between gap-2">
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-right"> <ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-right">
<EmojiButton {onEmoji} class="btn btn-neutral btn-xs h-[26px] rounded-box"> <EmojiButton {onEmoji} class="btn btn-neutral btn-xs h-[26px] rounded-box">
<Icon icon="smile-circle" size={4} /> <Icon icon="smile-circle" size={4} />
</EmojiButton> </EmojiButton>
+13 -6
View File
@@ -1,16 +1,23 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {ctx} from "@welshman/lib" import {formatTimestampRelative} from "@welshman/lib"
import type {Filter} from "@welshman/util" import type {Filter} from "@welshman/util"
import {deriveEvents} from "@welshman/store" import {deriveEvents} from "@welshman/store"
import {repository, load, loadRelaySelections, formatTimestampRelative} from "@welshman/app" import {load} from "@welshman/net"
import {Router} from "@welshman/router"
import {repository, loadRelaySelections} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Profile from "@app/components/Profile.svelte" import Profile from "@app/components/Profile.svelte"
import ProfileInfo from "@app/components/ProfileInfo.svelte" import ProfileInfo from "@app/components/ProfileInfo.svelte"
import {makeChatPath} from "@app/routes" import {makeChatPath} from "@app/routes"
const {pubkey} = $props() type Props = {
pubkey: string
url?: string
}
const {pubkey, url}: Props = $props()
const filters: Filter[] = [{authors: [pubkey], limit: 1}] const filters: Filter[] = [{authors: [pubkey], limit: 1}]
const events = deriveEvents(repository, {filters}) const events = deriveEvents(repository, {filters})
@@ -22,20 +29,20 @@
// Load at least one note, regardless of time frame // Load at least one note, regardless of time frame
load({ load({
filters: [{authors: [pubkey], limit: 1}], filters: [{authors: [pubkey], limit: 1}],
relays: ctx.app.router.FromPubkeys([pubkey]).getUrls(), relays: Router.get().FromPubkeys([pubkey]).getUrls(),
}) })
}) })
</script> </script>
<div class="card2 bg-alt col-2 shadow-xl"> <div class="card2 bg-alt col-2 shadow-xl">
<div class="flex justify-between"> <div class="flex justify-between">
<Profile {pubkey} /> <Profile {pubkey} {url} />
<Link class="btn btn-primary hidden sm:flex" href={makeChatPath([pubkey])}> <Link class="btn btn-primary hidden sm:flex" href={makeChatPath([pubkey])}>
<Icon icon="letter" /> <Icon icon="letter" />
Start a Chat Start a Chat
</Link> </Link>
</div> </div>
<ProfileInfo {pubkey} /> <ProfileInfo {pubkey} {url} />
{#if $events.length > 0} {#if $events.length > 0}
<div class="bg-alt badge badge-neutral border-none"> <div class="bg-alt badge badge-neutral border-none">
Last active {formatTimestampRelative($events[0].created_at)} Last active {formatTimestampRelative($events[0].created_at)}
+34 -8
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import {page} from "$app/stores" import {page} from "$app/stores"
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {splitAt} from "@welshman/lib"
import {userProfile} from "@welshman/app" import {userProfile} from "@welshman/app"
import Avatar from "@lib/components/Avatar.svelte" import Avatar from "@lib/components/Avatar.svelte"
import Divider from "@lib/components/Divider.svelte" import Divider from "@lib/components/Divider.svelte"
@@ -8,6 +9,7 @@
import SpaceAdd from "@app/components/SpaceAdd.svelte" import SpaceAdd from "@app/components/SpaceAdd.svelte"
import ChatEnable from "@app/components/ChatEnable.svelte" import ChatEnable from "@app/components/ChatEnable.svelte"
import MenuSpaces from "@app/components/MenuSpaces.svelte" import MenuSpaces from "@app/components/MenuSpaces.svelte"
import MenuOtherSpaces from "@app/components/MenuOtherSpaces.svelte"
import MenuSettings from "@app/components/MenuSettings.svelte" import MenuSettings from "@app/components/MenuSettings.svelte"
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte" import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
import {userRoomsByUrl, canDecrypt, PLATFORM_RELAY, PLATFORM_LOGO} from "@app/state" import {userRoomsByUrl, canDecrypt, PLATFORM_RELAY, PLATFORM_LOGO} from "@app/state"
@@ -22,20 +24,35 @@
const addSpace = () => pushModal(SpaceAdd) const addSpace = () => pushModal(SpaceAdd)
const showSpacesMenu = () => (spacePaths.length > 0 ? pushModal(MenuSpaces) : pushModal(SpaceAdd)) const showSpacesMenu = () => (spaceUrls.length > 0 ? pushModal(MenuSpaces) : pushModal(SpaceAdd))
const showOtherSpacesMenu = () => pushModal(MenuOtherSpaces, {urls: secondarySpaceUrls})
const showSettingsMenu = () => pushModal(MenuSettings) const showSettingsMenu = () => pushModal(MenuSettings)
const openChat = () => ($canDecrypt ? goto("/chat") : pushModal(ChatEnable, {next: "/chat"})) const openChat = () => ($canDecrypt ? goto("/chat") : pushModal(ChatEnable, {next: "/chat"}))
const hasNotification = (url: string) => {
const path = makeSpacePath(url)
return !$page.url.pathname.startsWith(path) && $notifications.has(path)
}
let windowHeight = $state(0)
const itemHeight = 56
const navPadding = 6 * itemHeight
const itemLimit = $derived((windowHeight - navPadding) / itemHeight)
const spaceUrls = $derived(Array.from($userRoomsByUrl.keys())) const spaceUrls = $derived(Array.from($userRoomsByUrl.keys()))
const spacePaths = $derived(spaceUrls.map(url => makeSpacePath(url))) const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, spaceUrls))
const anySpaceNotifications = $derived( const anySpaceNotifications = $derived(spaceUrls.some(hasNotification))
spacePaths.some(path => !$page.url.pathname.startsWith(path) && $notifications.has(path)), const otherSpaceNotifications = $derived(secondarySpaceUrls.some(hasNotification))
)
</script> </script>
<div class="sail sait saib relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block"> <svelte:window bind:innerHeight={windowHeight} />
<div
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 justify-between"> <div class="flex h-full flex-col justify-between">
<div> <div>
{#if PLATFORM_RELAY} {#if PLATFORM_RELAY}
@@ -45,9 +62,18 @@
<Avatar src={PLATFORM_LOGO} class="!h-10 !w-10" /> <Avatar src={PLATFORM_LOGO} class="!h-10 !w-10" />
</PrimaryNavItem> </PrimaryNavItem>
<Divider /> <Divider />
{#each spaceUrls as url (url)} {#each primarySpaceUrls as url (url)}
<PrimaryNavItemSpace {url} /> <PrimaryNavItemSpace {url} />
{/each} {/each}
{#if secondarySpaceUrls.length > 0}
<PrimaryNavItem
title="Other Spaces"
class="tooltip-right"
onclick={showOtherSpacesMenu}
notification={otherSpaceNotifications}>
<Avatar icon="widget" class="!h-10 !w-10" />
</PrimaryNavItem>
{/if}
<PrimaryNavItem title="Add Space" onclick={addSpace} class="tooltip-right"> <PrimaryNavItem title="Add Space" onclick={addSpace} class="tooltip-right">
<Avatar icon="settings-minimalistic" class="!h-10 !w-10" /> <Avatar icon="settings-minimalistic" class="!h-10 !w-10" />
</PrimaryNavItem> </PrimaryNavItem>
@@ -78,7 +104,7 @@
{@render children?.()} {@render children?.()}
<!-- a little extra something for ios --> <!-- a little extra something for ios -->
<div class="fixed bottom-0 left-0 right-0 z-nav h-14 bg-base-100 md:hidden"></div> <div class="fixed bottom-0 left-0 right-0 z-nav h-[var(--saib)] bg-base-100 md:hidden"></div>
<div <div
class="border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden"> class="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="content-padding-x content-sizing flex justify-between px-2">
+12 -5
View File
@@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
import {removeNil} from "@welshman/lib"
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util" import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
import { import {
session, session,
userFollows, userFollows,
deriveUserWotScore, deriveUserWotScore,
deriveProfile,
deriveHandleForPubkey, deriveHandleForPubkey,
displayHandle, displayHandle,
deriveProfile,
deriveProfileDisplay, deriveProfileDisplay,
} from "@welshman/app" } from "@welshman/app"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -15,14 +16,20 @@
import ProfileDetail from "@app/components/ProfileDetail.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
const {pubkey} = $props() type Props = {
pubkey: string
url?: string
}
const profile = deriveProfile(pubkey) const {pubkey, url}: Props = $props()
const profileDisplay = deriveProfileDisplay(pubkey)
const relays = removeNil([url])
const profile = deriveProfile(pubkey, relays)
const profileDisplay = deriveProfileDisplay(pubkey, relays)
const handle = deriveHandleForPubkey(pubkey) const handle = deriveHandleForPubkey(pubkey)
const score = deriveUserWotScore(pubkey) const score = deriveUserWotScore(pubkey)
const openProfile = () => pushModal(ProfileDetail, {pubkey}) const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
const following = $derived( const following = $derived(
pubkey === $session!.pubkey || getPubkeyTagValues(getListTags($userFollows)).includes(pubkey), pubkey === $session!.pubkey || getPubkeyTagValues(getListTags($userFollows)).includes(pubkey),
+9 -3
View File
@@ -1,10 +1,16 @@
<script lang="ts"> <script lang="ts">
import {deriveProfile} from "@welshman/app"
import Avatar from "@lib/components/Avatar.svelte" import Avatar from "@lib/components/Avatar.svelte"
import {removeNil} from "@welshman/lib"
import {deriveProfile} from "@welshman/app"
const {...props} = $props() type Props = {
pubkey: string
url?: string
} & Record<string, any>
const profile = deriveProfile(props.pubkey) const {pubkey, url, ...props}: Props = $props()
const profile = deriveProfile(pubkey, removeNil([url]))
</script> </script>
<Avatar src={$profile?.picture} icon="user-circle" {...props} /> <Avatar src={$profile?.picture} icon="user-circle" {...props} />
+3 -2
View File
@@ -7,8 +7,9 @@
DELETE, DELETE,
isReplaceable, isReplaceable,
getAddress, getAddress,
getRelaysFromList,
} from "@welshman/util" } from "@welshman/util"
import {pubkey, userRelaySelections, publishThunk, getRelayUrls, repository} from "@welshman/app" import {pubkey, userRelaySelections, publishThunk, repository} from "@welshman/app"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -40,7 +41,7 @@
const denominator = chunks.length + 2 const denominator = chunks.length + 2
const relays = uniq([ const relays = uniq([
...INDEXER_RELAYS, ...INDEXER_RELAYS,
...getRelayUrls($userRelaySelections), ...getRelaysFromList($userRelaySelections),
...getMembershipUrls($userMembership), ...getMembershipUrls($userMembership),
]) ])
+14 -7
View File
@@ -1,13 +1,14 @@
<script lang="ts"> <script lang="ts">
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {removeNil} from "@welshman/lib"
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util" import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
import { import {
session, session,
userFollows, userFollows,
deriveUserWotScore, deriveUserWotScore,
deriveProfile,
deriveHandleForPubkey, deriveHandleForPubkey,
displayHandle, displayHandle,
deriveProfile,
deriveProfileDisplay, deriveProfileDisplay,
} from "@welshman/app" } from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@@ -22,10 +23,16 @@
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {makeChatPath} from "@app/routes" import {makeChatPath} from "@app/routes"
const {pubkey} = $props() export type Props = {
pubkey: string
url?: string
}
const profile = deriveProfile(pubkey) const {pubkey, url}: Props = $props()
const profileDisplay = deriveProfileDisplay(pubkey)
const relays = removeNil([url])
const profile = deriveProfile(pubkey, relays)
const display = deriveProfileDisplay(pubkey, relays)
const handle = deriveHandleForPubkey(pubkey) const handle = deriveHandleForPubkey(pubkey)
const score = deriveUserWotScore(pubkey) const score = deriveUserWotScore(pubkey)
@@ -48,7 +55,7 @@
<div class="flex min-w-0 flex-col"> <div class="flex min-w-0 flex-col">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-bold overflow-hidden text-ellipsis"> <span class="text-bold overflow-hidden text-ellipsis">
{$profileDisplay} {$display}
</span> </span>
<WotScore score={$score} active={following} /> <WotScore score={$score} active={following} />
</div> </div>
@@ -57,9 +64,9 @@
</div> </div>
</div> </div>
</div> </div>
<ProfileInfo {pubkey} /> <ProfileInfo {pubkey} {url} />
<ModalFooter> <ModalFooter>
<Button onclick={back} class="btn btn-link"> <Button onclick={back} class="hidden md:btn md:btn-link">
<Icon icon="alt-arrow-left" /> <Icon icon="alt-arrow-left" />
Go back Go back
</Button> </Button>
+16 -4
View File
@@ -1,26 +1,38 @@
<script lang="ts"> <script lang="ts">
import {ctx} from "@welshman/lib"
import type {Profile} from "@welshman/util" import type {Profile} from "@welshman/util"
import { import {
getTag,
createEvent, createEvent,
makeProfile, makeProfile,
editProfile, editProfile,
createProfile, createProfile,
isPublishedProfile, isPublishedProfile,
uniqTags,
} from "@welshman/util" } from "@welshman/util"
import {Router} from "@welshman/router"
import {pubkey, profilesByPubkey, publishThunk} from "@welshman/app" import {pubkey, profilesByPubkey, publishThunk} from "@welshman/app"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ProfileEditForm from "@app/components/ProfileEditForm.svelte" import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
import {clearModals} from "@app/modal" import {clearModals} from "@app/modal"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {PROTECTED, getMembershipUrls, userMembership} from "@app/state"
const initialValues = {...($profilesByPubkey.get($pubkey!) || makeProfile())} const profile = $profilesByPubkey.get($pubkey!) || makeProfile()
const shouldBroadcast = !getTag(PROTECTED, profile.event?.tags || [])
const initialValues = {profile, shouldBroadcast}
const back = () => history.back() const back = () => history.back()
const onsubmit = (profile: Profile) => { const onsubmit = ({profile, shouldBroadcast}: {profile: Profile; shouldBroadcast: boolean}) => {
const relays = ctx.app.router.FromUser().getUrls()
const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile) const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
const relays = [...getMembershipUrls($userMembership)]
if (shouldBroadcast) {
relays.push(...Router.get().FromUser().getUrls())
} else {
template.tags = uniqTags([...template.tags, PROTECTED])
}
const event = createEvent(template.kind, template) const event = createEvent(template.kind, template)
publishThunk({event, relays}) publishThunk({event, relays})
+30 -9
View File
@@ -1,23 +1,28 @@
<script lang="ts"> <script lang="ts">
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import type {Profile} from "@welshman/util" import type {Profile} from "@welshman/util"
import {makeProfile} from "@welshman/util"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte" import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import InputProfilePicture from "@lib/components/InputProfilePicture.svelte" import InputProfilePicture from "@lib/components/InputProfilePicture.svelte"
import InfoHandle from "@app/components/InfoHandle.svelte" import InfoHandle from "@app/components/InfoHandle.svelte"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
type Values = {
profile: Profile
shouldBroadcast: boolean
}
type Props = { type Props = {
initialValues?: Profile initialValues: Values
onsubmit: (profile: Profile) => void onsubmit: (values: Values) => void
hideAddress?: boolean hideAddress?: boolean
footer: Snippet footer: Snippet
} }
const {initialValues = makeProfile(), hideAddress, onsubmit, footer}: Props = $props() const {initialValues, hideAddress, onsubmit, footer}: Props = $props()
const values = $state(initialValues) const values = $state(initialValues)
@@ -28,7 +33,7 @@
<form class="col-4" onsubmit={preventDefault(submit)}> <form class="col-4" onsubmit={preventDefault(submit)}>
<div class="flex justify-center py-2"> <div class="flex justify-center py-2">
<InputProfilePicture bind:file bind:url={values.picture} /> <InputProfilePicture bind:file bind:url={values.profile.picture} />
</div> </div>
<Field> <Field>
{#snippet label()} {#snippet label()}
@@ -37,7 +42,7 @@
{#snippet input()} {#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2"> <label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="user-circle" /> <Icon icon="user-circle" />
<input bind:value={values.name} class="grow" type="text" /> <input bind:value={values.profile.name} class="grow" type="text" />
</label> </label>
{/snippet} {/snippet}
{#snippet info()} {#snippet info()}
@@ -49,8 +54,10 @@
<p>About You</p> <p>About You</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<textarea class="textarea textarea-bordered leading-4" rows="3" bind:value={values.about} <textarea
></textarea> class="textarea textarea-bordered leading-4"
rows="5"
bind:value={values.profile.about}></textarea>
{/snippet} {/snippet}
{#snippet info()} {#snippet info()}
Give a brief introduction to why you're here. Give a brief introduction to why you're here.
@@ -64,7 +71,7 @@
{#snippet input()} {#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2"> <label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="map-point" /> <Icon icon="map-point" />
<input bind:value={values.nip05} class="grow" type="text" /> <input bind:value={values.profile.nip05} class="grow" type="text" />
</label> </label>
{/snippet} {/snippet}
{#snippet info()} {#snippet info()}
@@ -75,5 +82,19 @@
{/snippet} {/snippet}
</Field> </Field>
{/if} {/if}
<FieldInline>
{#snippet label()}
<p>Broadcast Profile</p>
{/snippet}
{#snippet input()}
<input type="checkbox" class="toggle toggle-primary" bind:checked={values.shouldBroadcast} />
{/snippet}
{#snippet info()}
<p>
If enabled, changes will be published to the broader nostr network in addition to spaces you
are a member of.
</p>
{/snippet}
</FieldInline>
{@render footer()} {@render footer()}
</form> </form>
+4 -3
View File
@@ -4,7 +4,7 @@
import {feedFromFilter, makeIntersectionFeed, makeRelayFeed} from "@welshman/feeds" import {feedFromFilter, makeIntersectionFeed, makeRelayFeed} from "@welshman/feeds"
import {NOTE, getReplyTags} from "@welshman/util" import {NOTE, getReplyTags} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {createFeedController} from "@welshman/app" import {makeFeedController} from "@welshman/app"
import {createScroller} from "@lib/html" import {createScroller} from "@lib/html"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
@@ -19,7 +19,7 @@
let {url, pubkey, events = $bindable([]), hideLoading = false}: Props = $props() let {url, pubkey, events = $bindable([]), hideLoading = false}: Props = $props()
const ctrl = createFeedController({ const ctrl = makeFeedController({
useWindowing: true, useWindowing: true,
feed: makeIntersectionFeed( feed: makeIntersectionFeed(
makeRelayFeed(url), makeRelayFeed(url),
@@ -45,7 +45,8 @@
e => e.id, e => e.id,
sortBy(e => -e.created_at, buffer), sortBy(e => -e.created_at, buffer),
) )
events = [...events, ...buffer.splice(0, 5)]
events = uniqBy(e => e.id, [...events, ...buffer.splice(0, 5)])
if (buffer.length < 50) { if (buffer.length < 50) {
ctrl.load(50) ctrl.load(50)
+9 -3
View File
@@ -1,12 +1,18 @@
<script lang="ts"> <script lang="ts">
import {removeNil} from "@welshman/lib"
import {deriveProfile} from "@welshman/app" import {deriveProfile} from "@welshman/app"
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
const {pubkey} = $props() export type Props = {
pubkey: string
url?: string
}
const profile = deriveProfile(pubkey) const {pubkey, url}: Props = $props()
const profile = deriveProfile(pubkey, removeNil([url]))
</script> </script>
{#if $profile} {#if $profile}
<Content event={{content: $profile.about, tags: []}} /> <Content event={{content: $profile.about, tags: []}} hideMediaAtDepth={0} />
{/if} {/if}
+8 -3
View File
@@ -5,11 +5,16 @@
import ProfileDetail from "@app/components/ProfileDetail.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
const {pubkey}: {pubkey: string} = $props() type Props = {
pubkey: string
url?: string
}
const openProfile = () => pushModal(ProfileDetail, {pubkey}) const {pubkey, url}: Props = $props()
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
</script> </script>
<Button onclick={preventDefault(openProfile)} class="link-content"> <Button onclick={preventDefault(openProfile)} class="link-content">
@<ProfileName {pubkey} /> @<ProfileName {pubkey} {url} />
</Button> </Button>
+4 -3
View File
@@ -5,11 +5,12 @@
interface Props { interface Props {
title: any title: any
subtitle?: string
pubkeys: any pubkeys: any
subtitle?: string
url?: string
} }
const {subtitle = "", pubkeys, ...restProps}: Props = $props() const {subtitle = "", pubkeys, url, ...restProps}: Props = $props()
</script> </script>
<div class="column gap-4"> <div class="column gap-4">
@@ -23,7 +24,7 @@
</ModalHeader> </ModalHeader>
{#each pubkeys as pubkey (pubkey)} {#each pubkeys as pubkey (pubkey)}
<div class="card2 bg-alt"> <div class="card2 bg-alt">
<Profile {pubkey} /> <Profile {pubkey} {url} />
</div> </div>
{/each} {/each}
<Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button> <Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button>
+8 -2
View File
@@ -1,9 +1,15 @@
<script lang="ts"> <script lang="ts">
import {removeNil} from "@welshman/lib"
import {deriveProfileDisplay} from "@welshman/app" import {deriveProfileDisplay} from "@welshman/app"
const {pubkey} = $props() type Props = {
pubkey: string
url?: string
}
const profileDisplay = deriveProfileDisplay(pubkey) const {pubkey, url}: Props = $props()
const profileDisplay = deriveProfileDisplay(pubkey, removeNil([url]))
</script> </script>
{$profileDisplay} {$profileDisplay}
+2 -2
View File
@@ -4,7 +4,7 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import {clip} from "@app/toast" import {clip} from "@app/toast"
const {code} = $props() const {code, ...props} = $props()
let canvas: Element | undefined = $state() let canvas: Element | undefined = $state()
let wrapper: Element | undefined = $state() let wrapper: Element | undefined = $state()
@@ -26,7 +26,7 @@
}) })
</script> </script>
<Button class="max-w-full" onclick={copy}> <Button class="max-w-full {props.class}" onclick={copy}>
<div bind:this={wrapper} style={`height: ${height}px`}> <div bind:this={wrapper} style={`height: ${height}px`}>
<canvas <canvas
class="rounded-box" class="rounded-box"
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import {parse, isEmoji, renderAsHtml} from "@welshman/content"
import Icon from "@lib/components/Icon.svelte"
import ContentEmoji from "@app/components/ContentEmoji.svelte"
export let event
</script>
{#if event.content === "+" || event.content === ""}
<Icon icon="heart" />
{:else if event.content === "-"}
<Icon icon="thumbs-down" />
{:else}
{#each parse(event) as parsed}
{#if isEmoji(parsed)}
<ContentEmoji value={parsed.value} />
{:else}
{@html renderAsHtml(parsed)}
{/if}
{/each}
{/if}
+51 -17
View File
@@ -1,21 +1,30 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {groupBy, uniq, uniqBy, batch} from "@welshman/lib" import {groupBy, uniq, uniqBy, batch, displayList} from "@welshman/lib"
import {REACTION, getTag, REPORT, DELETE} from "@welshman/util" import {
import type {TrustedEvent} from "@welshman/util" REACTION,
getReplyFilters,
getEmojiTags,
getEmojiTag,
getTag,
REPORT,
DELETE,
} from "@welshman/util"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {deriveEvents} from "@welshman/store" import {deriveEvents} from "@welshman/store"
import {pubkey, repository, load, displayProfileByPubkey} from "@welshman/app" import {load} from "@welshman/net"
import {displayList} from "@lib/util" import {pubkey, repository, displayProfileByPubkey} from "@welshman/app"
import {isMobile, preventDefault, stopPropagation} from "@lib/html" import {isMobile, preventDefault, stopPropagation} from "@lib/html"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Reaction from "@app/components/Reaction.svelte"
import EventReportDetails from "@app/components/EventReportDetails.svelte" import EventReportDetails from "@app/components/EventReportDetails.svelte"
import {displayReaction} from "@app/state"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
interface Props { interface Props {
event: any event: TrustedEvent
onReactionClick: any deleteReaction: (event: TrustedEvent) => void
createReaction: (event: EventContent) => void
url?: string url?: string
reactionClass?: string reactionClass?: string
noTooltip?: boolean noTooltip?: boolean
@@ -24,7 +33,8 @@
const { const {
event, event,
onReactionClick, deleteReaction,
createReaction,
url = "", url = "",
reactionClass = "", reactionClass = "",
noTooltip = false, noTooltip = false,
@@ -39,30 +49,54 @@
filters: [{kinds: [REACTION], "#e": [event.id]}], filters: [{kinds: [REACTION], "#e": [event.id]}],
}) })
const onReactionClick = (events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey)
if (reaction) {
deleteReaction(reaction)
} else {
const [event] = events
createReaction({
content: event.content,
tags: getEmojiTags(event.content.replace(/:/g, ""), event.tags),
})
}
}
const onReportClick = () => pushModal(EventReportDetails, {url, event}) const onReportClick = () => pushModal(EventReportDetails, {url, event})
const reportReasons = $derived(uniq($reports.map(e => getTag("e", e.tags)?.[2]))) const reportReasons = $derived(uniq($reports.map(e => getTag("e", e.tags)?.[2])))
const getReactionKey = (e: TrustedEvent) => getEmojiTag(e.content, e.tags)?.join("") || e.content
const groupedReactions = $derived( const groupedReactions = $derived(
groupBy( groupBy(
e => e.content, getReactionKey,
uniqBy(e => e.pubkey + e.content, $reactions), uniqBy(e => `${e.pubkey}${getReactionKey(e)}`, $reactions),
), ),
) )
onMount(() => { onMount(() => {
const controller = new AbortController()
if (url) { if (url) {
load({ load({
relays: [url], relays: [url],
filters: [{kinds: [REACTION, REPORT, DELETE], "#e": [event.id]}], signal: controller.signal,
filters: getReplyFilters([event], {kinds: [REACTION, REPORT, DELETE]}),
onEvent: batch(300, (events: TrustedEvent[]) => { onEvent: batch(300, (events: TrustedEvent[]) => {
load({ load({
relays: [url], relays: [url],
filters: [{kinds: [DELETE], "#e": events.map(e => e.id)}], filters: getReplyFilters(events, {kinds: [DELETE]}),
}) })
}), }),
}) })
} }
return () => {
controller.abort()
}
}) })
</script> </script>
@@ -79,12 +113,12 @@
<span>{$reports.length}</span> <span>{$reports.length}</span>
</button> </button>
{/if} {/if}
{#each groupedReactions.entries() as [content, events]} {#each groupedReactions.entries() as [key, events]}
{@const pubkeys = events.map(e => e.pubkey)} {@const pubkeys = events.map(e => e.pubkey)}
{@const isOwn = $pubkey && pubkeys.includes($pubkey)} {@const isOwn = $pubkey && pubkeys.includes($pubkey)}
{@const info = displayList(pubkeys.map(pubkey => displayProfileByPubkey(pubkey)))} {@const info = displayList(pubkeys.map(pubkey => displayProfileByPubkey(pubkey)))}
{@const tooltip = `${info} reacted ${displayReaction(content)}`} {@const tooltip = `${info} reacted`}
{@const onClick = () => onReactionClick(content, events)} {@const onClick = () => onReactionClick(events)}
<button <button
type="button" type="button"
data-tip={tooltip} data-tip={tooltip}
@@ -94,7 +128,7 @@
class:border-solid={isOwn} class:border-solid={isOwn}
class:border-primary={isOwn} class:border-primary={isOwn}
onclick={stopPropagation(preventDefault(onClick))}> onclick={stopPropagation(preventDefault(onClick))}>
<span>{displayReaction(content)}</span> <Reaction event={events[0]} />
{#if events.length > 1} {#if events.length > 1}
<span>{events.length}</span> <span>{events.length}</span>
{/if} {/if}
+1 -1
View File
@@ -29,7 +29,7 @@
>{displayUrl($relay.profile.contact)}</Link> >{displayUrl($relay.profile.contact)}</Link>
&bull; &bull;
{/if} {/if}
{#if $relay?.profile?.supported_nips} {#if Array.isArray($relay?.profile?.supported_nips)}
<span <span
class="tooltip cursor-pointer underline" class="tooltip cursor-pointer underline"
data-tip="NIPs supported: {$relay.profile.supported_nips.join(', ')}"> data-tip="NIPs supported: {$relay.profile.supported_nips.join(', ')}">
+2 -4
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {encrypt} from "nostr-tools/nip49" import {encrypt} from "nostr-tools/nip49"
import {hexToBytes} from "@noble/hashes/utils" import {hexToBytes} from "@noble/hashes/utils"
import {makeSecret, getPubkey} from "@welshman/signer" import {makeSecret} from "@welshman/signer"
import {preventDefault, downloadText} from "@lib/html" import {preventDefault, downloadText} from "@lib/html"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@@ -15,8 +15,6 @@
const secret = makeSecret() const secret = makeSecret()
const pubkey = getPubkey(secret)
const back = () => history.back() const back = () => history.back()
const next = () => { const next = () => {
@@ -31,7 +29,7 @@
downloadText("Nostr Secret Key.txt", ncryptsec) downloadText("Nostr Secret Key.txt", ncryptsec)
pushModal(SignUpKeyConfirm, {secret, pubkey, ncryptsec}) pushModal(SignUpKeyConfirm, {secret, ncryptsec})
} }
let password = "" let password = ""
+2 -3
View File
@@ -11,11 +11,10 @@
type Props = { type Props = {
secret: string secret: string
pubkey: string
ncryptsec: string ncryptsec: string
} }
const {secret, pubkey, ncryptsec}: Props = $props() const {secret, ncryptsec}: Props = $props()
const back = () => history.back() const back = () => history.back()
@@ -25,7 +24,7 @@
} }
const next = () => { const next = () => {
pushModal(SignUpProfile, {secret, pubkey}) pushModal(SignUpProfile, {secret})
} }
</script> </script>
+13 -8
View File
@@ -1,27 +1,32 @@
<script lang="ts"> <script lang="ts">
import type {Profile} from "@welshman/util" import type {Profile} from "@welshman/util"
import {PROFILE, createProfile, createEvent} from "@welshman/util" import {PROFILE, createProfile, makeProfile, createEvent} from "@welshman/util"
import {addSession, publishThunk} from "@welshman/app" import {loginWithNip01, publishThunk} from "@welshman/app"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ProfileEditForm from "@app/components/ProfileEditForm.svelte" import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
import {INDEXER_RELAYS} from "@app/state" import {INDEXER_RELAYS} from "@app/state"
type Props = { type Props = {
secret: string secret: string
pubkey: string
} }
const {secret, pubkey}: Props = $props() const {secret}: Props = $props()
const onsubmit = (profile: Profile) => { const initialValues = {
profile: makeProfile(),
shouldBroadcast: true,
}
const onsubmit = ({profile, shouldBroadcast}: {profile: Profile; shouldBroadcast: boolean}) => {
const event = createEvent(PROFILE, createProfile(profile)) const event = createEvent(PROFILE, createProfile(profile))
const relays = shouldBroadcast ? INDEXER_RELAYS : []
addSession({method: "nip01", secret, pubkey}) loginWithNip01(secret)
publishThunk({event, relays: INDEXER_RELAYS}) publishThunk({event, relays})
} }
</script> </script>
<ProfileEditForm hideAddress {onsubmit}> <ProfileEditForm hideAddress {initialValues} {onsubmit}>
{#snippet footer()} {#snippet footer()}
<Button type="submit" class="btn btn-primary">Create Account</Button> <Button type="submit" class="btn btn-primary">Create Account</Button>
{/snippet} {/snippet}
+9 -24
View File
@@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
import {displayRelayUrl} from "@welshman/util" import {displayRelayUrl} from "@welshman/util"
import {parse, renderAsHtml} from "@welshman/content"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import {ucFirst} from "@lib/util"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
@@ -15,8 +16,8 @@
const back = () => history.back() const back = () => history.back()
const joinRelay = async (claim: string) => { const joinRelay = async () => {
const error = await attemptRelayAccess(url, claim) const error = await attemptRelayAccess(url)
if (error) { if (error) {
return pushToast({theme: "error", message: error}) return pushToast({theme: "error", message: error})
@@ -33,13 +34,12 @@
loading = true loading = true
try { try {
await joinRelay(claim) await joinRelay()
} finally { } finally {
loading = false loading = false
} }
} }
let claim = $state("")
let loading = $state(false) let loading = $state(false)
</script> </script>
@@ -53,32 +53,17 @@
{/snippet} {/snippet}
</ModalHeader> </ModalHeader>
<p> <p>
We received an error from the relay indicating you don't have access to {displayRelayUrl(url)}. We received an error from the relay indicating you don't have access to {displayRelayUrl(url)}:
</p> </p>
<p class="border-l border-solid border-error pl-4 text-error"> <p class="bg-alt card2 welshman-content">
{error} {@html renderAsHtml(parse({content: ucFirst(error)}))}
</p> </p>
<p>If you have one, you can try entering an invite code below to request access.</p>
<Field>
{#snippet label()}
<p>Invite code</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="link-round" />
<input bind:value={claim} class="grow" type="text" />
</label>
{/snippet}
{#snippet info()}
<p>Enter an invite code provided to you by the admin of the relay.</p>
{/snippet}
</Field>
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back}> <Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" /> <Icon icon="alt-arrow-left" />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary" disabled={!claim || loading}> <Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Request Access</Spinner> <Spinner {loading}>Request Access</Spinner>
<Icon icon="alt-arrow-right" /> <Icon icon="alt-arrow-right" />
</Button> </Button>
+3 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {ctx, sleep} from "@welshman/lib" import {sleep} from "@welshman/lib"
import {Pool, AuthStatus} from "@welshman/net"
import {displayRelayUrl} from "@welshman/util" import {displayRelayUrl} from "@welshman/util"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@@ -17,7 +18,7 @@
const back = () => history.back() const back = () => history.back()
const next = () => { const next = () => {
if (!error && ctx.net.pool.get(url).stats.lastAuth === 0) { if (!error && Pool.get().get(url).auth.status === AuthStatus.None) {
pushModal(SpaceVisitConfirm, {url}, {replaceState: true}) pushModal(SpaceVisitConfirm, {url}, {replaceState: true})
} else { } else {
confirmSpaceVisit(url) confirmSpaceVisit(url)
+6 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {sleep, identity, nthEq} from "@welshman/lib" import {sleep, identity, nthEq} from "@welshman/lib"
import {load} from "@welshman/app" import {request} from "@welshman/net"
import {displayRelayUrl, AUTH_INVITE} from "@welshman/util" import {displayRelayUrl, AUTH_INVITE} from "@welshman/util"
import {slide} from "@lib/transition" import {slide} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
@@ -29,7 +29,11 @@
onMount(async () => { onMount(async () => {
const [[event]] = await Promise.all([ const [[event]] = await Promise.all([
load({filters: [{kinds: [AUTH_INVITE]}], relays: [url]}), request({
relays: [url],
autoClose: true,
filters: [{kinds: [AUTH_INVITE]}],
}),
sleep(2000), sleep(2000),
]) ])
+5 -4
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import {ctx, tryCatch} from "@welshman/lib" import {tryCatch} from "@welshman/lib"
import {isRelayUrl, normalizeRelayUrl} from "@welshman/util" import {isRelayUrl, normalizeRelayUrl} from "@welshman/util"
import {Pool, AuthStatus} from "@welshman/net"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -22,12 +23,12 @@
const error = await attemptRelayAccess(url, claim) const error = await attemptRelayAccess(url, claim)
if (error) { if (error) {
return pushToast({theme: "error", message: error}) return pushToast({theme: "error", message: error, timeout: 30_000})
} }
const connection = ctx.net.pool.get(url) const socket = Pool.get().get(url)
if (connection.stats.lastAuth === 0) { if (socket.auth.status === AuthStatus.None) {
pushModal(SpaceJoinConfirm, {url}, {replaceState: true}) pushModal(SpaceJoinConfirm, {url}, {replaceState: true})
} else { } else {
await confirmSpaceJoin(url) await confirmSpaceJoin(url)
+5 -11
View File
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {pubkey} from "@welshman/app"
import ReactionSummary from "@app/components/ReactionSummary.svelte" import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte" import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte" import EventActivity from "@app/components/EventActivity.svelte"
@@ -18,20 +17,15 @@
const path = makeThreadPath(url, event.id) const path = makeThreadPath(url, event.id)
const onReactionClick = (content: string, events: TrustedEvent[]) => { const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
const reaction = events.find(e => e.pubkey === $pubkey)
if (reaction) { const createReaction = (template: EventContent) =>
publishDelete({relays: [url], event: reaction}) publishReaction({...template, event, relays: [url]})
} else {
publishReaction({event, content, relays: [url]})
}
}
</script> </script>
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2"> <div class="flex flex-grow flex-wrap justify-end gap-2">
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-left" /> <ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} /> <ThunkStatusOrDeleted {event} />
{#if showActivity} {#if showActivity}
<EventActivity {url} {path} {event} /> <EventActivity {url} {path} {event} />
+8 -5
View File
@@ -19,7 +19,9 @@
const back = () => history.back() const back = () => history.back()
const submit = () => { const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => {
if ($uploading) return if ($uploading) return
if (!title) { if (!title) {
@@ -29,7 +31,8 @@
}) })
} }
const content = editor.getText({blockSeparator: "\n"}).trim() const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
if (!content.trim()) { if (!content.trim()) {
return pushToast({ return pushToast({
@@ -39,7 +42,7 @@
} }
const tags = [ const tags = [
...editor.storage.nostr.getEditorTags(), ...ed.storage.nostr.getEditorTags(),
tagRoom(GENERAL, url), tagRoom(GENERAL, url),
["title", title], ["title", title],
PROTECTED, PROTECTED,
@@ -53,7 +56,7 @@
history.back() history.back()
} }
const editor = makeEditor({submit, uploading, placeholder: "What's on your mind?"}) const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"})
let title: string = $state("") let title: string = $state("")
</script> </script>
@@ -97,7 +100,7 @@
<Button <Button
data-tip="Add an image" data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2" class="tooltip tooltip-left absolute bottom-1 right-2"
onclick={editor.commands.selectFiles}> onclick={selectFiles}>
{#if $uploading} {#if $uploading}
<span class="loading loading-spinner loading-xs"></span> <span class="loading loading-spinner loading-xs"></span>
{:else} {:else}
+7 -9
View File
@@ -1,20 +1,18 @@
<script lang="ts"> <script lang="ts">
import {nthEq} from "@welshman/lib" import {nthEq, formatTimestamp} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {formatTimestamp} from "@welshman/app"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte" import ProfileLink from "@app/components/ProfileLink.svelte"
import ThreadActions from "@app/components/ThreadActions.svelte" import ThreadActions from "@app/components/ThreadActions.svelte"
import {makeThreadPath} from "@app/routes" import {makeThreadPath} from "@app/routes"
const { type Props = {
url,
event,
}: {
url: string url: string
event: TrustedEvent event: TrustedEvent
} = $props() }
const {url, event}: Props = $props()
const title = event.tags.find(nthEq(0, "title"))?.[1] const title = event.tags.find(nthEq(0, "title"))?.[1]
</script> </script>
@@ -32,10 +30,10 @@
{formatTimestamp(event.created_at)} {formatTimestamp(event.created_at)}
</p> </p>
{/if} {/if}
<Content {event} expandMode="inline" relays={[url]} /> <Content {event} {url} expandMode="inline" />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row"> <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"> <span class="whitespace-nowrap py-1 text-sm opacity-75">
Posted by <ProfileLink pubkey={event.pubkey} /> Posted by <ProfileLink pubkey={event.pubkey} {url} />
</span> </span>
<ThreadActions showActivity {url} {event} /> <ThreadActions showActivity {url} {event} />
</div> </div>
+39 -25
View File
@@ -1,12 +1,16 @@
<script lang="ts"> <script lang="ts">
import {get} from "svelte/store" import {nth} from "@welshman/lib"
import {PublishStatus} from "@welshman/net" import {PublishStatus} from "@welshman/net"
import {mergeThunks, publishThunk} from "@welshman/app" import {
import type {Thunk, MergedThunk} from "@welshman/app" MergedThunk,
import {throttled} from "@welshman/store" publishThunk,
isMergedThunk,
thunkIsComplete,
thunkHasStatus,
} from "@welshman/app"
import type {Thunk} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte" import Tippy from "@lib/components/Tippy.svelte"
import Button from "@lib/components/Button.svelte"
import ThunkStatusDetail from "@app/components/ThunkStatusDetail.svelte" import ThunkStatusDetail from "@app/components/ThunkStatusDetail.svelte"
import {userSettingValues} from "@app/state" import {userSettingValues} from "@app/state"
@@ -17,31 +21,34 @@
let {thunk, ...restProps}: Props = $props() let {thunk, ...restProps}: Props = $props()
const {Pending, Failure, Timeout} = PublishStatus
const abort = () => thunk.controller.abort() const abort = () => thunk.controller.abort()
const retry = () => { const retry = () => {
thunk = (thunk as any).thunks thunk = isMergedThunk(thunk)
? mergeThunks((thunk as MergedThunk).thunks.map(t => publishThunk(t.request))) ? new MergedThunk(thunk.thunks.map(t => publishThunk(t.options)))
: publishThunk((thunk as Thunk).request) : publishThunk(thunk.options)
} }
const status = $derived(throttled(300, thunk.status)) const statuses = $derived(Object.entries($thunk.status))
const ps = $derived(Object.values($status)) const isSending = $derived(thunkHasStatus($thunk, PublishStatus.Sending))
const canCancel = $derived(ps.length === 0 && $userSettingValues.send_delay > 0) const canCancel = $derived(isSending && $userSettingValues.send_delay > 0)
const isFailure = $derived(!canCancel && ps.every(s => [Failure, Timeout].includes(s.status))) const failedUrls = $derived(
const failure = $derived( statuses
Object.entries($status).find(([url, s]) => [Failure, Timeout].includes(s.status)), .filter(([_, status]) => [PublishStatus.Failure, PublishStatus.Timeout].includes(status))
.map(nth(0)),
) )
let isPending = $state(Object.values(get(thunk.status)).some(s => s.status == Pending)) const showFailure = $derived(thunkIsComplete($thunk) && failedUrls.length > 0)
let isPending = $state(thunkHasStatus($thunk, PublishStatus.Pending))
const showPending = $derived(canCancel || isPending)
// Delay updating isPending so users can see that the message is sent // Delay updating isPending so users can see that the message is sent
$effect(() => { $effect(() => {
isPending = isPending || ps.some(s => s.status === Pending) isPending = isPending || thunkHasStatus($thunk, PublishStatus.Pending)
if (!ps.some(s => s.status === Pending)) { if (!thunkHasStatus($thunk, PublishStatus.Pending)) {
setTimeout(() => { setTimeout(() => {
isPending = false isPending = false
}, 2000) }, 2000)
@@ -49,8 +56,10 @@
}) })
</script> </script>
{#if isFailure && failure} {#if showFailure}
{@const [url, {message, status}] = failure} {@const url = failedUrls[0]}
{@const status = $thunk.status[url]}
{@const message = $thunk.details[url]}
<div class="flex justify-end px-1 text-xs {restProps.class}"> <div class="flex justify-end px-1 text-xs {restProps.class}">
<Tippy <Tippy
class="flex items-center {restProps.class}" class="flex items-center {restProps.class}"
@@ -65,14 +74,19 @@
{/snippet} {/snippet}
</Tippy> </Tippy>
</div> </div>
{:else if canCancel || isPending} {:else if showPending}
<div class="flex justify-end px-1 text-xs {restProps.class}"> <div class="flex justify-end px-1 text-xs {restProps.class}">
<span class="flex items-center gap-1 {restProps.class}"> <span class="flex items-center gap-1 {restProps.class}">
<span class="loading loading-spinner mx-1 h-3 w-3 translate-y-px"></span> <span class="loading loading-spinner mx-1 h-3 w-3 translate-y-px"></span>
<span class="opacity-50">Sending...</span> <span class="opacity-50">Sending...</span>
{#if canCancel} <button
<Button class="link" onclick={abort}>Cancel</Button> type="button"
{/if} class="underline transition-all"
class:link={canCancel}
class:opacity-25={!canCancel}
onclick={abort}>
Cancel
</button>
</span> </span>
</div> </div>
{/if} {/if}
+5 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import {parse, renderAsHtml} from "@welshman/content"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -7,7 +8,7 @@
{#if $toast} {#if $toast}
{@const theme = $toast.theme || "info"} {@const theme = $toast.theme || "info"}
<div transition:fly class="toast z-toast"> <div transition:fly class="bottom-sai right-sai toast z-toast">
{#key $toast.id} {#key $toast.id}
<div <div
role="alert" role="alert"
@@ -15,7 +16,9 @@
class:bg-base-100={theme === "info"} class:bg-base-100={theme === "info"}
class:text-base-content={theme === "info"} class:text-base-content={theme === "info"}
class:alert-error={theme === "error"}> class:alert-error={theme === "error"}>
{$toast.message} <p class="welshman-content-error">
{@html renderAsHtml(parse({content: $toast.message}))}
</p>
<Button class="flex items-center opacity-75" onclick={() => popToast($toast.id)}> <Button class="flex items-center opacity-75" onclick={() => popToast($toast.id)}>
<Icon icon="close-circle" /> <Icon icon="close-circle" />
</Button> </Button>
+15 -8
View File
@@ -1,22 +1,29 @@
<script lang="ts"> <script lang="ts">
import {Editor} from "@welshman/editor"
import {onDestroy, onMount} from "svelte" import {onDestroy, onMount} from "svelte"
const {editor} = $props() type Props = {
editor: Promise<Editor>
}
const {editor}: Props = $props()
let element: HTMLElement let element: HTMLElement
onMount(() => { onMount(() => {
if (editor.options.element) { editor.then(({options}) => {
element?.append(editor.options.element) if (options.element) {
} element?.append(options.element)
}
if (editor.options.autofocus) { if (options.autofocus) {
;(element?.querySelector("[contenteditable]") as HTMLElement)?.focus() ;(element?.querySelector("[contenteditable]") as HTMLElement)?.focus()
} }
})
}) })
onDestroy(() => { onDestroy(() => {
editor.destroy() editor.then($editor => $editor.destroy())
}) })
</script> </script>
+22 -19
View File
@@ -1,26 +1,29 @@
import type {NodeViewProps} from "@tiptap/core" import type {NodeViewProps} from "@tiptap/core"
import {removeNil} from "@welshman/lib"
import {deriveProfileDisplay} from "@welshman/app" import {deriveProfileDisplay} from "@welshman/app"
export const MentionNodeView = ({node}: NodeViewProps) => { export const makeMentionNodeView =
const dom = document.createElement("span") (url?: string) =>
const display = deriveProfileDisplay(node.attrs.pubkey) ({node}: NodeViewProps) => {
const dom = document.createElement("span")
const display = deriveProfileDisplay(node.attrs.pubkey, removeNil([url]))
dom.classList.add("tiptap-object") dom.classList.add("tiptap-object")
const unsubDisplay = display.subscribe($display => { const unsubDisplay = display.subscribe($display => {
dom.textContent = "@" + $display dom.textContent = "@" + $display
}) })
return { return {
dom, dom,
destroy: () => { destroy: () => {
unsubDisplay() unsubDisplay()
}, },
selectNode() { selectNode() {
dom.classList.add("tiptap-active") dom.classList.add("tiptap-active")
}, },
deselectNode() { deselectNode() {
dom.classList.remove("tiptap-active") dom.classList.remove("tiptap-active")
}, },
}
} }
}
+9 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import {removeNil} from "@welshman/lib"
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util" import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
import { import {
userFollows, userFollows,
@@ -10,10 +11,15 @@
import WotScore from "@lib/components/WotScore.svelte" import WotScore from "@lib/components/WotScore.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
const {value} = $props() type Props = {
value: string
url?: string
}
const {value, url}: Props = $props()
const pubkey = value const pubkey = value
const profileDisplay = deriveProfileDisplay(pubkey) const profileDisplay = deriveProfileDisplay(pubkey, removeNil([url]))
const handle = deriveHandleForPubkey(pubkey) const handle = deriveHandleForPubkey(pubkey)
const score = deriveUserWotScore(pubkey) const score = deriveUserWotScore(pubkey)
@@ -22,7 +28,7 @@
<div class="flex max-w-full gap-3"> <div class="flex max-w-full gap-3">
<div class="py-1"> <div class="py-1">
<ProfileCircle {pubkey} /> <ProfileCircle {pubkey} {url} />
</div> </div>
<div class="flex min-w-0 flex-col"> <div class="flex min-w-0 flex-col">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
+76 -29
View File
@@ -1,23 +1,63 @@
import {mount} from "svelte" import {mount} from "svelte"
import type {Writable} from "svelte/store" import type {Writable} from "svelte/store"
import {get} from "svelte/store" import {get} from "svelte/store"
import {Editor} from "@tiptap/core"
import {ctx} from "@welshman/lib"
import type {StampedEvent} from "@welshman/util" import type {StampedEvent} from "@welshman/util"
import {signer, profileSearch} from "@welshman/app" import {makeEvent, getTagValues, getListTags, BLOSSOM_AUTH} from "@welshman/util"
import {MentionSuggestion, WelshmanExtension} from "@welshman/editor" import {simpleCache, normalizeUrl, removeNil, now} from "@welshman/lib"
import {getSetting, userSettingValues} from "@app/state" import {Router} from "@welshman/router"
import {MentionNodeView} from "./MentionNodeView" import {signer, profileSearch, userBlossomServers} from "@welshman/app"
import {Editor, MentionSuggestion, WelshmanExtension} from "@welshman/editor"
import {makeMentionNodeView} from "./MentionNodeView"
import ProfileSuggestion from "./ProfileSuggestion.svelte" import ProfileSuggestion from "./ProfileSuggestion.svelte"
export const getUploadType = () => getSetting<"nip96" | "blossom">("upload_type") export const hasBlossomSupport = simpleCache(async ([url]: [string]) => {
const $signer = signer.get()
const headers: Record<string, string> = {
"X-Content-Type": "text/plain",
"X-Content-Length": "1",
"X-SHA-256": "73cb3858a687a8494ca3323053016282f3dad39d42cf62ca4e79dda2aac7d9ac",
}
export const getUploadUrl = () => { try {
const {upload_type, nip96_urls, blossom_urls} = userSettingValues.get() if ($signer) {
const event = await signer.get().sign(
makeEvent(BLOSSOM_AUTH, {
tags: [
["t", "upload"],
["server", url],
["expiration", String(now() + 30)],
],
}),
)
return upload_type === "nip96" headers.Authorization = `Nostr ${btoa(JSON.stringify(event))}`
? nip96_urls[0] || "https://nostr.build" }
: blossom_urls[0] || "https://cdn.satellite.earth"
const res = await fetch(normalizeUrl(url) + "/upload", {method: "head", headers})
return res.status === 200
} catch (e) {
if (!String(e).includes("Failed to fetch")) {
console.error(e)
}
}
return false
})
export const getUploadUrl = async (spaceUrl?: string) => {
const userUrls = getTagValues("server", getListTags(userBlossomServers.get()))
const allUrls = removeNil([spaceUrl, ...userUrls])
for (let url of allUrls) {
url = url.replace(/^ws/, "http")
if (await hasBlossomSupport(url)) {
return url
}
}
return "https://cdn.satellite.earth"
} }
export const signWithAssert = async (template: StampedEvent) => { export const signWithAssert = async (template: StampedEvent) => {
@@ -26,26 +66,30 @@ export const signWithAssert = async (template: StampedEvent) => {
return event! return event!
} }
export const makeEditor = ({ export const makeEditor = async ({
aggressive = false, aggressive = false,
autofocus = false, autofocus = false,
charCount, charCount,
content = "", content = "",
placeholder = "", placeholder = "",
url,
submit, submit,
uploading, uploading,
wordCount, wordCount,
disableFileUpload,
}: { }: {
aggressive?: boolean aggressive?: boolean
autofocus?: boolean autofocus?: boolean
charCount?: Writable<number> charCount?: Writable<number>
content?: string content?: string
placeholder?: string placeholder?: string
url?: string
submit: () => void submit: () => void
uploading?: Writable<boolean> uploading?: Writable<boolean>
wordCount?: Writable<number> wordCount?: Writable<number>
}) => disableFileUpload?: boolean
new Editor({ }) => {
return new Editor({
content, content,
autofocus, autofocus,
element: document.createElement("div"), element: document.createElement("div"),
@@ -53,8 +97,8 @@ export const makeEditor = ({
WelshmanExtension.configure({ WelshmanExtension.configure({
submit, submit,
sign: signWithAssert, sign: signWithAssert,
defaultUploadType: getUploadType(), defaultUploadType: "blossom",
defaultUploadUrl: getUploadUrl(), defaultUploadUrl: await getUploadUrl(url),
extensions: { extensions: {
placeholder: { placeholder: {
config: { config: {
@@ -66,29 +110,31 @@ export const makeEditor = ({
aggressive, aggressive,
}, },
}, },
fileUpload: { fileUpload: disableFileUpload
config: { ? false
onDrop() { : {
uploading?.set(true) config: {
onDrop() {
uploading?.set(true)
},
onComplete() {
uploading?.set(false)
},
},
}, },
onComplete() {
uploading?.set(false)
},
},
},
nprofile: { nprofile: {
extend: { extend: {
addNodeView: () => MentionNodeView, addNodeView: () => makeMentionNodeView(url),
addProseMirrorPlugins() { addProseMirrorPlugins() {
return [ return [
MentionSuggestion({ MentionSuggestion({
editor: (this as any).editor, editor: (this as any).editor,
search: (term: string) => get(profileSearch).searchValues(term), search: (term: string) => get(profileSearch).searchValues(term),
getRelays: (pubkey: string) => ctx.app.router.FromPubkeys([pubkey]).getUrls(), getRelays: (pubkey: string) => Router.get().FromPubkeys([pubkey]).getUrls(),
createSuggestion: (value: string) => { createSuggestion: (value: string) => {
const target = document.createElement("div") const target = document.createElement("div")
mount(ProfileSuggestion, {target, props: {value}}) mount(ProfileSuggestion, {target, props: {value, url}})
return target return target
}, },
@@ -105,3 +151,4 @@ export const makeEditor = ({
charCount?.set(editor.storage.wordCount.chars) charCount?.set(editor.storage.wordCount.chars)
}, },
}) })
}
+88 -65
View File
@@ -8,8 +8,8 @@ import {
uniq, uniq,
int, int,
YEAR, YEAR,
MONTH, DAY,
insert, insertAt,
sortBy, sortBy,
assoc, assoc,
now, now,
@@ -25,25 +25,25 @@ import {
getTagValue, getTagValue,
getAddress, getAddress,
isShareableRelayUrl, isShareableRelayUrl,
getRelaysFromList,
} from "@welshman/util" } from "@welshman/util"
import type {TrustedEvent, Filter, List} from "@welshman/util" import type {TrustedEvent, Filter, List} from "@welshman/util"
import {feedFromFilters, makeRelayFeed, makeIntersectionFeed} from "@welshman/feeds" import {feedFromFilters, makeRelayFeed, makeIntersectionFeed} from "@welshman/feeds"
import type {Subscription, SubscribeRequestWithHandlers} from "@welshman/net" import {load, request} from "@welshman/net"
import type {AppSyncOpts, Thunk} from "@welshman/app" import type {AppSyncOpts, Thunk} from "@welshman/app"
import { import {
subscribe,
load,
repository, repository,
pull, pull,
hasNegentropy, hasNegentropy,
thunkWorker, thunkQueue,
createFeedController, makeFeedController,
loadRelay, loadRelay,
loadMutes, loadMutes,
loadFollows, loadFollows,
loadProfile, loadProfile,
loadBlossomServers,
loadRelaySelections,
loadInboxRelaySelections, loadInboxRelaySelections,
getRelayUrls,
} from "@welshman/app" } from "@welshman/app"
import {createScroller} from "@lib/html" import {createScroller} from "@lib/html"
import {daysBetween} from "@lib/util" import {daysBetween} from "@lib/util"
@@ -102,8 +102,9 @@ export const makeFeed = ({
const seen = new Set<string>() const seen = new Set<string>()
const buffer = writable<TrustedEvent[]>([]) const buffer = writable<TrustedEvent[]>([])
const events = writable(initialEvents) const events = writable(initialEvents)
const controller = new AbortController()
for (const event of initialEvents) { const markEvent = (event: TrustedEvent) => {
if (!seen.has(event.id)) { if (!seen.has(event.id)) {
seen.add(event.id) seen.add(event.id)
onEvent?.(event) onEvent?.(event)
@@ -111,19 +112,32 @@ export const makeFeed = ({
} }
const insertEvent = (event: TrustedEvent) => { const insertEvent = (event: TrustedEvent) => {
buffer.update($buffer => { let handled = false
for (let i = 0; i < $buffer.length; i++) {
if ($buffer[i].id === event.id) return $buffer events.update($events => {
if ($buffer[i].created_at < event.created_at) return insert(i, event, $buffer) for (let i = 0; i < $events.length; i++) {
if ($events[i].id === event.id) return $events
if ($events[i].created_at < event.created_at) {
handled = true
return insertAt(i, event, $events)
}
} }
return [...$buffer, event] return $events
}) })
if (!seen.has(event.id)) { if (!handled) {
seen.add(event.id) buffer.update($buffer => {
onEvent?.(event) for (let i = 0; i < $buffer.length; i++) {
if ($buffer[i].id === event.id) return $buffer
if ($buffer[i].created_at < event.created_at) return insertAt(i, event, $buffer)
}
return [...$buffer, event]
})
} }
markEvent(event)
} }
const removeEvents = (ids: string[]) => { const removeEvents = (ids: string[]) => {
@@ -145,15 +159,20 @@ export const makeFeed = ({
} }
} }
const ctrl = createFeedController({ const ctrl = makeFeedController({
useWindowing: true, useWindowing: true,
feed: makeIntersectionFeed(makeRelayFeed(...relays), feedFromFilters(feedFilters)), feed: makeIntersectionFeed(makeRelayFeed(...relays), feedFromFilters(feedFilters)),
onEvent: insertEvent, onEvent: insertEvent,
onExhausted, onExhausted,
}) })
const sub = subscribe({ for (const event of initialEvents) {
markEvent(event)
}
request({
relays, relays,
signal: controller.signal,
filters: subscriptionFilters, filters: subscriptionFilters,
onEvent: (e: TrustedEvent) => { onEvent: (e: TrustedEvent) => {
if (matchFilters(feedFilters, e)) insertEvent(e) if (matchFilters(feedFilters, e)) insertEvent(e)
@@ -176,14 +195,14 @@ export const makeFeed = ({
}, },
}) })
thunkWorker.addGlobalHandler(onThunk) const unsubscribe = thunkQueue.subscribe(onThunk)
return { return {
events, events,
cleanup: () => { cleanup: () => {
sub.close() unsubscribe()
scroller.stop() scroller.stop()
thunkWorker.removeGlobalHandler(onThunk) controller.abort()
}, },
} }
} }
@@ -203,9 +222,12 @@ export const makeCalendarFeed = ({
onExhausted?: () => void onExhausted?: () => void
initialEvents?: TrustedEvent[] initialEvents?: TrustedEvent[]
}) => { }) => {
const interval = int(5, DAY)
const controller = new AbortController()
let exhaustedScrollers = 0 let exhaustedScrollers = 0
let backwardWindow = [now() - MONTH, now()] let backwardWindow = [now() - interval, now()]
let forwardWindow = [now(), now() + MONTH] let forwardWindow = [now(), now() + interval]
const getStart = (event: TrustedEvent) => parseInt(getTagValue("start", event.tags) || "") const getStart = (event: TrustedEvent) => parseInt(getTagValue("start", event.tags) || "")
@@ -222,7 +244,7 @@ export const makeCalendarFeed = ({
events.update($events => { events.update($events => {
for (let i = 0; i < $events.length; i++) { for (let i = 0; i < $events.length; i++) {
if ($events[i].id === event.id) return $events if ($events[i].id === event.id) return $events
if (getStart($events[i]) > start) return insert(i, event, $events) if (getStart($events[i]) > start) return insertAt(i, event, $events)
} }
return [...$events.filter(e => getAddress(e) !== address), event] return [...$events.filter(e => getAddress(e) !== address), event]
@@ -243,8 +265,9 @@ export const makeCalendarFeed = ({
} }
} }
const sub = subscribe({ request({
relays, relays,
signal: controller.signal,
filters: subscriptionFilters, filters: subscriptionFilters,
onEvent: (e: TrustedEvent) => { onEvent: (e: TrustedEvent) => {
if (matchFilters(feedFilters, e)) insertEvent(e) if (matchFilters(feedFilters, e)) insertEvent(e)
@@ -254,8 +277,10 @@ export const makeCalendarFeed = ({
const loadTimeframe = (since: number, until: number) => { const loadTimeframe = (since: number, until: number) => {
const hashes = daysBetween(since, until).map(String) const hashes = daysBetween(since, until).map(String)
load({ request({
relays, relays,
signal: controller.signal,
autoClose: true,
filters: [{kinds: [EVENT_TIME], "#D": hashes}], filters: [{kinds: [EVENT_TIME], "#D": hashes}],
onEvent: insertEvent, onEvent: insertEvent,
}) })
@@ -273,7 +298,7 @@ export const makeCalendarFeed = ({
onScroll: () => { onScroll: () => {
const [since, until] = backwardWindow const [since, until] = backwardWindow
backwardWindow = [since - MONTH, since] backwardWindow = [since - interval, since]
if (until > now() - int(2, YEAR)) { if (until > now() - int(2, YEAR)) {
loadTimeframe(since, until) loadTimeframe(since, until)
@@ -289,7 +314,7 @@ export const makeCalendarFeed = ({
onScroll: () => { onScroll: () => {
const [since, until] = forwardWindow const [since, until] = forwardWindow
forwardWindow = [until, until + MONTH] forwardWindow = [until, until + interval]
if (until < now() + int(2, YEAR)) { if (until < now() + int(2, YEAR)) {
loadTimeframe(since, until) loadTimeframe(since, until)
@@ -300,15 +325,15 @@ export const makeCalendarFeed = ({
}, },
}) })
thunkWorker.addGlobalHandler(onThunk) const unsubscribe = thunkQueue.subscribe(onThunk)
return { return {
events, events,
cleanup: () => { cleanup: () => {
thunkWorker.removeGlobalHandler(onThunk)
backwardScroller.stop() backwardScroller.stop()
forwardScroller.stop() forwardScroller.stop()
sub.close() controller.abort()
unsubscribe()
}, },
} }
} }
@@ -330,7 +355,7 @@ export const loadAlertStatuses = (pubkey: string) =>
// Application requests // Application requests
export const listenForNotifications = () => { export const listenForNotifications = () => {
const subs: Subscription[] = [] const controller = new AbortController()
for (const [url, allRooms] of userRoomsByUrl.get()) { for (const [url, allRooms] of userRoomsByUrl.get()) {
// Limit how many rooms we load at a time, since we have to send a separate filter // Limit how many rooms we load at a time, since we have to send a separate filter
@@ -338,48 +363,42 @@ export const listenForNotifications = () => {
const rooms = shuffle(Array.from(allRooms)).slice(0, 30) const rooms = shuffle(Array.from(allRooms)).slice(0, 30)
load({ load({
signal: controller.signal,
relays: [url], relays: [url],
filters: [ filters: [
{kinds: [THREAD], limit: 1}, {kinds: [THREAD], limit: 1},
{kinds: [EVENT_TIME], limit: 1},
{kinds: [COMMENT], "#K": [String(THREAD)], limit: 1}, {kinds: [COMMENT], "#K": [String(THREAD)], limit: 1},
{kinds: [COMMENT], "#K": [String(EVENT_TIME)], limit: 1},
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], limit: 1})), ...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], limit: 1})),
], ],
}) })
subs.push( request({
subscribe({ signal: controller.signal,
relays: [url], relays: [url],
filters: [ filters: [
{kinds: [THREAD, EVENT_TIME], since: now()}, {kinds: [THREAD], since: now()},
{kinds: [COMMENT], "#K": [String(THREAD), String(EVENT_TIME)], since: now()}, {kinds: [COMMENT], "#K": [String(THREAD)], since: now()},
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], since: now()})), ...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], since: now()})),
], ],
}), })
)
} }
return () => { return () => controller.abort()
for (const sub of subs) {
sub.close()
}
}
} }
export const loadUserData = ( export const loadUserData = async (pubkey: string, relays: string[] = []) => {
pubkey: string, await Promise.race([sleep(3000), loadRelaySelections(pubkey, relays)])
request: Partial<SubscribeRequestWithHandlers> = {},
) => {
const promise = Promise.race([ const promise = Promise.race([
sleep(3000), sleep(3000),
Promise.all([ Promise.all([
loadInboxRelaySelections(pubkey, request), loadInboxRelaySelections(pubkey, relays),
loadMembership(pubkey, request), loadBlossomServers(pubkey, relays),
loadSettings(pubkey, request), loadMembership(pubkey, relays),
loadProfile(pubkey, request), loadSettings(pubkey, relays),
loadFollows(pubkey, request), loadProfile(pubkey, relays),
loadMutes(pubkey, request), loadFollows(pubkey, relays),
loadMutes(pubkey, relays),
loadAlertStatuses(pubkey), loadAlertStatuses(pubkey),
loadAlerts(pubkey), loadAlerts(pubkey),
]), ]),
@@ -394,10 +413,10 @@ export const loadUserData = (
await sleep(1000) await sleep(1000)
for (const pubkey of pubkeys) { for (const pubkey of pubkeys) {
loadMembership(pubkey, {relays}) loadMembership(pubkey, relays)
loadProfile(pubkey, {relays}) loadProfile(pubkey, relays)
loadFollows(pubkey, {relays}) loadFollows(pubkey, relays)
loadMutes(pubkey, {relays}) loadMutes(pubkey, relays)
} }
} }
}) })
@@ -406,4 +425,8 @@ export const loadUserData = (
} }
export const discoverRelays = (lists: List[]) => export const discoverRelays = (lists: List[]) =>
Promise.all(uniq(lists.flatMap(getRelayUrls)).filter(isShareableRelayUrl).map(loadRelay)) Promise.all(
uniq(lists.flatMap($l => getRelaysFromList($l)))
.filter(isShareableRelayUrl)
.map(url => loadRelay(url)),
)
+34 -48
View File
@@ -1,9 +1,7 @@
import twColors from "tailwindcss/colors" import twColors from "tailwindcss/colors"
import {get, derived} from "svelte/store" import {get, derived} from "svelte/store"
import {nip19} from "nostr-tools" import * as nip19 from "nostr-tools/nip19"
import { import {
ctx,
setContext,
remove, remove,
sortBy, sortBy,
sort, sort,
@@ -16,7 +14,11 @@ import {
fromPairs, fromPairs,
memoize, memoize,
addToMapKey, addToMapKey,
identity,
always,
} from "@welshman/lib" } from "@welshman/lib"
import {load} from "@welshman/net"
import {collection} from "@welshman/store"
import { import {
getIdFilters, getIdFilters,
WRAP, WRAP,
@@ -42,15 +44,11 @@ import {
} from "@welshman/util" } from "@welshman/util"
import type {TrustedEvent, SignedEvent, PublishedList, List, Filter} from "@welshman/util" import type {TrustedEvent, SignedEvent, PublishedList, List, Filter} from "@welshman/util"
import {Nip59, decrypt} from "@welshman/signer" import {Nip59, decrypt} from "@welshman/signer"
import {routerContext, Router} from "@welshman/router"
import { import {
pubkey, pubkey,
repository, repository,
load,
collection,
profilesByPubkey, profilesByPubkey,
getDefaultAppContext,
getDefaultNetContext,
makeRouter,
tracker, tracker,
makeTrackerStore, makeTrackerStore,
makeRepositoryStore, makeRepositoryStore,
@@ -63,11 +61,14 @@ import {
thunks, thunks,
walkThunks, walkThunks,
signer, signer,
makeOutboxLoader,
appContext,
} from "@welshman/app" } from "@welshman/app"
import type {Thunk, Relay} from "@welshman/app" import type {Thunk, Relay} from "@welshman/app"
import type {SubscribeRequestWithHandlers} from "@welshman/net"
import {deriveEvents, deriveEventsMapped, withGetter, synced} from "@welshman/store" import {deriveEvents, deriveEventsMapped, withGetter, synced} from "@welshman/store"
export const fromCsv = (s: string) => (s || "").split(",").filter(identity)
export const ROOM = "h" export const ROOM = "h"
export const GENERAL = "_" export const GENERAL = "_"
@@ -78,18 +79,13 @@ export const ALERT = 32830
export const ALERT_STATUS = 32831 export const ALERT_STATUS = 32831
export const NOTIFIER_PUBKEY = "27b7c2ed89ef78322114225ea3ebf5f72c7767c2528d4d0c1854d039c00085df" export const NOTIFIER_PUBKEY = import.meta.env.VITE_NOTIFIER_PUBKEY
// export const NOTIFIER_RELAY = 'wss://notifier.flotilla.social/' export const NOTIFIER_RELAY = import.meta.env.VITE_NOTIFIER_RELAY
export const NOTIFIER_RELAY = "ws://localhost:4738/"
export const INDEXER_RELAYS = [ export const INDEXER_RELAYS = fromCsv(import.meta.env.VITE_INDEXER_RELAYS)
"wss://purplepag.es/",
"wss://relay.damus.io/",
"wss://relay.nostr.band/",
]
export const SIGNER_RELAYS = ["wss://relay.nsec.app/", "wss://bucket.coracle.social/"] export const SIGNER_RELAYS = fromCsv(import.meta.env.VITE_SIGNER_RELAYS)
export const PLATFORM_URL = window.location.origin export const PLATFORM_URL = window.location.origin
@@ -118,7 +114,7 @@ export const IMGPROXY_URL = "https://imgproxy.coracle.social"
export const REACTION_KINDS = [REACTION, ZAP_RESPONSE] export const REACTION_KINDS = [REACTION, ZAP_RESPONSE]
export const NIP46_PERMS = export const NIP46_PERMS =
"nip04_encrypt,nip04_decrypt,nip44_encrypt,nip44_decrypt," + "nip44_encrypt,nip44_decrypt," +
[CLIENT_AUTH, AUTH_JOIN, MESSAGE, THREAD, COMMENT, GROUPS, WRAP, REACTION] [CLIENT_AUTH, AUTH_JOIN, MESSAGE, THREAD, COMMENT, GROUPS, WRAP, REACTION]
.map(k => `sign_event:${k}`) .map(k => `sign_event:${k}`)
.join(",") .join(",")
@@ -163,10 +159,8 @@ export const imgproxy = (url: string, {w = 640, h = 1024} = {}) => {
export const entityLink = (entity: string) => `https://coracle.social/${entity}` export const entityLink = (entity: string) => `https://coracle.social/${entity}`
export const pubkeyLink = ( export const pubkeyLink = (pubkey: string, relays = Router.get().FromPubkeys([pubkey]).getUrls()) =>
pubkey: string, entityLink(nip19.nprofileEncode({pubkey, relays}))
relays = ctx.app.router.FromPubkeys([pubkey]).getUrls(),
) => entityLink(nip19.nprofileEncode({pubkey, relays}))
export const tagRoom = (room: string, url: string) => [ROOM, room] export const tagRoom = (room: string, url: string) => [ROOM, room]
@@ -255,7 +249,7 @@ export const getUrlsForEvent = derived([trackerStore, thunks], ([$tracker, $thun
const urls = Array.from($tracker.getRelays(id)) const urls = Array.from($tracker.getRelays(id))
for (const thunk of getThunksByEventId().get(id) || []) { for (const thunk of getThunksByEventId().get(id) || []) {
for (const url of thunk.request.relays) { for (const url of thunk.options.relays) {
urls.push(url) urls.push(url)
} }
} }
@@ -284,15 +278,9 @@ export const deriveEventsForUrl = (url: string, filters: Filter[]) =>
// Context // Context
setContext({ appContext.dufflepudUrl = DUFFLEPUD_URL
net: getDefaultNetContext(),
app: getDefaultAppContext({ routerContext.getIndexerRelays = always(INDEXER_RELAYS)
dufflepudUrl: DUFFLEPUD_URL,
indexerRelays: INDEXER_RELAYS,
requestTimeout: 5000,
router: makeRouter(),
}),
})
// Settings // Settings
@@ -308,9 +296,6 @@ export type Settings = {
report_usage: boolean report_usage: boolean
report_errors: boolean report_errors: boolean
send_delay: number send_delay: number
upload_type: "nip96" | "blossom"
nip96_urls: string[]
blossom_urls: string[]
} }
} }
@@ -318,11 +303,8 @@ export const defaultSettings = {
show_media: true, show_media: true,
hide_sensitive: true, hide_sensitive: true,
report_usage: true, report_usage: true,
report_errors: false, report_errors: true,
send_delay: 3000, send_delay: 3000,
upload_type: "nip96",
nip96_urls: ["https://nostr.build"],
blossom_urls: ["https://cdn.satellite.earth"],
} }
export const settings = deriveEventsMapped<Settings>(repository, { export const settings = deriveEventsMapped<Settings>(repository, {
@@ -342,8 +324,7 @@ export const {
name: "settings", name: "settings",
store: settings, store: settings,
getKey: settings => settings.event.pubkey, getKey: settings => settings.event.pubkey,
load: (pubkey: string, request: Partial<SubscribeRequestWithHandlers> = {}) => load: makeOutboxLoader(SETTINGS),
load({...request, filters: [{kinds: [SETTINGS], authors: [pubkey]}]}),
}) })
// Alerts // Alerts
@@ -420,8 +401,7 @@ export const {
name: "memberships", name: "memberships",
store: memberships, store: memberships,
getKey: list => list.event.pubkey, getKey: list => list.event.pubkey,
load: (pubkey: string, request: Partial<SubscribeRequestWithHandlers> = {}) => load: makeOutboxLoader(GROUPS),
load({...request, filters: [{kinds: [GROUPS], authors: [pubkey]}]}),
}) })
// Chats // Chats
@@ -482,6 +462,7 @@ export const {
name: "chats", name: "chats",
store: chats, store: chats,
getKey: chat => chat.id, getKey: chat => chat.id,
load: always(Promise.resolve()),
}) })
export const chatSearch = derived(chats, $chats => export const chatSearch = derived(chats, $chats =>
@@ -503,7 +484,7 @@ export const messages = derived(
export const groupMeta = deriveEvents(repository, {filters: [{kinds: [GROUP_META]}]}) export const groupMeta = deriveEvents(repository, {filters: [{kinds: [GROUP_META]}]})
export const hasNip29 = (relay?: Relay) => export const hasNip29 = (relay?: Relay) =>
relay?.profile?.supported_nips?.map(String)?.includes("29") relay?.profile?.supported_nips?.map?.(String)?.includes?.("29")
// Channels // Channels
@@ -647,11 +628,11 @@ export const userRoomsByUrl = withGetter(
const $userRoomsByUrl = new Map<string, Set<string>>() const $userRoomsByUrl = new Map<string, Set<string>>()
for (const [_, room, url] of getGroupTags(tags)) { for (const [_, room, url] of getGroupTags(tags)) {
addToMapKey($userRoomsByUrl, url, room) addToMapKey($userRoomsByUrl, normalizeRelayUrl(url), room)
} }
for (const url of getRelayTagValues(tags)) { for (const url of getRelayTagValues(tags)) {
addToMapKey($userRoomsByUrl, url, GENERAL) addToMapKey($userRoomsByUrl, normalizeRelayUrl(url), GENERAL)
} }
return $userRoomsByUrl return $userRoomsByUrl
@@ -673,7 +654,12 @@ export const deriveOtherRooms = (url: string) =>
// Other utils // Other utils
export const encodeRelay = (url: string) => encodeURIComponent(normalizeRelayUrl(url)) export const encodeRelay = (url: string) =>
encodeURIComponent(
normalizeRelayUrl(url)
.replace(/^wss:\/\//, "")
.replace(/\/$/, ""),
)
export const decodeRelay = (url: string) => normalizeRelayUrl(decodeURIComponent(url)) export const decodeRelay = (url: string) => normalizeRelayUrl(decodeURIComponent(url))
+5
View File
@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 7L4 7" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
<path d="M20 12L4 12" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
<path d="M20 17L4 17" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 350 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.99986 12H8.00887M12.0044 12H12.0134M15.9908 12H15.9999" stroke="#1C274C" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="12" cy="12" r="10" stroke="#1C274C" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 323 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.0672 11.8568L20.4253 11.469L21.0672 11.8568ZM12.1432 2.93276L11.7553 2.29085V2.29085L12.1432 2.93276ZM21.25 12C21.25 17.1086 17.1086 21.25 12 21.25V22.75C17.9371 22.75 22.75 17.9371 22.75 12H21.25ZM12 21.25C6.89137 21.25 2.75 17.1086 2.75 12H1.25C1.25 17.9371 6.06294 22.75 12 22.75V21.25ZM2.75 12C2.75 6.89137 6.89137 2.75 12 2.75V1.25C6.06294 1.25 1.25 6.06294 1.25 12H2.75ZM15.5 14.25C12.3244 14.25 9.75 11.6756 9.75 8.5H8.25C8.25 12.5041 11.4959 15.75 15.5 15.75V14.25ZM20.4253 11.469C19.4172 13.1373 17.5882 14.25 15.5 14.25V15.75C18.1349 15.75 20.4407 14.3439 21.7092 12.2447L20.4253 11.469ZM9.75 8.5C9.75 6.41182 10.8627 4.5828 12.531 3.57467L11.7553 2.29085C9.65609 3.5593 8.25 5.86509 8.25 8.5H9.75ZM12 2.75C11.9115 2.75 11.8077 2.71008 11.7324 2.63168C11.6686 2.56527 11.6538 2.50244 11.6503 2.47703C11.6461 2.44587 11.6482 2.35557 11.7553 2.29085L12.531 3.57467C13.0342 3.27065 13.196 2.71398 13.1368 2.27627C13.0754 1.82126 12.7166 1.25 12 1.25V2.75ZM21.7092 12.2447C21.6444 12.3518 21.5541 12.3539 21.523 12.3497C21.4976 12.3462 21.4347 12.3314 21.3683 12.2676C21.2899 12.1923 21.25 12.0885 21.25 12H22.75C22.75 11.2834 22.1787 10.9246 21.7237 10.8632C21.286 10.804 20.7293 10.9658 20.4253 11.469L21.7092 12.2447Z" fill="#1C274C"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.1414 2.07816C20.9097 3.88191 22 6.3527 22 9.07816C22 11.836 20.8836 14.333 19.0782 16.1421M5 16.2196C3.14864 14.4047 2 11.8756 2 9.07816C2 6.31313 3.12222 3.8102 4.93603 2" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16.2849 5.1221C17.3458 6.13689 18 7.52697 18 9.06033C18 10.6119 17.3302 12.0167 16.2469 13.0345M7.8 13.0781C6.68918 12.057 6 10.6342 6 9.06033C6 7.50471 6.67333 6.09655 7.76162 5.07812" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="12" cy="9.07812" r="2" stroke="#1C274C" stroke-width="1.5"/>
<path d="M12.5 11L16 22L10.5 15.5M11.5 11L8 22L13.5 15.5" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 871 B

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