Compare commits

..

53 Commits

Author SHA1 Message Date
Jon Staab 68ebd32e15 Bump welshman 2025-05-09 12:41:02 -07:00
Jon Staab e94aa3c119 Bump version, fix new messages thing 2025-05-09 12:26:05 -07:00
Jon Staab 4d10fe7cc0 Handle broken supported_nips 2025-05-08 11:16:02 -07:00
Jon Staab 841928783b Re-introduce safe inset areas 2025-05-08 11:05:27 -07:00
Jon Staab 6e5e1a0846 Remove safe area inset stuff to re-apply later 2025-05-08 09:11:10 -07:00
Jon Staab d57f4747a6 Tweak errors so that actionable links are rendered 2025-05-07 15:04:35 -07:00
Jon Staab 94a0077b09 Use non-singleton broker 2025-05-07 13:53:58 -07:00
Jon Staab f2eb04adff Bump version 2025-05-07 09:12:17 -07:00
Jon Staab d4d5979a35 Fix missing room images and room overflow in nav 2025-05-07 09:11:00 -07:00
Jon Staab dde6e54657 Add build in production script 2025-05-06 18:26:48 -07:00
Jon Staab 698a7513b8 Tweak some gradle stuff 2025-05-06 18:07:30 -07:00
Jon Staab ea3f5a6779 Bump version 2025-05-06 17:06:18 -07:00
Jon Staab f5fce8e2e7 Bump welshman and signer plugin 2025-05-06 10:34:14 -07:00
Jon Staab 46b5c01c49 Allow use of cleartext relays on native 2025-05-06 09:50:05 -07:00
Jon Staab dd069329ee Add timezone and locale to alerts 2025-05-05 15:39:07 -07:00
Jon Staab c1b52b66ff Use lib version of date functions 2025-05-05 10:11:02 -07:00
Jon Staab 5873e8aa60 Fix modal stuff 2025-04-29 15:20:40 -07:00
Jon Staab c582082816 Fix link detail for authenticated images 2025-04-29 12:30:01 -07:00
Jon Staab 6ddba63ff9 Use space as blossom server if supported 2025-04-29 12:26:29 -07:00
Jon Staab 5a7750a91b Use user blossom server list for settings, add InputList 2025-04-29 11:04:39 -07:00
Jon Staab 8c71b7d9b9 Update welshman 2025-04-29 09:56:52 -07:00
Jon Staab b5a28c71ad Support auth-protected images 2025-04-28 15:46:48 -07:00
Jon Staab ccdd18a863 Fill in default email for alerts 2025-04-28 12:28:39 -07:00
Jon Staab 2244ecad9b Update alerts to use new anchor 2025-04-28 09:48:09 -07:00
Jon Staab da2457da9f Use new relay getters 2025-04-25 10:41:38 -07:00
Jon Staab c18b29e7d6 Update welshman stuff, fix bug in makeFeed 2025-04-24 12:35:41 -07:00
Jon Staab 3a954201ce Tweak boot, stop saving alert events 2025-04-23 11:05:28 -07:00
Jon Staab c8bc8ee8bf Fix thunk indicator 2025-04-16 14:19:09 -07:00
Jon Staab 8c3e52ce8c Update storage adapters 2025-04-16 14:08:58 -07:00
Jon Staab 303b8967e9 Remove aliases, their time has not yet come 2025-04-16 10:36:21 -07:00
Jon Staab f3debe6c02 Use new ALIAS kind 2025-04-15 15:45:48 -07:00
Jon Staab 374ca7f265 Add per-url aliases 2025-04-15 15:07:54 -07:00
Jon Staab 91689e5b90 Optionally protect profiles 2025-04-15 09:36:59 -07:00
Jon Staab a64eaba45c Fix modal flash 2025-04-14 17:13:16 -07:00
Jon Staab 394a1e7d30 Update to new thunk stuff 2025-04-14 16:50:48 -07:00
Jon Staab d5b1fab1e7 Tweak data loading 2025-04-11 14:44:27 -07:00
Jon Staab 10a1e6e640 Update welshman session stuff 2025-04-11 11:51:15 -07:00
Jon Staab 84af4d2d8e Update welshman stuff again 2025-04-11 09:27:19 -07:00
Jon Staab acddff79f0 Improve loading a bit 2025-04-11 08:41:50 -07:00
Jon Staab 489707b9b2 Switch to pnpm, use new welshman stuff 2025-04-09 15:32:35 -07:00
Jon Staab 33902dbefe Make calendar window smaller to avoid tag limits 2025-04-03 15:56:37 -07:00
Jon Staab 1b318a7a52 Fix reactions on mobile 2025-04-03 15:40:52 -07:00
Jon Staab b6a4b38d14 Make relays configurable 2025-04-03 15:35:56 -07:00
Jon Staab a3eb6d52c0 Fix nip46 signer connect 2025-03-24 12:40:47 -07:00
Jon Staab d2c537d275 Refactor login, pass bunker to alerts 2025-03-20 13:00:07 -07:00
Jon Staab 9eefd6600d Add handler for alerts 2025-03-20 09:38:57 -07:00
Jon Staab ad034b1641 Tweak layout css 2025-03-19 11:22:57 -07:00
Jon Staab d94860014c Fix chat spacing 2025-03-19 09:56:00 -07:00
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
127 changed files with 11828 additions and 17203 deletions
+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 -2
View File
@@ -1,2 +1,2 @@
npm run lint pnpm run lint
npm run check pnpm run check
+31
View File
@@ -1,8 +1,39 @@
# Changelog # Changelog
# 1.0.2
* Fix add relay button
* Fix safe inset areas
* Better rendering for errors from relays
* Improve remote signer login
# 1.0.1
* Fix relay images in nav
* Fix relay nav overflow
# 1.0.0 # 1.0.0
* Add alerts via Anchor * Add alerts via Anchor
* Fix nip46 signer connect
* Allow use of cleartext relays on native builds
* Fix some modal state bugs caused by svelte 5
* Detect blossom support on community relays
* Use user blossom server list in settings
* Fix some feed bugs
* Improve thunk indicator
* Update storage adapters
* Fix modal flash
* Switch to pnpm
* Improve calendar windowing
# 0.2.14
* Add calendar event editing
# 0.2.13
* Fix android keyboard issue
# 0.2.12 # 0.2.12
+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.
+4 -4
View File
@@ -5,10 +5,10 @@ android {
compileSdk rootProject.ext.compileSdkVersion compileSdk rootProject.ext.compileSdkVersion
defaultConfig { defaultConfig {
applicationId "social.flotilla" applicationId "social.flotilla"
minSdkVersion rootProject.ext.minSdkVersion minSdk rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdk rootProject.ext.targetSdkVersion
versionCode 12 versionCode 16
versionName "0.2.12" versionName "1.0.2"
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')
+4 -2
View File
@@ -3,7 +3,9 @@ ext {
compileSdkVersion = 35 compileSdkVersion = 35
targetSdkVersion = 35 targetSdkVersion = 35
androidxActivityVersion = '1.9.2' androidxActivityVersion = '1.9.2'
androidxAppCompatVersion = '1.7.0' //https://github.com/ionic-team/capacitor/issues/7866
// androidxAppCompatVersion = '1.7.0'
androidxAppCompatVersion = '1.6.1'
androidxCoordinatorLayoutVersion = '1.2.0' androidxCoordinatorLayoutVersion = '1.2.0'
androidxCoreVersion = '1.15.0' androidxCoreVersion = '1.15.0'
androidxFragmentVersion = '1.8.4' androidxFragmentVersion = '1.8.4'
@@ -13,4 +15,4 @@ ext {
androidxJunitVersion = '1.2.1' androidxJunitVersion = '1.2.1'
androidxEspressoCoreVersion = '3.6.1' androidxEspressoCoreVersion = '3.6.1'
cordovaAndroidVersion = '10.1.1' cordovaAndroidVersion = '10.1.1'
} }
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Fetch tags and set to env vars
git fetch --prune --unshallow --tags
git describe --tags --abbrev=0
export VITE_BUILD_VERSION=$RENDER_GIT_COMMIT
export VITE_BUILD_HASH=$RENDER_GIT_COMMIT
# Remove link overrides
node remove-pnpm-overrides.js package.json
# When CI=true as it is on render.com, removing link overrides breaks the lockfile
pnpm i --no-frozen-lockfile
# Rebuild sharp
pnpm rebuild
# The build runs out of memory at times
NODE_OPTIONS=--max_old_space_size=16384 pnpm run build
+5 -1
View File
@@ -11,10 +11,14 @@ const config: CapacitorConfig = {
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: {
// url: "http://192.168.1.250:1847", // url: "http://192.168.1.115:1847",
// cleartext: true // cleartext: true
// }, // },
}; };
+4 -4
View File
@@ -351,14 +351,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 6; CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = S26U9DYW3A; DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.2.12; MARKETING_VERSION = 1.0.2;
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 = 6; CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = S26U9DYW3A; DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.2.12; MARKETING_VERSION = 1.0.2;
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
-15439
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.12", "version": "1.0.2",
"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.43", "@welshman/app": "^0.2.4",
"@welshman/content": "^0.1.0", "@welshman/content": "^0.2.1",
"@welshman/dvm": "^0.0.15", "@welshman/dvm": "^0.2.0",
"@welshman/editor": "^0.1.0", "@welshman/editor": "^0.2.1",
"@welshman/feeds": "^0.1.0", "@welshman/feeds": "^0.2.2",
"@welshman/lib": "^0.1.0", "@welshman/lib": "^0.2.2",
"@welshman/net": "^0.0.49", "@welshman/net": "^0.2.3",
"@welshman/signer": "^0.1.0", "@welshman/relay": "^0.2.0",
"@welshman/store": "^0.1.0", "@welshman/router": "^0.2.0",
"@welshman/util": "^0.1.0", "@welshman/signer": "^0.2.3",
"@welshman/store": "^0.2.0",
"@welshman/util": "^0.2.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
+20
View File
@@ -0,0 +1,20 @@
// This script is necessary for installing stuff on a host, since our links don't exist there.
import fs from "fs"
const pkgName = process.argv[2]
if (!pkgName?.endsWith("package.json")) {
console.log("File passed was not a package.json file")
process.exit(1)
}
const pkg = JSON.parse(fs.readFileSync(pkgName, "utf8"))
if (pkg.pnpm && pkg.pnpm.overrides) {
delete pkg.pnpm.overrides
fs.writeFileSync(pkgName, JSON.stringify(pkg, null, 2) + "\n")
console.log("Removed pnpm.overrides from package.json")
} else {
console.log("No pnpm.overrides found in package.json")
}
+84 -44
View File
@@ -54,6 +54,10 @@
--primary-content: oklch(var(--pc)); --primary-content: oklch(var(--pc));
--secondary: oklch(var(--s)); --secondary: oklch(var(--s));
--secondary-content: oklch(var(--sc)); --secondary-content: oklch(var(--sc));
--sait: env(safe-area-inset-top);
--saib: env(safe-area-inset-bottom);
--sail: env(safe-area-inset-left);
--sair: env(safe-area-inset-right);
} }
:root, :root,
@@ -62,50 +66,80 @@ html {
@apply bg-base-300; @apply bg-base-300;
} }
/* ios */ /* safe area insets */
.sait { @layer components {
padding-top: env(safe-area-inset-top); .pt-sai {
} padding-top: var(--sait);
}
.sair { .pr-sai {
padding-right: env(safe-area-inset-right); padding-right: var(--sair);
} }
.saib { .pb-sai {
padding-bottom: env(safe-area-inset-bottom); padding-bottom: var(--saib);
} }
.sail { .pl-sai {
padding-left: env(safe-area-inset-left); padding-left: var(--sail);
} }
.saix { .px-sai {
@apply sail sair; @apply pl-sai pr-sai;
} }
.saiy { .py-sai {
@apply sait saib; @apply pt-sai pb-sai;
} }
.sai { .p-sai {
@apply saiy saix; @apply py-sai px-sai;
} }
.top-sai { .mt-sai {
top: env(safe-area-inset-top); padding-top: var(--sait);
} }
.right-sai { .mr-sai {
right: env(safe-area-inset-right); padding-right: var(--sair);
} }
.bottom-sai { .mb-sai {
bottom: env(safe-area-inset-bottom); padding-bottom: var(--saib);
} }
.left-sai { .ml-sai {
left: env(safe-area-inset-left); padding-left: var(--sail);
}
.mx-sai {
@apply ml-sai mr-sai;
}
.my-sai {
@apply mt-sai mb-sai;
}
.m-sai {
@apply my-sai mx-sai;
}
.top-sai {
top: var(--sait);
}
.right-sai {
right: var(--sair);
}
.bottom-sai {
bottom: var(--saib);
}
.left-sai {
left: var(--sail);
}
} }
/* utilities */ /* utilities */
@@ -294,6 +328,16 @@ html {
color: var(--base-content); color: var(--base-content);
} }
/* content rendered by welshman/content */
.welshman-content a {
@apply link;
}
.welshman-content-error a {
@apply underline;
}
/* date input */ /* date input */
.picker { .picker {
@@ -335,23 +379,19 @@ progress[value]::-webkit-progress-value {
/* content width for fixed elements */ /* content width for fixed elements */
.cw { .cw {
@apply w-full md:w-[calc(100%-18.5rem)]; @apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))];
}
.cb {
@apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)];
} }
/* chat view */ /* chat view */
.chat__page-bar {
@apply sait cw !fixed top-0;
}
.chat__messages {
@apply saib cw fixed top-12 flex h-[calc(100%-10rem)] flex-col-reverse overflow-y-auto overflow-x-hidden md:h-[calc(100%-6rem)];
}
.chat__compose { .chat__compose {
@apply saib cw fixed bottom-14 md:bottom-0; @apply cb cw fixed;
} }
.chat__scroll-down { .chat__scroll-down {
@apply saib fixed bottom-28 right-4 md:bottom-16; @apply fixed bottom-28 right-4 md:bottom-16;
} }
+77 -122
View File
@@ -1,6 +1,8 @@
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import {get} from "svelte/store" import {get} from "svelte/store"
import {ctx, randomId, uniq, equals} from "@welshman/lib" import {randomId, poll, uniq, equals, TIMEZONE, LOCALE} from "@welshman/lib"
import type {Feed} from "@welshman/feeds"
import type {TrustedEvent, EventContent} from "@welshman/util"
import { import {
DELETE, DELETE,
REPORT, REPORT,
@@ -28,32 +30,29 @@ import {
getRelayTags, getRelayTags,
getRelayTagValues, getRelayTagValues,
toNostrURI, toNostrURI,
unionFilters, getRelaysFromList,
RelayMode,
} from "@welshman/util" } from "@welshman/util"
import type {TrustedEvent, Filter, EventContent, EventTemplate} from "@welshman/util" import {Pool, PublishStatus, AuthStatus, SocketStatus} from "@welshman/net"
import {PublishStatus, AuthStatus, SocketStatus} from "@welshman/net" import {Router} from "@welshman/router"
import {Nip59, makeSecret, stamp, Nip46Broker} from "@welshman/signer"
import { import {
pubkey, pubkey,
signer, signer,
repository, repository,
publishThunk, publishThunk,
publishThunks,
profilesByPubkey, profilesByPubkey,
relaySelectionsByPubkey, relaySelectionsByPubkey,
getWriteRelayUrls,
tagEvent, tagEvent,
tagEventForReaction, tagEventForReaction,
getRelayUrls,
userRelaySelections, userRelaySelections,
userInboxRelaySelections, userInboxRelaySelections,
nip44EncryptToSelf, nip44EncryptToSelf,
loadRelay, loadRelay,
addSession,
clearStorage, clearStorage,
dropSession, dropSession,
tagEventForComment, tagEventForComment,
tagEventForQuote, tagEventForQuote,
thunkIsComplete,
} from "@welshman/app" } from "@welshman/app"
import type {Thunk} from "@welshman/app" import type {Thunk} from "@welshman/app"
import { import {
@@ -61,19 +60,17 @@ import {
PROTECTED, PROTECTED,
userMembership, userMembership,
INDEXER_RELAYS, INDEXER_RELAYS,
NIP46_PERMS,
ALERT, ALERT,
NOTIFIER_PUBKEY, NOTIFIER_PUBKEY,
NOTIFIER_RELAY, NOTIFIER_RELAY,
userRoomsByUrl, userRoomsByUrl,
} from "@app/state" } from "@app/state"
import {loadUserData} from "@app/requests"
// Utils // Utils
export const getPubkeyHints = (pubkey: string) => { export const getPubkeyHints = (pubkey: string) => {
const selections = relaySelectionsByPubkey.get().get(pubkey) const selections = relaySelectionsByPubkey.get().get(pubkey)
const relays = selections ? getWriteRelayUrls(selections) : [] const relays = selections ? getRelaysFromList(selections, RelayMode.Write) : []
const hints = relays.length ? relays : INDEXER_RELAYS const hints = relays.length ? relays : INDEXER_RELAYS
return hints return hints
@@ -86,14 +83,20 @@ export const getPubkeyPetname = (pubkey: string) => {
return display return display
} }
export const getThunkError = async (thunk: Thunk) => { export const getThunkError = (thunk: Thunk) =>
const result = await thunk.result new Promise<string>(resolve => {
const [{status, message}] = Object.values(result) as any thunk.subscribe($thunk => {
for (const [relay, status] of Object.entries($thunk.status)) {
if (status === PublishStatus.Failure) {
resolve($thunk.details[relay])
}
}
if (status !== PublishStatus.Success) { if (thunkIsComplete($thunk)) {
return message resolve("")
} }
} })
})
export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => { export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => {
if (parent) { if (parent) {
@@ -101,7 +104,7 @@ export const prependParent = (parent: TrustedEvent | undefined, {content, tags}:
id: parent.id, id: parent.id,
kind: parent.kind, kind: parent.kind,
author: parent.pubkey, author: parent.pubkey,
relays: ctx.app.router.Event(parent).limit(3).getUrls(), relays: Router.get().Event(parent).limit(3).getUrls(),
}) })
tags = [...tags, tagEventForQuote(parent)] tags = [...tags, tagEventForQuote(parent)]
@@ -111,38 +114,6 @@ export const prependParent = (parent: TrustedEvent | undefined, {content, tags}:
return {content, tags} return {content, tags}
} }
// Log in
export const loginWithNip46 = async ({
relays,
signerPubkey,
clientSecret = makeSecret(),
connectSecret = "",
}: {
relays: string[]
signerPubkey: string
clientSecret?: string
connectSecret?: string
}) => {
const broker = Nip46Broker.get({relays, clientSecret, signerPubkey})
const result = await broker.connect(connectSecret, NIP46_PERMS)
// TODO: remove ack result
if (!["ack", connectSecret].includes(result)) return false
const pubkey = await broker.getPublicKey()
if (!pubkey) return false
await loadUserData(pubkey)
const handler = {relays, pubkey: signerPubkey}
addSession({method: "nip46", pubkey, secret: clientSecret, handler})
return true
}
// Log out // Log out
export const logout = async () => { export const logout = async () => {
@@ -203,7 +174,7 @@ export const nip29 = {
export const addSpaceMembership = async (url: string) => { export const addSpaceMembership = async (url: string) => {
const list = get(userMembership) || makeList({kind: GROUPS}) const list = get(userMembership) || makeList({kind: GROUPS})
const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf) const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf)
const relays = uniq([...ctx.app.router.FromUser().getUrls(), ...getRelayTagValues(event.tags)]) const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays}) return publishThunk({event, relays})
} }
@@ -212,11 +183,7 @@ export const removeSpaceMembership = async (url: string) => {
const list = get(userMembership) || makeList({kind: GROUPS}) const list = get(userMembership) || makeList({kind: GROUPS})
const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf) const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([ const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
url,
...ctx.app.router.FromUser().getUrls(),
...getRelayTagValues(event.tags),
])
return publishThunk({event, relays}) return publishThunk({event, relays})
} }
@@ -228,7 +195,7 @@ export const addRoomMembership = async (url: string, room: string, name: string)
["group", room, url, name], ["group", room, url, name],
] ]
const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf) const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf)
const relays = uniq([...ctx.app.router.FromUser().getUrls(), ...getRelayTagValues(event.tags)]) const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays}) return publishThunk({event, relays})
} }
@@ -237,11 +204,7 @@ export const removeRoomMembership = async (url: string, room: string) => {
const list = get(userMembership) || makeList({kind: GROUPS}) const list = get(userMembership) || makeList({kind: GROUPS})
const pred = (t: string[]) => equals(["group", room, url], t.slice(0, 3)) const pred = (t: string[]) => equals(["group", room, url], t.slice(0, 3))
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf) const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([ const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
url,
...ctx.app.router.FromUser().getUrls(),
...getRelayTagValues(event.tags),
])
return publishThunk({event, relays}) return publishThunk({event, relays})
} }
@@ -263,7 +226,7 @@ export const setRelayPolicy = (url: string, read: boolean, write: boolean) => {
relays: [ relays: [
url, url,
...INDEXER_RELAYS, ...INDEXER_RELAYS,
...ctx.app.router.FromUser().getUrls(), ...Router.get().FromUser().getUrls(),
...userRoomsByUrl.get().keys(), ...userRoomsByUrl.get().keys(),
], ],
}) })
@@ -273,7 +236,7 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
const list = get(userInboxRelaySelections) || makeList({kind: INBOX_RELAYS}) const list = get(userInboxRelaySelections) || makeList({kind: INBOX_RELAYS})
// Only update inbox policies if they already exist or we're adding them // Only update inbox policies if they already exist or we're adding them
if (enabled || getRelayUrls(list).includes(url)) { if (enabled || getRelaysFromList(list).includes(url)) {
const tags = getRelayTags(getListTags(list)).filter(t => normalizeRelayUrl(t[1]) !== url) const tags = getRelayTags(getListTags(list)).filter(t => normalizeRelayUrl(t[1]) !== url)
if (enabled) { if (enabled) {
@@ -284,7 +247,7 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
event: createEvent(list.kind, {tags}), event: createEvent(list.kind, {tags}),
relays: [ relays: [
...INDEXER_RELAYS, ...INDEXER_RELAYS,
...ctx.app.router.FromUser().getUrls(), ...Router.get().FromUser().getUrls(),
...userRoomsByUrl.get().keys(), ...userRoomsByUrl.get().keys(),
], ],
}) })
@@ -294,27 +257,27 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
// Relay access // Relay access
export const checkRelayAccess = async (url: string, claim = "") => { export const checkRelayAccess = async (url: string, claim = "") => {
const connection = ctx.net.pool.get(url) const socket = Pool.get().get(url)
await connection.auth.attempt(5000) await socket.auth.attemptAuth(e => signer.get()?.sign(e))
const thunk = publishThunk({ const thunk = publishThunk({
event: createEvent(AUTH_JOIN, {tags: [["claim", claim]]}), event: createEvent(AUTH_JOIN, {tags: [["claim", claim]]}),
relays: [url], relays: [url],
}) })
const result = await thunk.result const error = await getThunkError(thunk)
if (result[url].status === PublishStatus.Failure) { if (error) {
const message = const message =
connection.auth.message?.replace(/^.*: /, "") || socket.auth.details?.replace(/^\w+: /, "") ||
result[url].message?.replace(/^.*: /, "") || error?.replace(/^\w+: /, "") ||
"join request rejected" "join request rejected"
// If it's a strict NIP 29 relay don't worry about requesting access // If it's a strict NIP 29 relay don't worry about requesting access
// TODO: remove this if relay29 ever gets less strict // TODO: remove this if relay29 ever gets less strict
if (message !== "missing group (`h`) tag") { if (message !== "missing group (`h`) tag") {
return `Failed to join relay (${message})` return message
} }
} }
} }
@@ -328,26 +291,30 @@ export const checkRelayProfile = async (url: string) => {
} }
export const checkRelayConnection = async (url: string) => { export const checkRelayConnection = async (url: string) => {
const connection = ctx.net.pool.get(url) const socket = Pool.get().get(url)
await connection.socket.open() socket.attemptToOpen()
await connection.socket.wait(3000)
if (connection.socket.status !== SocketStatus.Open) { await poll({
signal: AbortSignal.timeout(3000),
condition: () => socket.status === SocketStatus.Open,
})
if (socket.status !== SocketStatus.Open) {
return `Failed to connect` return `Failed to connect`
} }
} }
export const checkRelayAuth = async (url: string, timeout = 3000) => { export const checkRelayAuth = async (url: string, timeout = 3000) => {
const connection = ctx.net.pool.get(url) const socket = Pool.get().get(url)
const okStatuses = [AuthStatus.None, AuthStatus.Ok] const okStatuses = [AuthStatus.None, AuthStatus.Ok]
await connection.auth.attempt(timeout) await socket.auth.attemptAuth(e => signer.get()?.sign(e))
// Only raise an error if it's not a timeout. // Only raise an error if it's not a timeout.
// If it is, odds are the problem is with our signer, not the relay // If it is, odds are the problem is with our signer, not the relay
if (!okStatuses.includes(connection.auth.status) && connection.auth.message) { if (!okStatuses.includes(socket.auth.status) && socket.auth.details) {
return `Failed to authenticate (${connection.auth.message})` return `Failed to authenticate (${socket.auth.details})`
} }
} }
@@ -369,28 +336,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)
@@ -462,33 +407,43 @@ export const publishComment = ({relays, ...params}: CommentParams & {relays: str
publishThunk({event: makeComment(params), relays}) publishThunk({event: makeComment(params), relays})
export type AlertParams = { export type AlertParams = {
feed: Feed
cron: string cron: string
email: string email: string
relay: string bunker: string
handler: string secret: string
filters: Filter[] description: string
} }
export const makeAlert = async ({cron, email, handler, relay, filters}: AlertParams) => export const makeAlert = async ({cron, email, feed, bunker, secret, description}: AlertParams) => {
createEvent(ALERT, { const tags = [
content: await signer ["feed", JSON.stringify(feed)],
.get() ["cron", cron],
.nip44.encrypt( ["email", email],
NOTIFIER_PUBKEY, ["locale", LOCALE],
JSON.stringify([ ["timezone", TIMEZONE],
["cron", cron], ["description", description],
["email", email], ["channel", "email"],
["relay", relay], [
["handler", handler], "handler",
["channel", "email"], "31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1737058597050",
...unionFilters(filters).map(filter => ["filter", JSON.stringify(filter)]), "wss://relay.nostr.band/",
]), "web",
), ],
]
if (bunker) {
tags.push(["nip46", secret, bunker])
}
return createEvent(ALERT, {
content: await signer.get().nip44.encrypt(NOTIFIER_PUBKEY, JSON.stringify(tags)),
tags: [ tags: [
["d", randomId()], ["d", randomId()],
["p", NOTIFIER_PUBKEY], ["p", NOTIFIER_PUBKEY],
], ],
}) })
}
export const publishAlert = async (params: AlertParams) => export const publishAlert = async (params: AlertParams) =>
publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]}) publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]})
+141 -70
View File
@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import {Capacitor} from "@capacitor/core"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import {randomInt} from "@welshman/lib" import {randomInt, displayList, TIMEZONE, identity} from "@welshman/lib"
import {displayRelayUrl, THREAD, MESSAGE, EVENT_TIME, COMMENT} from "@welshman/util" import {displayRelayUrl, getTagValue, THREAD, MESSAGE, EVENT_TIME, COMMENT} from "@welshman/util"
import type {Filter} from "@welshman/util" import type {Filter} from "@welshman/util"
import type {Nip46ResponseWithResult} from "@welshman/signer"
import {makeIntersectionFeed, makeRelayFeed, feedFromFilters} from "@welshman/feeds"
import {pubkey} from "@welshman/app" import {pubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -11,20 +12,21 @@
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import {getMembershipUrls, getMembershipRoomsByUrl, userMembership} from "@app/state" import InfoBunker from "@app/components/InfoBunker.svelte"
import BunkerConnect, {BunkerConnectController} from "@app/components/BunkerConnect.svelte"
import {
GENERAL,
alerts,
getMembershipUrls,
getMembershipRoomsByUrl,
userMembership,
} from "@app/state"
import {loadAlertStatuses} from "@app/requests" import {loadAlertStatuses} from "@app/requests"
import {publishAlert} from "@app/commands" import {publishAlert} from "@app/commands"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
const handler = Capacitor.isNativePlatform() const timezoneOffset = parseInt(TIMEZONE.slice(3)) / 100
? "https://app.flotilla.social"
: window.location.origin
const timezone = new Date()
.toString()
.match(/GMT[^\s]+/)![0]
.slice(3)
const timezoneOffset = parseInt(timezone) / 100
const minute = randomInt(0, 59) const minute = randomInt(0, 59)
const hour = (17 - timezoneOffset) % 24 const hour = (17 - timezoneOffset) % 24
const WEEKLY = `0 ${minute} ${hour} * * 1` const WEEKLY = `0 ${minute} ${hour} * * 1`
@@ -32,14 +34,38 @@
let loading = false let loading = false
let cron = WEEKLY let cron = WEEKLY
let email = "" let email = $alerts.map(a => getTagValue("email", a.tags)).filter(identity)[0] || ""
let relay = "" let relay = ""
let bunker = ""
let secret = ""
let notifyThreads = true let notifyThreads = true
let notifyCalendar = true let notifyCalendar = true
let notifyChat = false let notifyChat = false
let showBunker = false
const back = () => history.back() const back = () => history.back()
const controller = new BunkerConnectController({
onNostrConnect: (response: Nip46ResponseWithResult) => {
bunker = controller.broker.getBunkerUrl()
secret = controller.broker.params.clientSecret
showBunker = false
},
})
const connectBunker = () => {
showBunker = true
}
const hideBunker = () => {
showBunker = false
}
const clearBunker = () => {
bunker = ""
secret = ""
}
const submit = async () => { const submit = async () => {
if (!email.includes("@")) { if (!email.includes("@")) {
return pushToast({ return pushToast({
@@ -63,25 +89,37 @@
} }
const filters: Filter[] = [] const filters: Filter[] = []
const display: string[] = []
if (notifyThreads) { if (notifyThreads) {
display.push("threads")
filters.push({kinds: [THREAD]}) filters.push({kinds: [THREAD]})
filters.push({kinds: [COMMENT], "#k": [String(THREAD)]}) filters.push({kinds: [COMMENT], "#k": [String(THREAD)]})
} }
if (notifyCalendar) { if (notifyCalendar) {
display.push("calendar events")
filters.push({kinds: [EVENT_TIME]}) filters.push({kinds: [EVENT_TIME]})
filters.push({kinds: [COMMENT], "#k": [String(EVENT_TIME)]}) filters.push({kinds: [COMMENT], "#k": [String(EVENT_TIME)]})
} }
if (notifyChat) { if (notifyChat) {
filters.push({kinds: [MESSAGE], "#h": getMembershipRoomsByUrl(relay, $userMembership)}) display.push("chat")
filters.push({
kinds: [MESSAGE],
"#h": [GENERAL, ...getMembershipRoomsByUrl(relay, $userMembership)],
})
} }
loading = true loading = true
try { try {
await publishAlert({cron, email, relay, handler, filters}) const cadence = cron?.endsWith("1") ? "Weekly" : "Daily"
const description = `${cadence} alert for ${displayList(display)} on ${displayRelayUrl(relay)}, sent via email.`
const feed = makeIntersectionFeed(feedFromFilters(filters), makeRelayFeed(relay))
const thunk = await publishAlert({cron, email, feed, bunker, secret, description})
await thunk.result
await loadAlertStatuses($pubkey!) await loadAlertStatuses($pubkey!)
pushToast({message: "Your alert has been successfully created!"}) pushToast({message: "Your alert has been successfully created!"})
@@ -98,67 +136,100 @@
Add an Alert Add an Alert
{/snippet} {/snippet}
</ModalHeader> </ModalHeader>
<FieldInline> {#if showBunker}
{#snippet label()} <div class="card2 flex flex-col items-center gap-4 bg-base-300">
<p>Email Address*</p> <p>Scan using a nostr signer, or click to copy.</p>
{/snippet} <BunkerConnect {controller} />
{#snippet input()} <Button class="btn btn-neutral btn-sm" onclick={hideBunker}>Cancel</Button>
<label class="input input-bordered flex w-full items-center gap-2"> </div>
<input placeholder="email@example.com" bind:value={email} /> {:else}
</label> <FieldInline>
{/snippet} {#snippet label()}
</FieldInline> <p>Email Address*</p>
<FieldInline> {/snippet}
{#snippet label()} {#snippet input()}
<p>Frequency*</p> <label class="input input-bordered flex w-full items-center gap-2">
{/snippet} <input placeholder="email@example.com" bind:value={email} />
{#snippet input()} </label>
<select bind:value={cron} class="select select-bordered"> {/snippet}
<option value={WEEKLY}>Weekly</option> </FieldInline>
<option value={DAILY}>Daily</option> <FieldInline>
</select> {#snippet label()}
{/snippet} <p>Frequency*</p>
</FieldInline> {/snippet}
<FieldInline> {#snippet input()}
{#snippet label()} <select bind:value={cron} class="select select-bordered">
<p>Space*</p> <option value={WEEKLY}>Weekly</option>
{/snippet} <option value={DAILY}>Daily</option>
{#snippet input()} </select>
<select bind:value={relay} class="select select-bordered"> {/snippet}
<option value="" disabled selected>Choose a space URL</option> </FieldInline>
{#each getMembershipUrls($userMembership) as url (url)} <FieldInline>
<option value={url}>{displayRelayUrl(url)}</option> {#snippet label()}
{/each} <p>Space*</p>
</select> {/snippet}
{/snippet} {#snippet input()}
</FieldInline> <select bind:value={relay} class="select select-bordered">
<FieldInline> <option value="" disabled selected>Choose a space URL</option>
{#snippet label()} {#each getMembershipUrls($userMembership) as url (url)}
<p>Notifications*</p> <option value={url}>{displayRelayUrl(url)}</option>
{/snippet} {/each}
{#snippet input()} </select>
<div class="flex items-center justify-end gap-4"> {/snippet}
<span class="flex gap-3"> </FieldInline>
<input type="checkbox" class="checkbox" bind:checked={notifyThreads} /> <FieldInline>
Threads {#snippet label()}
</span> <p>Notifications*</p>
<span class="flex gap-3"> {/snippet}
<input type="checkbox" class="checkbox" bind:checked={notifyCalendar} /> {#snippet input()}
Calendar <div class="flex items-center justify-end gap-4">
</span> <span class="flex gap-3">
<span class="flex gap-3"> <input type="checkbox" class="checkbox" bind:checked={notifyThreads} />
<input type="checkbox" class="checkbox" bind:checked={notifyChat} /> Threads
Chat </span>
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyCalendar} />
Calendar
</span>
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyChat} />
Chat
</span>
</div>
{/snippet}
</FieldInline>
<div class="card2 flex flex-col gap-3 bg-base-300">
<div class="flex items-center justify-between">
<strong>Connect a Bunker</strong>
<span class="flex items-center gap-2 text-sm" class:text-primary={bunker}>
{#if bunker}
<Icon icon="check-circle" size={5} />
Connected
{:else}
<Icon icon="close-circle" size={5} />
Not Connected
{/if}
</span> </span>
</div> </div>
{/snippet} <p class="text-sm">
</FieldInline> Required for receiving alerts about spaces with access controls. You can get one from your
<Button class="text-primary" onclick={() => pushModal(InfoBunker)}>remote signer app</Button
>.
</p>
{#if bunker}
<Button class="btn btn-neutral btn-sm flex-grow" onclick={clearBunker}>Disconnect</Button>
{:else}
<Button class="btn btn-primary btn-sm w-full flex-grow" onclick={connectBunker}
>Connect</Button>
{/if}
</div>
{/if}
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back}> <Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" /> <Icon icon="alt-arrow-left" />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary" disabled={loading}> <Button type="submit" class="btn btn-primary" disabled={loading || showBunker}>
<Spinner {loading}>Confirm</Spinner> <Spinner {loading}>Confirm</Spinner>
<Icon icon="alt-arrow-right" /> <Icon icon="alt-arrow-right" />
</Button> </Button>
+16 -32
View File
@@ -1,22 +1,12 @@
<script lang="ts"> <script lang="ts">
import {parseJson, nthEq} from "@welshman/lib" import {parseJson, nthEq} from "@welshman/lib"
import { import {displayFeeds} from "@welshman/feeds"
getAddress, import {getAddress, getTagValue, getTagValues} from "@welshman/util"
getTagValue,
getTagValues,
displayRelayUrl,
EVENT_TIME,
MESSAGE,
THREAD,
} from "@welshman/util"
import {displayList} from "@lib/util"
import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import AlertDelete from "@app/components/AlertDelete.svelte" import AlertDelete from "@app/components/AlertDelete.svelte"
import type {Alert} from "@app/state" import type {Alert} from "@app/state"
import {alertStatuses} from "@app/state" import {alertStatuses} from "@app/state"
import {makeSpacePath} from "@app/routes"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
type Props = { type Props = {
@@ -29,31 +19,25 @@
const status = $derived($alertStatuses.find(s => s.event.tags.some(nthEq(1, address)))) const status = $derived($alertStatuses.find(s => s.event.tags.some(nthEq(1, address))))
const cron = $derived(getTagValue("cron", alert.tags)) const cron = $derived(getTagValue("cron", alert.tags))
const channel = $derived(getTagValue("channel", alert.tags)) const channel = $derived(getTagValue("channel", alert.tags))
const relay = $derived(getTagValue("relay", alert.tags)!) const feeds = $derived(getTagValues("feed", alert.tags))
const filters = $derived(getTagValues("filter", alert.tags).map(parseJson)) const description = $derived(
const types = $derived.by(() => { getTagValue("description", alert.tags) ||
const t: string[] = [] [
`${cron?.endsWith("1") ? "Weekly" : "Daily"} alert for events`,
if (filters.some(f => f.kinds?.includes(THREAD))) t.push("threads") displayFeeds(feeds.map(parseJson)),
if (filters.some(f => f.kinds?.includes(EVENT_TIME))) t.push("calendar events") `sent via ${channel}.`,
if (filters.some(f => f.kinds?.includes(MESSAGE))) t.push("chat") ].join(" "),
)
return t
})
const startDelete = () => pushModal(AlertDelete, {alert}) const startDelete = () => pushModal(AlertDelete, {alert})
</script> </script>
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<Button class="py-1" onclick={startDelete}> <div class="flex items-start gap-4">
<Icon icon="trash-bin-2" /> <Button class="py-1" onclick={startDelete}>
</Button> <Icon icon="trash-bin-2" />
<div class="flex-inline gap-1"> </Button>
{cron?.endsWith("1") ? "Weekly" : "Daily"} alert for <div class="flex-inline gap-1">{description}</div>
{displayList(types)} on
<Link class="link" href={makeSpacePath(relay)}>
{displayRelayUrl(relay)}
</Link>, sent via {channel}.
</div> </div>
{#if status} {#if status}
{@const statusText = getTagValue("status", status.tags) || "error"} {@const statusText = getTagValue("status", status.tags) || "error"}
+8
View File
@@ -1,12 +1,20 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte"
import {pubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import AlertAdd from "@app/components/AlertAdd.svelte" import AlertAdd from "@app/components/AlertAdd.svelte"
import AlertItem from "@app/components/AlertItem.svelte" import AlertItem from "@app/components/AlertItem.svelte"
import {loadAlertStatuses, loadAlerts} from "@app/requests"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {alerts} from "@app/state" import {alerts} from "@app/state"
const startAlert = () => pushModal(AlertAdd) const startAlert = () => pushModal(AlertAdd)
onMount(() => {
loadAlertStatuses($pubkey!)
loadAlerts($pubkey!)
})
</script> </script>
<div class="card2 bg-alt flex flex-col gap-6 shadow-xl"> <div class="card2 bg-alt flex flex-col gap-6 shadow-xl">
+79
View File
@@ -0,0 +1,79 @@
<script module lang="ts">
import type {Nip46ResponseWithResult} from "@welshman/signer"
import {Nip46Broker, makeSecret} from "@welshman/signer"
import {NIP46_PERMS, PLATFORM_URL, PLATFORM_NAME, PLATFORM_LOGO, SIGNER_RELAYS} from "@app/state"
export class BunkerConnectController {
url = $state("")
bunker = $state("")
loading = $state(false)
clientSecret = makeSecret()
abortController = new AbortController()
broker = new Nip46Broker({clientSecret: this.clientSecret, relays: SIGNER_RELAYS})
onNostrConnect: (response: Nip46ResponseWithResult) => void
constructor({onNostrConnect}: {onNostrConnect: (response: Nip46ResponseWithResult) => void}) {
this.onNostrConnect = onNostrConnect
}
async start() {
this.url = await this.broker.makeNostrconnectUrl({
perms: NIP46_PERMS,
url: PLATFORM_URL,
name: PLATFORM_NAME,
image: PLATFORM_LOGO,
})
let response
try {
response = await this.broker.waitForNostrconnect(this.url, this.abortController.signal)
} catch (errorResponse: any) {
if (errorResponse?.error) {
pushToast({
theme: "error",
message: `Received error from signer: ${errorResponse.error}`,
})
} else if (errorResponse) {
console.error(errorResponse)
}
}
if (response) {
this.loading = true
this.onNostrConnect(response)
}
}
stop() {
this.broker.cleanup()
this.abortController.abort()
}
}
</script>
<script lang="ts">
import {onMount, onDestroy} from "svelte"
import {slideAndFade} from "@lib/transition"
import QRCode from "@app/components/QRCode.svelte"
import {pushToast} from "@app/toast"
type Props = {
controller: BunkerConnectController
}
const {controller}: Props = $props()
onMount(() => {
controller.start()
})
onDestroy(() => {
controller.stop()
})
</script>
{#if controller.url}
<div class="flex justify-center" out:slideAndFade>
<QRCode code={controller.url} />
</div>
{/if}
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts">
import {pushModal} from "@app/modal"
import InfoBunker from "@app/components/InfoBunker.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte"
type Props = {
bunker: string
loading: boolean
}
let {loading, bunker = $bindable("")}: Props = $props()
</script>
<Field>
{#snippet label()}
<p>Bunker Link*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="cpu" />
<input disabled={loading} bind:value={bunker} class="grow" placeholder="bunker://" />
</label>
{/snippet}
{#snippet info()}
<p>
A login link provided by a nostr signing app.
<Button class="link" onclick={() => pushModal(InfoBunker)}>What is a bunker link?</Button>
</p>
{/snippet}
</Field>
+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)}>
+10 -18
View File
@@ -1,15 +1,7 @@
<script lang="ts"> <script lang="ts">
import {hash} from "@welshman/lib" import {hash, now, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib"
import {now} from "@welshman/lib"
import type {TrustedEvent} 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 TapTarget from "@lib/components/TapTarget.svelte" import TapTarget from "@lib/components/TapTarget.svelte"
import Avatar from "@lib/components/Avatar.svelte" import Avatar from "@lib/components/Avatar.svelte"
@@ -27,10 +19,10 @@
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
interface Props { interface Props {
url: any url: string
room: any room: string
event: TrustedEvent event: TrustedEvent
replyTo?: any replyTo?: (event: TrustedEvent) => void
showPubkey?: boolean showPubkey?: boolean
inert?: boolean inert?: boolean
} }
@@ -39,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 onTap = () => pushModal(ChannelMessageMenuMobile, {url, event, reply}) const onTap = () => pushModal(ChannelMessageMenuMobile, {url, event, reply})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey}) const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
const onReactionClick = (content: string, events: TrustedEvent[]) => { const onReactionClick = (content: string, events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey) const reaction = events.find(e => e.pubkey === $pubkey)
@@ -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}
+87 -68
View File
@@ -1,20 +1,21 @@
<script lang="ts"> <script lang="ts">
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {onMount} from "svelte" import {onMount} from "svelte"
import {int, nthNe, MINUTE, sortBy, remove} from "@welshman/lib" import {int, nthNe, MINUTE, sortBy, remove, formatTimestampAsDate} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {createEvent, DIRECT_MESSAGE, INBOX_RELAYS} from "@welshman/util" import {createEvent, DIRECT_MESSAGE, INBOX_RELAYS} from "@welshman/util"
import { import {
pubkey, pubkey,
tagPubkey, tagPubkey,
formatTimestampAsDate, sendWrapped,
loadUsingOutbox,
inboxRelaySelectionsByPubkey, inboxRelaySelectionsByPubkey,
load,
} from "@welshman/app" } from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import PageBar from "@lib/components/PageBar.svelte" import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte" import Divider from "@lib/components/Divider.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ProfileName from "@app/components/ProfileName.svelte" import ProfileName from "@app/components/ProfileName.svelte"
@@ -23,19 +24,24 @@
import ProfileDetail from "@app/components/ProfileDetail.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ProfileList from "@app/components/ProfileList.svelte" import ProfileList from "@app/components/ProfileList.svelte"
import ChatMessage from "@app/components/ChatMessage.svelte" import ChatMessage from "@app/components/ChatMessage.svelte"
import ChatCompose from "@app/components/ChannelCompose.svelte" import ChatCompose from "@app/components/ChatCompose.svelte"
import ChatComposeParent from "@app/components/ChannelComposeParent.svelte" import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
import {userSettingValues, deriveChat, splitChatId, PLATFORM_NAME} from "@app/state" import {
INDEXER_RELAYS,
userSettingValues,
deriveChat,
splitChatId,
PLATFORM_NAME,
} from "@app/state"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {sendWrapped, prependParent} from "@app/commands" import {prependParent} from "@app/commands"
const { type Props = {
id,
info,
}: {
id: string id: string
info?: Snippet info?: Snippet
} = $props() }
const {id, info}: Props = $props()
const chat = deriveChat(id) const chat = deriveChat(id)
const pubkeys = splitChatId(id) const pubkeys = splitChatId(id)
@@ -70,7 +76,7 @@
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 parentPreview: HTMLElement | undefined = $state() let chatCompose: HTMLElement | undefined = $state()
let dynamicPadding: HTMLElement | undefined = $state() let dynamicPadding: HTMLElement | undefined = $state()
const elements = $derived.by(() => { const elements = $derived.by(() => {
@@ -105,16 +111,26 @@
onMount(() => { onMount(() => {
// Don't use loadInboxRelaySelection because we want to force reload // Don't use loadInboxRelaySelection because we want to force reload
load({filters: [{kinds: [INBOX_RELAYS], authors: others}]}) for (const pubkey of others) {
loadUsingOutbox({
pubkey,
kind: INBOX_RELAYS,
relays: INDEXER_RELAYS,
})
}
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
dynamicPadding!.style.minHeight = `${parentPreview!.offsetHeight}px` if (dynamicPadding && chatCompose) {
dynamicPadding.style.minHeight = `${chatCompose.offsetHeight}px`
}
}) })
observer.observe(parentPreview!) observer.observe(chatCompose!)
observer.observe(dynamicPadding!)
return () => { return () => {
observer.unobserve(parentPreview!) observer.unobserve(chatCompose!)
observer.unobserve(dynamicPadding!)
} }
}) })
@@ -123,56 +139,59 @@
}, 5000) }, 5000)
</script> </script>
{#if others.length > 0} <PageBar>
<PageBar class="chat__page-bar"> {#snippet title()}
{#snippet title()} <div class="flex flex-col gap-1 sm:flex-row sm:gap-2">
<div class="flex flex-col gap-1 sm:flex-row sm:gap-2"> {#if others.length === 0}
{#if others.length === 1} <div class="row-2">
{@const pubkey = others[0]} <ProfileCircle pubkey={$pubkey!} size={5} />
{@const onClick = () => pushModal(ProfileDetail, {pubkey})} <ProfileName pubkey={$pubkey!} />
<Button onclick={onClick} class="row-2"> </div>
<ProfileCircle {pubkey} size={5} /> {:else if others.length === 1}
<ProfileName {pubkey} /> {@const pubkey = others[0]}
</Button> {@const onClick = () => pushModal(ProfileDetail, {pubkey})}
{:else} <Button onclick={onClick} class="row-2">
<div class="flex items-center gap-2"> <ProfileCircle {pubkey} size={5} />
<ProfileCircles pubkeys={others} size={5} /> <ProfileName {pubkey} />
<p class="overflow-hidden text-ellipsis whitespace-nowrap"> </Button>
<ProfileName pubkey={others[0]} /> {:else}
and <div class="flex items-center gap-2">
{#if others.length === 2} <ProfileCircles pubkeys={others} size={5} />
<ProfileName pubkey={others[1]} /> <p class="overflow-hidden text-ellipsis whitespace-nowrap">
{:else} <ProfileName pubkey={others[0]} />
{others.length - 1} and
{others.length > 2 ? "others" : "other"} {#if others.length === 2}
{/if} <ProfileName pubkey={others[1]} />
</p> {:else}
</div> {others.length - 1}
{#if others.length > 2} {others.length > 2 ? "others" : "other"}
<Button onclick={showMembers} class="btn btn-link hidden sm:block" {/if}
>Show all members</Button> </p>
{/if} </div>
{#if others.length > 2}
<Button onclick={showMembers} class="btn btn-link hidden sm:block"
>Show all members</Button>
{/if} {/if}
</div> {/if}
{/snippet} </div>
{#snippet action()} {/snippet}
<div> {#snippet action()}
{#if remove($pubkey, missingInboxes).length > 0} <div>
{@const count = remove($pubkey, missingInboxes).length} {#if remove($pubkey, missingInboxes).length > 0}
{@const label = count > 1 ? "inboxes are" : "inbox is"} {@const count = remove($pubkey, missingInboxes).length}
<div {@const label = count > 1 ? "inboxes are" : "inbox is"}
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer" <div
data-tip="{count} {label} not configured."> class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer"
<Icon icon="danger" /> data-tip="{count} {label} not configured.">
{count} <Icon icon="danger" />
</div> {count}
{/if} </div>
</div> {/if}
{/snippet} </div>
</PageBar> {/snippet}
{/if} </PageBar>
<div class="chat__messages scroll-container"> <PageContent class="flex flex-col-reverse pt-4">
<div bind:this={dynamicPadding}></div> <div bind:this={dynamicPadding}></div>
{#if missingInboxes.includes($pubkey!)} {#if missingInboxes.includes($pubkey!)}
<div class="py-12"> <div class="py-12">
@@ -223,10 +242,10 @@
</Spinner> </Spinner>
{@render info?.()} {@render info?.()}
</p> </p>
</div> </PageContent>
<div class="chat__compose bg-base-200"> <div class="chat__compose bg-base-200" bind:this={chatCompose}>
<div bind:this={parentPreview}> <div>
{#if parent} {#if parent}
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" /> <ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if} {/if}
+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} />
+4 -10
View File
@@ -1,14 +1,8 @@
<script lang="ts"> <script lang="ts">
import {type Instance} from "tippy.js" import {type Instance} from "tippy.js"
import {hash} from "@welshman/lib" import {hash, formatTimestampAsTime} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import { import {thunks, pubkey, deriveProfile, deriveProfileDisplay, sendWrapped} from "@welshman/app"
thunks,
deriveProfile,
deriveProfileDisplay,
formatTimestampAsTime,
pubkey,
} from "@welshman/app"
import {isMobile} from "@lib/html" import {isMobile} from "@lib/html"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -22,7 +16,7 @@
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte" import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte" import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte"
import {colors} from "@app/state" import {colors} from "@app/state"
import {makeDelete, makeReaction, sendWrapped} from "@app/commands" import {makeDelete, makeReaction} from "@app/commands"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
interface Props { interface Props {
@@ -125,7 +119,7 @@
<Content showEntire {event} /> <Content showEntire {event} />
</div> </div>
</TapTarget> </TapTarget>
<div class="row-2 z-feature -mt-1 ml-4"> <div class="row-2 z-feature -mt-4 ml-4">
<ReactionSummary {event} {onReactionClick} noTooltip /> <ReactionSummary {event} {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,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared" import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {sendWrapped} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte" import EmojiPicker from "@lib/components/EmojiPicker.svelte"
import EventInfo from "@app/components/EventInfo.svelte" import EventInfo from "@app/components/EventInfo.svelte"
import {makeReaction, sendWrapped} from "@app/commands" import {makeReaction} from "@app/commands"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {clip} from "@app/toast" import {clip} from "@app/toast"
@@ -17,10 +18,10 @@
const {event, pubkeys, reply}: Props = $props() const {event, pubkeys, reply}: Props = $props()
const onEmoji = ((event: TrustedEvent, emoji: NativeEmoji) => { const onEmoji = ((event: TrustedEvent, pubkeys: string[], emoji: NativeEmoji) => {
history.back() history.back()
sendWrapped({template: makeReaction({event, content: emoji.unicode}), pubkeys}) sendWrapped({template: makeReaction({event, content: emoji.unicode}), pubkeys})
}).bind(undefined, event) }).bind(undefined, event, pubkeys)
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true}) const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
+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"
+3 -3
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import {nip19} from "nostr-tools" import * as nip19 from "nostr-tools/nip19"
import {ctx} from "@welshman/lib" import {Router} from "@welshman/router"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import FieldInline from "@lib/components/FieldInline.svelte" import FieldInline from "@lib/components/FieldInline.svelte"
@@ -15,7 +15,7 @@
const {url, event}: Props = $props() const {url, event}: Props = $props()
const relays = url ? [url] : ctx.app.router.Event(event).getUrls() const relays = url ? [url] : Router.get().Event(event).getUrls()
const nevent1 = nip19.neventEncode({...event, relays}) const nevent1 = nip19.neventEncode({...event, relays})
const npub1 = nip19.npubEncode(event.pubkey) const npub1 = nip19.npubEncode(event.pubkey)
const json = JSON.stringify(event, null, 2) const json = JSON.stringify(event, null, 2)
+19 -24
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"
@@ -10,42 +12,34 @@
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.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, {url, event})
}
const share = () => { const share = () => pushModal(EventShare, {url, event})
onClick()
pushModal(EventShare, {url, event})
}
const showDelete = () => { const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
onClick()
pushModal(EventDeleteConfirm, {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",
+46 -107
View File
@@ -1,38 +1,39 @@
<script lang="ts"> <script lang="ts">
import {onMount, onDestroy} from "svelte" import type {Nip46ResponseWithResult} from "@welshman/signer"
import {Nip46Broker, getPubkey, makeSecret} from "@welshman/signer" import {Nip46Broker, makeSecret} from "@welshman/signer"
import {addSession} from "@welshman/app" import {loginWithNip01, loginWithNip46} from "@welshman/app"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import {slideAndFade} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import QRCode from "@app/components/QRCode.svelte" import BunkerConnect, {BunkerConnectController} from "@app/components/BunkerConnect.svelte"
import InfoBunker from "@app/components/InfoBunker.svelte" import BunkerUrl from "@app/components/BunkerUrl.svelte"
import {loginWithNip46} from "@app/commands"
import {loadUserData} from "@app/requests" import {loadUserData} from "@app/requests"
import {pushModal, clearModals} from "@app/modal" import {clearModals} from "@app/modal"
import {setChecked} from "@app/notifications" import {setChecked} from "@app/notifications"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {NIP46_PERMS, PLATFORM_URL, PLATFORM_NAME, PLATFORM_LOGO, SIGNER_RELAYS} from "@app/state" import {SIGNER_RELAYS, NIP46_PERMS} from "@app/state"
const clientSecret = makeSecret()
const abortController = new AbortController()
const broker = Nip46Broker.get({clientSecret, relays: SIGNER_RELAYS})
const back = () => history.back() const back = () => history.back()
const onSubmit = async () => { const controller = new BunkerConnectController({
const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(bunker) onNostrConnect: async (response: Nip46ResponseWithResult) => {
const pubkey = await controller.broker.getPublicKey()
if (loading) { await loadUserData(pubkey)
return
} loginWithNip46(pubkey, controller.clientSecret, response.event.pubkey, SIGNER_RELAYS)
setChecked("*")
clearModals()
},
})
const onSubmit = async () => {
if (controller.loading) return
const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(controller.bunker)
if (!signerPubkey || relays.length === 0) { if (!signerPubkey || relays.length === 0) {
return pushToast({ return pushToast({
@@ -41,13 +42,22 @@
}) })
} }
loading = true controller.loading = true
try { try {
const success = await loginWithNip46({connectSecret, clientSecret, signerPubkey, relays}) const {clientSecret} = controller
const broker = new Nip46Broker({relays, clientSecret, signerPubkey})
const result = await broker.connect(connectSecret, NIP46_PERMS)
const pubkey = await broker.getPublicKey()
if (success) { // TODO: remove ack result
abortController.abort() if (pubkey && ["ack", connectSecret].includes(result)) {
broker.cleanup()
controller.stop()
await loadUserData(pubkey)
loginWithNip46(pubkey, clientSecret, signerPubkey, relays)
} else { } else {
return pushToast({ return pushToast({
theme: "error", theme: "error",
@@ -55,72 +65,18 @@
}) })
} }
} finally { } finally {
loading = false controller.loading = false
} }
clearModals() clearModals()
} }
let url = $state("")
let bunker = $state("")
let loading = $state(false)
$effect(() => { $effect(() => {
// For testing and for play store reviewers // For testing and for play store reviewers
if (bunker === "reviewkey") { if (controller.bunker === "reviewkey") {
const secret = makeSecret() loginWithNip01(makeSecret())
addSession({method: "nip01", secret, pubkey: getPubkey(secret)})
} }
}) })
onMount(async () => {
url = await broker.makeNostrconnectUrl({
perms: NIP46_PERMS,
url: PLATFORM_URL,
name: PLATFORM_NAME,
image: PLATFORM_LOGO,
})
let response
try {
response = await broker.waitForNostrconnect(url, abortController)
} catch (errorResponse: any) {
if (errorResponse?.error) {
pushToast({
theme: "error",
message: `Received error from signer: ${errorResponse.error}`,
})
} else if (errorResponse) {
console.error(errorResponse)
}
}
if (response) {
loading = true
const userPubkey = await broker.getPublicKey()
await loadUserData(userPubkey)
addSession({
method: "nip46",
pubkey: userPubkey,
secret: clientSecret,
handler: {
pubkey: response.event.pubkey,
relays: SIGNER_RELAYS,
},
})
setChecked("*")
clearModals()
}
})
onDestroy(() => {
abortController.abort()
})
</script> </script>
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}> <form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
@@ -132,35 +88,18 @@
<div>Connect your signer by scanning the QR code below or pasting a bunker link.</div> <div>Connect your signer by scanning the QR code below or pasting a bunker link.</div>
{/snippet} {/snippet}
</ModalHeader> </ModalHeader>
{#if !loading && url} <BunkerConnect {controller} />
<div class="flex justify-center" out:slideAndFade> <BunkerUrl loading={controller.loading} bind:bunker={controller.bunker} />
<QRCode code={url} />
</div>
{/if}
<Field>
{#snippet label()}
<p>Bunker Link*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="cpu" />
<input disabled={loading} bind:value={bunker} class="grow" placeholder="bunker://" />
</label>
{/snippet}
{#snippet info()}
<p>
A login link provided by a nostr signing app.
<Button class="link" onclick={() => pushModal(InfoBunker)}>What is a bunker link?</Button>
</p>
{/snippet}
</Field>
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}> <Button class="btn btn-link" onclick={back} disabled={controller.loading}>
<Icon icon="alt-arrow-left" /> <Icon icon="alt-arrow-left" />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary" disabled={loading || !bunker}> <Button
<Spinner {loading}>Next</Spinner> type="submit"
class="btn btn-primary"
disabled={controller.loading || !controller.bunker}>
<Spinner loading={controller.loading}>Next</Spinner>
<Icon icon="alt-arrow-right" /> <Icon icon="alt-arrow-right" />
</Button> </Button>
</ModalFooter> </ModalFooter>
+8 -13
View File
@@ -3,7 +3,7 @@
import {postJson, stripProtocol} from "@welshman/lib" import {postJson, stripProtocol} from "@welshman/lib"
import {Nip46Broker, makeSecret} from "@welshman/signer" import {Nip46Broker, makeSecret} from "@welshman/signer"
import {normalizeRelayUrl} from "@welshman/util" import {normalizeRelayUrl} from "@welshman/util"
import {addSession} from "@welshman/app" import {addSession, makeNip46Session} from "@welshman/app"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -34,7 +34,7 @@
? [normalizeRelayUrl("ws://" + stripProtocol(BURROW_URL))] ? [normalizeRelayUrl("ws://" + stripProtocol(BURROW_URL))]
: [normalizeRelayUrl(BURROW_URL)] : [normalizeRelayUrl(BURROW_URL)]
const broker = Nip46Broker.get({clientSecret, relays}) const broker = new Nip46Broker({clientSecret, relays})
const back = () => history.back() const back = () => history.back()
@@ -68,7 +68,7 @@
let response let response
try { try {
response = await broker.waitForNostrconnect(url, abortController) response = await broker.waitForNostrconnect(url, abortController.signal)
} catch (errorResponse: any) { } catch (errorResponse: any) {
if (errorResponse?.error) { if (errorResponse?.error) {
pushToast({ pushToast({
@@ -83,18 +83,13 @@
if (response) { if (response) {
loading = true loading = true
const userPubkey = await broker.getPublicKey() const pubkey = await broker.getPublicKey()
const session = makeNip46Session(pubkey, clientSecret, response.event.pubkey, relays)
await loadUserData(userPubkey) await loadUserData(pubkey)
addSession({
email,
method: "nip46",
pubkey: userPubkey,
secret: clientSecret,
handler: {pubkey: response.event.pubkey, relays},
})
addSession({...session, email})
broker.cleanup()
setChecked("*") setChecked("*")
clearModals() clearModals()
} }
+15
View File
@@ -0,0 +1,15 @@
<script lang="ts">
import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte"
type Props = {
urls: string[]
}
const {urls}: Props = $props()
</script>
<div class="column menu gap-2">
{#each urls as url (url)}
<MenuSpacesItem {url} />
{/each}
</div>
+2 -4
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {displayRelayUrl, GROUP_META} from "@welshman/util" import {displayRelayUrl} from "@welshman/util"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -22,7 +22,6 @@
deriveOtherRooms, deriveOtherRooms,
} from "@app/state" } from "@app/state"
import {notifications} from "@app/notifications" import {notifications} from "@app/notifications"
import {pullConservatively} from "@app/requests"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {makeSpacePath} from "@app/routes" import {makeSpacePath} from "@app/routes"
@@ -44,7 +43,7 @@
const showMembers = () => const showMembers = () =>
pushModal( pushModal(
ProfileList, ProfileList,
{pubkeys: members, title: `Members of`, subtitle: displayRelayUrl(url)}, {url, pubkeys: members, title: `Members of`, subtitle: displayRelayUrl(url)},
{replaceState}, {replaceState},
) )
@@ -66,7 +65,6 @@
onMount(() => { onMount(() => {
replaceState = Boolean(element?.closest(".drawer")) replaceState = Boolean(element?.closest(".drawer"))
pullConservatively({relays: [url], filters: [{kinds: [GROUP_META]}]})
}) })
</script> </script>
+3 -10
View File
@@ -10,26 +10,19 @@
} }
} }
let modal: any = $state.raw()
const hash = $derived($page.url.hash.slice(1)) const hash = $derived($page.url.hash.slice(1))
const hashIsValid = $derived(Boolean($modals[hash])) const modal = $derived($modals[hash])
$effect(() => {
if ($modals[hash]) {
modal = $modals[hash]
}
})
</script> </script>
<svelte:window onkeydown={onKeyDown} /> <svelte:window onkeydown={onKeyDown} />
{#if hashIsValid && modal?.options?.drawer} {#if modal?.options?.drawer}
<Drawer onClose={clearModals} {...modal.options}> <Drawer onClose={clearModals} {...modal.options}>
{#key modal.id} {#key modal.id}
<modal.component {...modal.props} /> <modal.component {...modal.props} />
{/key} {/key}
</Drawer> </Drawer>
{:else if hashIsValid && modal} {:else if modal}
<Dialog onClose={clearModals} {...modal.options}> <Dialog onClose={clearModals} {...modal.options}>
{#key modal.id} {#key modal.id}
<modal.component {...modal.props} /> <modal.component {...modal.props} />
+9 -6
View File
@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames" import cx from "classnames"
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {nip19} from "nostr-tools" import * as nip19 from "nostr-tools/nip19"
import {ctx} from "@welshman/lib" import {formatTimestamp} from "@welshman/lib"
import {getListTags, getPubkeyTagValues} from "@welshman/util" import {getListTags, getPubkeyTagValues} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {formatTimestamp, userMutes} from "@welshman/app" import {Router} from "@welshman/router"
import {userMutes} from "@welshman/app"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -18,16 +19,18 @@
children, children,
minimal = false, minimal = false,
hideProfile = false, hideProfile = false,
url,
...restProps ...restProps
}: { }: {
event: TrustedEvent event: TrustedEvent
children: Snippet children: Snippet
minimal?: boolean minimal?: boolean
hideProfile?: boolean hideProfile?: boolean
url?: string
class?: string class?: string
} = $props() } = $props()
const relays = ctx.app.router.Event(event).getUrls() const relays = Router.get().Event(event).getUrls()
const nevent = nip19.neventEncode({id: event.id, relays}) const nevent = nip19.neventEncode({id: event.id, relays})
const ignoreMute = () => { const ignoreMute = () => {
@@ -50,9 +53,9 @@
<div class="flex justify-between gap-2"> <div class="flex justify-between gap-2">
{#if !hideProfile} {#if !hideProfile}
{#if minimal} {#if minimal}
@<ProfileName pubkey={event.pubkey} /> @<ProfileName pubkey={event.pubkey} {url} />
{:else} {:else}
<Profile pubkey={event.pubkey} /> <Profile pubkey={event.pubkey} {url} />
{/if} {/if}
{/if} {/if}
<Link <Link
+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)}
+34 -8
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import {page} from "$app/stores" import {page} from "$app/stores"
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {splitAt} from "@welshman/lib"
import {userProfile} from "@welshman/app" import {userProfile} from "@welshman/app"
import Avatar from "@lib/components/Avatar.svelte" import Avatar from "@lib/components/Avatar.svelte"
import Divider from "@lib/components/Divider.svelte" import Divider from "@lib/components/Divider.svelte"
@@ -8,6 +9,7 @@
import SpaceAdd from "@app/components/SpaceAdd.svelte" import SpaceAdd from "@app/components/SpaceAdd.svelte"
import ChatEnable from "@app/components/ChatEnable.svelte" import ChatEnable from "@app/components/ChatEnable.svelte"
import MenuSpaces from "@app/components/MenuSpaces.svelte" import MenuSpaces from "@app/components/MenuSpaces.svelte"
import MenuOtherSpaces from "@app/components/MenuOtherSpaces.svelte"
import MenuSettings from "@app/components/MenuSettings.svelte" import MenuSettings from "@app/components/MenuSettings.svelte"
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte" import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
import {userRoomsByUrl, canDecrypt, PLATFORM_RELAY, PLATFORM_LOGO} from "@app/state" import {userRoomsByUrl, canDecrypt, PLATFORM_RELAY, PLATFORM_LOGO} from "@app/state"
@@ -22,20 +24,35 @@
const addSpace = () => pushModal(SpaceAdd) const addSpace = () => pushModal(SpaceAdd)
const showSpacesMenu = () => (spacePaths.length > 0 ? pushModal(MenuSpaces) : pushModal(SpaceAdd)) const showSpacesMenu = () => (spaceUrls.length > 0 ? pushModal(MenuSpaces) : pushModal(SpaceAdd))
const showOtherSpacesMenu = () => pushModal(MenuOtherSpaces, {urls: secondarySpaceUrls})
const showSettingsMenu = () => pushModal(MenuSettings) const showSettingsMenu = () => pushModal(MenuSettings)
const openChat = () => ($canDecrypt ? goto("/chat") : pushModal(ChatEnable, {next: "/chat"})) const openChat = () => ($canDecrypt ? goto("/chat") : pushModal(ChatEnable, {next: "/chat"}))
const hasNotification = (url: string) => {
const path = makeSpacePath(url)
return !$page.url.pathname.startsWith(path) && $notifications.has(path)
}
let windowHeight = $state(0)
const itemHeight = 56
const navPadding = 6 * itemHeight
const itemLimit = $derived((windowHeight - navPadding) / itemHeight)
const spaceUrls = $derived(Array.from($userRoomsByUrl.keys())) const spaceUrls = $derived(Array.from($userRoomsByUrl.keys()))
const spacePaths = $derived(spaceUrls.map(url => makeSpacePath(url))) const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, spaceUrls))
const anySpaceNotifications = $derived( const anySpaceNotifications = $derived(spaceUrls.some(hasNotification))
spacePaths.some(path => !$page.url.pathname.startsWith(path) && $notifications.has(path)), const otherSpaceNotifications = $derived(secondarySpaceUrls.some(hasNotification))
)
</script> </script>
<div class="sail sait saib relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block"> <svelte:window bind:innerHeight={windowHeight} />
<div
class="ml-sai mt-sai mb-sai relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block">
<div class="flex h-full flex-col justify-between"> <div class="flex h-full flex-col justify-between">
<div> <div>
{#if PLATFORM_RELAY} {#if PLATFORM_RELAY}
@@ -45,9 +62,18 @@
<Avatar src={PLATFORM_LOGO} class="!h-10 !w-10" /> <Avatar src={PLATFORM_LOGO} class="!h-10 !w-10" />
</PrimaryNavItem> </PrimaryNavItem>
<Divider /> <Divider />
{#each spaceUrls as url (url)} {#each primarySpaceUrls as url (url)}
<PrimaryNavItemSpace {url} /> <PrimaryNavItemSpace {url} />
{/each} {/each}
{#if secondarySpaceUrls.length > 0}
<PrimaryNavItem
title="Other Spaces"
class="tooltip-right"
onclick={showOtherSpacesMenu}
notification={otherSpaceNotifications}>
<Avatar icon="widget" class="!h-10 !w-10" />
</PrimaryNavItem>
{/if}
<PrimaryNavItem title="Add Space" onclick={addSpace} class="tooltip-right"> <PrimaryNavItem title="Add Space" onclick={addSpace} class="tooltip-right">
<Avatar icon="settings-minimalistic" class="!h-10 !w-10" /> <Avatar icon="settings-minimalistic" class="!h-10 !w-10" />
</PrimaryNavItem> </PrimaryNavItem>
@@ -78,7 +104,7 @@
{@render children?.()} {@render children?.()}
<!-- a little extra something for ios --> <!-- a little extra something for ios -->
<div class="fixed bottom-0 left-0 right-0 z-nav h-14 bg-base-100 md:hidden"></div> <div class="fixed bottom-0 left-0 right-0 z-nav h-[var(--saib)] bg-base-100 md:hidden"></div>
<div <div
class="border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden"> class="border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
<div class="content-padding-x content-sizing flex justify-between px-2"> <div class="content-padding-x content-sizing flex justify-between px-2">
+12 -5
View File
@@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
import {removeNil} from "@welshman/lib"
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util" import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
import { import {
session, session,
userFollows, userFollows,
deriveUserWotScore, deriveUserWotScore,
deriveProfile,
deriveHandleForPubkey, deriveHandleForPubkey,
displayHandle, displayHandle,
deriveProfile,
deriveProfileDisplay, deriveProfileDisplay,
} from "@welshman/app" } from "@welshman/app"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -15,14 +16,20 @@
import ProfileDetail from "@app/components/ProfileDetail.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
const {pubkey} = $props() type Props = {
pubkey: string
url?: string
}
const profile = deriveProfile(pubkey) const {pubkey, url}: Props = $props()
const profileDisplay = deriveProfileDisplay(pubkey)
const relays = removeNil([url])
const profile = deriveProfile(pubkey, relays)
const profileDisplay = deriveProfileDisplay(pubkey, relays)
const handle = deriveHandleForPubkey(pubkey) const handle = deriveHandleForPubkey(pubkey)
const score = deriveUserWotScore(pubkey) const score = deriveUserWotScore(pubkey)
const openProfile = () => pushModal(ProfileDetail, {pubkey}) const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
const following = $derived( const following = $derived(
pubkey === $session!.pubkey || getPubkeyTagValues(getListTags($userFollows)).includes(pubkey), pubkey === $session!.pubkey || getPubkeyTagValues(getListTags($userFollows)).includes(pubkey),
+9 -3
View File
@@ -1,10 +1,16 @@
<script lang="ts"> <script lang="ts">
import {deriveProfile} from "@welshman/app"
import Avatar from "@lib/components/Avatar.svelte" import Avatar from "@lib/components/Avatar.svelte"
import {removeNil} from "@welshman/lib"
import {deriveProfile} from "@welshman/app"
const {...props} = $props() type Props = {
pubkey: string
url?: string
} & Record<string, any>
const profile = deriveProfile(props.pubkey) const {pubkey, url, ...props}: Props = $props()
const profile = deriveProfile(pubkey, removeNil([url]))
</script> </script>
<Avatar src={$profile?.picture} icon="user-circle" {...props} /> <Avatar src={$profile?.picture} icon="user-circle" {...props} />
+3 -2
View File
@@ -7,8 +7,9 @@
DELETE, DELETE,
isReplaceable, isReplaceable,
getAddress, getAddress,
getRelaysFromList,
} from "@welshman/util" } from "@welshman/util"
import {pubkey, userRelaySelections, publishThunk, getRelayUrls, repository} from "@welshman/app" import {pubkey, userRelaySelections, publishThunk, repository} from "@welshman/app"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -40,7 +41,7 @@
const denominator = chunks.length + 2 const denominator = chunks.length + 2
const relays = uniq([ const relays = uniq([
...INDEXER_RELAYS, ...INDEXER_RELAYS,
...getRelayUrls($userRelaySelections), ...getRelaysFromList($userRelaySelections),
...getMembershipUrls($userMembership), ...getMembershipUrls($userMembership),
]) ])
+14 -7
View File
@@ -1,13 +1,14 @@
<script lang="ts"> <script lang="ts">
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {removeNil} from "@welshman/lib"
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util" import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
import { import {
session, session,
userFollows, userFollows,
deriveUserWotScore, deriveUserWotScore,
deriveProfile,
deriveHandleForPubkey, deriveHandleForPubkey,
displayHandle, displayHandle,
deriveProfile,
deriveProfileDisplay, deriveProfileDisplay,
} from "@welshman/app" } from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@@ -22,10 +23,16 @@
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {makeChatPath} from "@app/routes" import {makeChatPath} from "@app/routes"
const {pubkey} = $props() export type Props = {
pubkey: string
url?: string
}
const profile = deriveProfile(pubkey) const {pubkey, url}: Props = $props()
const profileDisplay = deriveProfileDisplay(pubkey)
const relays = removeNil([url])
const profile = deriveProfile(pubkey, relays)
const display = deriveProfileDisplay(pubkey, relays)
const handle = deriveHandleForPubkey(pubkey) const handle = deriveHandleForPubkey(pubkey)
const score = deriveUserWotScore(pubkey) const score = deriveUserWotScore(pubkey)
@@ -48,7 +55,7 @@
<div class="flex min-w-0 flex-col"> <div class="flex min-w-0 flex-col">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-bold overflow-hidden text-ellipsis"> <span class="text-bold overflow-hidden text-ellipsis">
{$profileDisplay} {$display}
</span> </span>
<WotScore score={$score} active={following} /> <WotScore score={$score} active={following} />
</div> </div>
@@ -57,9 +64,9 @@
</div> </div>
</div> </div>
</div> </div>
<ProfileInfo {pubkey} /> <ProfileInfo {pubkey} {url} />
<ModalFooter> <ModalFooter>
<Button onclick={back} class="btn btn-link"> <Button onclick={back} class="hidden md:btn md:btn-link">
<Icon icon="alt-arrow-left" /> <Icon icon="alt-arrow-left" />
Go back Go back
</Button> </Button>
+16 -4
View File
@@ -1,26 +1,38 @@
<script lang="ts"> <script lang="ts">
import {ctx} from "@welshman/lib"
import type {Profile} from "@welshman/util" import type {Profile} from "@welshman/util"
import { import {
getTag,
createEvent, createEvent,
makeProfile, makeProfile,
editProfile, editProfile,
createProfile, createProfile,
isPublishedProfile, isPublishedProfile,
uniqTags,
} from "@welshman/util" } from "@welshman/util"
import {Router} from "@welshman/router"
import {pubkey, profilesByPubkey, publishThunk} from "@welshman/app" import {pubkey, profilesByPubkey, publishThunk} from "@welshman/app"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ProfileEditForm from "@app/components/ProfileEditForm.svelte" import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
import {clearModals} from "@app/modal" import {clearModals} from "@app/modal"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {PROTECTED, getMembershipUrls, userMembership} from "@app/state"
const initialValues = {...($profilesByPubkey.get($pubkey!) || makeProfile())} const profile = $profilesByPubkey.get($pubkey!) || makeProfile()
const shouldBroadcast = !getTag(PROTECTED, profile.event?.tags || [])
const initialValues = {profile, shouldBroadcast}
const back = () => history.back() const back = () => history.back()
const onsubmit = (profile: Profile) => { const onsubmit = ({profile, shouldBroadcast}: {profile: Profile; shouldBroadcast: boolean}) => {
const relays = ctx.app.router.FromUser().getUrls()
const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile) const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
const relays = [...getMembershipUrls($userMembership)]
if (shouldBroadcast) {
relays.push(...Router.get().FromUser().getUrls())
} else {
template.tags = uniqTags([...template.tags, PROTECTED])
}
const event = createEvent(template.kind, template) const event = createEvent(template.kind, template)
publishThunk({event, relays}) publishThunk({event, relays})
+30 -9
View File
@@ -1,23 +1,28 @@
<script lang="ts"> <script lang="ts">
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import type {Profile} from "@welshman/util" import type {Profile} from "@welshman/util"
import {makeProfile} from "@welshman/util"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte" import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import InputProfilePicture from "@lib/components/InputProfilePicture.svelte" import InputProfilePicture from "@lib/components/InputProfilePicture.svelte"
import InfoHandle from "@app/components/InfoHandle.svelte" import InfoHandle from "@app/components/InfoHandle.svelte"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
type Values = {
profile: Profile
shouldBroadcast: boolean
}
type Props = { type Props = {
initialValues?: Profile initialValues: Values
onsubmit: (profile: Profile) => void onsubmit: (values: Values) => void
hideAddress?: boolean hideAddress?: boolean
footer: Snippet footer: Snippet
} }
const {initialValues = makeProfile(), hideAddress, onsubmit, footer}: Props = $props() const {initialValues, hideAddress, onsubmit, footer}: Props = $props()
const values = $state(initialValues) const values = $state(initialValues)
@@ -28,7 +33,7 @@
<form class="col-4" onsubmit={preventDefault(submit)}> <form class="col-4" onsubmit={preventDefault(submit)}>
<div class="flex justify-center py-2"> <div class="flex justify-center py-2">
<InputProfilePicture bind:file bind:url={values.picture} /> <InputProfilePicture bind:file bind:url={values.profile.picture} />
</div> </div>
<Field> <Field>
{#snippet label()} {#snippet label()}
@@ -37,7 +42,7 @@
{#snippet input()} {#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2"> <label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="user-circle" /> <Icon icon="user-circle" />
<input bind:value={values.name} class="grow" type="text" /> <input bind:value={values.profile.name} class="grow" type="text" />
</label> </label>
{/snippet} {/snippet}
{#snippet info()} {#snippet info()}
@@ -49,8 +54,10 @@
<p>About You</p> <p>About You</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<textarea class="textarea textarea-bordered leading-4" rows="3" bind:value={values.about} <textarea
></textarea> class="textarea textarea-bordered leading-4"
rows="5"
bind:value={values.profile.about}></textarea>
{/snippet} {/snippet}
{#snippet info()} {#snippet info()}
Give a brief introduction to why you're here. Give a brief introduction to why you're here.
@@ -64,7 +71,7 @@
{#snippet input()} {#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2"> <label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="map-point" /> <Icon icon="map-point" />
<input bind:value={values.nip05} class="grow" type="text" /> <input bind:value={values.profile.nip05} class="grow" type="text" />
</label> </label>
{/snippet} {/snippet}
{#snippet info()} {#snippet info()}
@@ -75,5 +82,19 @@
{/snippet} {/snippet}
</Field> </Field>
{/if} {/if}
<FieldInline>
{#snippet label()}
<p>Broadcast Profile</p>
{/snippet}
{#snippet input()}
<input type="checkbox" class="toggle toggle-primary" bind:checked={values.shouldBroadcast} />
{/snippet}
{#snippet info()}
<p>
If enabled, changes will be published to the broader nostr network in addition to spaces you
are a member of.
</p>
{/snippet}
</FieldInline>
{@render footer()} {@render footer()}
</form> </form>
+4 -3
View File
@@ -4,7 +4,7 @@
import {feedFromFilter, makeIntersectionFeed, makeRelayFeed} from "@welshman/feeds" import {feedFromFilter, makeIntersectionFeed, makeRelayFeed} from "@welshman/feeds"
import {NOTE, getReplyTags} from "@welshman/util" import {NOTE, getReplyTags} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {createFeedController} from "@welshman/app" import {makeFeedController} from "@welshman/app"
import {createScroller} from "@lib/html" import {createScroller} from "@lib/html"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
@@ -19,7 +19,7 @@
let {url, pubkey, events = $bindable([]), hideLoading = false}: Props = $props() let {url, pubkey, events = $bindable([]), hideLoading = false}: Props = $props()
const ctrl = createFeedController({ const ctrl = makeFeedController({
useWindowing: true, useWindowing: true,
feed: makeIntersectionFeed( feed: makeIntersectionFeed(
makeRelayFeed(url), makeRelayFeed(url),
@@ -45,7 +45,8 @@
e => e.id, e => e.id,
sortBy(e => -e.created_at, buffer), sortBy(e => -e.created_at, buffer),
) )
events = [...events, ...buffer.splice(0, 5)]
events = uniqBy(e => e.id, [...events, ...buffer.splice(0, 5)])
if (buffer.length < 50) { if (buffer.length < 50) {
ctrl.load(50) ctrl.load(50)
+9 -3
View File
@@ -1,12 +1,18 @@
<script lang="ts"> <script lang="ts">
import {removeNil} from "@welshman/lib"
import {deriveProfile} from "@welshman/app" import {deriveProfile} from "@welshman/app"
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
const {pubkey} = $props() export type Props = {
pubkey: string
url?: string
}
const profile = deriveProfile(pubkey) const {pubkey, url}: Props = $props()
const profile = deriveProfile(pubkey, removeNil([url]))
</script> </script>
{#if $profile} {#if $profile}
<Content event={{content: $profile.about, tags: []}} /> <Content event={{content: $profile.about, tags: []}} hideMediaAtDepth={0} />
{/if} {/if}
+8 -3
View File
@@ -5,11 +5,16 @@
import ProfileDetail from "@app/components/ProfileDetail.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
const {pubkey}: {pubkey: string} = $props() type Props = {
pubkey: string
url?: string
}
const openProfile = () => pushModal(ProfileDetail, {pubkey}) const {pubkey, url}: Props = $props()
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
</script> </script>
<Button onclick={preventDefault(openProfile)} class="link-content"> <Button onclick={preventDefault(openProfile)} class="link-content">
@<ProfileName {pubkey} /> @<ProfileName {pubkey} {url} />
</Button> </Button>
+4 -3
View File
@@ -5,11 +5,12 @@
interface Props { interface Props {
title: any title: any
subtitle?: string
pubkeys: any pubkeys: any
subtitle?: string
url?: string
} }
const {subtitle = "", pubkeys, ...restProps}: Props = $props() const {subtitle = "", pubkeys, url, ...restProps}: Props = $props()
</script> </script>
<div class="column gap-4"> <div class="column gap-4">
@@ -23,7 +24,7 @@
</ModalHeader> </ModalHeader>
{#each pubkeys as pubkey (pubkey)} {#each pubkeys as pubkey (pubkey)}
<div class="card2 bg-alt"> <div class="card2 bg-alt">
<Profile {pubkey} /> <Profile {pubkey} {url} />
</div> </div>
{/each} {/each}
<Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button> <Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button>
+8 -2
View File
@@ -1,9 +1,15 @@
<script lang="ts"> <script lang="ts">
import {removeNil} from "@welshman/lib"
import {deriveProfileDisplay} from "@welshman/app" import {deriveProfileDisplay} from "@welshman/app"
const {pubkey} = $props() type Props = {
pubkey: string
url?: string
}
const profileDisplay = deriveProfileDisplay(pubkey) const {pubkey, url}: Props = $props()
const profileDisplay = deriveProfileDisplay(pubkey, removeNil([url]))
</script> </script>
{$profileDisplay} {$profileDisplay}
+2 -2
View File
@@ -4,7 +4,7 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import {clip} from "@app/toast" import {clip} from "@app/toast"
const {code} = $props() const {code, ...props} = $props()
let canvas: Element | undefined = $state() let canvas: Element | undefined = $state()
let wrapper: Element | undefined = $state() let wrapper: Element | undefined = $state()
@@ -26,7 +26,7 @@
}) })
</script> </script>
<Button class="max-w-full" onclick={copy}> <Button class="max-w-full {props.class}" onclick={copy}>
<div bind:this={wrapper} style={`height: ${height}px`}> <div bind:this={wrapper} style={`height: ${height}px`}>
<canvas <canvas
class="rounded-box" class="rounded-box"
+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>
+1 -1
View File
@@ -29,7 +29,7 @@
>{displayUrl($relay.profile.contact)}</Link> >{displayUrl($relay.profile.contact)}</Link>
&bull; &bull;
{/if} {/if}
{#if $relay?.profile?.supported_nips} {#if Array.isArray($relay?.profile?.supported_nips)}
<span <span
class="tooltip cursor-pointer underline" class="tooltip cursor-pointer underline"
data-tip="NIPs supported: {$relay.profile.supported_nips.join(', ')}"> data-tip="NIPs supported: {$relay.profile.supported_nips.join(', ')}">
+2 -4
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {encrypt} from "nostr-tools/nip49" import {encrypt} from "nostr-tools/nip49"
import {hexToBytes} from "@noble/hashes/utils" import {hexToBytes} from "@noble/hashes/utils"
import {makeSecret, getPubkey} from "@welshman/signer" import {makeSecret} from "@welshman/signer"
import {preventDefault, downloadText} from "@lib/html" import {preventDefault, downloadText} from "@lib/html"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@@ -15,8 +15,6 @@
const secret = makeSecret() const secret = makeSecret()
const pubkey = getPubkey(secret)
const back = () => history.back() const back = () => history.back()
const next = () => { const next = () => {
@@ -31,7 +29,7 @@
downloadText("Nostr Secret Key.txt", ncryptsec) downloadText("Nostr Secret Key.txt", ncryptsec)
pushModal(SignUpKeyConfirm, {secret, pubkey, ncryptsec}) pushModal(SignUpKeyConfirm, {secret, ncryptsec})
} }
let password = "" let password = ""
+2 -3
View File
@@ -11,11 +11,10 @@
type Props = { type Props = {
secret: string secret: string
pubkey: string
ncryptsec: string ncryptsec: string
} }
const {secret, pubkey, ncryptsec}: Props = $props() const {secret, ncryptsec}: Props = $props()
const back = () => history.back() const back = () => history.back()
@@ -25,7 +24,7 @@
} }
const next = () => { const next = () => {
pushModal(SignUpProfile, {secret, pubkey}) pushModal(SignUpProfile, {secret})
} }
</script> </script>
+13 -8
View File
@@ -1,27 +1,32 @@
<script lang="ts"> <script lang="ts">
import type {Profile} from "@welshman/util" import type {Profile} from "@welshman/util"
import {PROFILE, createProfile, createEvent} from "@welshman/util" import {PROFILE, createProfile, makeProfile, createEvent} from "@welshman/util"
import {addSession, publishThunk} from "@welshman/app" import {loginWithNip01, publishThunk} from "@welshman/app"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ProfileEditForm from "@app/components/ProfileEditForm.svelte" import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
import {INDEXER_RELAYS} from "@app/state" import {INDEXER_RELAYS} from "@app/state"
type Props = { type Props = {
secret: string secret: string
pubkey: string
} }
const {secret, pubkey}: Props = $props() const {secret}: Props = $props()
const onsubmit = (profile: Profile) => { const initialValues = {
profile: makeProfile(),
shouldBroadcast: true,
}
const onsubmit = ({profile, shouldBroadcast}: {profile: Profile; shouldBroadcast: boolean}) => {
const event = createEvent(PROFILE, createProfile(profile)) const event = createEvent(PROFILE, createProfile(profile))
const relays = shouldBroadcast ? INDEXER_RELAYS : []
addSession({method: "nip01", secret, pubkey}) loginWithNip01(secret)
publishThunk({event, relays: INDEXER_RELAYS}) publishThunk({event, relays})
} }
</script> </script>
<ProfileEditForm hideAddress {onsubmit}> <ProfileEditForm hideAddress {initialValues} {onsubmit}>
{#snippet footer()} {#snippet footer()}
<Button type="submit" class="btn btn-primary">Create Account</Button> <Button type="submit" class="btn btn-primary">Create Account</Button>
{/snippet} {/snippet}
+9 -24
View File
@@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
import {displayRelayUrl} from "@welshman/util" import {displayRelayUrl} from "@welshman/util"
import {parse, renderAsHtml} from "@welshman/content"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import {ucFirst} from "@lib/util"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
@@ -15,8 +16,8 @@
const back = () => history.back() const back = () => history.back()
const joinRelay = async (claim: string) => { const joinRelay = async () => {
const error = await attemptRelayAccess(url, claim) const error = await attemptRelayAccess(url)
if (error) { if (error) {
return pushToast({theme: "error", message: error}) return pushToast({theme: "error", message: error})
@@ -33,13 +34,12 @@
loading = true loading = true
try { try {
await joinRelay(claim) await joinRelay()
} finally { } finally {
loading = false loading = false
} }
} }
let claim = $state("")
let loading = $state(false) let loading = $state(false)
</script> </script>
@@ -53,32 +53,17 @@
{/snippet} {/snippet}
</ModalHeader> </ModalHeader>
<p> <p>
We received an error from the relay indicating you don't have access to {displayRelayUrl(url)}. We received an error from the relay indicating you don't have access to {displayRelayUrl(url)}:
</p> </p>
<p class="border-l border-solid border-error pl-4 text-error"> <p class="bg-alt card2 welshman-content">
{error} {@html renderAsHtml(parse({content: ucFirst(error)}))}
</p> </p>
<p>If you have one, you can try entering an invite code below to request access.</p>
<Field>
{#snippet label()}
<p>Invite code</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="link-round" />
<input bind:value={claim} class="grow" type="text" />
</label>
{/snippet}
{#snippet info()}
<p>Enter an invite code provided to you by the admin of the relay.</p>
{/snippet}
</Field>
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back}> <Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" /> <Icon icon="alt-arrow-left" />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary" disabled={!claim || loading}> <Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Request Access</Spinner> <Spinner {loading}>Request Access</Spinner>
<Icon icon="alt-arrow-right" /> <Icon icon="alt-arrow-right" />
</Button> </Button>
+3 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {ctx, sleep} from "@welshman/lib" import {sleep} from "@welshman/lib"
import {Pool, AuthStatus} from "@welshman/net"
import {displayRelayUrl} from "@welshman/util" import {displayRelayUrl} from "@welshman/util"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@@ -17,7 +18,7 @@
const back = () => history.back() const back = () => history.back()
const next = () => { const next = () => {
if (!error && ctx.net.pool.get(url).stats.lastAuth === 0) { if (!error && Pool.get().get(url).auth.status === AuthStatus.None) {
pushModal(SpaceVisitConfirm, {url}, {replaceState: true}) pushModal(SpaceVisitConfirm, {url}, {replaceState: true})
} else { } else {
confirmSpaceVisit(url) confirmSpaceVisit(url)
+6 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {sleep, identity, nthEq} from "@welshman/lib" import {sleep, identity, nthEq} from "@welshman/lib"
import {load} from "@welshman/app" import {request} from "@welshman/net"
import {displayRelayUrl, AUTH_INVITE} from "@welshman/util" import {displayRelayUrl, AUTH_INVITE} from "@welshman/util"
import {slide} from "@lib/transition" import {slide} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
@@ -29,7 +29,11 @@
onMount(async () => { onMount(async () => {
const [[event]] = await Promise.all([ const [[event]] = await Promise.all([
load({filters: [{kinds: [AUTH_INVITE]}], relays: [url]}), request({
relays: [url],
autoClose: true,
filters: [{kinds: [AUTH_INVITE]}],
}),
sleep(2000), sleep(2000),
]) ])
+5 -4
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import {ctx, tryCatch} from "@welshman/lib" import {tryCatch} from "@welshman/lib"
import {isRelayUrl, normalizeRelayUrl} from "@welshman/util" import {isRelayUrl, normalizeRelayUrl} from "@welshman/util"
import {Pool, AuthStatus} from "@welshman/net"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -22,12 +23,12 @@
const error = await attemptRelayAccess(url, claim) const error = await attemptRelayAccess(url, claim)
if (error) { if (error) {
return pushToast({theme: "error", message: error}) return pushToast({theme: "error", message: error, timeout: 30_000})
} }
const connection = ctx.net.pool.get(url) const socket = Pool.get().get(url)
if (connection.stats.lastAuth === 0) { if (socket.auth.status === AuthStatus.None) {
pushModal(SpaceJoinConfirm, {url}, {replaceState: true}) pushModal(SpaceJoinConfirm, {url}, {replaceState: true})
} else { } else {
await confirmSpaceJoin(url) await confirmSpaceJoin(url)
+8 -5
View File
@@ -19,7 +19,9 @@
const back = () => history.back() const back = () => history.back()
const submit = () => { const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => {
if ($uploading) return if ($uploading) return
if (!title) { if (!title) {
@@ -29,7 +31,8 @@
}) })
} }
const content = editor.getText({blockSeparator: "\n"}).trim() const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
if (!content.trim()) { if (!content.trim()) {
return pushToast({ return pushToast({
@@ -39,7 +42,7 @@
} }
const tags = [ const tags = [
...editor.storage.nostr.getEditorTags(), ...ed.storage.nostr.getEditorTags(),
tagRoom(GENERAL, url), tagRoom(GENERAL, url),
["title", title], ["title", title],
PROTECTED, PROTECTED,
@@ -53,7 +56,7 @@
history.back() history.back()
} }
const editor = makeEditor({submit, uploading, placeholder: "What's on your mind?"}) const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"})
let title: string = $state("") let title: string = $state("")
</script> </script>
@@ -97,7 +100,7 @@
<Button <Button
data-tip="Add an image" data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2" class="tooltip tooltip-left absolute bottom-1 right-2"
onclick={editor.commands.selectFiles}> onclick={selectFiles}>
{#if $uploading} {#if $uploading}
<span class="loading loading-spinner loading-xs"></span> <span class="loading loading-spinner loading-xs"></span>
{:else} {:else}
+7 -9
View File
@@ -1,20 +1,18 @@
<script lang="ts"> <script lang="ts">
import {nthEq} from "@welshman/lib" import {nthEq, formatTimestamp} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {formatTimestamp} from "@welshman/app"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte" import ProfileLink from "@app/components/ProfileLink.svelte"
import ThreadActions from "@app/components/ThreadActions.svelte" import ThreadActions from "@app/components/ThreadActions.svelte"
import {makeThreadPath} from "@app/routes" import {makeThreadPath} from "@app/routes"
const { type Props = {
url,
event,
}: {
url: string url: string
event: TrustedEvent event: TrustedEvent
} = $props() }
const {url, event}: Props = $props()
const title = event.tags.find(nthEq(0, "title"))?.[1] const title = event.tags.find(nthEq(0, "title"))?.[1]
</script> </script>
@@ -32,10 +30,10 @@
{formatTimestamp(event.created_at)} {formatTimestamp(event.created_at)}
</p> </p>
{/if} {/if}
<Content {event} expandMode="inline" relays={[url]} /> <Content {event} {url} expandMode="inline" />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row"> <div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75"> <span class="whitespace-nowrap py-1 text-sm opacity-75">
Posted by <ProfileLink pubkey={event.pubkey} /> Posted by <ProfileLink pubkey={event.pubkey} {url} />
</span> </span>
<ThreadActions showActivity {url} {event} /> <ThreadActions showActivity {url} {event} />
</div> </div>
+39 -25
View File
@@ -1,12 +1,16 @@
<script lang="ts"> <script lang="ts">
import {get} from "svelte/store" import {nth} from "@welshman/lib"
import {PublishStatus} from "@welshman/net" import {PublishStatus} from "@welshman/net"
import {mergeThunks, publishThunk} from "@welshman/app" import {
import type {Thunk, MergedThunk} from "@welshman/app" MergedThunk,
import {throttled} from "@welshman/store" publishThunk,
isMergedThunk,
thunkIsComplete,
thunkHasStatus,
} from "@welshman/app"
import type {Thunk} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte" import Tippy from "@lib/components/Tippy.svelte"
import Button from "@lib/components/Button.svelte"
import ThunkStatusDetail from "@app/components/ThunkStatusDetail.svelte" import ThunkStatusDetail from "@app/components/ThunkStatusDetail.svelte"
import {userSettingValues} from "@app/state" import {userSettingValues} from "@app/state"
@@ -17,31 +21,34 @@
let {thunk, ...restProps}: Props = $props() let {thunk, ...restProps}: Props = $props()
const {Pending, Failure, Timeout} = PublishStatus
const abort = () => thunk.controller.abort() const abort = () => thunk.controller.abort()
const retry = () => { const retry = () => {
thunk = (thunk as any).thunks thunk = isMergedThunk(thunk)
? mergeThunks((thunk as MergedThunk).thunks.map(t => publishThunk(t.request))) ? new MergedThunk(thunk.thunks.map(t => publishThunk(t.options)))
: publishThunk((thunk as Thunk).request) : publishThunk(thunk.options)
} }
const status = $derived(throttled(300, thunk.status)) const statuses = $derived(Object.entries($thunk.status))
const ps = $derived(Object.values($status)) const isSending = $derived(thunkHasStatus($thunk, PublishStatus.Sending))
const canCancel = $derived(ps.length === 0 && $userSettingValues.send_delay > 0) const canCancel = $derived(isSending && $userSettingValues.send_delay > 0)
const isFailure = $derived(!canCancel && ps.every(s => [Failure, Timeout].includes(s.status))) const failedUrls = $derived(
const failure = $derived( statuses
Object.entries($status).find(([url, s]) => [Failure, Timeout].includes(s.status)), .filter(([_, status]) => [PublishStatus.Failure, PublishStatus.Timeout].includes(status))
.map(nth(0)),
) )
let isPending = $state(Object.values(get(thunk.status)).some(s => s.status == Pending)) const showFailure = $derived(thunkIsComplete($thunk) && failedUrls.length > 0)
let isPending = $state(thunkHasStatus($thunk, PublishStatus.Pending))
const showPending = $derived(canCancel || isPending)
// Delay updating isPending so users can see that the message is sent // Delay updating isPending so users can see that the message is sent
$effect(() => { $effect(() => {
isPending = isPending || ps.some(s => s.status === Pending) isPending = isPending || thunkHasStatus($thunk, PublishStatus.Pending)
if (!ps.some(s => s.status === Pending)) { if (!thunkHasStatus($thunk, PublishStatus.Pending)) {
setTimeout(() => { setTimeout(() => {
isPending = false isPending = false
}, 2000) }, 2000)
@@ -49,8 +56,10 @@
}) })
</script> </script>
{#if isFailure && failure} {#if showFailure}
{@const [url, {message, status}] = failure} {@const url = failedUrls[0]}
{@const status = $thunk.status[url]}
{@const message = $thunk.details[url]}
<div class="flex justify-end px-1 text-xs {restProps.class}"> <div class="flex justify-end px-1 text-xs {restProps.class}">
<Tippy <Tippy
class="flex items-center {restProps.class}" class="flex items-center {restProps.class}"
@@ -65,14 +74,19 @@
{/snippet} {/snippet}
</Tippy> </Tippy>
</div> </div>
{:else if canCancel || isPending} {:else if showPending}
<div class="flex justify-end px-1 text-xs {restProps.class}"> <div class="flex justify-end px-1 text-xs {restProps.class}">
<span class="flex items-center gap-1 {restProps.class}"> <span class="flex items-center gap-1 {restProps.class}">
<span class="loading loading-spinner mx-1 h-3 w-3 translate-y-px"></span> <span class="loading loading-spinner mx-1 h-3 w-3 translate-y-px"></span>
<span class="opacity-50">Sending...</span> <span class="opacity-50">Sending...</span>
{#if canCancel} <button
<Button class="link" onclick={abort}>Cancel</Button> type="button"
{/if} class="underline transition-all"
class:link={canCancel}
class:opacity-25={!canCancel}
onclick={abort}>
Cancel
</button>
</span> </span>
</div> </div>
{/if} {/if}
+5 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import {parse, renderAsHtml} from "@welshman/content"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -7,7 +8,7 @@
{#if $toast} {#if $toast}
{@const theme = $toast.theme || "info"} {@const theme = $toast.theme || "info"}
<div transition:fly class="toast z-toast"> <div transition:fly class="bottom-sai right-sai toast z-toast">
{#key $toast.id} {#key $toast.id}
<div <div
role="alert" role="alert"
@@ -15,7 +16,9 @@
class:bg-base-100={theme === "info"} class:bg-base-100={theme === "info"}
class:text-base-content={theme === "info"} class:text-base-content={theme === "info"}
class:alert-error={theme === "error"}> class:alert-error={theme === "error"}>
{$toast.message} <p class="welshman-content-error">
{@html renderAsHtml(parse({content: $toast.message}))}
</p>
<Button class="flex items-center opacity-75" onclick={() => popToast($toast.id)}> <Button class="flex items-center opacity-75" onclick={() => popToast($toast.id)}>
<Icon icon="close-circle" /> <Icon icon="close-circle" />
</Button> </Button>
+15 -8
View File
@@ -1,22 +1,29 @@
<script lang="ts"> <script lang="ts">
import {Editor} from "@welshman/editor"
import {onDestroy, onMount} from "svelte" import {onDestroy, onMount} from "svelte"
const {editor} = $props() type Props = {
editor: Promise<Editor>
}
const {editor}: Props = $props()
let element: HTMLElement let element: HTMLElement
onMount(() => { onMount(() => {
if (editor.options.element) { editor.then(({options}) => {
element?.append(editor.options.element) if (options.element) {
} element?.append(options.element)
}
if (editor.options.autofocus) { if (options.autofocus) {
;(element?.querySelector("[contenteditable]") as HTMLElement)?.focus() ;(element?.querySelector("[contenteditable]") as HTMLElement)?.focus()
} }
})
}) })
onDestroy(() => { onDestroy(() => {
editor.destroy() editor.then($editor => $editor.destroy())
}) })
</script> </script>
+22 -19
View File
@@ -1,26 +1,29 @@
import type {NodeViewProps} from "@tiptap/core" import type {NodeViewProps} from "@tiptap/core"
import {removeNil} from "@welshman/lib"
import {deriveProfileDisplay} from "@welshman/app" import {deriveProfileDisplay} from "@welshman/app"
export const MentionNodeView = ({node}: NodeViewProps) => { export const makeMentionNodeView =
const dom = document.createElement("span") (url?: string) =>
const display = deriveProfileDisplay(node.attrs.pubkey) ({node}: NodeViewProps) => {
const dom = document.createElement("span")
const display = deriveProfileDisplay(node.attrs.pubkey, removeNil([url]))
dom.classList.add("tiptap-object") dom.classList.add("tiptap-object")
const unsubDisplay = display.subscribe($display => { const unsubDisplay = display.subscribe($display => {
dom.textContent = "@" + $display dom.textContent = "@" + $display
}) })
return { return {
dom, dom,
destroy: () => { destroy: () => {
unsubDisplay() unsubDisplay()
}, },
selectNode() { selectNode() {
dom.classList.add("tiptap-active") dom.classList.add("tiptap-active")
}, },
deselectNode() { deselectNode() {
dom.classList.remove("tiptap-active") dom.classList.remove("tiptap-active")
}, },
}
} }
}
+9 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import {removeNil} from "@welshman/lib"
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util" import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
import { import {
userFollows, userFollows,
@@ -10,10 +11,15 @@
import WotScore from "@lib/components/WotScore.svelte" import WotScore from "@lib/components/WotScore.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
const {value} = $props() type Props = {
value: string
url?: string
}
const {value, url}: Props = $props()
const pubkey = value const pubkey = value
const profileDisplay = deriveProfileDisplay(pubkey) const profileDisplay = deriveProfileDisplay(pubkey, removeNil([url]))
const handle = deriveHandleForPubkey(pubkey) const handle = deriveHandleForPubkey(pubkey)
const score = deriveUserWotScore(pubkey) const score = deriveUserWotScore(pubkey)
@@ -22,7 +28,7 @@
<div class="flex max-w-full gap-3"> <div class="flex max-w-full gap-3">
<div class="py-1"> <div class="py-1">
<ProfileCircle {pubkey} /> <ProfileCircle {pubkey} {url} />
</div> </div>
<div class="flex min-w-0 flex-col"> <div class="flex min-w-0 flex-col">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
+76 -29
View File
@@ -1,23 +1,63 @@
import {mount} from "svelte" import {mount} from "svelte"
import type {Writable} from "svelte/store" import type {Writable} from "svelte/store"
import {get} from "svelte/store" import {get} from "svelte/store"
import {Editor} from "@tiptap/core"
import {ctx} from "@welshman/lib"
import type {StampedEvent} from "@welshman/util" import type {StampedEvent} from "@welshman/util"
import {signer, profileSearch} from "@welshman/app" import {makeEvent, getTagValues, getListTags, BLOSSOM_AUTH} from "@welshman/util"
import {MentionSuggestion, WelshmanExtension} from "@welshman/editor" import {simpleCache, normalizeUrl, removeNil, now} from "@welshman/lib"
import {getSetting, userSettingValues} from "@app/state" import {Router} from "@welshman/router"
import {MentionNodeView} from "./MentionNodeView" import {signer, profileSearch, userBlossomServers} from "@welshman/app"
import {Editor, MentionSuggestion, WelshmanExtension} from "@welshman/editor"
import {makeMentionNodeView} from "./MentionNodeView"
import ProfileSuggestion from "./ProfileSuggestion.svelte" import ProfileSuggestion from "./ProfileSuggestion.svelte"
export const getUploadType = () => getSetting<"nip96" | "blossom">("upload_type") export const hasBlossomSupport = simpleCache(async ([url]: [string]) => {
const $signer = signer.get()
const headers: Record<string, string> = {
"X-Content-Type": "text/plain",
"X-Content-Length": "1",
"X-SHA-256": "73cb3858a687a8494ca3323053016282f3dad39d42cf62ca4e79dda2aac7d9ac",
}
export const getUploadUrl = () => { try {
const {upload_type, nip96_urls, blossom_urls} = userSettingValues.get() if ($signer) {
const event = await signer.get().sign(
makeEvent(BLOSSOM_AUTH, {
tags: [
["t", "upload"],
["server", url],
["expiration", String(now() + 30)],
],
}),
)
return upload_type === "nip96" headers.Authorization = `Nostr ${btoa(JSON.stringify(event))}`
? nip96_urls[0] || "https://nostr.build" }
: blossom_urls[0] || "https://cdn.satellite.earth"
const res = await fetch(normalizeUrl(url) + "/upload", {method: "head", headers})
return res.status === 200
} catch (e) {
if (!String(e).includes("Failed to fetch")) {
console.error(e)
}
}
return false
})
export const getUploadUrl = async (spaceUrl?: string) => {
const userUrls = getTagValues("server", getListTags(userBlossomServers.get()))
const allUrls = removeNil([spaceUrl, ...userUrls])
for (let url of allUrls) {
url = url.replace(/^ws/, "http")
if (await hasBlossomSupport(url)) {
return url
}
}
return "https://cdn.satellite.earth"
} }
export const signWithAssert = async (template: StampedEvent) => { export const signWithAssert = async (template: StampedEvent) => {
@@ -26,26 +66,30 @@ export const signWithAssert = async (template: StampedEvent) => {
return event! return event!
} }
export const makeEditor = ({ export const makeEditor = async ({
aggressive = false, aggressive = false,
autofocus = false, autofocus = false,
charCount, charCount,
content = "", content = "",
placeholder = "", placeholder = "",
url,
submit, submit,
uploading, uploading,
wordCount, wordCount,
disableFileUpload,
}: { }: {
aggressive?: boolean aggressive?: boolean
autofocus?: boolean autofocus?: boolean
charCount?: Writable<number> charCount?: Writable<number>
content?: string content?: string
placeholder?: string placeholder?: string
url?: string
submit: () => void submit: () => void
uploading?: Writable<boolean> uploading?: Writable<boolean>
wordCount?: Writable<number> wordCount?: Writable<number>
}) => disableFileUpload?: boolean
new Editor({ }) => {
return new Editor({
content, content,
autofocus, autofocus,
element: document.createElement("div"), element: document.createElement("div"),
@@ -53,8 +97,8 @@ export const makeEditor = ({
WelshmanExtension.configure({ WelshmanExtension.configure({
submit, submit,
sign: signWithAssert, sign: signWithAssert,
defaultUploadType: getUploadType(), defaultUploadType: "blossom",
defaultUploadUrl: getUploadUrl(), defaultUploadUrl: await getUploadUrl(url),
extensions: { extensions: {
placeholder: { placeholder: {
config: { config: {
@@ -66,29 +110,31 @@ export const makeEditor = ({
aggressive, aggressive,
}, },
}, },
fileUpload: { fileUpload: disableFileUpload
config: { ? false
onDrop() { : {
uploading?.set(true) config: {
onDrop() {
uploading?.set(true)
},
onComplete() {
uploading?.set(false)
},
},
}, },
onComplete() {
uploading?.set(false)
},
},
},
nprofile: { nprofile: {
extend: { extend: {
addNodeView: () => MentionNodeView, addNodeView: () => makeMentionNodeView(url),
addProseMirrorPlugins() { addProseMirrorPlugins() {
return [ return [
MentionSuggestion({ MentionSuggestion({
editor: (this as any).editor, editor: (this as any).editor,
search: (term: string) => get(profileSearch).searchValues(term), search: (term: string) => get(profileSearch).searchValues(term),
getRelays: (pubkey: string) => ctx.app.router.FromPubkeys([pubkey]).getUrls(), getRelays: (pubkey: string) => Router.get().FromPubkeys([pubkey]).getUrls(),
createSuggestion: (value: string) => { createSuggestion: (value: string) => {
const target = document.createElement("div") const target = document.createElement("div")
mount(ProfileSuggestion, {target, props: {value}}) mount(ProfileSuggestion, {target, props: {value, url}})
return target return target
}, },
@@ -105,3 +151,4 @@ export const makeEditor = ({
charCount?.set(editor.storage.wordCount.chars) charCount?.set(editor.storage.wordCount.chars)
}, },
}) })
}
+91 -66
View File
@@ -8,8 +8,8 @@ import {
uniq, uniq,
int, int,
YEAR, YEAR,
MONTH, DAY,
insert, insertAt,
sortBy, sortBy,
assoc, assoc,
now, now,
@@ -23,26 +23,27 @@ 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"
@@ -101,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)
@@ -110,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[]) => {
@@ -144,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)
@@ -175,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()
}, },
} }
} }
@@ -202,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) || "")
@@ -214,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]
}) })
} }
@@ -241,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)
@@ -252,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,
}) })
@@ -271,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)
@@ -287,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)
@@ -298,15 +325,15 @@ export const makeCalendarFeed = ({
}, },
}) })
thunkWorker.addGlobalHandler(onThunk) const unsubscribe = thunkQueue.subscribe(onThunk)
return { return {
events, events,
cleanup: () => { cleanup: () => {
thunkWorker.removeGlobalHandler(onThunk)
backwardScroller.stop() backwardScroller.stop()
forwardScroller.stop() forwardScroller.stop()
sub.close() controller.abort()
unsubscribe()
}, },
} }
} }
@@ -328,7 +355,7 @@ export const loadAlertStatuses = (pubkey: string) =>
// Application requests // Application requests
export const listenForNotifications = () => { export const listenForNotifications = () => {
const subs: Subscription[] = [] const controller = new AbortController()
for (const [url, allRooms] of userRoomsByUrl.get()) { for (const [url, allRooms] of userRoomsByUrl.get()) {
// Limit how many rooms we load at a time, since we have to send a separate filter // Limit how many rooms we load at a time, since we have to send a separate filter
@@ -336,48 +363,42 @@ export const listenForNotifications = () => {
const rooms = shuffle(Array.from(allRooms)).slice(0, 30) const rooms = shuffle(Array.from(allRooms)).slice(0, 30)
load({ load({
signal: controller.signal,
relays: [url], relays: [url],
filters: [ filters: [
{kinds: [THREAD], limit: 1}, {kinds: [THREAD], limit: 1},
{kinds: [EVENT_TIME], limit: 1},
{kinds: [COMMENT], "#K": [String(THREAD)], limit: 1}, {kinds: [COMMENT], "#K": [String(THREAD)], limit: 1},
{kinds: [COMMENT], "#K": [String(EVENT_TIME)], limit: 1},
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], limit: 1})), ...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], limit: 1})),
], ],
}) })
subs.push( request({
subscribe({ signal: controller.signal,
relays: [url], relays: [url],
filters: [ filters: [
{kinds: [THREAD, EVENT_TIME], since: now()}, {kinds: [THREAD], since: now()},
{kinds: [COMMENT], "#K": [String(THREAD), String(EVENT_TIME)], since: now()}, {kinds: [COMMENT], "#K": [String(THREAD)], since: now()},
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], since: now()})), ...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], since: now()})),
], ],
}), })
)
} }
return () => { return () => controller.abort()
for (const sub of subs) {
sub.close()
}
}
} }
export const loadUserData = ( export const loadUserData = async (pubkey: string, relays: string[] = []) => {
pubkey: string, await Promise.race([sleep(3000), loadRelaySelections(pubkey, relays)])
request: Partial<SubscribeRequestWithHandlers> = {},
) => {
const promise = Promise.race([ const promise = Promise.race([
sleep(3000), sleep(3000),
Promise.all([ Promise.all([
loadInboxRelaySelections(pubkey, request), loadInboxRelaySelections(pubkey, relays),
loadMembership(pubkey, request), loadBlossomServers(pubkey, relays),
loadSettings(pubkey, request), loadMembership(pubkey, relays),
loadProfile(pubkey, request), loadSettings(pubkey, relays),
loadFollows(pubkey, request), loadProfile(pubkey, relays),
loadMutes(pubkey, request), loadFollows(pubkey, relays),
loadMutes(pubkey, relays),
loadAlertStatuses(pubkey), loadAlertStatuses(pubkey),
loadAlerts(pubkey), loadAlerts(pubkey),
]), ]),
@@ -392,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)
} }
} }
}) })
@@ -404,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)),
)
+32 -45
View File
@@ -1,9 +1,7 @@
import twColors from "tailwindcss/colors" import twColors from "tailwindcss/colors"
import {get, derived} from "svelte/store" import {get, derived} from "svelte/store"
import {nip19} from "nostr-tools" import * as nip19 from "nostr-tools/nip19"
import { import {
ctx,
setContext,
remove, remove,
sortBy, sortBy,
sort, sort,
@@ -16,7 +14,11 @@ import {
fromPairs, fromPairs,
memoize, memoize,
addToMapKey, addToMapKey,
identity,
always,
} from "@welshman/lib" } from "@welshman/lib"
import {load} from "@welshman/net"
import {collection} from "@welshman/store"
import { import {
getIdFilters, getIdFilters,
WRAP, WRAP,
@@ -42,15 +44,11 @@ import {
} from "@welshman/util" } from "@welshman/util"
import type {TrustedEvent, SignedEvent, PublishedList, List, Filter} from "@welshman/util" import type {TrustedEvent, SignedEvent, PublishedList, List, Filter} from "@welshman/util"
import {Nip59, decrypt} from "@welshman/signer" import {Nip59, decrypt} from "@welshman/signer"
import {routerContext, Router} from "@welshman/router"
import { import {
pubkey, pubkey,
repository, repository,
load,
collection,
profilesByPubkey, profilesByPubkey,
getDefaultAppContext,
getDefaultNetContext,
makeRouter,
tracker, tracker,
makeTrackerStore, makeTrackerStore,
makeRepositoryStore, makeRepositoryStore,
@@ -63,11 +61,14 @@ import {
thunks, thunks,
walkThunks, walkThunks,
signer, signer,
makeOutboxLoader,
appContext,
} from "@welshman/app" } from "@welshman/app"
import type {Thunk, Relay} from "@welshman/app" import type {Thunk, Relay} from "@welshman/app"
import type {SubscribeRequestWithHandlers} from "@welshman/net"
import {deriveEvents, deriveEventsMapped, withGetter, synced} from "@welshman/store" import {deriveEvents, deriveEventsMapped, withGetter, synced} from "@welshman/store"
export const fromCsv = (s: string) => (s || "").split(",").filter(identity)
export const ROOM = "h" export const ROOM = "h"
export const GENERAL = "_" export const GENERAL = "_"
@@ -83,13 +84,9 @@ export const NOTIFIER_PUBKEY = "27b7c2ed89ef78322114225ea3ebf5f72c7767c2528d4d0c
// export const NOTIFIER_RELAY = 'wss://notifier.flotilla.social/' // export const NOTIFIER_RELAY = 'wss://notifier.flotilla.social/'
export const NOTIFIER_RELAY = "ws://localhost:4738/" export const NOTIFIER_RELAY = "ws://localhost:4738/"
export const INDEXER_RELAYS = [ export const INDEXER_RELAYS = fromCsv(import.meta.env.VITE_INDEXER_RELAYS)
"wss://purplepag.es/",
"wss://relay.damus.io/",
"wss://relay.nostr.band/",
]
export const SIGNER_RELAYS = ["wss://relay.nsec.app/", "wss://bucket.coracle.social/"] export const SIGNER_RELAYS = fromCsv(import.meta.env.VITE_SIGNER_RELAYS)
export const PLATFORM_URL = window.location.origin export const PLATFORM_URL = window.location.origin
@@ -118,7 +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(",")
@@ -163,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]
@@ -255,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)
} }
} }
@@ -284,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
@@ -308,9 +297,6 @@ export type Settings = {
report_usage: boolean report_usage: boolean
report_errors: boolean report_errors: boolean
send_delay: number send_delay: number
upload_type: "nip96" | "blossom"
nip96_urls: string[]
blossom_urls: string[]
} }
} }
@@ -318,11 +304,8 @@ export const defaultSettings = {
show_media: true, show_media: true,
hide_sensitive: true, hide_sensitive: true,
report_usage: true, report_usage: true,
report_errors: false, report_errors: true,
send_delay: 3000, send_delay: 3000,
upload_type: "nip96",
nip96_urls: ["https://nostr.build"],
blossom_urls: ["https://cdn.satellite.earth"],
} }
export const settings = deriveEventsMapped<Settings>(repository, { export const settings = deriveEventsMapped<Settings>(repository, {
@@ -342,8 +325,7 @@ export const {
name: "settings", name: "settings",
store: settings, store: settings,
getKey: settings => settings.event.pubkey, getKey: settings => settings.event.pubkey,
load: (pubkey: string, request: Partial<SubscribeRequestWithHandlers> = {}) => load: makeOutboxLoader(SETTINGS),
load({...request, filters: [{kinds: [SETTINGS], authors: [pubkey]}]}),
}) })
// Alerts // Alerts
@@ -420,8 +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
@@ -482,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 =>
@@ -503,7 +485,7 @@ export const messages = derived(
export const groupMeta = deriveEvents(repository, {filters: [{kinds: [GROUP_META]}]}) export const groupMeta = deriveEvents(repository, {filters: [{kinds: [GROUP_META]}]})
export const hasNip29 = (relay?: Relay) => export const hasNip29 = (relay?: Relay) =>
relay?.profile?.supported_nips?.map(String)?.includes("29") relay?.profile?.supported_nips?.map?.(String)?.includes?.("29")
// Channels // Channels
@@ -647,11 +629,11 @@ export const userRoomsByUrl = withGetter(
const $userRoomsByUrl = new Map<string, Set<string>>() const $userRoomsByUrl = new Map<string, Set<string>>()
for (const [_, room, url] of getGroupTags(tags)) { for (const [_, room, url] of getGroupTags(tags)) {
addToMapKey($userRoomsByUrl, url, room) addToMapKey($userRoomsByUrl, normalizeRelayUrl(url), room)
} }
for (const url of getRelayTagValues(tags)) { for (const url of getRelayTagValues(tags)) {
addToMapKey($userRoomsByUrl, url, GENERAL) addToMapKey($userRoomsByUrl, normalizeRelayUrl(url), GENERAL)
} }
return $userRoomsByUrl return $userRoomsByUrl
@@ -673,7 +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))
+5
View File
@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 7L4 7" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
<path d="M20 12L4 12" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
<path d="M20 17L4 17" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 350 B

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

After

Width:  |  Height:  |  Size: 323 B

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

After

Width:  |  Height:  |  Size: 871 B

+2 -2
View File
@@ -8,13 +8,13 @@
const {...props}: Props = $props() const {...props}: Props = $props()
</script> </script>
<div class="col-2 content-padding-t content-padding-x h-full {props.class}"> <div class="content-padding-t content-padding-x flex h-full flex-col gap-1 {props.class}">
<div class="z-feature"> <div class="z-feature">
<div class="content-sizing"> <div class="content-sizing">
{@render props.input?.()} {@render props.input?.()}
</div> </div>
</div> </div>
<div class="scroll-container overflow-auto pt-2"> <div class="scroll-container overflow-auto">
<div class="content-sizing"> <div class="content-sizing">
{@render props.content?.()} {@render props.content?.()}
</div> </div>

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