Compare commits

...

68 Commits

Author SHA1 Message Date
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
Jon Staab 33af39ee93 Add calendar event editing 2025-03-18 15:36:52 -07:00
Jon Staab 1d56a2193d Clean up calendar header 2025-03-17 09:53:14 -07:00
Jon Staab 75905e4652 Take a guess at fixing android keyboard issue 2025-03-07 09:01:47 -08:00
Jon Staab d07b9cde5f Tweak spacing 2025-03-04 17:39:54 -08:00
Jon Staab d8a9cc5a7e Fix sizing for big chat inputs 2025-03-04 12:53:38 -08:00
Jon Staab 863d11352f Bump versions 2025-03-04 11:28:41 -08:00
Jon Staab b4cc770cdf Update changelog 2025-03-04 11:20:24 -08:00
Jon Staab 901e56a625 Tweak settings page, hide alerts 2025-03-04 10:58:14 -08:00
Jon Staab 479fed34f7 Fix chat layout on ios 2025-03-04 10:52:27 -08:00
Jon Staab 81d7b08aed Fix profile suggestions 2025-03-04 10:47:58 -08:00
Jon Staab a582b1ea73 Apply layout changes to chat 2025-03-04 10:20:06 -08:00
Jon Staab 1c0b2a09df ellipsize page bar title 2025-03-04 10:00:24 -08:00
Jon Staab 3a42a1b560 Rework css on room view to avoid losing input visibility 2025-03-04 09:49:56 -08:00
Jon Staab db203bf00d Move page bar closer to top of screen 2025-03-03 17:10:17 -08:00
Jon Staab ffb36af734 Make analytics and error reporting optional 2025-03-03 15:09:58 -08:00
Jon Staab b399fa8dcc Replace long press with tap target 2025-03-03 13:59:38 -08:00
Jon Staab 5bba5959f7 Attempt to fix keyboard placement, wait for connection 2025-03-03 13:23:44 -08:00
Jon Staab 2ad65e394e Remember user minute selection 2025-03-03 13:04:12 -08:00
Jon Staab 345b20bf5d Fix nevent hints for url-specific stuff 2025-03-03 12:10:47 -08:00
Jon Staab b9fb251b32 Randomize subscription minute 2025-02-27 09:33:40 -08:00
Jon Staab dd9a9c0df2 Add status to alert items 2025-02-25 13:36:32 -08:00
Jon Staab 115b5f9fbe Extend timeout for setChecked 2025-02-24 13:40:26 -08:00
Jon Staab 3ad7dcfeb4 Ignore some files 2025-02-19 11:13:38 -08:00
Jon Staab 60d107aed2 Fix some state stuff, snapshot things in the right places 2025-02-18 17:15:41 -08:00
Jon Staab 08d8d45ecb Refactor confirm to avoid passing closures 2025-02-18 09:03:10 -08:00
Jon Staab c40e8ce1a7 Fix reactions on mobile 2025-02-17 17:33:21 -08:00
Jon Staab 993bf8d2e6 Bump gradle build number 2025-02-14 16:20:16 -08:00
Jon Staab c3c65c3970 Use in-app onboarding on all native platforms 2025-02-14 16:07:40 -08:00
Jon Staab a5b868cd56 Update changelog 2025-02-14 16:02:11 -08:00
Jon Staab 8fcc56a408 Bump version 2025-02-14 16:00:18 -08:00
Jon Staab c8dfbc936b Spruce up nstart, add profile deletion 2025-02-14 15:59:20 -08:00
127 changed files with 12474 additions and 17251 deletions
+2
View File
@@ -3,4 +3,6 @@
--ignore-dir=build --ignore-dir=build
--ignore-dir=ios/DerivedData --ignore-dir=ios/DerivedData
--ignore-dir=ios/App/App/public --ignore-dir=ios/App/App/public
--ignore-file=match:.svg
--ignore-file=match:package-lock.json
+2
View File
@@ -8,5 +8,7 @@ VITE_PLATFORM_LOGO=static/flotilla.png
VITE_PLATFORM_RELAY= VITE_PLATFORM_RELAY=
VITE_PLATFORM_ACCENT="#7161FF" VITE_PLATFORM_ACCENT="#7161FF"
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_GLITCHTIP_API_KEY= VITE_GLITCHTIP_API_KEY=
GLITCHTIP_AUTH_TOKEN= GLITCHTIP_AUTH_TOKEN=
+2
View File
@@ -56,6 +56,8 @@ out/
.gradle/ .gradle/
local.properties local.properties
proguard/ proguard/
google-services.json
GoogleService-Info.plist
# IDEs and editors # IDEs and editors
.idea/ .idea/
+2 -2
View File
@@ -1,2 +1,2 @@
npm run lint pnpm run lint
npm run check pnpm run check
+40
View File
@@ -1,5 +1,45 @@
# Changelog # Changelog
# 1.0.0
* 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
* Add calendar event editing
# 0.2.13
* Fix android keyboard issue
# 0.2.12
* Fix keyboard covering chat input
* Fix thread replies
* Make error reporting and analytics optional
* Replace long press with tap target
* Fix time input
* Fix nevent hints for url-specific stuff
* Fix confirm and reactions on mobile
* Add reply to chat on mobile
* Fix profile suggestions
# 0.2.11
* Add in-app signup flow on ios
* Add profile deletion
# 0.2.10 # 0.2.10
* Improve space discovery * Improve space discovery
+6 -6
View File
@@ -2,14 +2,14 @@
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
@@ -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.local 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.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "social.flotilla" applicationId "social.flotilla"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10 versionCode 14
versionName "0.2.10" versionName "1.0.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+1
View File
@@ -10,6 +10,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies { dependencies {
implementation project(':capacitor-app') implementation project(':capacitor-app')
implementation project(':capacitor-keyboard')
implementation project(':nostr-signer-capacitor-plugin') implementation project(':nostr-signer-capacitor-plugin')
} }
+3 -1
View File
@@ -6,12 +6,14 @@
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"
android:label="@string/title_activity_main" android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch" android:theme="@style/AppTheme.NoActionBarLaunch"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask" android:launchMode="singleTask"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
+6 -3
View File
@@ -1,9 +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'
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')
+5 -1
View File
@@ -10,7 +10,11 @@ const config: CapacitorConfig = {
plugins: { plugins: {
SplashScreen: { SplashScreen: {
androidSplashResourceName: "splash" androidSplashResourceName: "splash"
} },
Keyboard: {
style: "DARK",
resizeOnFullScreen: true,
},
}, },
// 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: {
+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 = 4; CURRENT_PROJECT_VERSION = 8;
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.10; MARKETING_VERSION = 1.0.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -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 = 4; CURRENT_PROJECT_VERSION = 8;
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.10; MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
+6 -5
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,10 +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 'NostrSignerCapacitorPlugin', :path => '../../node_modules/nostr-signer-capacitor-plugin' 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/.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
-15453
View File
File diff suppressed because it is too large Load Diff
+38 -12
View File
@@ -1,6 +1,6 @@
{ {
"name": "flotilla", "name": "flotilla",
"version": "0.2.10", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -42,6 +42,7 @@
"@capacitor/cli": "^7.0.0", "@capacitor/cli": "^7.0.0",
"@capacitor/core": "^7.0.1", "@capacitor/core": "^7.0.1",
"@capacitor/ios": "^7.0.0", "@capacitor/ios": "^7.0.0",
"@capacitor/keyboard": "^7.0.0",
"@noble/curves": "^1.5.0", "@noble/curves": "^1.5.0",
"@noble/hashes": "^1.4.0", "@noble/hashes": "^1.4.0",
"@poppanator/sveltekit-svg": "^4.2.1", "@poppanator/sveltekit-svg": "^4.2.1",
@@ -50,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.42", "@welshman/app": "^0.2.3",
"@welshman/content": "~0.0.18", "@welshman/content": "^0.2.0",
"@welshman/dvm": "~0.0.14", "@welshman/dvm": "^0.2.0",
"@welshman/editor": "~0.0.15", "@welshman/editor": "^0.2.0",
"@welshman/feeds": "~0.0.30", "@welshman/feeds": "^0.2.2",
"@welshman/lib": "~0.0.41", "@welshman/lib": "^0.2.1",
"@welshman/net": "~0.0.47", "@welshman/net": "^0.2.2",
"@welshman/signer": "~0.0.20", "@welshman/relay": "^0.2.0",
"@welshman/store": "~0.0.16", "@welshman/router": "^0.2.0",
"@welshman/util": "~0.0.61", "@welshman/signer": "^0.2.1",
"@welshman/store": "^0.2.0",
"@welshman/util": "^0.2.2",
"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",
@@ -67,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
+28
View File
@@ -1,3 +1,5 @@
@import "@welshman/editor/index.css";
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@@ -323,3 +325,29 @@ emoji-picker {
--input-font-color: var(--base-content); --input-font-color: var(--base-content);
--outline-color: var(--base-100); --outline-color: var(--base-100);
} }
/* progress */
progress[value]::-webkit-progress-value {
transition: width 0.5s;
}
/* content width for fixed elements */
.cw {
@apply w-full md:w-[calc(100%-18.5rem)];
}
.cb {
@apply saib bottom-14 md:bottom-0;
}
/* chat view */
.chat__compose {
@apply cb cw fixed;
}
.chat__scroll-down {
@apply saib fixed bottom-28 right-4 md:bottom-16;
}
+3 -1
View File
@@ -2,7 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0" /> <meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="theme-color" content="{ACCENT}" /> <meta name="theme-color" content="{ACCENT}" />
<meta name="description" content="{DESCRIPTION}" /> <meta name="description" content="{DESCRIPTION}" />
<meta name="og:url" content="{URL}" /> <meta name="og:url" content="{URL}" />
+2 -1
View File
@@ -1,6 +1,7 @@
/* eslint prefer-rest-params: 0 */ /* eslint prefer-rest-params: 0 */
import {page} from "$app/stores" import {page} from "$app/stores"
import {getSetting} from "@app/state"
const w = window as any const w = window as any
@@ -12,7 +13,7 @@ w.plausible =
export const setupAnalytics = () => { export const setupAnalytics = () => {
page.subscribe($page => { page.subscribe($page => {
if ($page.route) { if ($page.route && getSetting("report_usage")) {
w.plausible("pageview", {u: $page.route.id}) w.plausible("pageview", {u: $page.route.id})
} }
}) })
+93 -103
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, uniq, equals} from "@welshman/lib" import {randomId, ifLet, 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,31 +30,29 @@ import {
getRelayTags, getRelayTags,
getRelayTagValues, getRelayTagValues,
toNostrURI, toNostrURI,
getRelaysFromList,
RelayMode,
} from "@welshman/util" } from "@welshman/util"
import type {TrustedEvent, 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 {
@@ -60,16 +60,17 @@ import {
PROTECTED, PROTECTED,
userMembership, userMembership,
INDEXER_RELAYS, INDEXER_RELAYS,
NIP46_PERMS, ALERT,
NOTIFIER_PUBKEY,
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
@@ -82,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) {
@@ -97,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)]
@@ -107,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 () => {
@@ -199,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})
} }
@@ -208,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})
} }
@@ -224,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})
} }
@@ -233,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})
} }
@@ -259,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(),
], ],
}) })
@@ -269,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) {
@@ -280,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(),
], ],
}) })
@@ -290,21 +257,19 @@ 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(signer.get().sign)
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 ifLet(await getThunkError(thunk), error => {
if (result[url].status === PublishStatus.Failure) {
const message = const message =
connection.auth.message?.replace(/^.*: /, "") || socket.auth.details?.replace(/^.*: /, "") ||
result[url].message?.replace(/^.*: /, "") || error?.replace(/^.*: /, "") ||
"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
@@ -312,7 +277,7 @@ export const checkRelayAccess = async (url: string, claim = "") => {
if (message !== "missing group (`h`) tag") { if (message !== "missing group (`h`) tag") {
return `Failed to join relay (${message})` return `Failed to join relay (${message})`
} }
} })
} }
export const checkRelayProfile = async (url: string) => { export const checkRelayProfile = async (url: string) => {
@@ -324,25 +289,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()
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(signer.get().sign)
// 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})`
} }
} }
@@ -364,28 +334,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)
@@ -455,3 +403,45 @@ export const makeComment = ({event, content, tags = []}: CommentParams) =>
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) => export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
publishThunk({event: makeComment(params), relays}) publishThunk({event: makeComment(params), relays})
export type AlertParams = {
feed: Feed
cron: string
email: string
bunker: string
secret: string
description: string
}
export const makeAlert = async ({cron, email, feed, bunker, secret, description}: AlertParams) => {
const tags = [
["feed", JSON.stringify(feed)],
["cron", cron],
["email", email],
["locale", LOCALE],
["timezone", TIMEZONE],
["description", description],
["channel", "email"],
[
"handler",
"31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1737058597050",
"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: [
["d", randomId()],
["p", NOTIFIER_PUBKEY],
],
})
}
export const publishAlert = async (params: AlertParams) =>
publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]})
+237
View File
@@ -0,0 +1,237 @@
<script lang="ts">
import {preventDefault} from "@lib/html"
import {randomInt, displayList, TIMEZONE, identity} from "@welshman/lib"
import {displayRelayUrl, getTagValue, THREAD, MESSAGE, EVENT_TIME, COMMENT} 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 Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
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 {publishAlert} from "@app/commands"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
const timezoneOffset = parseInt(TIMEZONE.slice(3)) / 100
const minute = randomInt(0, 59)
const hour = (17 - timezoneOffset) % 24
const WEEKLY = `0 ${minute} ${hour} * * 1`
const DAILY = `0 ${minute} ${hour} * * *`
let loading = false
let cron = WEEKLY
let email = $alerts.map(a => getTagValue("email", a.tags)).filter(identity)[0] || ""
let relay = ""
let bunker = ""
let secret = ""
let notifyThreads = true
let notifyCalendar = true
let notifyChat = false
let showBunker = false
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 () => {
if (!email.includes("@")) {
return pushToast({
theme: "error",
message: "Please provide an email address",
})
}
if (!relay) {
return pushToast({
theme: "error",
message: "Please select a space",
})
}
if (!notifyThreads && !notifyCalendar && !notifyChat) {
return pushToast({
theme: "error",
message: "Please select something to be notified about",
})
}
const filters: Filter[] = []
const display: string[] = []
if (notifyThreads) {
display.push("threads")
filters.push({kinds: [THREAD]})
filters.push({kinds: [COMMENT], "#k": [String(THREAD)]})
}
if (notifyCalendar) {
display.push("calendar events")
filters.push({kinds: [EVENT_TIME]})
filters.push({kinds: [COMMENT], "#k": [String(EVENT_TIME)]})
}
if (notifyChat) {
display.push("chat")
filters.push({
kinds: [MESSAGE],
"#h": [GENERAL, ...getMembershipRoomsByUrl(relay, $userMembership)],
})
}
loading = true
try {
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!)
pushToast({message: "Your alert has been successfully created!"})
back()
} finally {
loading = false
}
}
</script>
<form class="column gap-4" onsubmit={preventDefault(submit)}>
<ModalHeader>
{#snippet title()}
Add an Alert
{/snippet}
</ModalHeader>
{#if showBunker}
<div class="card2 flex flex-col items-center gap-4 bg-base-300">
<p>Scan using a nostr signer, or click to copy.</p>
<BunkerConnect {controller} />
<Button class="btn btn-neutral btn-sm" onclick={hideBunker}>Cancel</Button>
</div>
{:else}
<FieldInline>
{#snippet label()}
<p>Email Address*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<input placeholder="email@example.com" bind:value={email} />
</label>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Frequency*</p>
{/snippet}
{#snippet input()}
<select bind:value={cron} class="select select-bordered">
<option value={WEEKLY}>Weekly</option>
<option value={DAILY}>Daily</option>
</select>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Space*</p>
{/snippet}
{#snippet input()}
<select bind:value={relay} class="select select-bordered">
<option value="" disabled selected>Choose a space URL</option>
{#each getMembershipUrls($userMembership) as url (url)}
<option value={url}>{displayRelayUrl(url)}</option>
{/each}
</select>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Notifications*</p>
{/snippet}
{#snippet input()}
<div class="flex items-center justify-end gap-4">
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyThreads} />
Threads
</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>
</div>
<p class="text-sm">
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>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading || showBunker}>
<Spinner {loading}>Confirm</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
</form>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import Confirm from "@lib/components/Confirm.svelte"
import type {Alert} from "@app/state"
import {NOTIFIER_RELAY} from "@app/state"
import {publishDelete} from "@app/commands"
import {pushToast} from "@app/toast"
type Props = {
alert: Alert
}
const {alert}: Props = $props()
const confirm = () => {
publishDelete({event: alert.event, relays: [NOTIFIER_RELAY]})
pushToast({message: "Your alert has been deleted!"})
history.back()
}
</script>
<Confirm {confirm} message="You'll no longer receive messages for this alert." />
+70
View File
@@ -0,0 +1,70 @@
<script lang="ts">
import {parseJson, nthEq} from "@welshman/lib"
import {displayFeeds} from "@welshman/feeds"
import {getAddress, getTagValue, getTagValues} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import AlertDelete from "@app/components/AlertDelete.svelte"
import type {Alert} from "@app/state"
import {alertStatuses} from "@app/state"
import {pushModal} from "@app/modal"
type Props = {
alert: Alert
}
const {alert}: Props = $props()
const address = $derived(getAddress(alert.event))
const status = $derived($alertStatuses.find(s => s.event.tags.some(nthEq(1, address))))
const cron = $derived(getTagValue("cron", alert.tags))
const channel = $derived(getTagValue("channel", alert.tags))
const feeds = $derived(getTagValues("feed", alert.tags))
const description = $derived(
getTagValue("description", alert.tags) ||
[
`${cron?.endsWith("1") ? "Weekly" : "Daily"} alert for events`,
displayFeeds(feeds.map(parseJson)),
`sent via ${channel}.`,
].join(" "),
)
const startDelete = () => pushModal(AlertDelete, {alert})
</script>
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4">
<Button class="py-1" onclick={startDelete}>
<Icon icon="trash-bin-2" />
</Button>
<div class="flex-inline gap-1">{description}</div>
</div>
{#if status}
{@const statusText = getTagValue("status", status.tags) || "error"}
{#if statusText === "ok"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content px-3 py-1 text-sm"
data-tip={getTagValue("message", status.tags)}>
Active
</span>
{:else if statusText === "pending"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content border-yellow-500 px-3 py-1 text-sm text-yellow-500"
data-tip={getTagValue("message", status.tags)}>
Pending
</span>
{:else}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip={getTagValue("message", status.tags)}>
{statusText.replace("-", " ").replace(/^(.)/, x => x.toUpperCase())}
</span>
{/if}
{:else}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip="The notification server did not respond to your request.">
Inactive
</span>
{/if}
</div>
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
import {onMount} from "svelte"
import {pubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import AlertAdd from "@app/components/AlertAdd.svelte"
import AlertItem from "@app/components/AlertItem.svelte"
import {loadAlertStatuses, loadAlerts} from "@app/requests"
import {pushModal} from "@app/modal"
import {alerts} from "@app/state"
const startAlert = () => pushModal(AlertAdd)
onMount(() => {
loadAlertStatuses($pubkey!)
loadAlerts($pubkey!)
})
</script>
<div class="card2 bg-alt flex flex-col gap-6 shadow-xl">
<div class="flex items-center justify-between">
<strong class="flex items-center gap-3">
<Icon icon="inbox" />
Alerts
</strong>
<Button class="btn btn-primary btn-sm" onclick={startAlert}>
<Icon icon="add-circle" />
Add Alert
</Button>
</div>
<div class="col-4">
{#each $alerts as alert (alert.event.id)}
<AlertItem {alert} />
{:else}
<p class="text-center opacity-75 py-12">No alerts found</p>
{/each}
</div>
</div>
+78
View File
@@ -0,0 +1,78 @@
<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 = Nip46Broker.get({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.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>
+18 -1
View File
@@ -1,12 +1,16 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {pubkey} from "@welshman/app" import {pubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
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"
import EventActions from "@app/components/EventActions.svelte" import EventActions from "@app/components/EventActions.svelte"
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
import {publishDelete, publishReaction} from "@app/commands" import {publishDelete, publishReaction} from "@app/commands"
import {makeCalendarPath} from "@app/routes" import {makeCalendarPath} from "@app/routes"
import {pushModal} from "@app/modal"
const { const {
url, url,
@@ -20,6 +24,8 @@
const path = makeCalendarPath(url, event.id) const path = makeCalendarPath(url, event.id)
const editEvent = () => pushModal(CalendarEventEdit, {url, event})
const onReactionClick = (content: string, events: TrustedEvent[]) => { const onReactionClick = (content: string, events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey) const reaction = events.find(e => e.pubkey === $pubkey)
@@ -38,6 +44,17 @@
{#if showActivity} {#if showActivity}
<EventActivity {url} {path} {event} /> <EventActivity {url} {path} {event} />
{/if} {/if}
<EventActions {url} {event} noun="Event" /> <EventActions {url} {event} noun="Event">
{#snippet customActions()}
{#if event.pubkey === $pubkey}
<li>
<Button onclick={editEvent}>
<Icon size={4} icon="pen" />
Edit Event
</Button>
</li>
{/if}
{/snippet}
</EventActions>
</div> </div>
</div> </div>
+16 -157
View File
@@ -1,164 +1,23 @@
<script lang="ts"> <script lang="ts">
import {writable} from "svelte/store"
import {randomId, HOUR} from "@welshman/lib"
import {createEvent, EVENT_TIME} from "@welshman/util"
import {publishThunk} from "@welshman/app"
import {preventDefault} from "@lib/html"
import {daysBetween} from "@lib/util"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.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 CalendarEventForm from "@app/components/CalendarEventForm.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {PROTECTED, GENERAL, tagRoom} from "@app/state"
import {makeEditor} from "@app/editor"
import {pushToast} from "@app/toast"
const {url} = $props() type Props = {
url: string
const uploading = writable(false)
const back = () => history.back()
const submit = () => {
if ($uploading) return
if (!title) {
return pushToast({
theme: "error",
message: "Please provide a title.",
})
}
if (!start || !end) {
return pushToast({
theme: "error",
message: "Please provide start and end times.",
})
}
if (start >= end) {
return pushToast({
theme: "error",
message: "End time must be later than start time.",
})
}
const event = createEvent(EVENT_TIME, {
content: editor.getText({blockSeparator: "\n"}).trim(),
tags: [
["d", randomId()],
["title", title],
["location", location],
["start", start.toString()],
["end", end.toString()],
...daysBetween(start, end).map(D => ["D", D.toString()]),
...editor.storage.nostr.getEditorTags(),
tagRoom(GENERAL, url),
PROTECTED,
],
})
pushToast({message: "Your event has been published!"})
publishThunk({event, relays: [url]})
history.back()
} }
const editor = makeEditor({submit, uploading}) const {url}: Props = $props()
let title = $state("")
let location = $state("")
let start: number | undefined = $state()
let end: number | undefined = $state()
let endDirty = false
$effect(() => {
if (!endDirty && start) {
end = start + HOUR
} else if (end) {
endDirty = true
}
})
</script> </script>
<form class="column gap-4" onsubmit={preventDefault(submit)}> <CalendarEventForm {url}>
<ModalHeader> {#snippet header()}
{#snippet title()} <ModalHeader>
<div>Create an Event</div> {#snippet title()}
{/snippet} <div>Create an Event</div>
{#snippet info()} {/snippet}
<div>Invite other group members to events online or in real life.</div> {#snippet info()}
{/snippet} <div>Invite other group members to events online or in real life.</div>
</ModalHeader> {/snippet}
<Field> </ModalHeader>
{#snippet label()} {/snippet}
<p>Title*</p> </CalendarEventForm>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<input bind:value={title} class="grow" type="text" />
</label>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Summary</p>
{/snippet}
{#snippet input()}
<div class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
<div class="input-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
<Button
data-tip="Add an image"
class="center btn tooltip"
onclick={() => editor.chain().selectFiles().run()}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="gallery-send" />
{/if}
</Button>
</div>
{/snippet}
</Field>
<Field>
{#snippet label()}
Start*
{/snippet}
{#snippet input()}
<DateTimeInput bind:value={start} />
{/snippet}
</Field>
<Field>
{#snippet label()}
End*
{/snippet}
{#snippet input()}
<DateTimeInput bind:value={end} />
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Location (optional)</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="map-point" />
<input bind:value={location} class="grow" type="text" />
</label>
{/snippet}
</Field>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={$uploading}>
<Spinner loading={$uploading}>Create Event</Spinner>
</Button>
</ModalFooter>
</form>
+6 -5
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
@@ -13,7 +12,9 @@
</script> </script>
<div <div
class="flex h-14 w-14 flex-col items-center justify-center gap-1 rounded-box border border-solid border-base-content p-2 sm:h-24 sm:w-24"> class="hidden h-32 w-32 min-w-32 flex-col items-center justify-center gap-1 rounded-box bg-base-300 p-2 sm:flex">
<span class="sm:text-lg">{Intl.DateTimeFormat(LOCALE, {month: "short"}).format(startDate)}</span> <strong>{Intl.DateTimeFormat(LOCALE, {month: "short"}).format(startDate)}</strong>
<span class="sm:text-4xl">{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(startDate)}</span> <span class="text-4xl">{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(startDate)}</span>
<span class="text-xs opacity-75"
>{Intl.DateTimeFormat(LOCALE, {weekday: "long"}).format(startDate)}</span>
</div> </div>
@@ -0,0 +1,35 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {getTagValue} from "@welshman/util"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import CalendarEventForm from "@app/components/CalendarEventForm.svelte"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const initialValues = {
d: getTagValue("d", event.tags)!,
title: getTagValue("title", event.tags)!,
location: getTagValue("location", event.tags)!,
start: parseInt(getTagValue("start", event.tags)!),
end: parseInt(getTagValue("end", event.tags)!),
content: event.content,
}
</script>
<CalendarEventForm {url} {initialValues}>
{#snippet header()}
<ModalHeader>
{#snippet title()}
<div>Edit this Event</div>
{/snippet}
{#snippet info()}
<div>Invite other group members to events online or in real life.</div>
{/snippet}
</ModalHeader>
{/snippet}
</CalendarEventForm>
+171
View File
@@ -0,0 +1,171 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {writable} from "svelte/store"
import {randomId, HOUR} from "@welshman/lib"
import {createEvent, EVENT_TIME} from "@welshman/util"
import {publishThunk} from "@welshman/app"
import {preventDefault} from "@lib/html"
import {daysBetween} from "@lib/util"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {PROTECTED, GENERAL, tagRoom} from "@app/state"
import {makeEditor} from "@app/editor"
import {pushToast} from "@app/toast"
type Props = {
url: string
header: Snippet
initialValues?: {
d: string
title: string
content: string
location: string
start: number
end: number
}
}
const {url, header, initialValues}: Props = $props()
const uploading = writable(false)
const back = () => history.back()
const selectFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const submit = async () => {
if ($uploading) return
if (!title) {
return pushToast({
theme: "error",
message: "Please provide a title.",
})
}
if (!start || !end) {
return pushToast({
theme: "error",
message: "Please provide start and end times.",
})
}
if (start >= end) {
return pushToast({
theme: "error",
message: "End time must be later than start time.",
})
}
const ed = await editor
const event = createEvent(EVENT_TIME, {
content: ed.getText({blockSeparator: "\n"}).trim(),
tags: [
["d", initialValues?.d || randomId()],
["title", title],
["location", location || ""],
["start", start.toString()],
["end", end.toString()],
...daysBetween(start, end).map(D => ["D", D.toString()]),
...ed.storage.nostr.getEditorTags(),
tagRoom(GENERAL, url),
PROTECTED,
],
})
pushToast({message: "Your event has been saved!"})
publishThunk({event, relays: [url]})
history.back()
}
const content = initialValues?.content || ""
const editor = makeEditor({url, submit, uploading, content})
let title = $state(initialValues?.title || "")
let location = $state(initialValues?.location || "")
let start: number | undefined = $state(initialValues?.start)
let end: number | undefined = $state(initialValues?.end)
let endDirty = Boolean(initialValues?.end)
$effect(() => {
if (!endDirty && start) {
end = start + HOUR
} else if (end) {
endDirty = true
}
})
</script>
<form novalidate class="column gap-4" onsubmit={preventDefault(submit)}>
{@render header()}
<Field>
{#snippet label()}
<p>Title*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<input bind:value={title} class="grow" type="text" />
</label>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Summary</p>
{/snippet}
{#snippet input()}
<div class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
<div class="input-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
<Button data-tip="Add an image" class="center btn tooltip" onclick={selectFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="gallery-send" />
{/if}
</Button>
</div>
{/snippet}
</Field>
<Field>
{#snippet label()}
Start*
{/snippet}
{#snippet input()}
<DateTimeInput bind:value={start} />
{/snippet}
</Field>
<Field>
{#snippet label()}
End*
{/snippet}
{#snippet input()}
<DateTimeInput bind:value={end} />
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Location (optional)</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="map-point" />
<input bind:value={location} class="grow" type="text" />
</label>
{/snippet}
</Field>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={$uploading}>
<Spinner loading={$uploading}>Save Event</Spinner>
</Button>
</ModalFooter>
</form>
+15 -6
View File
@@ -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
@@ -17,8 +21,13 @@
const isSingleDay = $derived(startDateDisplay === endDateDisplay) const isSingleDay = $derived(startDateDisplay === endDateDisplay)
</script> </script>
<p class="text-xl">{meta.title || meta.name}</p> <div class="flex flex-grow flex-wrap justify-between gap-2">
<div class="flex items-center gap-2 text-sm"> <p class="text-xl">{meta.title || meta.name}</p>
<Icon icon="clock-circle" size={4} /> <div class="flex items-center gap-2 text-sm">
{formatTimestampAsTime(start)}{isSingleDay ? formatTimestampAsTime(end) : formatTimestamp(end)} <Icon icon="clock-circle" size={4} />
<span class="sm:hidden">{formatTimestampAsDate(start)}</span>
{formatTimestampAsTime(start)}{isSingleDay
? formatTimestampAsTime(end)
: formatTimestamp(end)}
</div>
</div> </div>
+2 -4
View File
@@ -15,12 +15,10 @@
</script> </script>
<Link class="col-3 card2 bg-alt w-full cursor-pointer" href={makeCalendarPath(url, event.id)}> <Link class="col-3 card2 bg-alt w-full cursor-pointer" href={makeCalendarPath(url, event.id)}>
<div class="flex items-center justify-between gap-2"> <CalendarEventHeader {event} />
<CalendarEventHeader {event} />
</div>
<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>
+12 -9
View File
@@ -6,19 +6,22 @@
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>
<span> <div class="flex min-w-0 flex-col gap-1 text-sm opacity-75">
Posted by <ProfileLink pubkey={event.pubkey} />
</span>
{#if meta.location}
<span></span>
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<Icon icon="map-point" size={4} /> <Icon icon="user-circle" size={4} />
{meta.location} Posted by <ProfileLink pubkey={event.pubkey} {url} />
</span> </span>
{/if} {#if meta.location}
<span class="flex items-start gap-1">
<Icon icon="map-point" class="mt-[2px]" size={4} />
<span class="break-words">{meta.location}</span>
</span>
{/if}
</div>
+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)}>
+28 -34
View File
@@ -1,17 +1,9 @@
<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} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import { import {thunks, pubkey, deriveProfile, deriveProfileDisplay} from "@welshman/app"
thunks,
pubkey,
deriveProfile,
deriveProfileDisplay,
formatTimestampAsDate,
formatTimestampAsTime,
} from "@welshman/app"
import {isMobile} from "@lib/html" import {isMobile} from "@lib/html"
import LongPress from "@lib/components/LongPress.svelte" import TapTarget from "@lib/components/TapTarget.svelte"
import Avatar from "@lib/components/Avatar.svelte" import Avatar from "@lib/components/Avatar.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"
@@ -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,15 +31,15 @@
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 onLongPress = () => 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 onReactionClick = (content: string, events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey) const reaction = events.find(e => e.pubkey === $pubkey)
@@ -60,9 +52,9 @@
} }
</script> </script>
<LongPress <TapTarget
data-event={event.id} data-event={event.id}
onLongPress={inert ? null : onLongPress} onTap={inert ? null : onTap}
class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left"> class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left">
<div class="flex w-full gap-3 overflow-auto"> <div class="flex w-full gap-3 overflow-auto">
{#if showPubkey} {#if showPubkey}
@@ -89,7 +81,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}
@@ -99,15 +91,17 @@
<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} {onReactionClick} reactionClass="tooltip-right" />
</div> </div>
<button {#if !isMobile}
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all" <button
class:group-hover:opacity-100={!isMobile}> class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
<ChannelMessageEmojiButton {url} {room} {event} /> class:group-hover:opacity-100={!isMobile}>
{#if replyTo} <ChannelMessageEmojiButton {url} {room} {event} />
<Button class="btn join-item btn-xs" onclick={reply}> {#if replyTo}
<Icon icon="reply" size={4} /> <Button class="btn join-item btn-xs" onclick={reply}>
</Button> <Icon icon="reply" size={4} />
{/if} </Button>
<ChannelMessageMenuButton {url} {event} /> {/if}
</button> <ChannelMessageMenuButton {url} {event} />
</LongPress> </button>
{/if}
</TapTarget>
+3 -3
View File
@@ -4,7 +4,7 @@
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import EventInfo from "@app/components/EventInfo.svelte" import EventInfo from "@app/components/EventInfo.svelte"
import EventReport from "@app/components/EventReport.svelte" import EventReport from "@app/components/EventReport.svelte"
import ConfirmDelete from "@app/components/ConfirmDelete.svelte" import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
const {url, event, onClick} = $props() const {url, event, onClick} = $props()
@@ -16,12 +16,12 @@
const showInfo = () => { const showInfo = () => {
onClick() onClick()
pushModal(EventInfo, {event}) pushModal(EventInfo, {url, event})
} }
const showDelete = () => { const showDelete = () => {
onClick() onClick()
pushModal(ConfirmDelete, {url, event}) pushModal(EventDeleteConfirm, {url, event})
} }
</script> </script>
@@ -1,20 +1,27 @@
<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 {pubkey} from "@welshman/app" import {pubkey} from "@welshman/app"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.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 ConfirmDelete from "@app/components/ConfirmDelete.svelte" import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {publishReaction} from "@app/commands" import {publishReaction} from "@app/commands"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
const {url, event, reply} = $props() type Props = {
url: string
event: TrustedEvent
reply: () => void
}
const onEmoji = (emoji: NativeEmoji) => { const {url, event, reply}: Props = $props()
const onEmoji = ((event: TrustedEvent, url: string, emoji: NativeEmoji) => {
history.back() history.back()
publishReaction({event, relays: [url], content: emoji.unicode}) publishReaction({event, relays: [url], content: emoji.unicode})
} }).bind(undefined, event, url)
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true}) const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
@@ -23,9 +30,9 @@
reply() reply()
} }
const showInfo = () => pushModal(EventInfo, {event}, {replaceState: true}) const showInfo = () => pushModal(EventInfo, {url, event}, {replaceState: true})
const showDelete = () => pushModal(ConfirmDelete, {url, event}) const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
</script> </script>
<div class="col-2"> <div class="col-2">
+146 -112
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,27 +24,30 @@
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)
const others = remove($pubkey!, pubkeys) const others = remove($pubkey!, pubkeys)
const missingInboxes = $derived(pubkeys.filter(pk => !$inboxRelaySelectionsByPubkey.has(pk))) const missingInboxes = $derived(pubkeys.filter(pk => !$inboxRelaySelectionsByPubkey.has(pk)))
const assertEvent = (e: any) => e as TrustedEvent
const showMembers = () => const showMembers = () =>
pushModal(ProfileList, {pubkeys: others, title: `People in this conversation`}) pushModal(ProfileList, {pubkeys: others, title: `People in this conversation`})
@@ -72,6 +76,8 @@
let loading = $state(true) let loading = $state(true)
let compose: ChatCompose | undefined = $state() let compose: ChatCompose | undefined = $state()
let parent: TrustedEvent | undefined = $state() let parent: TrustedEvent | undefined = $state()
let chatCompose: HTMLElement | undefined = $state()
let dynamicPadding: HTMLElement | undefined = $state()
const elements = $derived.by(() => { const elements = $derived.by(() => {
const elements = [] const elements = []
@@ -105,7 +111,25 @@
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(() => {
if (dynamicPadding && chatCompose) {
dynamicPadding.style.minHeight = `${chatCompose.offsetHeight}px`
}
})
observer.observe(chatCompose!)
return () => {
observer.unobserve(chatCompose!)
}
}) })
setTimeout(() => { setTimeout(() => {
@@ -113,106 +137,116 @@
}, 5000) }, 5000)
</script> </script>
<div class="relative flex h-full w-full flex-col"> <PageBar>
{#if others.length > 0} {#snippet title()}
<PageBar> <div class="flex flex-col gap-1 sm:flex-row sm:gap-2">
{#snippet title()} {#if others.length === 0}
<div class="flex flex-col gap-1 sm:flex-row sm:gap-2"> <div class="row-2">
{#if others.length === 1} <ProfileCircle pubkey={$pubkey!} size={5} />
{@const pubkey = others[0]} <ProfileName pubkey={$pubkey!} />
{@const onClick = () => pushModal(ProfileDetail, {pubkey})}
<Button onclick={onClick} class="row-2">
<ProfileCircle {pubkey} size={5} />
<ProfileName {pubkey} />
</Button>
{:else}
<div class="flex items-center gap-2">
<ProfileCircles pubkeys={others} size={5} />
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
<ProfileName pubkey={others[0]} />
and
{#if others.length === 2}
<ProfileName pubkey={others[1]} />
{:else}
{others.length - 1}
{others.length > 2 ? "others" : "other"}
{/if}
</p>
</div>
{#if others.length > 2}
<Button onclick={showMembers} class="btn btn-link hidden sm:block"
>Show all members</Button>
{/if}
{/if}
</div> </div>
{/snippet} {:else if others.length === 1}
{#snippet action()} {@const pubkey = others[0]}
<div> {@const onClick = () => pushModal(ProfileDetail, {pubkey})}
{#if remove($pubkey, missingInboxes).length > 0} <Button onclick={onClick} class="row-2">
{@const count = remove($pubkey, missingInboxes).length} <ProfileCircle {pubkey} size={5} />
{@const label = count > 1 ? "inboxes are" : "inbox is"} <ProfileName {pubkey} />
<div </Button>
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer"
data-tip="{count} {label} not configured.">
<Icon icon="danger" />
{count}
</div>
{/if}
</div>
{/snippet}
</PageBar>
{/if}
<div class="-mt-2 flex flex-grow flex-col-reverse overflow-auto py-2">
{#if missingInboxes.includes($pubkey!)}
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error">
<Icon icon="danger" />
Your inbox is not configured.
</p>
<p>
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please visit
your <Link class="link" href="/settings/relays">relay settings page</Link> to set up your
inbox.
</p>
</div>
</div>
{:else if missingInboxes.length > 0}
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error">
<Icon icon="danger" />
{missingInboxes.length}
{missingInboxes.length > 1 ? "inboxes are" : "inbox is"} not configured.
</p>
<p>
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please make
sure everyone in this conversation has set up their inbox relays.
</p>
</div>
</div>
{/if}
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "date"}
<Divider>{value}</Divider>
{:else} {:else}
<ChatMessage event={assertEvent(value)} {pubkeys} {showPubkey} {replyTo} /> <div class="flex items-center gap-2">
{/if} <ProfileCircles pubkeys={others} size={5} />
{/each} <p class="overflow-hidden text-ellipsis whitespace-nowrap">
<p <ProfileName pubkey={others[0]} />
class="m-auto flex h-10 max-w-sm flex-col items-center justify-center gap-4 py-20 text-center"> and
<Spinner {loading}> {#if others.length === 2}
{#if loading} <ProfileName pubkey={others[1]} />
Looking for messages... {:else}
{:else} {others.length - 1}
End of message history {others.length > 2 ? "others" : "other"}
{/if}
</p>
</div>
{#if others.length > 2}
<Button onclick={showMembers} class="btn btn-link hidden sm:block"
>Show all members</Button>
{/if} {/if}
</Spinner> {/if}
{@render info?.()} </div>
</p> {/snippet}
</div> {#snippet action()}
{#if parent} <div>
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" /> {#if remove($pubkey, missingInboxes).length > 0}
{@const count = remove($pubkey, missingInboxes).length}
{@const label = count > 1 ? "inboxes are" : "inbox is"}
<div
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer"
data-tip="{count} {label} not configured.">
<Icon icon="danger" />
{count}
</div>
{/if}
</div>
{/snippet}
</PageBar>
<PageContent class="flex flex-col-reverse pt-4">
<div bind:this={dynamicPadding}></div>
{#if missingInboxes.includes($pubkey!)}
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error">
<Icon icon="danger" />
Your inbox is not configured.
</p>
<p>
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please visit
your <Link class="link" href="/settings/relays">relay settings page</Link> to set up your inbox.
</p>
</div>
</div>
{:else if missingInboxes.length > 0}
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error">
<Icon icon="danger" />
{missingInboxes.length}
{missingInboxes.length > 1 ? "inboxes are" : "inbox is"} not configured.
</p>
<p>
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please make
sure everyone in this conversation has set up their inbox relays.
</p>
</div>
</div>
{/if} {/if}
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "date"}
<Divider>{value}</Divider>
{:else}
<ChatMessage
event={$state.snapshot(value as TrustedEvent)}
{pubkeys}
{showPubkey}
{replyTo} />
{/if}
{/each}
<p class="m-auto flex h-10 max-w-sm flex-col items-center justify-center gap-4 py-20 text-center">
<Spinner {loading}>
{#if loading}
Looking for messages...
{:else}
End of message history
{/if}
</Spinner>
{@render info?.()}
</p>
</PageContent>
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
<div>
{#if parent}
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
</div>
<ChatCompose bind:this={compose} {onSubmit} /> <ChatCompose bind:this={compose} {onSubmit} />
</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} />
+38 -40
View File
@@ -1,19 +1,13 @@
<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} 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"
import Tippy from "@lib/components/Tippy.svelte" import Tippy from "@lib/components/Tippy.svelte"
import LongPress from "@lib/components/LongPress.svelte" import TapTarget from "@lib/components/TapTarget.svelte"
import Avatar from "@lib/components/Avatar.svelte" import Avatar from "@lib/components/Avatar.svelte"
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte" import ReactionSummary from "@app/components/ReactionSummary.svelte"
@@ -22,17 +16,17 @@
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 {
event: TrustedEvent event: TrustedEvent
replyTo?: any replyTo: (event: TrustedEvent) => void
pubkeys: string[] pubkeys: string[]
showPubkey?: boolean showPubkey?: boolean
} }
const {event, replyTo = undefined, pubkeys, showPubkey = false}: Props = $props() const {event, replyTo, pubkeys, showPubkey = false}: Props = $props()
const thunk = $thunks[event.id] const thunk = $thunks[event.id]
const isOwn = event.pubkey === $pubkey const isOwn = event.pubkey === $pubkey
@@ -40,6 +34,8 @@
const profileDisplay = deriveProfileDisplay(event.pubkey) const profileDisplay = deriveProfileDisplay(event.pubkey)
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length] const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
const reply = () => replyTo(event)
const onReactionClick = async (content: string, events: TrustedEvent[]) => { const onReactionClick = async (content: string, events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey) const reaction = events.find(e => e.pubkey === $pubkey)
const template = reaction ? makeDelete({event: reaction}) : makeReaction({event, content}) const template = reaction ? makeDelete({event: reaction}) : makeReaction({event, content})
@@ -49,7 +45,7 @@
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey}) const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys}) const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys, reply})
const togglePopover = () => { const togglePopover = () => {
if (popoverIsVisible) { if (popoverIsVisible) {
@@ -72,32 +68,34 @@
class:chat-start={!isOwn} class:chat-start={!isOwn}
class:flex-row-reverse={!isOwn} class:flex-row-reverse={!isOwn}
class:chat-end={isOwn}> class:chat-end={isOwn}>
<Tippy {#if !isMobile}
bind:popover <Tippy
component={ChatMessageMenu} bind:popover
props={{event, pubkeys, popover, replyTo}} component={ChatMessageMenu}
params={{ props={{event, pubkeys, popover, replyTo}}
interactive: true, params={{
trigger: "manual", interactive: true,
onShow() { trigger: "manual",
popoverIsVisible = true onShow() {
}, popoverIsVisible = true
onHidden() { },
popoverIsVisible = false onHidden() {
}, popoverIsVisible = false
}}> },
<button }}>
type="button" <button
class="opacity-0 transition-all" type="button"
class:group-hover:opacity-100={!isMobile} class="opacity-0 transition-all"
onclick={togglePopover}> class:group-hover:opacity-100={!isMobile}
<Icon icon="menu-dots" size={4} /> onclick={togglePopover}>
</button> <Icon icon="menu-dots" size={4} />
</Tippy> </button>
</Tippy>
{/if}
<div class="flex min-w-0 flex-col" class:items-end={isOwn}> <div class="flex min-w-0 flex-col" class:items-end={isOwn}>
<LongPress <TapTarget
class="bg-alt chat-bubble mx-1 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl" class="bg-alt chat-bubble mx-1 mb-2 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl"
onLongPress={showMobileMenu}> onTap={showMobileMenu}>
{#if showPubkey} {#if showPubkey}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if !isOwn} {#if !isOwn}
@@ -120,8 +118,8 @@
<div class="text-sm"> <div class="text-sm">
<Content showEntire {event} /> <Content showEntire {event} />
</div> </div>
</LongPress> </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} {onReactionClick} noTooltip />
</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,22 +1,35 @@
<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 {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"
const {event, pubkeys} = $props() type Props = {
pubkeys: string[]
const onEmoji = (emoji: NativeEmoji) => { event: TrustedEvent
history.back() reply: () => void
sendWrapped({template: makeReaction({event, content: emoji.unicode}), pubkeys})
} }
const {event, pubkeys, reply}: Props = $props()
const onEmoji = ((event: TrustedEvent, pubkeys: string[], emoji: NativeEmoji) => {
history.back()
sendWrapped({template: makeReaction({event, content: emoji.unicode}), pubkeys})
}).bind(undefined, event, pubkeys)
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true}) const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
const sendReply = () => {
history.back()
reply()
}
const copyText = () => { const copyText = () => {
history.back() history.back()
clip(event.content) clip(event.content)
@@ -30,6 +43,10 @@
<Icon size={4} icon="smile-circle" /> <Icon size={4} icon="smile-circle" />
Send Reaction Send Reaction
</Button> </Button>
<Button class="btn btn-neutral w-full" onclick={sendReply}>
<Icon size={4} icon="reply" />
Send Reply
</Button>
<Button class="btn btn-neutral w-full" onclick={copyText}> <Button class="btn btn-neutral w-full" onclick={copyText}>
<Icon size={4} icon="copy" /> <Icon size={4} icon="copy" />
Copy Text Copy Text
+5 -5
View File
@@ -38,8 +38,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 +49,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)
@@ -141,15 +141,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
+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">
+7 -7
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import type {Snippet} from "svelte"
import type {Instance} from "tippy.js" import type {Instance} from "tippy.js"
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"
@@ -9,15 +10,14 @@
import EventMenu from "@app/components/EventMenu.svelte" import EventMenu from "@app/components/EventMenu.svelte"
import {publishReaction} from "@app/commands" import {publishReaction} from "@app/commands"
const { type Props = {
url,
noun,
event,
}: {
url: string url: string
noun: string noun: string
event: TrustedEvent event: TrustedEvent
} = $props() customActions?: Snippet
}
const {url, noun, event, customActions}: Props = $props()
const showPopover = () => popover?.show() const showPopover = () => popover?.show()
@@ -36,7 +36,7 @@
<Tippy <Tippy
bind:popover bind:popover
component={EventMenu} component={EventMenu}
props={{url, noun, event, onClick: hidePopover}} props={{url, noun, event, customActions, onClick: hidePopover}}
params={{trigger: "manual", interactive: true}}> params={{trigger: "manual", interactive: true}}>
<Button class="btn join-item btn-neutral btn-xs" onclick={showPopover}> <Button class="btn join-item btn-neutral btn-xs" onclick={showPopover}>
<Icon icon="menu-dots" size={4} /> <Icon icon="menu-dots" size={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"
@@ -1,14 +1,18 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import Confirm from "@lib/components/Confirm.svelte" import Confirm from "@lib/components/Confirm.svelte"
import {publishDelete} from "@app/commands" import {publishDelete} from "@app/commands"
import {clearModals} from "@app/modal" import {clearModals} from "@app/modal"
const {url, event} = $props() type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const confirm = async () => { const confirm = async () => {
const snapshot = $state.snapshot(event) await publishDelete({event, relays: [url]})
await publishDelete({event: snapshot, relays: [url]})
clearModals() clearModals()
} }
+10 -4
View File
@@ -1,15 +1,21 @@
<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 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"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import {clip} from "@app/toast" import {clip} from "@app/toast"
const {event} = $props() type Props = {
url?: string
event: TrustedEvent
}
const relays = ctx.app.router.Event(event).getUrls() const {url, event}: Props = $props()
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)
+20 -25
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte"
import type {Snippet} from "svelte"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {COMMENT} from "@welshman/util" import {COMMENT} from "@welshman/util"
import {pubkey} from "@welshman/app" import {pubkey} from "@welshman/app"
@@ -7,45 +9,37 @@
import EventInfo from "@app/components/EventInfo.svelte" import EventInfo from "@app/components/EventInfo.svelte"
import EventReport from "@app/components/EventReport.svelte" import EventReport from "@app/components/EventReport.svelte"
import EventShare from "@app/components/EventShare.svelte" import EventShare from "@app/components/EventShare.svelte"
import ConfirmDelete from "@app/components/ConfirmDelete.svelte" import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
const { type Props = {
url,
noun,
event,
onClick,
}: {
url: string url: string
noun: string noun: string
event: TrustedEvent event: TrustedEvent
onClick: () => void onClick: () => void
} = $props() customActions?: Snippet
}
const {url, noun, event, onClick, customActions}: Props = $props()
const isRoot = event.kind !== COMMENT const isRoot = event.kind !== COMMENT
const report = () => { const report = () => pushModal(EventReport, {url, event})
onClick()
pushModal(EventReport, {url, event})
}
const showInfo = () => { const showInfo = () => pushModal(EventInfo, {url, event})
onClick()
pushModal(EventInfo, {event})
}
const share = () => { const share = () => pushModal(EventShare, {url, event})
onClick()
pushModal(EventShare, {url, event})
}
const showDelete = () => { const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
onClick()
pushModal(ConfirmDelete, {url, event}) let ul: Element
}
onMount(() => {
ul.addEventListener("click", onClick)
})
</script> </script>
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-xl"> <ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-xl" bind:this={ul}>
{#if isRoot} {#if isRoot}
<li> <li>
<Button onclick={share}> <Button onclick={share}>
@@ -60,6 +54,7 @@
{noun} Details {noun} Details
</Button> </Button>
</li> </li>
{@render customActions?.()}
{#if event.pubkey === $pubkey} {#if event.pubkey === $pubkey}
<li> <li>
<Button onclick={showDelete} class="text-error"> <Button onclick={showDelete} class="text-error">
+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",
+45 -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,21 @@
}) })
} }
loading = true controller.loading = true
try { try {
const success = await loginWithNip46({connectSecret, clientSecret, signerPubkey, relays}) const {clientSecret} = controller
const broker = Nip46Broker.get({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)) {
controller.stop()
await loadUserData(pubkey)
loginWithNip46(pubkey, clientSecret, signerPubkey, relays)
} else { } else {
return pushToast({ return pushToast({
theme: "error", theme: "error",
@@ -55,72 +64,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 +87,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>
+6 -11
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"
@@ -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,17 +83,12 @@
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({ addSession({...session, email})
email,
method: "nip46",
pubkey: userPubkey,
secret: clientSecret,
handler: {pubkey: response.event.pubkey, relays},
})
setChecked("*") setChecked("*")
clearModals() clearModals()
+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()
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
+1 -3
View File
@@ -12,9 +12,7 @@
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<CalendarEventDate event={props.event} /> <CalendarEventDate event={props.event} />
<div class="flex flex-grow flex-col"> <div class="flex flex-grow flex-col">
<div class="flex flex-grow flex-wrap justify-between gap-2"> <CalendarEventHeader event={props.event} />
<CalendarEventHeader event={props.event} />
</div>
<div class="flex py-2 opacity-50"> <div class="flex py-2 opacity-50">
<div class="h-px flex-grow bg-base-content opacity-25"></div> <div class="h-px flex-grow bg-base-content opacity-25"></div>
</div> </div>
+1 -1
View File
@@ -25,7 +25,7 @@
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} {onReactionClick} reactionClass="tooltip-right">
+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)}
+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} />
+147
View File
@@ -0,0 +1,147 @@
<script lang="ts">
import {chunk, sleep, uniq} from "@welshman/lib"
import {
createEvent,
createProfile,
PROFILE,
DELETE,
isReplaceable,
getAddress,
getRelaysFromList,
} from "@welshman/util"
import {pubkey, userRelaySelections, publishThunk, repository} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {pushToast} from "@app/toast"
import {logout} from "@app/commands"
import {INDEXER_RELAYS, PLATFORM_NAME, userMembership, getMembershipUrls} from "@app/state"
let progress: number | undefined = $state(undefined)
let confirmText = $state("")
const CONFIRM_TEXT = "permanently delete my nostr account"
const confirmOk = $derived(confirmText.toLowerCase().trim() === CONFIRM_TEXT)
const showProgress = $derived(progress !== undefined)
const deleteProfile = async () => {
if (!confirmOk) {
return pushToast({
theme: "error",
message: "Please type your confirmation into the text box.",
})
}
const chunks = chunk(500, repository.query([{authors: [$pubkey!]}]))
const profileEvent = createEvent(PROFILE, createProfile({name: "[deleted]"}))
const vanishEvent = createEvent(62, {tags: [["relay", "ALL_RELAYS"]]})
const denominator = chunks.length + 2
const relays = uniq([
...INDEXER_RELAYS,
...getRelaysFromList($userRelaySelections),
...getMembershipUrls($userMembership),
])
let step = 0
const incrementProgress = async () => {
progress = ++step / denominator
return sleep(800)
}
// First, blank out their profile in case relays don't support deletion by address
await publishThunk({relays, event: profileEvent})
await incrementProgress()
// Next, send a "right to vanish" event to all relays
await publishThunk({relays, event: vanishEvent})
await incrementProgress()
// Finally, send deletion requests for all known events in case relays don't support right to vanish
for (const events of chunks) {
const tags: string[][] = []
for (const event of events) {
tags.push(["e", event.id])
if (isReplaceable(event)) {
tags.push(["a", getAddress(event)])
}
}
await publishThunk({relays, event: createEvent(DELETE, {tags})})
await incrementProgress()
}
// Let them see that progress is complete
await sleep(2000)
// Goodbye forever!
await logout()
window.location.href = "/"
}
const confirm = async () => {
progress = 0
try {
await deleteProfile()
} catch (e) {
progress = undefined
throw e
}
}
const back = () => history.back()
</script>
<form class="column gap-4" onsubmit={preventDefault(confirm)}>
<ModalHeader>
{#snippet title()}
Delete your account
{/snippet}
{#snippet info()}
From the Nostr network
{/snippet}
</ModalHeader>
{#if showProgress}
<p>
We are currently sending deletion requests to your relay selections and space hosts. Please
wait while we complete this process. Once we're done, you'll be automatically logged out.
</p>
<progress class="progress progress-primary w-full" value={progress! * 100} max="100"></progress>
{:else}
<p>
This will delete your nostr account everywhere, not just on {PLATFORM_NAME}.
</p>
<p>
To confirm, please type "{CONFIRM_TEXT}" into the text box below. This action can't be undone.
</p>
<label class="input input-bordered flex w-full items-center gap-2">
<input bind:value={confirmText} class="grow" type="text" />
</label>
<p>
<strong>Note:</strong> not all relays may honor your request for deletion. If you find that your
content continues to be available, please contact the offending relays directly.
</p>
{/if}
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-error" disabled={showProgress || !confirmOk}>
<Spinner loading={progress !== undefined}>Confirm</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
</form>
+13 -6
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,7 +64,7 @@
</div> </div>
</div> </div>
</div> </div>
<ProfileInfo {pubkey} /> <ProfileInfo {pubkey} {url} />
<ModalFooter> <ModalFooter>
<Button onclick={back} class="btn btn-link"> <Button onclick={back} class="btn btn-link">
<Icon icon="alt-arrow-left" /> <Icon icon="alt-arrow-left" />
+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>
+2 -2
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),
+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}
+13 -6
View File
@@ -1,12 +1,12 @@
<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 {REACTION, getReplyFilters, getTag, REPORT, DELETE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} 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 EventReportDetails from "@app/components/EventReportDetails.svelte" import EventReportDetails from "@app/components/EventReportDetails.svelte"
@@ -51,18 +51,25 @@
) )
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>
+11 -10
View File
@@ -12,14 +12,19 @@
import SignUpKey from "@app/components/SignUpKey.svelte" import SignUpKey from "@app/components/SignUpKey.svelte"
import SignUpSuccess from "@app/components/SignUpSuccess.svelte" import SignUpSuccess from "@app/components/SignUpSuccess.svelte"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {BURROW_URL, PLATFORM_NAME} from "@app/state" import {BURROW_URL, PLATFORM_NAME, PLATFORM_ACCENT} from "@app/state"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
const ac = window.location.origin const params = new URLSearchParams({
an: PLATFORM_NAME,
ac: window.location.origin,
at: isMobile ? "android" : "web",
aa: PLATFORM_ACCENT.slice(1),
am: "dark",
asf: "yes",
})
const at = isMobile ? "android" : "web" const nstart = `https://start.njump.me/?${params.toString()}`
const nstart = `https://start.njump.me/?an=Flotilla&at=${at}&ac=${ac}`
const login = () => pushModal(LogIn) const login = () => pushModal(LogIn)
@@ -93,15 +98,11 @@
</p> </p>
<Divider>Or</Divider> <Divider>Or</Divider>
{/if} {/if}
{#if Capacitor.getPlatform() === "ios"} {#if Capacitor.isNativePlatform()}
<Button onclick={useKey} class="btn {email || password ? 'btn-neutral' : 'btn-primary'}"> <Button onclick={useKey} class="btn {email || password ? 'btn-neutral' : 'btn-primary'}">
<Icon icon="key" /> <Icon icon="key" />
Generate a key Generate a key
</Button> </Button>
<a href={nstart} class="btn">
<Icon icon="square-share-line" />
Create an account on Nstart
</a>
{:else} {:else}
<a href={nstart} class="btn {email || password ? 'btn-neutral' : 'btn-primary'}"> <a href={nstart} class="btn {email || password ? 'btn-neutral' : 'btn-primary'}">
<Icon icon="square-share-line" /> <Icon icon="square-share-line" />
+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}
+6 -15
View File
@@ -1,36 +1,27 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {goto} from "$app/navigation" import {sleep} from "@welshman/lib"
import {ctx, 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"
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 Confirm from "@lib/components/Confirm.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 SpaceVisitConfirm, {confirmSpaceVisit} from "@app/components/SpaceVisitConfirm.svelte"
import {attemptRelayAccess} from "@app/commands" import {attemptRelayAccess} from "@app/commands"
import {makeSpacePath} from "@app/routes"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
const {url} = $props() const {url} = $props()
const path = makeSpacePath(url)
const back = () => history.back() const back = () => history.back()
const confirm = () => goto(path, {replaceState: true})
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(Confirm, { pushModal(SpaceVisitConfirm, {url}, {replaceState: true})
confirm,
message: `This space does not appear to limit who can post to it. This can result
in a large amount of spam or other objectionable content. Continue?`,
})
} else { } else {
confirm() 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),
]) ])
+8 -23
View File
@@ -1,33 +1,22 @@
<script lang="ts"> <script lang="ts">
import {goto} from "$app/navigation" import {tryCatch} from "@welshman/lib"
import {ctx, 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"
import Field from "@lib/components/Field.svelte" import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Confirm from "@lib/components/Confirm.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 InfoRelay from "@app/components/InfoRelay.svelte" import InfoRelay from "@app/components/InfoRelay.svelte"
import SpaceJoinConfirm, {confirmSpaceJoin} from "@app/components/SpaceJoinConfirm.svelte"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {addSpaceMembership, attemptRelayAccess} from "@app/commands" import {attemptRelayAccess} from "@app/commands"
import {makeSpacePath} from "@app/routes"
const back = () => history.back() const back = () => history.back()
const confirm = async (url: string) => {
await addSpaceMembership(url)
goto(makeSpacePath(url), {replaceState: true})
pushToast({
message: "Welcome to the space!",
})
}
const joinRelay = async (invite: string) => { const joinRelay = async (invite: string) => {
const [raw, claim] = invite.split("|") const [raw, claim] = invite.split("|")
const url = normalizeRelayUrl(raw) const url = normalizeRelayUrl(raw)
@@ -37,16 +26,12 @@
return pushToast({theme: "error", message: error}) return pushToast({theme: "error", message: error})
} }
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(Confirm, { pushModal(SpaceJoinConfirm, {url}, {replaceState: true})
confirm: () => confirm(url),
message: `This space does not appear to limit who can post to it. This can result
in a large amount of spam or other objectionable content. Continue?`,
})
} else { } else {
await confirm(url) await confirmSpaceJoin(url)
} }
} }
@@ -0,0 +1,32 @@
<script module lang="ts">
import {goto} from "$app/navigation"
import {makeSpacePath} from "@app/routes"
import {addSpaceMembership} from "@app/commands"
import {pushToast} from "@app/toast"
export const confirmSpaceJoin = async (url: string) => {
await addSpaceMembership(url)
goto(makeSpacePath(url), {replaceState: true})
pushToast({
message: "Welcome to the space!",
})
}
</script>
<script lang="ts">
import Confirm from "@lib/components/Confirm.svelte"
type Props = {
url: string
}
const {url}: Props = $props()
const confirm = () => confirmSpaceJoin(url)
</script>
<Confirm
{confirm}
message="This space does not appear to limit who can post to it. This can result in a large amount of spam or other objectionable content. Continue?" />
@@ -0,0 +1,24 @@
<script module lang="ts">
import {goto} from "$app/navigation"
import {makeSpacePath} from "@app/routes"
export const confirmSpaceVisit = (url: string) => {
goto(makeSpacePath(url), {replaceState: true})
}
</script>
<script lang="ts">
import Confirm from "@lib/components/Confirm.svelte"
type Props = {
url: string
}
const {url}: Props = $props()
const confirm = () => confirmSpaceVisit(url)
</script>
<Confirm
{confirm}
message="This space does not appear to limit who can post to it. This can result in a large amount of spam or other objectionable content. Continue?" />
+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}
+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">
+71 -31
View File
@@ -1,25 +1,58 @@
import "@welshman/editor/index.css"
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]) => {
try {
const event = await signer.get()!.sign(
makeEvent(BLOSSOM_AUTH, {
tags: [
["t", "upload"],
["server", url],
["expiration", String(now() + 30)],
],
}),
)
export const getUploadUrl = () => { const res = await fetch(normalizeUrl(url) + "/upload", {
const {upload_type, nip96_urls, blossom_urls} = userSettingValues.get() method: "head",
headers: {
Authorization: `Nostr ${btoa(JSON.stringify(event))}`,
"X-Content-Type": "text/plain",
"X-Content-Length": "1",
"X-SHA-256": "73cb3858a687a8494ca3323053016282f3dad39d42cf62ca4e79dda2aac7d9ac",
},
})
return upload_type === "nip96" return res.status === 200
? nip96_urls[0] || "https://nostr.build" } catch (e) {
: blossom_urls[0] || "https://cdn.satellite.earth" if (!String(e).includes("Failed to fetch")) {
console.error(e)
}
}
})
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) => {
@@ -28,26 +61,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"),
@@ -55,8 +92,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: {
@@ -68,29 +105,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
}, },
@@ -107,3 +146,4 @@ export const makeEditor = ({
charCount?.set(editor.storage.wordCount.chars) charCount?.set(editor.storage.wordCount.chars)
}, },
}) })
}
+110 -64
View File
@@ -8,8 +8,8 @@ import {
uniq, uniq,
int, int,
YEAR, YEAR,
MONTH, DAY,
insert, insertAt,
sortBy, sortBy,
assoc, assoc,
now, now,
@@ -23,30 +23,34 @@ import {
matchFilters, matchFilters,
getTagValues, getTagValues,
getTagValue, getTagValue,
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"
import { import {
ALERT,
ALERT_STATUS,
NOTIFIER_RELAY,
INDEXER_RELAYS, INDEXER_RELAYS,
getDefaultPubkeys, getDefaultPubkeys,
userRoomsByUrl, userRoomsByUrl,
@@ -98,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)
@@ -107,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[]) => {
@@ -141,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)
@@ -172,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()
}, },
} }
} }
@@ -199,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) || "")
@@ -211,16 +237,17 @@ export const makeCalendarFeed = ({
const insertEvent = (event: TrustedEvent) => { const insertEvent = (event: TrustedEvent) => {
const start = getStart(event) const start = getStart(event)
const address = getAddress(event)
if (isNaN(start) || isNaN(getEnd(event))) return if (isNaN(start) || isNaN(getEnd(event))) return
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, event] return [...$events.filter(e => getAddress(e) !== address), event]
}) })
} }
@@ -238,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)
@@ -249,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,
}) })
@@ -268,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)
@@ -284,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)
@@ -295,23 +325,37 @@ 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()
}, },
} }
} }
// Domain specific
export const loadAlerts = (pubkey: string) =>
load({
relays: [NOTIFIER_RELAY],
filters: [{kinds: [ALERT], authors: [pubkey]}],
})
export const loadAlertStatuses = (pubkey: string) =>
load({
relays: [NOTIFIER_RELAY],
filters: [{kinds: [ALERT_STATUS], "#p": [pubkey]}],
})
// 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
@@ -319,6 +363,7 @@ 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},
@@ -327,38 +372,35 @@ export const listenForNotifications = () => {
], ],
}) })
subs.push( request({
subscribe({ signal: controller.signal,
relays: [url], relays: [url],
filters: [ filters: [
{kinds: [THREAD], since: now()}, {kinds: [THREAD], since: now()},
{kinds: [COMMENT], "#K": [String(THREAD)], 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),
loadAlerts(pubkey),
]), ]),
]) ])
@@ -371,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)
} }
} }
}) })
@@ -383,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)),
)
+78 -47
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,
@@ -41,16 +43,12 @@ import {
normalizeRelayUrl, normalizeRelayUrl,
} 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} 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,
@@ -62,24 +60,33 @@ import {
ensurePlaintext, ensurePlaintext,
thunks, thunks,
walkThunks, walkThunks,
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 = "_"
export const PROTECTED = ["-"] export const PROTECTED = ["-"]
export const INDEXER_RELAYS = [ export const ALERT = 32830
"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 ALERT_STATUS = 32831
export const NOTIFIER_PUBKEY = "27b7c2ed89ef78322114225ea3ebf5f72c7767c2528d4d0c1854d039c00085df"
// export const NOTIFIER_RELAY = 'wss://notifier.flotilla.social/'
export const NOTIFIER_RELAY = "ws://localhost:4738/"
export const INDEXER_RELAYS = fromCsv(import.meta.env.VITE_INDEXER_RELAYS)
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
@@ -108,7 +115,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(",")
@@ -153,10 +160,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]
@@ -245,7 +250,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)
} }
} }
@@ -274,15 +279,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
@@ -295,20 +294,18 @@ export type Settings = {
values: { values: {
show_media: boolean show_media: boolean
hide_sensitive: boolean hide_sensitive: boolean
report_usage: boolean
report_errors: boolean
send_delay: number send_delay: number
upload_type: "nip96" | "blossom"
nip96_urls: string[]
blossom_urls: string[]
} }
} }
export const defaultSettings = { export const defaultSettings = {
show_media: true, show_media: true,
hide_sensitive: true, hide_sensitive: true,
report_usage: true,
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, {
@@ -328,8 +325,41 @@ 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
export type Alert = {
event: TrustedEvent
tags: string[][]
}
export const alerts = deriveEventsMapped<Alert>(repository, {
filters: [{kinds: [ALERT]}],
itemToEvent: item => item.event,
eventToItem: async event => {
const tags = parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, event.content))
return {event, tags}
},
})
// Alert Statuses
export type AlertStatus = {
event: TrustedEvent
tags: string[][]
}
export const alertStatuses = deriveEventsMapped<AlertStatus>(repository, {
filters: [{kinds: [ALERT_STATUS]}],
itemToEvent: item => item.event,
eventToItem: async event => {
const tags = parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, event.content))
return {event, tags}
},
}) })
// Membership // Membership
@@ -356,11 +386,7 @@ export const getMembershipRooms = (list?: List) =>
getGroupTags(getListTags(list)).map(([_, room, url, name = ""]) => ({url, room, name})) getGroupTags(getListTags(list)).map(([_, room, url, name = ""]) => ({url, room, name}))
export const getMembershipRoomsByUrl = (url: string, list?: List) => export const getMembershipRoomsByUrl = (url: string, list?: List) =>
sort( sort(getGroupTags(getListTags(list)).filter(nthEq(2, url)).map(nth(1)))
getGroupTags(getListTags(list))
.filter(t => t[2] === url)
.map(nth(1)),
)
export const memberships = deriveEventsMapped<PublishedList>(repository, { export const memberships = deriveEventsMapped<PublishedList>(repository, {
filters: [{kinds: [GROUPS]}], filters: [{kinds: [GROUPS]}],
@@ -376,8 +402,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
@@ -438,6 +463,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 =>
@@ -629,7 +655,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))
+8 -1
View File
@@ -1,10 +1,17 @@
import * as Sentry from "@sentry/browser" import * as Sentry from "@sentry/browser"
import {getSetting} from "@app/state"
export const setupTracking = () => { export const setupTracking = () => {
if (import.meta.env.VITE_GLITCHTIP_API_KEY) { if (import.meta.env.VITE_GLITCHTIP_API_KEY) {
Sentry.init({ Sentry.init({
dsn: import.meta.env.VITE_GLITCHTIP_API_KEY, dsn: import.meta.env.VITE_GLITCHTIP_API_KEY,
tracesSampleRate: 0.01, beforeSend(event: any) {
if (!getSetting("report_errors")) {
return null
}
return event
},
integrations(integrations) { integrations(integrations) {
return integrations.filter(integration => integration.name !== "Breadcrumbs") return integrations.filter(integration => integration.name !== "Breadcrumbs")
}, },
+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

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