Compare commits

..

95 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
Jon Staab 863d11352f Bump versions 2025-03-04 11:28:41 -08:00
Jon Staab b4cc770cdf Update changelog 2025-03-04 11:20:24 -08:00
Jon Staab 901e56a625 Tweak settings page, hide alerts 2025-03-04 10:58:14 -08:00
Jon Staab 479fed34f7 Fix chat layout on ios 2025-03-04 10:52:27 -08:00
Jon Staab 81d7b08aed Fix profile suggestions 2025-03-04 10:47:58 -08:00
Jon Staab a582b1ea73 Apply layout changes to chat 2025-03-04 10:20:06 -08:00
Jon Staab 1c0b2a09df ellipsize page bar title 2025-03-04 10:00:24 -08:00
Jon Staab 3a42a1b560 Rework css on room view to avoid losing input visibility 2025-03-04 09:49:56 -08:00
Jon Staab db203bf00d Move page bar closer to top of screen 2025-03-03 17:10:17 -08:00
Jon Staab ffb36af734 Make analytics and error reporting optional 2025-03-03 15:09:58 -08:00
Jon Staab b399fa8dcc Replace long press with tap target 2025-03-03 13:59:38 -08:00
Jon Staab 5bba5959f7 Attempt to fix keyboard placement, wait for connection 2025-03-03 13:23:44 -08:00
Jon Staab 2ad65e394e Remember user minute selection 2025-03-03 13:04:12 -08:00
Jon Staab 345b20bf5d Fix nevent hints for url-specific stuff 2025-03-03 12:10:47 -08:00
Jon Staab b9fb251b32 Randomize subscription minute 2025-02-27 09:33:40 -08:00
Jon Staab dd9a9c0df2 Add status to alert items 2025-02-25 13:36:32 -08:00
Jon Staab 115b5f9fbe Extend timeout for setChecked 2025-02-24 13:40:26 -08:00
Jon Staab 3ad7dcfeb4 Ignore some files 2025-02-19 11:13:38 -08:00
Jon Staab 60d107aed2 Fix some state stuff, snapshot things in the right places 2025-02-18 17:15:41 -08:00
Jon Staab 08d8d45ecb Refactor confirm to avoid passing closures 2025-02-18 09:03:10 -08:00
Jon Staab c40e8ce1a7 Fix reactions on mobile 2025-02-17 17:33:21 -08:00
Jon Staab 993bf8d2e6 Bump gradle build number 2025-02-14 16:20:16 -08:00
Jon Staab c3c65c3970 Use in-app onboarding on all native platforms 2025-02-14 16:07:40 -08:00
Jon Staab a5b868cd56 Update changelog 2025-02-14 16:02:11 -08:00
Jon Staab 8fcc56a408 Bump version 2025-02-14 16:00:18 -08:00
Jon Staab c8dfbc936b Spruce up nstart, add profile deletion 2025-02-14 15:59:20 -08:00
Jon Staab f1e76a1ed1 Bump versions, limit key generation to ios 2025-02-14 12:18:52 -08:00
Jon Staab 6ecc3e6770 Improve discover page 2025-02-14 12:14:00 -08:00
Jon Staab b05c408977 Move loadUserData to requests 2025-02-14 11:12:19 -08:00
Jon Staab e484c3cb00 Bump versions 2025-02-14 10:56:00 -08:00
Jon Staab 69d0e11ba4 Add nip 01 login flow to mobile 2025-02-14 10:53:36 -08:00
Jon Staab 27d9d4fff1 Update changelog 2025-02-13 16:32:34 -08:00
Jon Staab c089812363 Show spinner when joining room 2025-02-13 15:34:14 -08:00
Jon Staab 07dd1e97dc Fix long-running subscriptions clogging things up 2025-02-13 14:50:03 -08:00
Jon Staab 7f6a1bff34 Re-work threads page, fix some iphone bugs 2025-02-13 10:52:00 -08:00
Jon Staab 7d1310722a Factor out calendar event component, render calendar event notes better 2025-02-11 16:00:14 -08:00
Jon Staab cb57710654 Clean up quotes/depth 2025-02-11 15:37:55 -08:00
Jon Staab c74c116667 Fix page bar margin #112 2025-02-11 15:16:24 -08:00
Jon Staab 0ba55f2387 Attempt to fix new messages button #114 2025-02-11 11:49:17 -08:00
Jon Staab 622214713b replace state when navigating from space menu 2025-02-11 11:42:49 -08:00
Jon Staab d8cf48381b Build before other stuff 2025-02-06 12:52:42 -08:00
Jon Staab 7dc7b5abeb Bump android version 2025-02-06 12:43:18 -08:00
148 changed files with 13336 additions and 17552 deletions
+2
View File
@@ -3,4 +3,6 @@
--ignore-dir=build
--ignore-dir=ios/DerivedData
--ignore-dir=ios/App/App/public
--ignore-file=match:.svg
--ignore-file=match:package-lock.json
+3 -1
View File
@@ -1,4 +1,4 @@
VITE_DEFAULT_PUBKEYS=fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322
VITE_DEFAULT_PUBKEYS=06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71,266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed,6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e,76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa,7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3
VITE_BURROW_URL=
VITE_PLATFORM_URL=https://flotilla.social
VITE_PLATFORM_TERMS=https://flotilla.social/terms
@@ -8,5 +8,7 @@ VITE_PLATFORM_LOGO=static/flotilla.png
VITE_PLATFORM_RELAY=
VITE_PLATFORM_ACCENT="#7161FF"
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=
GLITCHTIP_AUTH_TOKEN=
+2
View File
@@ -56,6 +56,8 @@ out/
.gradle/
local.properties
proguard/
google-services.json
GoogleService-Info.plist
# IDEs and editors
.idea/
+2 -2
View File
@@ -1,2 +1,2 @@
npm run lint
npm run check
pnpm run lint
pnpm run check
+69
View File
@@ -1,5 +1,74 @@
# 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
* Add alerts via Anchor
* Fix nip46 signer connect
* Allow use of cleartext relays on native builds
* Fix some modal state bugs caused by svelte 5
* Detect blossom support on community relays
* Use user blossom server list in settings
* Fix some feed bugs
* Improve thunk indicator
* Update storage adapters
* Fix modal flash
* Switch to pnpm
* Improve calendar windowing
# 0.2.14
* Add calendar event editing
# 0.2.13
* Fix android keyboard issue
# 0.2.12
* Fix keyboard covering chat input
* Fix thread replies
* Make error reporting and analytics optional
* Replace long press with tap target
* Fix time input
* Fix nevent hints for url-specific stuff
* Fix confirm and reactions on mobile
* Add reply to chat on mobile
* Fix profile suggestions
# 0.2.11
* Add in-app signup flow on ios
* Add profile deletion
# 0.2.10
* Improve space discovery
# 0.2.9
* Add NIP 01 signup flow on mobile
# 0.2.8
* Show spinner when joining a room
* Reduce self-rate limiting of REQs
* Fix disabled signers link
* Prepare for iOS release
* Improve threads and calendar pages
* Improve quote rendering and new messages button
# 0.2.7
* Add calendar events
+6 -6
View File
@@ -2,14 +2,14 @@
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
To run your own Flotilla, it's as simple as:
- `npm install`
- `npm run build`
- `pnpm install`
- `pnpm run build`
- `npx serve build`
## Environment
@@ -65,12 +65,12 @@ git clone https://github.com/coracle-social/flotilla.git
cd ~/flotilla
nvm install
nvm use
npm i
pnpm i
# Optionally create and populate .env.local to suit your use case
# 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
@@ -108,4 +108,4 @@ Now, visit your domain. You should be all set up!
# 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
defaultConfig {
applicationId "social.flotilla"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 7
versionName "0.2.7"
minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion
versionCode 16
versionName "1.0.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// 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"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-keyboard')
implementation project(':nostr-signer-capacitor-plugin')
}
+3 -1
View File
@@ -6,12 +6,14 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
+6 -3
View File
@@ -1,9 +1,12 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
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'
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'
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
targetSdkVersion = 35
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'
androidxCoreVersion = '1.15.0'
androidxFragmentVersion = '1.8.4'
@@ -13,4 +15,4 @@ ext {
androidxJunitVersion = '1.2.1'
androidxEspressoCoreVersion = '3.6.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
+6 -2
View File
@@ -10,11 +10,15 @@ const config: CapacitorConfig = {
plugins: {
SplashScreen: {
androidSplashResourceName: "splash"
}
},
Keyboard: {
style: "DARK",
resizeOnFullScreen: true,
},
},
// Use this for live reload https://capacitorjs.com/docs/guides/live-reload
// server: {
// url: "http://192.168.1.251:1847",
// url: "http://192.168.1.115:1847",
// cleartext: true
// },
};
+8 -4
View File
@@ -351,12 +351,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.0.2;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -374,12 +376,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
+5 -1
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Flotilla</string>
<string>Flotilla</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -30,6 +30,8 @@
<array>
<string>armv7</string>
</array>
<key>UIStatusBarStyle</key>
<string></string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
@@ -45,5 +47,7 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
</dict>
</plist>
+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'
use_frameworks!
@@ -9,10 +9,11 @@ use_frameworks!
install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/nostr-signer-capacitor-plugin'
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/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios'
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app'
pod 'CapacitorKeyboard', :path => '../../node_modules/.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
target 'Flotilla Chat' do
-15439
View File
File diff suppressed because it is too large Load Diff
+40 -14
View File
@@ -1,12 +1,12 @@
{
"name": "flotilla",
"version": "0.2.7",
"version": "1.0.2",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "./build.sh",
"sourcemaps": "./sourcemaps.sh",
"release:android": "cap build android --androidreleasetype APK --signing-type apksigner",
"sourcemaps": "./build.sh && ./sourcemaps.sh",
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check src && eslint src",
@@ -42,6 +42,7 @@
"@capacitor/cli": "^7.0.0",
"@capacitor/core": "^7.0.1",
"@capacitor/ios": "^7.0.0",
"@capacitor/keyboard": "^7.0.0",
"@noble/curves": "^1.5.0",
"@noble/hashes": "^1.4.0",
"@poppanator/sveltekit-svg": "^4.2.1",
@@ -50,16 +51,18 @@
"@types/qrcode": "^1.5.5",
"@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.6",
"@welshman/app": "~0.0.42",
"@welshman/content": "~0.0.17",
"@welshman/dvm": "~0.0.14",
"@welshman/editor": "~0.0.13",
"@welshman/feeds": "~0.0.30",
"@welshman/lib": "~0.0.40",
"@welshman/net": "~0.0.46",
"@welshman/signer": "~0.0.20",
"@welshman/store": "~0.0.16",
"@welshman/util": "~0.0.60",
"@welshman/app": "^0.2.4",
"@welshman/content": "^0.2.1",
"@welshman/dvm": "^0.2.0",
"@welshman/editor": "^0.2.1",
"@welshman/feeds": "^0.2.2",
"@welshman/lib": "^0.2.2",
"@welshman/net": "^0.2.3",
"@welshman/relay": "^0.2.0",
"@welshman/router": "^0.2.0",
"@welshman/signer": "^0.2.3",
"@welshman/store": "^0.2.0",
"@welshman/util": "^0.2.2",
"daisyui": "^4.12.10",
"date-picker-svelte": "^2.13.0",
"dotenv": "^16.4.5",
@@ -67,9 +70,32 @@
"fuse.js": "^7.0.0",
"husky": "^9.1.6",
"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",
"prettier-plugin-tailwindcss": "^0.6.5",
"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")
}
+105 -33
View File
@@ -1,3 +1,5 @@
@import "@welshman/editor/index.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -52,6 +54,10 @@
--primary-content: oklch(var(--pc));
--secondary: oklch(var(--s));
--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,
@@ -60,50 +66,80 @@ html {
@apply bg-base-300;
}
/* ios */
/* safe area insets */
.sait {
padding-top: env(safe-area-inset-top);
}
@layer components {
.pt-sai {
padding-top: var(--sait);
}
.sair {
padding-right: env(safe-area-inset-right);
}
.pr-sai {
padding-right: var(--sair);
}
.saib {
padding-bottom: env(safe-area-inset-bottom);
}
.pb-sai {
padding-bottom: var(--saib);
}
.sail {
padding-left: env(safe-area-inset-left);
}
.pl-sai {
padding-left: var(--sail);
}
.saix {
@apply sail sair;
}
.px-sai {
@apply pl-sai pr-sai;
}
.saiy {
@apply sait saib;
}
.py-sai {
@apply pt-sai pb-sai;
}
.sai {
@apply saiy saix;
}
.p-sai {
@apply py-sai px-sai;
}
.top-sai {
top: env(safe-area-inset-top);
}
.mt-sai {
padding-top: var(--sait);
}
.right-sai {
right: env(safe-area-inset-right);
}
.mr-sai {
padding-right: var(--sair);
}
.bottom-sai {
bottom: env(safe-area-inset-bottom);
}
.mb-sai {
padding-bottom: var(--saib);
}
.left-sai {
left: env(safe-area-inset-left);
.ml-sai {
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 */
@@ -292,6 +328,16 @@ html {
color: var(--base-content);
}
/* content rendered by welshman/content */
.welshman-content a {
@apply link;
}
.welshman-content-error a {
@apply underline;
}
/* date input */
.picker {
@@ -323,3 +369,29 @@ emoji-picker {
--input-font-color: var(--base-content);
--outline-color: var(--base-100);
}
/* progress */
progress[value]::-webkit-progress-value {
transition: width 0.5s;
}
/* content width for fixed elements */
.cw {
@apply w-full md: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__compose {
@apply cb cw fixed;
}
.chat__scroll-down {
@apply fixed bottom-28 right-4 md:bottom-16;
}
+3 -1
View File
@@ -2,7 +2,9 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="theme-color" content="{ACCENT}" />
<meta name="description" content="{DESCRIPTION}" />
<meta name="og:url" content="{URL}" />
+2 -1
View File
@@ -1,6 +1,7 @@
/* eslint prefer-rest-params: 0 */
import {page} from "$app/stores"
import {getSetting} from "@app/state"
const w = window as any
@@ -12,7 +13,7 @@ w.plausible =
export const setupAnalytics = () => {
page.subscribe($page => {
if ($page.route) {
if ($page.route && getSetting("report_usage")) {
w.plausible("pageview", {u: $page.route.id})
}
})
+94 -151
View File
@@ -1,6 +1,8 @@
import * as nip19 from "nostr-tools/nip19"
import {get} from "svelte/store"
import {ctx, sample, uniq, sleep, chunk, 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 {
DELETE,
REPORT,
@@ -26,39 +28,31 @@ import {
getTag,
getListTags,
getRelayTags,
isShareableRelayUrl,
getRelayTagValues,
toNostrURI,
getRelaysFromList,
RelayMode,
} from "@welshman/util"
import type {TrustedEvent, EventContent, EventTemplate, List} from "@welshman/util"
import type {SubscribeRequestWithHandlers} from "@welshman/net"
import {PublishStatus, AuthStatus, SocketStatus} from "@welshman/net"
import {Nip59, makeSecret, stamp, Nip46Broker} from "@welshman/signer"
import {Pool, PublishStatus, AuthStatus, SocketStatus} from "@welshman/net"
import {Router} from "@welshman/router"
import {
pubkey,
signer,
repository,
publishThunk,
publishThunks,
loadProfile,
loadInboxRelaySelections,
profilesByPubkey,
relaySelectionsByPubkey,
getWriteRelayUrls,
loadFollows,
loadMutes,
tagEvent,
tagEventForReaction,
getRelayUrls,
userRelaySelections,
userInboxRelaySelections,
nip44EncryptToSelf,
loadRelay,
addSession,
clearStorage,
dropSession,
tagEventForComment,
tagEventForQuote,
thunkIsComplete,
} from "@welshman/app"
import type {Thunk} from "@welshman/app"
import {
@@ -66,10 +60,9 @@ import {
PROTECTED,
userMembership,
INDEXER_RELAYS,
NIP46_PERMS,
loadMembership,
loadSettings,
getDefaultPubkeys,
ALERT,
NOTIFIER_PUBKEY,
NOTIFIER_RELAY,
userRoomsByUrl,
} from "@app/state"
@@ -77,7 +70,7 @@ import {
export const getPubkeyHints = (pubkey: string) => {
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
return hints
@@ -90,14 +83,20 @@ export const getPubkeyPetname = (pubkey: string) => {
return display
}
export const getThunkError = async (thunk: Thunk) => {
const result = await thunk.result
const [{status, message}] = Object.values(result) as any
export const getThunkError = (thunk: Thunk) =>
new Promise<string>(resolve => {
thunk.subscribe($thunk => {
for (const [relay, status] of Object.entries($thunk.status)) {
if (status === PublishStatus.Failure) {
resolve($thunk.details[relay])
}
}
if (status !== PublishStatus.Success) {
return message
}
}
if (thunkIsComplete($thunk)) {
resolve("")
}
})
})
export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => {
if (parent) {
@@ -105,7 +104,7 @@ export const prependParent = (parent: TrustedEvent | undefined, {content, tags}:
id: parent.id,
kind: parent.kind,
author: parent.pubkey,
relays: ctx.app.router.Event(parent).limit(3).getUrls(),
relays: Router.get().Event(parent).limit(3).getUrls(),
})
tags = [...tags, tagEventForQuote(parent)]
@@ -115,38 +114,6 @@ export const prependParent = (parent: TrustedEvent | undefined, {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
export const logout = async () => {
@@ -161,47 +128,6 @@ export const logout = async () => {
localStorage.clear()
}
// Loaders
export const loadUserData = (
pubkey: string,
request: Partial<SubscribeRequestWithHandlers> = {},
) => {
const promise = Promise.race([
sleep(3000),
Promise.all([
loadInboxRelaySelections(pubkey, request),
loadMembership(pubkey, request),
loadSettings(pubkey, request),
loadProfile(pubkey, request),
loadFollows(pubkey, request),
loadMutes(pubkey, request),
]),
])
// Load followed profiles slowly in the background without clogging other stuff up. Only use a single
// indexer relay to avoid too many redundant validations, which slow things down and eat bandwidth
promise.then(async () => {
for (const pubkeys of chunk(50, getDefaultPubkeys())) {
const relays = sample(1, INDEXER_RELAYS)
await sleep(1000)
for (const pubkey of pubkeys) {
loadMembership(pubkey, {relays})
loadProfile(pubkey, {relays})
loadFollows(pubkey, {relays})
loadMutes(pubkey, {relays})
}
}
})
return promise
}
export const discoverRelays = (lists: List[]) =>
Promise.all(uniq(lists.flatMap(getRelayUrls)).filter(isShareableRelayUrl).map(loadRelay))
// Synchronization
export const broadcastUserData = async (relays: string[]) => {
@@ -248,7 +174,7 @@ export const nip29 = {
export const addSpaceMembership = async (url: string) => {
const list = get(userMembership) || makeList({kind: GROUPS})
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})
}
@@ -257,11 +183,7 @@ export const removeSpaceMembership = async (url: string) => {
const list = get(userMembership) || makeList({kind: GROUPS})
const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([
url,
...ctx.app.router.FromUser().getUrls(),
...getRelayTagValues(event.tags),
])
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays})
}
@@ -273,7 +195,7 @@ export const addRoomMembership = async (url: string, room: string, name: string)
["group", room, url, name],
]
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})
}
@@ -282,11 +204,7 @@ export const removeRoomMembership = async (url: string, room: string) => {
const list = get(userMembership) || makeList({kind: GROUPS})
const pred = (t: string[]) => equals(["group", room, url], t.slice(0, 3))
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([
url,
...ctx.app.router.FromUser().getUrls(),
...getRelayTagValues(event.tags),
])
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays})
}
@@ -308,7 +226,7 @@ export const setRelayPolicy = (url: string, read: boolean, write: boolean) => {
relays: [
url,
...INDEXER_RELAYS,
...ctx.app.router.FromUser().getUrls(),
...Router.get().FromUser().getUrls(),
...userRoomsByUrl.get().keys(),
],
})
@@ -318,7 +236,7 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
const list = get(userInboxRelaySelections) || makeList({kind: INBOX_RELAYS})
// 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)
if (enabled) {
@@ -329,7 +247,7 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
event: createEvent(list.kind, {tags}),
relays: [
...INDEXER_RELAYS,
...ctx.app.router.FromUser().getUrls(),
...Router.get().FromUser().getUrls(),
...userRoomsByUrl.get().keys(),
],
})
@@ -339,27 +257,27 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
// Relay access
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({
event: createEvent(AUTH_JOIN, {tags: [["claim", claim]]}),
relays: [url],
})
const result = await thunk.result
const error = await getThunkError(thunk)
if (result[url].status === PublishStatus.Failure) {
if (error) {
const message =
connection.auth.message?.replace(/^.*: /, "") ||
result[url].message?.replace(/^.*: /, "") ||
socket.auth.details?.replace(/^\w+: /, "") ||
error?.replace(/^\w+: /, "") ||
"join request rejected"
// If it's a strict NIP 29 relay don't worry about requesting access
// TODO: remove this if relay29 ever gets less strict
if (message !== "missing group (`h`) tag") {
return `Failed to join relay (${message})`
return message
}
}
}
@@ -373,25 +291,30 @@ export const checkRelayProfile = async (url: string) => {
}
export const checkRelayConnection = async (url: string) => {
const connection = ctx.net.pool.get(url)
const socket = Pool.get().get(url)
await connection.socket.open()
socket.attemptToOpen()
if (connection.socket.status !== SocketStatus.Open) {
await poll({
signal: AbortSignal.timeout(3000),
condition: () => socket.status === SocketStatus.Open,
})
if (socket.status !== SocketStatus.Open) {
return `Failed to connect`
}
}
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]
await connection.auth.attempt(timeout)
await socket.auth.attemptAuth(e => signer.get()?.sign(e))
// 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 (!okStatuses.includes(connection.auth.status) && connection.auth.message) {
return `Failed to authenticate (${connection.auth.message})`
if (!okStatuses.includes(socket.auth.status) && socket.auth.details) {
return `Failed to authenticate (${socket.auth.details})`
}
}
@@ -413,28 +336,6 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
// 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}) => {
const tags = [["k", String(event.kind)], ...tagEvent(event)]
const groupTag = getTag("h", event.tags)
@@ -504,3 +405,45 @@ export const makeComment = ({event, content, tags = []}: CommentParams) =>
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
publishThunk({event: makeComment(params), relays})
export type AlertParams = {
feed: Feed
cron: string
email: string
bunker: string
secret: string
description: string
}
export const makeAlert = async ({cron, email, feed, bunker, secret, description}: AlertParams) => {
const tags = [
["feed", JSON.stringify(feed)],
["cron", cron],
["email", email],
["locale", LOCALE],
["timezone", TIMEZONE],
["description", description],
["channel", "email"],
[
"handler",
"31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1737058597050",
"wss://relay.nostr.band/",
"web",
],
]
if (bunker) {
tags.push(["nip46", secret, bunker])
}
return createEvent(ALERT, {
content: await signer.get().nip44.encrypt(NOTIFIER_PUBKEY, JSON.stringify(tags)),
tags: [
["d", randomId()],
["p", NOTIFIER_PUBKEY],
],
})
}
export const publishAlert = async (params: AlertParams) =>
publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]})
+237
View File
@@ -0,0 +1,237 @@
<script lang="ts">
import {preventDefault} from "@lib/html"
import {randomInt, displayList, TIMEZONE, identity} from "@welshman/lib"
import {displayRelayUrl, getTagValue, THREAD, MESSAGE, EVENT_TIME, COMMENT} from "@welshman/util"
import type {Filter} from "@welshman/util"
import type {Nip46ResponseWithResult} from "@welshman/signer"
import {makeIntersectionFeed, makeRelayFeed, feedFromFilters} from "@welshman/feeds"
import {pubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import InfoBunker from "@app/components/InfoBunker.svelte"
import BunkerConnect, {BunkerConnectController} from "@app/components/BunkerConnect.svelte"
import {
GENERAL,
alerts,
getMembershipUrls,
getMembershipRoomsByUrl,
userMembership,
} from "@app/state"
import {loadAlertStatuses} from "@app/requests"
import {publishAlert} from "@app/commands"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
const timezoneOffset = parseInt(TIMEZONE.slice(3)) / 100
const minute = randomInt(0, 59)
const hour = (17 - timezoneOffset) % 24
const WEEKLY = `0 ${minute} ${hour} * * 1`
const DAILY = `0 ${minute} ${hour} * * *`
let loading = false
let cron = WEEKLY
let email = $alerts.map(a => getTagValue("email", a.tags)).filter(identity)[0] || ""
let relay = ""
let bunker = ""
let secret = ""
let notifyThreads = true
let notifyCalendar = true
let notifyChat = false
let showBunker = false
const back = () => history.back()
const controller = new BunkerConnectController({
onNostrConnect: (response: Nip46ResponseWithResult) => {
bunker = controller.broker.getBunkerUrl()
secret = controller.broker.params.clientSecret
showBunker = false
},
})
const connectBunker = () => {
showBunker = true
}
const hideBunker = () => {
showBunker = false
}
const clearBunker = () => {
bunker = ""
secret = ""
}
const submit = async () => {
if (!email.includes("@")) {
return pushToast({
theme: "error",
message: "Please provide an email address",
})
}
if (!relay) {
return pushToast({
theme: "error",
message: "Please select a space",
})
}
if (!notifyThreads && !notifyCalendar && !notifyChat) {
return pushToast({
theme: "error",
message: "Please select something to be notified about",
})
}
const filters: Filter[] = []
const display: string[] = []
if (notifyThreads) {
display.push("threads")
filters.push({kinds: [THREAD]})
filters.push({kinds: [COMMENT], "#k": [String(THREAD)]})
}
if (notifyCalendar) {
display.push("calendar events")
filters.push({kinds: [EVENT_TIME]})
filters.push({kinds: [COMMENT], "#k": [String(EVENT_TIME)]})
}
if (notifyChat) {
display.push("chat")
filters.push({
kinds: [MESSAGE],
"#h": [GENERAL, ...getMembershipRoomsByUrl(relay, $userMembership)],
})
}
loading = true
try {
const cadence = cron?.endsWith("1") ? "Weekly" : "Daily"
const description = `${cadence} alert for ${displayList(display)} on ${displayRelayUrl(relay)}, sent via email.`
const feed = makeIntersectionFeed(feedFromFilters(filters), makeRelayFeed(relay))
const thunk = await publishAlert({cron, email, feed, bunker, secret, description})
await thunk.result
await loadAlertStatuses($pubkey!)
pushToast({message: "Your alert has been successfully created!"})
back()
} finally {
loading = false
}
}
</script>
<form class="column gap-4" onsubmit={preventDefault(submit)}>
<ModalHeader>
{#snippet title()}
Add an Alert
{/snippet}
</ModalHeader>
{#if showBunker}
<div class="card2 flex flex-col items-center gap-4 bg-base-300">
<p>Scan using a nostr signer, or click to copy.</p>
<BunkerConnect {controller} />
<Button class="btn btn-neutral btn-sm" onclick={hideBunker}>Cancel</Button>
</div>
{:else}
<FieldInline>
{#snippet label()}
<p>Email Address*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<input placeholder="email@example.com" bind:value={email} />
</label>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Frequency*</p>
{/snippet}
{#snippet input()}
<select bind:value={cron} class="select select-bordered">
<option value={WEEKLY}>Weekly</option>
<option value={DAILY}>Daily</option>
</select>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Space*</p>
{/snippet}
{#snippet input()}
<select bind:value={relay} class="select select-bordered">
<option value="" disabled selected>Choose a space URL</option>
{#each getMembershipUrls($userMembership) as url (url)}
<option value={url}>{displayRelayUrl(url)}</option>
{/each}
</select>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Notifications*</p>
{/snippet}
{#snippet input()}
<div class="flex items-center justify-end gap-4">
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyThreads} />
Threads
</span>
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyCalendar} />
Calendar
</span>
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyChat} />
Chat
</span>
</div>
{/snippet}
</FieldInline>
<div class="card2 flex flex-col gap-3 bg-base-300">
<div class="flex items-center justify-between">
<strong>Connect a Bunker</strong>
<span class="flex items-center gap-2 text-sm" class:text-primary={bunker}>
{#if bunker}
<Icon icon="check-circle" size={5} />
Connected
{:else}
<Icon icon="close-circle" size={5} />
Not Connected
{/if}
</span>
</div>
<p class="text-sm">
Required for receiving alerts about spaces with access controls. You can get one from your
<Button class="text-primary" onclick={() => pushModal(InfoBunker)}>remote signer app</Button
>.
</p>
{#if bunker}
<Button class="btn btn-neutral btn-sm flex-grow" onclick={clearBunker}>Disconnect</Button>
{:else}
<Button class="btn btn-primary btn-sm w-full flex-grow" onclick={connectBunker}
>Connect</Button>
{/if}
</div>
{/if}
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading || showBunker}>
<Spinner {loading}>Confirm</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
</form>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import Confirm from "@lib/components/Confirm.svelte"
import type {Alert} from "@app/state"
import {NOTIFIER_RELAY} from "@app/state"
import {publishDelete} from "@app/commands"
import {pushToast} from "@app/toast"
type Props = {
alert: Alert
}
const {alert}: Props = $props()
const confirm = () => {
publishDelete({event: alert.event, relays: [NOTIFIER_RELAY]})
pushToast({message: "Your alert has been deleted!"})
history.back()
}
</script>
<Confirm {confirm} message="You'll no longer receive messages for this alert." />
+70
View File
@@ -0,0 +1,70 @@
<script lang="ts">
import {parseJson, nthEq} from "@welshman/lib"
import {displayFeeds} from "@welshman/feeds"
import {getAddress, getTagValue, getTagValues} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import AlertDelete from "@app/components/AlertDelete.svelte"
import type {Alert} from "@app/state"
import {alertStatuses} from "@app/state"
import {pushModal} from "@app/modal"
type Props = {
alert: Alert
}
const {alert}: Props = $props()
const address = $derived(getAddress(alert.event))
const status = $derived($alertStatuses.find(s => s.event.tags.some(nthEq(1, address))))
const cron = $derived(getTagValue("cron", alert.tags))
const channel = $derived(getTagValue("channel", alert.tags))
const feeds = $derived(getTagValues("feed", alert.tags))
const description = $derived(
getTagValue("description", alert.tags) ||
[
`${cron?.endsWith("1") ? "Weekly" : "Daily"} alert for events`,
displayFeeds(feeds.map(parseJson)),
`sent via ${channel}.`,
].join(" "),
)
const startDelete = () => pushModal(AlertDelete, {alert})
</script>
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4">
<Button class="py-1" onclick={startDelete}>
<Icon icon="trash-bin-2" />
</Button>
<div class="flex-inline gap-1">{description}</div>
</div>
{#if status}
{@const statusText = getTagValue("status", status.tags) || "error"}
{#if statusText === "ok"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content px-3 py-1 text-sm"
data-tip={getTagValue("message", status.tags)}>
Active
</span>
{:else if statusText === "pending"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content border-yellow-500 px-3 py-1 text-sm text-yellow-500"
data-tip={getTagValue("message", status.tags)}>
Pending
</span>
{:else}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip={getTagValue("message", status.tags)}>
{statusText.replace("-", " ").replace(/^(.)/, x => x.toUpperCase())}
</span>
{/if}
{:else}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip="The notification server did not respond to your request.">
Inactive
</span>
{/if}
</div>
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
import {onMount} from "svelte"
import {pubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import AlertAdd from "@app/components/AlertAdd.svelte"
import AlertItem from "@app/components/AlertItem.svelte"
import {loadAlertStatuses, loadAlerts} from "@app/requests"
import {pushModal} from "@app/modal"
import {alerts} from "@app/state"
const startAlert = () => pushModal(AlertAdd)
onMount(() => {
loadAlertStatuses($pubkey!)
loadAlerts($pubkey!)
})
</script>
<div class="card2 bg-alt flex flex-col gap-6 shadow-xl">
<div class="flex items-center justify-between">
<strong class="flex items-center gap-3">
<Icon icon="inbox" />
Alerts
</strong>
<Button class="btn btn-primary btn-sm" onclick={startAlert}>
<Icon icon="add-circle" />
Add Alert
</Button>
</div>
<div class="col-4">
{#each $alerts as alert (alert.event.id)}
<AlertItem {alert} />
{:else}
<p class="text-center opacity-75 py-12">No alerts found</p>
{/each}
</div>
</div>
+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">
import type {TrustedEvent} from "@welshman/util"
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 ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
import {publishDelete, publishReaction} from "@app/commands"
import {makeCalendarPath} from "@app/routes"
import {pushModal} from "@app/modal"
const {
url,
@@ -20,6 +24,8 @@
const path = makeCalendarPath(url, event.id)
const editEvent = () => pushModal(CalendarEventEdit, {url, event})
const onReactionClick = (content: string, events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey)
@@ -38,6 +44,17 @@
{#if showActivity}
<EventActivity {url} {path} {event} />
{/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>
+16 -157
View File
@@ -1,164 +1,23 @@
<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 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"
import CalendarEventForm from "@app/components/CalendarEventForm.svelte"
const {url} = $props()
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()
type Props = {
url: string
}
const editor = makeEditor({submit, uploading})
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
}
})
const {url}: Props = $props()
</script>
<form class="column gap-4" onsubmit={preventDefault(submit)}>
<ModalHeader>
{#snippet title()}
<div>Create an Event</div>
{/snippet}
{#snippet info()}
<div>Invite other group members to events online or in real life.</div>
{/snippet}
</ModalHeader>
<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={() => 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>
<CalendarEventForm {url}>
{#snippet header()}
<ModalHeader>
{#snippet title()}
<div>Create an Event</div>
{/snippet}
{#snippet info()}
<div>Invite other group members to events online or in real life.</div>
{/snippet}
</ModalHeader>
{/snippet}
</CalendarEventForm>
@@ -0,0 +1,20 @@
<script lang="ts">
import {fromPairs, LOCALE, secondsToDate} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
type Props = {
event: TrustedEvent
}
const {event}: Props = $props()
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
const startDate = $derived(secondsToDate(parseInt(meta.start)))
</script>
<div
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">
<strong>{Intl.DateTimeFormat(LOCALE, {month: "short"}).format(startDate)}</strong>
<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>
@@ -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>
@@ -0,0 +1,33 @@
<script lang="ts">
import {
fromPairs,
formatTimestamp,
formatTimestampAsDate,
formatTimestampAsTime,
} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
type Props = {
event: TrustedEvent
}
const {event}: Props = $props()
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
const start = $derived(parseInt(meta.start))
const end = $derived(parseInt(meta.end))
const startDateDisplay = $derived(formatTimestampAsDate(start))
const endDateDisplay = $derived(formatTimestampAsDate(end))
const isSingleDay = $derived(startDateDisplay === endDateDisplay)
</script>
<div class="flex flex-grow flex-wrap justify-between gap-2">
<p class="text-xl">{meta.title || meta.name}</p>
<div class="flex items-center gap-2 text-sm">
<Icon icon="clock-circle" size={4} />
<span class="sm:hidden">{formatTimestampAsDate(start)}</span>
{formatTimestampAsTime(start)}{isSingleDay
? formatTimestampAsTime(end)
: formatTimestamp(end)}
</div>
</div>
+6 -24
View File
@@ -1,42 +1,24 @@
<script lang="ts">
import {fromPairs} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {formatTimestamp, formatTimestampAsDate, formatTimestampAsTime} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import CalendarEventActions from "@app/components/CalendarEventActions.svelte"
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import {makeCalendarPath} from "@app/routes"
const {
url,
event,
}: {
type Props = {
url: string
event: TrustedEvent
} = $props()
}
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
const end = $derived(parseInt(meta.end))
const start = $derived(parseInt(meta.start))
const startDateDisplay = $derived(formatTimestampAsDate(start))
const endDateDisplay = $derived(formatTimestampAsDate(end))
const isSingleDay = $derived(startDateDisplay === endDateDisplay)
const {url, event}: Props = $props()
</script>
<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">
<p class="text-xl">{meta.title || meta.name}</p>
<div class="flex items-center gap-2 text-sm">
<Icon icon="clock-circle" size={4} />
{formatTimestampAsTime(start)}{isSingleDay
? formatTimestampAsTime(end)
: formatTimestamp(end)}
</div>
</div>
<CalendarEventHeader {event} />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75">
Posted by <ProfileLink pubkey={event.pubkey} />
Posted by <ProfileLink pubkey={event.pubkey} {url} />
</span>
<CalendarEventActions showActivity {url} {event} />
</div>
@@ -0,0 +1,27 @@
<script lang="ts">
import {fromPairs} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
type Props = {
event: TrustedEvent
url: string
}
const {event, url}: Props = $props()
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
</script>
<div class="flex min-w-0 flex-col gap-1 text-sm opacity-75">
<span class="flex items-center gap-1">
<Icon icon="user-circle" size={4} />
Posted by <ProfileLink pubkey={event.pubkey} {url} />
</span>
{#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">
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"
interface Props {
onSubmit: any
type Props = {
url?: string
onSubmit: (event: EventContent) => void
}
const {onSubmit}: Props = $props()
const {onSubmit, url}: Props = $props()
const autofocus = !isMobile
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
const content = editor.getText({blockSeparator: "\n"}).trim()
const tags = editor.storage.nostr.getEditorTags()
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = ed.storage.nostr.getEditorTags()
if (!content) return
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>
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
@@ -4,7 +4,7 @@
import {slide} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Content from "@app/components/Content.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
const {
verb,
@@ -22,7 +22,12 @@
transition:slide>
<p class="text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p>
{#key event.id}
<Content {event} hideMedia minLength={100} maxLength={300} expandMode="disabled" />
<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" />
+28 -34
View File
@@ -1,17 +1,9 @@
<script lang="ts">
import {hash} from "@welshman/lib"
import {now} from "@welshman/lib"
import {hash, now, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {
thunks,
pubkey,
deriveProfile,
deriveProfileDisplay,
formatTimestampAsDate,
formatTimestampAsTime,
} from "@welshman/app"
import {thunks, pubkey, deriveProfile, deriveProfileDisplay} from "@welshman/app"
import {isMobile} from "@lib/html"
import LongPress from "@lib/components/LongPress.svelte"
import TapTarget from "@lib/components/TapTarget.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -27,10 +19,10 @@
import {pushModal} from "@app/modal"
interface Props {
url: any
room: any
url: string
room: string
event: TrustedEvent
replyTo?: any
replyTo?: (event: TrustedEvent) => void
showPubkey?: boolean
inert?: boolean
}
@@ -39,15 +31,15 @@
const thunk = $thunks[event.id]
const today = formatTimestampAsDate(now())
const profile = deriveProfile(event.pubkey)
const profileDisplay = deriveProfileDisplay(event.pubkey)
const profile = deriveProfile(event.pubkey, [url])
const profileDisplay = deriveProfileDisplay(event.pubkey, [url])
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
const reply = () => replyTo(event)
const reply = () => replyTo!(event)
const onLongPress = () => pushModal(ChannelMessageMenuMobile, {url, event, reply})
const onTap = () => pushModal(ChannelMessageMenuMobile, {url, event, reply})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
const onReactionClick = (content: string, events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey)
@@ -60,9 +52,9 @@
}
</script>
<LongPress
<TapTarget
data-event={event.id}
onLongPress={inert ? null : onLongPress}
onTap={inert ? null : onTap}
class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left">
<div class="flex w-full gap-3 overflow-auto">
{#if showPubkey}
@@ -89,7 +81,7 @@
</div>
{/if}
<div class="text-sm">
<Content {event} quoteProps={{minimal: true, relays: [url]}} />
<Content {event} {url} />
{#if thunk}
<ThunkStatus {thunk} class="mt-2" />
{/if}
@@ -99,15 +91,17 @@
<div class="row-2 ml-10 mt-1">
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-right" />
</div>
<button
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}>
<ChannelMessageEmojiButton {url} {room} {event} />
{#if replyTo}
<Button class="btn join-item btn-xs" onclick={reply}>
<Icon icon="reply" size={4} />
</Button>
{/if}
<ChannelMessageMenuButton {url} {event} />
</button>
</LongPress>
{#if !isMobile}
<button
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}>
<ChannelMessageEmojiButton {url} {room} {event} />
{#if replyTo}
<Button class="btn join-item btn-xs" onclick={reply}>
<Icon icon="reply" size={4} />
</Button>
{/if}
<ChannelMessageMenuButton {url} {event} />
</button>
{/if}
</TapTarget>
+3 -3
View File
@@ -4,7 +4,7 @@
import Icon from "@lib/components/Icon.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import EventReport from "@app/components/EventReport.svelte"
import ConfirmDelete from "@app/components/ConfirmDelete.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {pushModal} from "@app/modal"
const {url, event, onClick} = $props()
@@ -16,12 +16,12 @@
const showInfo = () => {
onClick()
pushModal(EventInfo, {event})
pushModal(EventInfo, {url, event})
}
const showDelete = () => {
onClick()
pushModal(ConfirmDelete, {url, event})
pushModal(EventDeleteConfirm, {url, event})
}
</script>
@@ -1,20 +1,27 @@
<script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import ConfirmDelete from "@app/components/ConfirmDelete.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {publishReaction} from "@app/commands"
import {pushModal} from "@app/modal"
const {url, event, reply} = $props()
type Props = {
url: string
event: TrustedEvent
reply: () => void
}
const onEmoji = (emoji: NativeEmoji) => {
const {url, event, reply}: Props = $props()
const onEmoji = ((event: TrustedEvent, url: string, emoji: NativeEmoji) => {
history.back()
publishReaction({event, relays: [url], content: emoji.unicode})
}
}).bind(undefined, event, url)
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
@@ -23,9 +30,9 @@
reply()
}
const showInfo = () => pushModal(EventInfo, {event}, {replaceState: true})
const showInfo = () => pushModal(EventInfo, {url, event}, {replaceState: true})
const showDelete = () => pushModal(ConfirmDelete, {url, event})
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
</script>
<div class="col-2">
+148 -112
View File
@@ -1,20 +1,21 @@
<script lang="ts">
import type {Snippet} 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 {createEvent, DIRECT_MESSAGE, INBOX_RELAYS} from "@welshman/util"
import {
pubkey,
tagPubkey,
formatTimestampAsDate,
sendWrapped,
loadUsingOutbox,
inboxRelaySelectionsByPubkey,
load,
} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte"
import Button from "@lib/components/Button.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
@@ -23,27 +24,30 @@
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ProfileList from "@app/components/ProfileList.svelte"
import ChatMessage from "@app/components/ChatMessage.svelte"
import ChatCompose from "@app/components/ChannelCompose.svelte"
import ChatComposeParent from "@app/components/ChannelComposeParent.svelte"
import {userSettingValues, deriveChat, splitChatId, PLATFORM_NAME} from "@app/state"
import ChatCompose from "@app/components/ChatCompose.svelte"
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
import {
INDEXER_RELAYS,
userSettingValues,
deriveChat,
splitChatId,
PLATFORM_NAME,
} from "@app/state"
import {pushModal} from "@app/modal"
import {sendWrapped, prependParent} from "@app/commands"
import {prependParent} from "@app/commands"
const {
id,
info,
}: {
type Props = {
id: string
info?: Snippet
} = $props()
}
const {id, info}: Props = $props()
const chat = deriveChat(id)
const pubkeys = splitChatId(id)
const others = remove($pubkey!, pubkeys)
const missingInboxes = $derived(pubkeys.filter(pk => !$inboxRelaySelectionsByPubkey.has(pk)))
const assertEvent = (e: any) => e as TrustedEvent
const showMembers = () =>
pushModal(ProfileList, {pubkeys: others, title: `People in this conversation`})
@@ -72,6 +76,8 @@
let loading = $state(true)
let compose: ChatCompose | undefined = $state()
let parent: TrustedEvent | undefined = $state()
let chatCompose: HTMLElement | undefined = $state()
let dynamicPadding: HTMLElement | undefined = $state()
const elements = $derived.by(() => {
const elements = []
@@ -105,7 +111,27 @@
onMount(() => {
// Don't use loadInboxRelaySelection because we want to force reload
load({filters: [{kinds: [INBOX_RELAYS], authors: others}]})
for (const pubkey of others) {
loadUsingOutbox({
pubkey,
kind: INBOX_RELAYS,
relays: INDEXER_RELAYS,
})
}
const observer = new ResizeObserver(() => {
if (dynamicPadding && chatCompose) {
dynamicPadding.style.minHeight = `${chatCompose.offsetHeight}px`
}
})
observer.observe(chatCompose!)
observer.observe(dynamicPadding!)
return () => {
observer.unobserve(chatCompose!)
observer.unobserve(dynamicPadding!)
}
})
setTimeout(() => {
@@ -113,106 +139,116 @@
}, 5000)
</script>
<div class="relative flex h-full w-full flex-col">
{#if others.length > 0}
<PageBar>
{#snippet title()}
<div class="flex flex-col gap-1 sm:flex-row sm:gap-2">
{#if others.length === 1}
{@const pubkey = others[0]}
{@const onClick = () => pushModal(ProfileDetail, {pubkey})}
<Button onclick={onClick} class="row-2">
<ProfileCircle {pubkey} size={5} />
<ProfileName {pubkey} />
</Button>
{:else}
<div class="flex items-center gap-2">
<ProfileCircles pubkeys={others} size={5} />
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
<ProfileName pubkey={others[0]} />
and
{#if others.length === 2}
<ProfileName pubkey={others[1]} />
{:else}
{others.length - 1}
{others.length > 2 ? "others" : "other"}
{/if}
</p>
</div>
{#if others.length > 2}
<Button onclick={showMembers} class="btn btn-link hidden sm:block"
>Show all members</Button>
{/if}
{/if}
<PageBar>
{#snippet title()}
<div class="flex flex-col gap-1 sm:flex-row sm:gap-2">
{#if others.length === 0}
<div class="row-2">
<ProfileCircle pubkey={$pubkey!} size={5} />
<ProfileName pubkey={$pubkey!} />
</div>
{/snippet}
{#snippet action()}
<div>
{#if remove($pubkey, missingInboxes).length > 0}
{@const count = remove($pubkey, missingInboxes).length}
{@const label = count > 1 ? "inboxes are" : "inbox is"}
<div
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer"
data-tip="{count} {label} not configured.">
<Icon icon="danger" />
{count}
</div>
{/if}
</div>
{/snippet}
</PageBar>
{/if}
<div class="-mt-2 flex flex-grow flex-col-reverse overflow-auto py-2">
{#if missingInboxes.includes($pubkey!)}
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error">
<Icon icon="danger" />
Your inbox is not configured.
</p>
<p>
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please visit
your <Link class="link" href="/settings/relays">relay settings page</Link> to set up your
inbox.
</p>
</div>
</div>
{:else if missingInboxes.length > 0}
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error">
<Icon icon="danger" />
{missingInboxes.length}
{missingInboxes.length > 1 ? "inboxes are" : "inbox is"} not configured.
</p>
<p>
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please make
sure everyone in this conversation has set up their inbox relays.
</p>
</div>
</div>
{/if}
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "date"}
<Divider>{value}</Divider>
{:else if others.length === 1}
{@const pubkey = others[0]}
{@const onClick = () => pushModal(ProfileDetail, {pubkey})}
<Button onclick={onClick} class="row-2">
<ProfileCircle {pubkey} size={5} />
<ProfileName {pubkey} />
</Button>
{:else}
<ChatMessage event={assertEvent(value)} {pubkeys} {showPubkey} {replyTo} />
{/if}
{/each}
<p
class="m-auto flex h-10 max-w-sm flex-col items-center justify-center gap-4 py-20 text-center">
<Spinner {loading}>
{#if loading}
Looking for messages...
{:else}
End of message history
<div class="flex items-center gap-2">
<ProfileCircles pubkeys={others} size={5} />
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
<ProfileName pubkey={others[0]} />
and
{#if others.length === 2}
<ProfileName pubkey={others[1]} />
{:else}
{others.length - 1}
{others.length > 2 ? "others" : "other"}
{/if}
</p>
</div>
{#if others.length > 2}
<Button onclick={showMembers} class="btn btn-link hidden sm:block"
>Show all members</Button>
{/if}
</Spinner>
{@render info?.()}
</p>
</div>
{#if parent}
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
</div>
{/snippet}
{#snippet action()}
<div>
{#if remove($pubkey, missingInboxes).length > 0}
{@const count = remove($pubkey, missingInboxes).length}
{@const label = count > 1 ? "inboxes are" : "inbox is"}
<div
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer"
data-tip="{count} {label} not configured.">
<Icon icon="danger" />
{count}
</div>
{/if}
</div>
{/snippet}
</PageBar>
<PageContent class="flex flex-col-reverse pt-4">
<div bind:this={dynamicPadding}></div>
{#if missingInboxes.includes($pubkey!)}
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error">
<Icon icon="danger" />
Your inbox is not configured.
</p>
<p>
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please visit
your <Link class="link" href="/settings/relays">relay settings page</Link> to set up your inbox.
</p>
</div>
</div>
{:else if missingInboxes.length > 0}
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error">
<Icon icon="danger" />
{missingInboxes.length}
{missingInboxes.length > 1 ? "inboxes are" : "inbox is"} not configured.
</p>
<p>
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please make
sure everyone in this conversation has set up their inbox relays.
</p>
</div>
</div>
{/if}
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "date"}
<Divider>{value}</Divider>
{:else}
<ChatMessage
event={$state.snapshot(value as TrustedEvent)}
{pubkeys}
{showPubkey}
{replyTo} />
{/if}
{/each}
<p class="m-auto flex h-10 max-w-sm flex-col items-center justify-center gap-4 py-20 text-center">
<Spinner {loading}>
{#if loading}
Looking for messages...
{:else}
End of message history
{/if}
</Spinner>
{@render info?.()}
</p>
</PageContent>
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
<div>
{#if parent}
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
</div>
<ChatCompose bind:this={compose} {onSubmit} />
</div>
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import {writable} from "svelte/store"
import type {EventContent} from "@welshman/util"
import {isMobile, preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
type Props = {
url?: string
onSubmit: (event: EventContent) => void
}
const {onSubmit, url}: Props = $props()
const autofocus = !isMobile
const uploading = writable(false)
export const focus = () => editor.then(ed => ed.chain().focus().run())
const submit = async () => {
if ($uploading) return
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = ed.storage.nostr.getEditorTags()
if (!content) return
onSubmit({content, tags})
ed.chain().clearContent().run()
}
const editor = makeEditor({
url,
autofocus,
submit,
uploading,
aggressive: true,
disableFileUpload: true,
})
</script>
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
<div class="chat-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
<Button
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full"
disabled={$uploading}
onclick={submit}>
<Icon icon="plain" />
</Button>
</form>
@@ -0,0 +1,35 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {displayProfileByPubkey} from "@welshman/app"
import {slide} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
const {
verb,
event,
clear,
}: {
verb: string
event: TrustedEvent
clear: () => void
} = $props()
</script>
<div
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8 text-xs"
transition:slide>
<p class="text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p>
{#key event.id}
<NoteContent
{event}
hideMediaAtDepth={0}
minLength={100}
maxLength={300}
expandMode="disabled" />
{/key}
<Button class="absolute right-2 top-2 cursor-pointer" onclick={clear}>
<Icon icon="close-circle" />
</Button>
</div>
+3 -1
View File
@@ -13,6 +13,8 @@
const {next} = $props()
const nextUrl = $state.snapshot(next)
let loading = $state(false)
const enableChat = async () => {
@@ -23,7 +25,7 @@
}
clearModals()
goto(next)
goto(nextUrl)
}
const submit = async () => {
+1 -1
View File
@@ -40,7 +40,7 @@
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
{#if others.length === 0}
<ProfileCircle pubkey={$pubkey} size={5} />
<ProfileCircle pubkey={$pubkey!} size={5} />
Note to self
{:else if others.length === 1}
<ProfileCircle pubkey={others[0]} size={5} />
+38 -40
View File
@@ -1,19 +1,13 @@
<script lang="ts">
import {type Instance} from "tippy.js"
import {hash} from "@welshman/lib"
import {hash, formatTimestampAsTime} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {
thunks,
deriveProfile,
deriveProfileDisplay,
formatTimestampAsTime,
pubkey,
} from "@welshman/app"
import {thunks, pubkey, deriveProfile, deriveProfileDisplay, sendWrapped} from "@welshman/app"
import {isMobile} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import LongPress from "@lib/components/LongPress.svelte"
import TapTarget from "@lib/components/TapTarget.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import Content from "@app/components/Content.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
@@ -22,17 +16,17 @@
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte"
import {colors} from "@app/state"
import {makeDelete, makeReaction, sendWrapped} from "@app/commands"
import {makeDelete, makeReaction} from "@app/commands"
import {pushModal} from "@app/modal"
interface Props {
event: TrustedEvent
replyTo?: any
replyTo: (event: TrustedEvent) => void
pubkeys: string[]
showPubkey?: boolean
}
const {event, replyTo = undefined, pubkeys, showPubkey = false}: Props = $props()
const {event, replyTo, pubkeys, showPubkey = false}: Props = $props()
const thunk = $thunks[event.id]
const isOwn = event.pubkey === $pubkey
@@ -40,6 +34,8 @@
const profileDisplay = deriveProfileDisplay(event.pubkey)
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
const reply = () => replyTo(event)
const onReactionClick = async (content: string, events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey)
const template = reaction ? makeDelete({event: reaction}) : makeReaction({event, content})
@@ -49,7 +45,7 @@
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys})
const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys, reply})
const togglePopover = () => {
if (popoverIsVisible) {
@@ -72,32 +68,34 @@
class:chat-start={!isOwn}
class:flex-row-reverse={!isOwn}
class:chat-end={isOwn}>
<Tippy
bind:popover
component={ChatMessageMenu}
props={{event, pubkeys, popover, replyTo}}
params={{
interactive: true,
trigger: "manual",
onShow() {
popoverIsVisible = true
},
onHidden() {
popoverIsVisible = false
},
}}>
<button
type="button"
class="opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}
onclick={togglePopover}>
<Icon icon="menu-dots" size={4} />
</button>
</Tippy>
{#if !isMobile}
<Tippy
bind:popover
component={ChatMessageMenu}
props={{event, pubkeys, popover, replyTo}}
params={{
interactive: true,
trigger: "manual",
onShow() {
popoverIsVisible = true
},
onHidden() {
popoverIsVisible = false
},
}}>
<button
type="button"
class="opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}
onclick={togglePopover}>
<Icon icon="menu-dots" size={4} />
</button>
</Tippy>
{/if}
<div class="flex min-w-0 flex-col" class:items-end={isOwn}>
<LongPress
class="bg-alt chat-bubble mx-1 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl"
onLongPress={showMobileMenu}>
<TapTarget
class="bg-alt chat-bubble mx-1 mb-2 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl"
onTap={showMobileMenu}>
{#if showPubkey}
<div class="flex items-center gap-2">
{#if !isOwn}
@@ -120,8 +118,8 @@
<div class="text-sm">
<Content showEntire {event} />
</div>
</LongPress>
<div class="row-2 z-feature -mt-1 ml-4">
</TapTarget>
<div class="row-2 z-feature -mt-4 ml-4">
<ReactionSummary {event} {onReactionClick} noTooltip />
</div>
</div>
@@ -1,9 +1,10 @@
<script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import {sendWrapped} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte"
import {makeReaction, sendWrapped} from "@app/commands"
import {makeReaction} from "@app/commands"
interface Props {
event: TrustedEvent
@@ -1,22 +1,35 @@
<script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import {sendWrapped} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.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 {clip} from "@app/toast"
const {event, pubkeys} = $props()
const onEmoji = (emoji: NativeEmoji) => {
history.back()
sendWrapped({template: makeReaction({event, content: emoji.unicode}), pubkeys})
type Props = {
pubkeys: string[]
event: TrustedEvent
reply: () => void
}
const {event, pubkeys, reply}: Props = $props()
const onEmoji = ((event: TrustedEvent, pubkeys: string[], emoji: NativeEmoji) => {
history.back()
sendWrapped({template: makeReaction({event, content: emoji.unicode}), pubkeys})
}).bind(undefined, event, pubkeys)
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
const sendReply = () => {
history.back()
reply()
}
const copyText = () => {
history.back()
clip(event.content)
@@ -30,6 +43,10 @@
<Icon size={4} icon="smile-circle" />
Send Reaction
</Button>
<Button class="btn btn-neutral w-full" onclick={sendReply}>
<Icon size={4} icon="reply" />
Send Reply
</Button>
<Button class="btn btn-neutral w-full" onclick={copyText}>
<Icon size={4} icon="copy" />
Copy Text
+10 -16
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {fromNostrURI} from "@welshman/util"
import {nthEq} from "@welshman/lib"
import {
@@ -22,7 +21,6 @@
import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Content from "@app/components/Content.svelte"
import ContentToken from "@app/components/ContentToken.svelte"
import ContentCode from "@app/components/ContentCode.svelte"
import ContentLinkInline from "@app/components/ContentLinkInline.svelte"
@@ -38,10 +36,10 @@
minLength?: number
maxLength?: number
showEntire?: boolean
hideMedia?: boolean
hideMediaAtDepth?: number
expandMode?: string
quoteProps?: Record<string, any>
depth?: number
url?: string
}
let {
@@ -49,10 +47,10 @@
minLength = 500,
maxLength = 700,
showEntire = $bindable(false),
hideMedia = false,
hideMediaAtDepth = 1,
expandMode = "block",
quoteProps = {},
depth = 0,
url,
}: Props = $props()
const fullContent = parse(event)
@@ -64,13 +62,13 @@
const isBlock = (i: number) => {
const parsed = fullContent[i]
if (!parsed || hideMedia) return false
if (!parsed || hideMediaAtDepth <= depth) return false
if (isLink(parsed) && $userSettingValues.show_media && isStartOrEnd(i)) {
return true
}
if ((isEvent(parsed) || isAddress(parsed)) && isStartOrEnd(i) && depth < 1) {
if ((isEvent(parsed) || isAddress(parsed)) && isStartOrEnd(i)) {
return true
}
@@ -108,7 +106,7 @@
: truncate(fullContent, {
minLength,
maxLength,
mediaLength: hideMedia ? 20 : 200,
mediaLength: hideMediaAtDepth <= depth ? 20 : 200,
}),
)
@@ -143,19 +141,15 @@
<ContentToken value={parsed.value} />
{:else if isLink(parsed)}
{#if isBlock(i)}
<ContentLinkBlock value={parsed.value} />
<ContentLinkBlock value={parsed.value} {event} />
{:else}
<ContentLinkInline value={parsed.value} />
{/if}
{:else if isProfile(parsed)}
<ContentMention value={parsed.value} />
<ContentMention value={parsed.value} {url} />
{:else if isEvent(parsed) || isAddress(parsed)}
{#if isBlock(i)}
<ContentQuote {...quoteProps} value={parsed.value} {event}>
{#snippet noteContent({event, minimal}: {event: TrustedEvent; minimal: boolean})}
<Content {quoteProps} hideMedia={minimal || hideMedia} {event} depth={depth + 1} />
{/snippet}
</ContentQuote>
<ContentQuote {depth} {url} {hideMediaAtDepth} value={parsed.value} {event} />
{:else}
<Link
external
+12 -4
View File
@@ -4,9 +4,12 @@
import {preventDefault, stopPropagation} from "@lib/html"
import Link from "@lib/components/Link.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
import {pushModal} from "@app/modal"
const {value} = $props()
const {value, event} = $props()
let hideImage = $state(false)
const url = value.url.toString()
@@ -20,7 +23,11 @@
return json
}
const expand = () => pushModal(ContentLinkDetail, {url}, {fullscreen: true})
const onError = () => {
hideImage = true
}
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
</script>
<Link external href={url} class="my-2 block">
@@ -31,7 +38,7 @@
</video>
{:else if url.match(/\.(jpe?g|png|gif|webp)$/)}
<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>
{:else}
{#await loadPreview()}
@@ -40,9 +47,10 @@
</div>
{:then preview}
<div class="bg-alt flex max-w-xl flex-col leading-normal">
{#if preview.image}
{#if preview.image && !hideImage}
<img
alt="Link preview"
onerror={onError}
src={imgproxy(preview.image)}
class="bg-alt max-h-72 object-contain object-center" />
{/if}
@@ -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">
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
import Button from "@lib/components/Button.svelte"
import {imgproxy} from "@app/state"
const {url} = $props()
const {value, event} = $props()
const back = () => history.back()
</script>
<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>
+10 -3
View File
@@ -1,15 +1,22 @@
<script lang="ts">
import {removeNil} from "@welshman/lib"
import type {ProfilePointer} from "@welshman/content"
import {displayProfile} from "@welshman/util"
import {deriveProfile} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
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>
<Button onclick={openProfile} class="link-content">
+25 -33
View File
@@ -1,56 +1,48 @@
<script lang="ts">
import {nip19} from "nostr-tools"
import * as nip19 from "nostr-tools/nip19"
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 type {TrustedEvent} 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 Spinner from "@lib/components/Spinner.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import {deriveEvent, entityLink, ROOM} from "@app/state"
import {makeThreadPath, makeCalendarPath, makeRoomPath} from "@app/routes"
const {value, event, noteContent, relays = [], minimal = false} = $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 mergedRelays = [
...relays,
...ctx.app.router.Quote(event, idOrAddress, relayHints).getUrls(),
]
const mergedRelays = Router.get().Quote(event, idOrAddress, relays).getUrls()
if (url) {
mergedRelays.push(url)
}
const quote = deriveEvent(idOrAddress, mergedRelays)
const entity = id
? nip19.neventEncode({id, relays: mergedRelays})
: 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 event = repository.getEvent(id)
if (event) {
goto(makeRoomPath(url, room))
// TODO: if the event doesn't immediately load, this won't work. Scroll up until it's found
setTimeout(() => scrollToEvent(id), 300)
scrollToEvent(id)
}
return Boolean(event)
@@ -103,8 +95,8 @@
<Button class="my-2 block max-w-full text-left" {onclick}>
{#if $quote}
<NoteCard {minimal} event={$quote} class="bg-alt rounded-box p-4">
{@render noteContent({event: $quote, minimal})}
<NoteCard event={$quote} {url} class="bg-alt rounded-box p-4">
<NoteContent {hideMediaAtDepth} {url} event={$quote} depth={depth + 1} />
</NoteCard>
{:else}
<div class="rounded-box p-4">
+7 -7
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import type {Snippet} from "svelte"
import type {Instance} from "tippy.js"
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
@@ -9,15 +10,14 @@
import EventMenu from "@app/components/EventMenu.svelte"
import {publishReaction} from "@app/commands"
const {
url,
noun,
event,
}: {
type Props = {
url: string
noun: string
event: TrustedEvent
} = $props()
customActions?: Snippet
}
const {url, noun, event, customActions}: Props = $props()
const showPopover = () => popover?.show()
@@ -36,7 +36,7 @@
<Tippy
bind:popover
component={EventMenu}
props={{url, noun, event, onClick: hidePopover}}
props={{url, noun, event, customActions, onClick: hidePopover}}
params={{trigger: "manual", interactive: true}}>
<Button class="btn join-item btn-neutral btn-xs" onclick={showPopover}>
<Icon icon="menu-dots" size={4} />
+3 -2
View File
@@ -1,10 +1,11 @@
<script lang="ts">
import {onMount} from "svelte"
import {max} from "@welshman/lib"
import {max, formatTimestampRelative} from "@welshman/lib"
import {COMMENT} from "@welshman/util"
import {load} from "@welshman/net"
import {deriveEvents} from "@welshman/store"
import type {TrustedEvent} from "@welshman/util"
import {formatTimestampRelative, repository, load} from "@welshman/app"
import {repository} from "@welshman/app"
import {notifications} from "@app/notifications"
import Icon from "@lib/components/Icon.svelte"
@@ -1,14 +1,18 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import Confirm from "@lib/components/Confirm.svelte"
import {publishDelete} from "@app/commands"
import {clearModals} from "@app/modal"
const {url, event} = $props()
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const confirm = async () => {
const snapshot = $state.snapshot(event)
await publishDelete({event: snapshot, relays: [url]})
await publishDelete({event, relays: [url]})
clearModals()
}
+10 -4
View File
@@ -1,15 +1,21 @@
<script lang="ts">
import {nip19} from "nostr-tools"
import {ctx} from "@welshman/lib"
import * as nip19 from "nostr-tools/nip19"
import {Router} from "@welshman/router"
import type {TrustedEvent} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import {clip} from "@app/toast"
const {event} = $props()
type Props = {
url?: string
event: TrustedEvent
}
const relays = ctx.app.router.Event(event).getUrls()
const {url, event}: Props = $props()
const relays = url ? [url] : Router.get().Event(event).getUrls()
const nevent1 = nip19.neventEncode({...event, relays})
const npub1 = nip19.npubEncode(event.pubkey)
const json = JSON.stringify(event, null, 2)
+20 -25
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import {onMount} from "svelte"
import type {Snippet} from "svelte"
import type {TrustedEvent} from "@welshman/util"
import {COMMENT} from "@welshman/util"
import {pubkey} from "@welshman/app"
@@ -7,45 +9,37 @@
import EventInfo from "@app/components/EventInfo.svelte"
import EventReport from "@app/components/EventReport.svelte"
import EventShare from "@app/components/EventShare.svelte"
import ConfirmDelete from "@app/components/ConfirmDelete.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {pushModal} from "@app/modal"
const {
url,
noun,
event,
onClick,
}: {
type Props = {
url: string
noun: string
event: TrustedEvent
onClick: () => void
} = $props()
customActions?: Snippet
}
const {url, noun, event, onClick, customActions}: Props = $props()
const isRoot = event.kind !== COMMENT
const report = () => {
onClick()
pushModal(EventReport, {url, event})
}
const report = () => pushModal(EventReport, {url, event})
const showInfo = () => {
onClick()
pushModal(EventInfo, {event})
}
const showInfo = () => pushModal(EventInfo, {url, event})
const share = () => {
onClick()
pushModal(EventShare, {url, event})
}
const share = () => pushModal(EventShare, {url, event})
const showDelete = () => {
onClick()
pushModal(ConfirmDelete, {url, event})
}
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</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}
<li>
<Button onclick={share}>
@@ -60,6 +54,7 @@
{noun} Details
</Button>
</li>
{@render customActions?.()}
{#if event.pubkey === $pubkey}
<li>
<Button onclick={showDelete} class="text-error">
+50 -24
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import {onMount} from "svelte"
import {writable} from "svelte/store"
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 Button from "@lib/components/Button.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
@@ -15,11 +16,14 @@
const uploading = writable(false)
const submit = () => {
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => {
if ($uploading) return
const content = editor.getText({blockSeparator: "\n"}).trim()
const tags = [...editor.storage.nostr.getEditorTags(), tagRoom(GENERAL, url), PROTECTED]
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = [...ed.storage.nostr.getEditorTags(), tagRoom(GENERAL, url), PROTECTED]
if (!content) {
return pushToast({
@@ -31,31 +35,53 @@
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>
<div bind:this={spacer}></div>
<form
in:fly
out:slideAndFade
bind:this={form}
onsubmit={preventDefault(submit)}
class="card2 sticky bottom-2 z-feature mx-2 mt-4 bg-neutral">
<div class="relative">
<div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} />
class="cb cw fixed z-feature -mx-2 pt-3">
<div class="card2 mx-2 my-2 bg-neutral">
<div class="relative">
<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>
<Button
data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2"
onclick={editor.commands.selectFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="paperclip" size={3} />
{/if}
</Button>
<ModalFooter>
<Button class="btn btn-link" onclick={onClose}>Cancel</Button>
<Button type="submit" class="btn btn-primary">Post Reply</Button>
</ModalFooter>
</div>
<ModalFooter>
<Button class="btn btn-link" onclick={onClose}>Cancel</Button>
<Button type="submit" class="btn btn-primary">Post Reply</Button>
</ModalFooter>
</form>
+1 -1
View File
@@ -42,7 +42,7 @@
<div class="column gap-2">
<div class="flex justify-between">
<div>
<Profile pubkey={report.pubkey} />
<Profile pubkey={report.pubkey} {url} />
<span>Reported this event as "{reason}"</span>
</div>
{#if report.pubkey === $pubkey}
+16 -14
View File
@@ -2,7 +2,7 @@
import {onMount} from "svelte"
import {Capacitor} from "@capacitor/core"
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 Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
@@ -13,13 +13,18 @@
import {pushModal, clearModals} from "@app/modal"
import {PLATFORM_NAME, BURROW_URL} from "@app/state"
import {pushToast} from "@app/toast"
import {loadUserData} from "@app/commands"
import {loadUserData} from "@app/requests"
import {setChecked} from "@app/notifications"
let signers: any[] = $state([])
let loading: string | undefined = $state()
const disabled = $derived(loading ? true : undefined)
const signUp = () => pushModal(SignUp)
const onSuccess = async (session: Session, relays: string[] = []) => {
await loadUserData(session.pubkey, {relays})
await loadUserData(session.pubkey, relays)
addSession(session)
pushToast({message: "Successfully logged in!"})
@@ -34,7 +39,7 @@
const pubkey = await getNip07()?.getPublicKey()
if (pubkey) {
await onSuccess({method: "nip07", pubkey})
await onSuccess(makeNip07Session(pubkey))
} else {
pushToast({
theme: "error",
@@ -54,7 +59,7 @@
const pubkey = await signer.getPubkey()
if (pubkey) {
await onSuccess({method: "nip55", pubkey, signer: app.packageName})
await onSuccess(makeNip55Session(pubkey, app.packageName))
} else {
pushToast({
theme: "error",
@@ -70,9 +75,6 @@
const loginWithBunker = () => pushModal(LogInBunker)
let signers: any[] = $state([])
let loading: string | undefined = $state()
const hasSigner = $derived(getNip07() || signers.length > 0)
onMount(async () => {
@@ -90,7 +92,7 @@
you to own your social identity.
</p>
{#if getNip07()}
<Button disabled={Boolean(loading)} onclick={loginWithNip07} class="btn btn-primary">
<Button {disabled} onclick={loginWithNip07} class="btn btn-primary">
{#if loading === "nip07"}
<span class="loading loading-spinner mr-3"></span>
{:else}
@@ -100,7 +102,7 @@
</Button>
{/if}
{#each signers as app}
<Button disabled={Boolean(loading)} class="btn btn-primary" onclick={() => loginWithNip55(app)}>
<Button {disabled} class="btn btn-primary" onclick={() => loginWithNip55(app)}>
{#if loading === "nip55"}
<span class="loading loading-spinner mr-3"></span>
{:else}
@@ -110,7 +112,7 @@
</Button>
{/each}
{#if BURROW_URL && !hasSigner}
<Button disabled={Boolean(loading)} onclick={loginWithPassword} class="btn btn-primary">
<Button {disabled} onclick={loginWithPassword} class="btn btn-primary">
{#if loading === "password"}
<span class="loading loading-spinner mr-3"></span>
{:else}
@@ -121,13 +123,13 @@
{/if}
<Button
onclick={loginWithBunker}
disabled={Boolean(loading)}
{disabled}
class="btn {hasSigner || BURROW_URL ? 'btn-neutral' : 'btn-primary'}">
<Icon icon="cpu" />
Log in with Remote Signer
</Button>
{#if BURROW_URL && hasSigner}
<Button disabled={Boolean(loading)} onclick={loginWithPassword} class="btn">
<Button {disabled} onclick={loginWithPassword} class="btn">
{#if loading === "password"}
<span class="loading loading-spinner mr-3"></span>
{:else}
@@ -139,7 +141,7 @@
{#if !hasSigner || !BURROW_URL}
<Link
external
disabled={Boolean(loading)}
{disabled}
href="https://nostrapps.com#signers"
class="btn {hasSigner || BURROW_URL ? '' : 'btn-neutral'}">
<Icon icon="compass" />
+47 -107
View File
@@ -1,37 +1,39 @@
<script lang="ts">
import {onMount, onDestroy} from "svelte"
import {Nip46Broker, getPubkey, makeSecret} from "@welshman/signer"
import {addSession} from "@welshman/app"
import type {Nip46ResponseWithResult} from "@welshman/signer"
import {Nip46Broker, makeSecret} from "@welshman/signer"
import {loginWithNip01, loginWithNip46} from "@welshman/app"
import {preventDefault} from "@lib/html"
import {slideAndFade} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import QRCode from "@app/components/QRCode.svelte"
import InfoBunker from "@app/components/InfoBunker.svelte"
import {loginWithNip46, loadUserData} from "@app/commands"
import {pushModal, clearModals} from "@app/modal"
import BunkerConnect, {BunkerConnectController} from "@app/components/BunkerConnect.svelte"
import BunkerUrl from "@app/components/BunkerUrl.svelte"
import {loadUserData} from "@app/requests"
import {clearModals} from "@app/modal"
import {setChecked} from "@app/notifications"
import {pushToast} from "@app/toast"
import {NIP46_PERMS, PLATFORM_URL, PLATFORM_NAME, PLATFORM_LOGO, SIGNER_RELAYS} from "@app/state"
const clientSecret = makeSecret()
const abortController = new AbortController()
const broker = Nip46Broker.get({clientSecret, relays: SIGNER_RELAYS})
import {SIGNER_RELAYS, NIP46_PERMS} from "@app/state"
const back = () => history.back()
const onSubmit = async () => {
const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(bunker)
const controller = new BunkerConnectController({
onNostrConnect: async (response: Nip46ResponseWithResult) => {
const pubkey = await controller.broker.getPublicKey()
if (loading) {
return
}
await loadUserData(pubkey)
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) {
return pushToast({
@@ -40,13 +42,22 @@
})
}
loading = true
controller.loading = true
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) {
abortController.abort()
// TODO: remove ack result
if (pubkey && ["ack", connectSecret].includes(result)) {
broker.cleanup()
controller.stop()
await loadUserData(pubkey)
loginWithNip46(pubkey, clientSecret, signerPubkey, relays)
} else {
return pushToast({
theme: "error",
@@ -54,72 +65,18 @@
})
}
} finally {
loading = false
controller.loading = false
}
clearModals()
}
let url = $state("")
let bunker = $state("")
let loading = $state(false)
$effect(() => {
// For testing and for play store reviewers
if (bunker === "reviewkey") {
const secret = makeSecret()
addSession({method: "nip01", secret, pubkey: getPubkey(secret)})
if (controller.bunker === "reviewkey") {
loginWithNip01(makeSecret())
}
})
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>
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
@@ -131,35 +88,18 @@
<div>Connect your signer by scanning the QR code below or pasting a bunker link.</div>
{/snippet}
</ModalHeader>
{#if !loading && url}
<div class="flex justify-center" out:slideAndFade>
<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>
<BunkerConnect {controller} />
<BunkerUrl loading={controller.loading} bind:bunker={controller.bunker} />
<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" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading || !bunker}>
<Spinner {loading}>Next</Spinner>
<Button
type="submit"
class="btn btn-primary"
disabled={controller.loading || !controller.bunker}>
<Spinner loading={controller.loading}>Next</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
+9 -14
View File
@@ -3,7 +3,7 @@
import {postJson, stripProtocol} from "@welshman/lib"
import {Nip46Broker, makeSecret} from "@welshman/signer"
import {normalizeRelayUrl} from "@welshman/util"
import {addSession} from "@welshman/app"
import {addSession, makeNip46Session} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
@@ -12,7 +12,7 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import PasswordResetRequest from "@app/components/PasswordResetRequest.svelte"
import {loadUserData} from "@app/commands"
import {loadUserData} from "@app/requests"
import {clearModals, pushModal} from "@app/modal"
import {setChecked} from "@app/notifications"
import {pushToast} from "@app/toast"
@@ -34,7 +34,7 @@
? [normalizeRelayUrl("ws://" + stripProtocol(BURROW_URL))]
: [normalizeRelayUrl(BURROW_URL)]
const broker = Nip46Broker.get({clientSecret, relays})
const broker = new Nip46Broker({clientSecret, relays})
const back = () => history.back()
@@ -68,7 +68,7 @@
let response
try {
response = await broker.waitForNostrconnect(url, abortController)
response = await broker.waitForNostrconnect(url, abortController.signal)
} catch (errorResponse: any) {
if (errorResponse?.error) {
pushToast({
@@ -83,18 +83,13 @@
if (response) {
loading = true
const userPubkey = await broker.getPublicKey()
const pubkey = await broker.getPublicKey()
const session = makeNip46Session(pubkey, clientSecret, response.event.pubkey, relays)
await loadUserData(userPubkey)
addSession({
email,
method: "nip46",
pubkey: userPubkey,
secret: clientSecret,
handler: {pubkey: response.event.pubkey, relays},
})
await loadUserData(pubkey)
addSession({...session, email})
broker.cleanup()
setChecked("*")
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>
+16 -12
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import {onMount} from "svelte"
import {displayRelayUrl, GROUP_META} from "@welshman/util"
import {displayRelayUrl} from "@welshman/util"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -22,7 +22,6 @@
deriveOtherRooms,
} from "@app/state"
import {notifications} from "@app/notifications"
import {pullConservatively} from "@app/requests"
import {pushModal} from "@app/modal"
import {makeSpacePath} from "@app/routes"
@@ -44,7 +43,7 @@
const showMembers = () =>
pushModal(
ProfileList,
{pubkeys: members, title: `Members of`, subtitle: displayRelayUrl(url)},
{url, pubkeys: members, title: `Members of`, subtitle: displayRelayUrl(url)},
{replaceState},
)
@@ -57,16 +56,15 @@
const addRoom = () => pushModal(RoomCreate, {url}, {replaceState})
let showMenu = $state(false)
let replaceState = false
let replaceState = $state(false)
let element: Element | undefined = $state()
const members = $derived(
$memberships.filter(l => hasMembershipUrl(l, url)).map(l => l.event.pubkey),
)
onMount(async () => {
onMount(() => {
replaceState = Boolean(element?.closest(".drawer"))
pullConservatively({relays: [url], filters: [{kinds: [GROUP_META]}]})
})
</script>
@@ -112,19 +110,25 @@
{/if}
</div>
<div class="flex min-h-0 flex-col gap-1 overflow-auto">
<SecondaryNavItem href={makeSpacePath(url)}>
<SecondaryNavItem {replaceState} href={makeSpacePath(url)}>
<Icon icon="home-smile" /> Home
</SecondaryNavItem>
<SecondaryNavItem href={threadsPath} notification={$notifications.has(threadsPath)}>
<SecondaryNavItem
{replaceState}
href={threadsPath}
notification={$notifications.has(threadsPath)}>
<Icon icon="notes-minimalistic" /> Threads
</SecondaryNavItem>
<SecondaryNavItem href={calendarPath} notification={$notifications.has(calendarPath)}>
<SecondaryNavItem
{replaceState}
href={calendarPath}
notification={$notifications.has(calendarPath)}>
<Icon icon="calendar-minimalistic" /> Calendar
</SecondaryNavItem>
<div class="h-2"></div>
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{#each $userRooms as room, i (room)}
<MenuSpaceRoomItem notify {url} {room} />
<MenuSpaceRoomItem {replaceState} notify {url} {room} />
{/each}
{#if $otherRooms.length > 0}
<div class="h-2"></div>
@@ -137,9 +141,9 @@
</SecondaryNavHeader>
{/if}
{#each $otherRooms as room, i (room)}
<MenuSpaceRoomItem {url} {room} />
<MenuSpaceRoomItem {replaceState} {url} {room} />
{/each}
<SecondaryNavItem onclick={addRoom}>
<SecondaryNavItem {replaceState} onclick={addRoom}>
<Icon icon="add-circle" />
Create room
</SecondaryNavItem>
+6 -2
View File
@@ -10,15 +10,19 @@
url: any
room: any
notify?: boolean
replaceState?: boolean
}
const {url, room, notify = false}: Props = $props()
const {url, room, notify = false, replaceState = false}: Props = $props()
const path = makeRoomPath(url, room)
const channel = deriveChannel(url, room)
</script>
<SecondaryNavItem href={path} notification={notify ? $notifications.has(path) : false}>
<SecondaryNavItem
href={path}
{replaceState}
notification={notify ? $notifications.has(path) : false}>
{#if channelIsLocked($channel)}
<Icon icon="lock" size={4} />
{:else}
+3 -10
View File
@@ -10,26 +10,19 @@
}
}
let modal: any = $state()
const hash = $derived($page.url.hash.slice(1))
const hashIsValid = $derived(Boolean($modals[hash]))
$effect(() => {
if ($modals[hash]) {
modal = $modals[hash]
}
})
const modal = $derived($modals[hash])
</script>
<svelte:window onkeydown={onKeyDown} />
{#if hashIsValid && modal?.options?.drawer}
{#if modal?.options?.drawer}
<Drawer onClose={clearModals} {...modal.options}>
{#key modal.id}
<modal.component {...modal.props} />
{/key}
</Drawer>
{:else if hashIsValid && modal}
{:else if modal}
<Dialog onClose={clearModals} {...modal.options}>
{#key modal.id}
<modal.component {...modal.props} />
+9 -6
View File
@@ -1,11 +1,12 @@
<script lang="ts">
import cx from "classnames"
import type {Snippet} from "svelte"
import {nip19} from "nostr-tools"
import {ctx} from "@welshman/lib"
import * as nip19 from "nostr-tools/nip19"
import {formatTimestamp} from "@welshman/lib"
import {getListTags, getPubkeyTagValues} 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 Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -18,16 +19,18 @@
children,
minimal = false,
hideProfile = false,
url,
...restProps
}: {
event: TrustedEvent
children: Snippet
minimal?: boolean
hideProfile?: boolean
url?: string
class?: string
} = $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 ignoreMute = () => {
@@ -50,9 +53,9 @@
<div class="flex justify-between gap-2">
{#if !hideProfile}
{#if minimal}
@<ProfileName pubkey={event.pubkey} />
@<ProfileName pubkey={event.pubkey} {url} />
{:else}
<Profile pubkey={event.pubkey} />
<Profile pubkey={event.pubkey} {url} />
{/if}
{/if}
<Link
+24
View File
@@ -0,0 +1,24 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {EVENT_TIME} from "@welshman/util"
import Content from "@app/components/Content.svelte"
import CalendarEventDate from "@app/components/CalendarEventDate.svelte"
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
const props: ComponentProps<typeof Content> = $props()
</script>
{#if props.event.kind === EVENT_TIME}
<div class="flex items-start gap-4">
<CalendarEventDate event={props.event} />
<div class="flex flex-grow flex-col">
<CalendarEventHeader event={props.event} />
<div class="flex py-2 opacity-50">
<div class="h-px flex-grow bg-base-content opacity-25"></div>
</div>
<Content {...props} />
</div>
</div>
{:else}
<Content {...props} />
{/if}
+3 -3
View File
@@ -4,7 +4,7 @@
import {pubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte"
import Content from "@app/components/Content.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import {publishDelete, publishReaction} from "@app/commands"
@@ -25,8 +25,8 @@
publishReaction({event, content: emoji.unicode, relays: [url]})
</script>
<NoteCard {event} class="card2 bg-alt">
<Content {event} expandMode="inline" />
<NoteCard {event} {url} class="card2 bg-alt">
<NoteContent {event} expandMode="inline" />
<div class="flex w-full justify-between gap-2">
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-right">
<EmojiButton {onEmoji} class="btn btn-neutral btn-xs h-[26px] rounded-box">
+13 -6
View File
@@ -1,16 +1,23 @@
<script lang="ts">
import {onMount} from "svelte"
import {ctx} from "@welshman/lib"
import {formatTimestampRelative} from "@welshman/lib"
import type {Filter} from "@welshman/util"
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 Link from "@lib/components/Link.svelte"
import Profile from "@app/components/Profile.svelte"
import ProfileInfo from "@app/components/ProfileInfo.svelte"
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 events = deriveEvents(repository, {filters})
@@ -22,20 +29,20 @@
// Load at least one note, regardless of time frame
load({
filters: [{authors: [pubkey], limit: 1}],
relays: ctx.app.router.FromPubkeys([pubkey]).getUrls(),
relays: Router.get().FromPubkeys([pubkey]).getUrls(),
})
})
</script>
<div class="card2 bg-alt col-2 shadow-xl">
<div class="flex justify-between">
<Profile {pubkey} />
<Profile {pubkey} {url} />
<Link class="btn btn-primary hidden sm:flex" href={makeChatPath([pubkey])}>
<Icon icon="letter" />
Start a Chat
</Link>
</div>
<ProfileInfo {pubkey} />
<ProfileInfo {pubkey} {url} />
{#if $events.length > 0}
<div class="bg-alt badge badge-neutral border-none">
Last active {formatTimestampRelative($events[0].created_at)}
+34 -8
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import {splitAt} from "@welshman/lib"
import {userProfile} from "@welshman/app"
import Avatar from "@lib/components/Avatar.svelte"
import Divider from "@lib/components/Divider.svelte"
@@ -8,6 +9,7 @@
import SpaceAdd from "@app/components/SpaceAdd.svelte"
import ChatEnable from "@app/components/ChatEnable.svelte"
import MenuSpaces from "@app/components/MenuSpaces.svelte"
import MenuOtherSpaces from "@app/components/MenuOtherSpaces.svelte"
import MenuSettings from "@app/components/MenuSettings.svelte"
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
import {userRoomsByUrl, canDecrypt, PLATFORM_RELAY, PLATFORM_LOGO} from "@app/state"
@@ -22,20 +24,35 @@
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 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 spacePaths = $derived(spaceUrls.map(url => makeSpacePath(url)))
const anySpaceNotifications = $derived(
spacePaths.some(path => !$page.url.pathname.startsWith(path) && $notifications.has(path)),
)
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, spaceUrls))
const anySpaceNotifications = $derived(spaceUrls.some(hasNotification))
const otherSpaceNotifications = $derived(secondarySpaceUrls.some(hasNotification))
</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>
{#if PLATFORM_RELAY}
@@ -45,9 +62,18 @@
<Avatar src={PLATFORM_LOGO} class="!h-10 !w-10" />
</PrimaryNavItem>
<Divider />
{#each spaceUrls as url (url)}
{#each primarySpaceUrls as url (url)}
<PrimaryNavItemSpace {url} />
{/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">
<Avatar icon="settings-minimalistic" class="!h-10 !w-10" />
</PrimaryNavItem>
@@ -78,7 +104,7 @@
{@render children?.()}
<!-- 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
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">
+12 -5
View File
@@ -1,12 +1,13 @@
<script lang="ts">
import {removeNil} from "@welshman/lib"
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
import {
session,
userFollows,
deriveUserWotScore,
deriveProfile,
deriveHandleForPubkey,
displayHandle,
deriveProfile,
deriveProfileDisplay,
} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
@@ -15,14 +16,20 @@
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal"
const {pubkey} = $props()
type Props = {
pubkey: string
url?: string
}
const profile = deriveProfile(pubkey)
const profileDisplay = deriveProfileDisplay(pubkey)
const {pubkey, url}: Props = $props()
const relays = removeNil([url])
const profile = deriveProfile(pubkey, relays)
const profileDisplay = deriveProfileDisplay(pubkey, relays)
const handle = deriveHandleForPubkey(pubkey)
const score = deriveUserWotScore(pubkey)
const openProfile = () => pushModal(ProfileDetail, {pubkey})
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
const following = $derived(
pubkey === $session!.pubkey || getPubkeyTagValues(getListTags($userFollows)).includes(pubkey),
+9 -3
View File
@@ -1,10 +1,16 @@
<script lang="ts">
import {deriveProfile} from "@welshman/app"
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>
<Avatar src={$profile?.picture} icon="user-circle" {...props} />
+147
View File
@@ -0,0 +1,147 @@
<script lang="ts">
import {chunk, sleep, uniq} from "@welshman/lib"
import {
createEvent,
createProfile,
PROFILE,
DELETE,
isReplaceable,
getAddress,
getRelaysFromList,
} from "@welshman/util"
import {pubkey, userRelaySelections, publishThunk, repository} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {pushToast} from "@app/toast"
import {logout} from "@app/commands"
import {INDEXER_RELAYS, PLATFORM_NAME, userMembership, getMembershipUrls} from "@app/state"
let progress: number | undefined = $state(undefined)
let confirmText = $state("")
const CONFIRM_TEXT = "permanently delete my nostr account"
const confirmOk = $derived(confirmText.toLowerCase().trim() === CONFIRM_TEXT)
const showProgress = $derived(progress !== undefined)
const deleteProfile = async () => {
if (!confirmOk) {
return pushToast({
theme: "error",
message: "Please type your confirmation into the text box.",
})
}
const chunks = chunk(500, repository.query([{authors: [$pubkey!]}]))
const profileEvent = createEvent(PROFILE, createProfile({name: "[deleted]"}))
const vanishEvent = createEvent(62, {tags: [["relay", "ALL_RELAYS"]]})
const denominator = chunks.length + 2
const relays = uniq([
...INDEXER_RELAYS,
...getRelaysFromList($userRelaySelections),
...getMembershipUrls($userMembership),
])
let step = 0
const incrementProgress = async () => {
progress = ++step / denominator
return sleep(800)
}
// First, blank out their profile in case relays don't support deletion by address
await publishThunk({relays, event: profileEvent})
await incrementProgress()
// Next, send a "right to vanish" event to all relays
await publishThunk({relays, event: vanishEvent})
await incrementProgress()
// Finally, send deletion requests for all known events in case relays don't support right to vanish
for (const events of chunks) {
const tags: string[][] = []
for (const event of events) {
tags.push(["e", event.id])
if (isReplaceable(event)) {
tags.push(["a", getAddress(event)])
}
}
await publishThunk({relays, event: createEvent(DELETE, {tags})})
await incrementProgress()
}
// Let them see that progress is complete
await sleep(2000)
// Goodbye forever!
await logout()
window.location.href = "/"
}
const confirm = async () => {
progress = 0
try {
await deleteProfile()
} catch (e) {
progress = undefined
throw e
}
}
const back = () => history.back()
</script>
<form class="column gap-4" onsubmit={preventDefault(confirm)}>
<ModalHeader>
{#snippet title()}
Delete your account
{/snippet}
{#snippet info()}
From the Nostr network
{/snippet}
</ModalHeader>
{#if showProgress}
<p>
We are currently sending deletion requests to your relay selections and space hosts. Please
wait while we complete this process. Once we're done, you'll be automatically logged out.
</p>
<progress class="progress progress-primary w-full" value={progress! * 100} max="100"></progress>
{:else}
<p>
This will delete your nostr account everywhere, not just on {PLATFORM_NAME}.
</p>
<p>
To confirm, please type "{CONFIRM_TEXT}" into the text box below. This action can't be undone.
</p>
<label class="input input-bordered flex w-full items-center gap-2">
<input bind:value={confirmText} class="grow" type="text" />
</label>
<p>
<strong>Note:</strong> not all relays may honor your request for deletion. If you find that your
content continues to be available, please contact the offending relays directly.
</p>
{/if}
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-error" disabled={showProgress || !confirmOk}>
<Spinner loading={progress !== undefined}>Confirm</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
</form>
+14 -7
View File
@@ -1,13 +1,14 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {removeNil} from "@welshman/lib"
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
import {
session,
userFollows,
deriveUserWotScore,
deriveProfile,
deriveHandleForPubkey,
displayHandle,
deriveProfile,
deriveProfileDisplay,
} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
@@ -22,10 +23,16 @@
import {pushModal} from "@app/modal"
import {makeChatPath} from "@app/routes"
const {pubkey} = $props()
export type Props = {
pubkey: string
url?: string
}
const profile = deriveProfile(pubkey)
const profileDisplay = deriveProfileDisplay(pubkey)
const {pubkey, url}: Props = $props()
const relays = removeNil([url])
const profile = deriveProfile(pubkey, relays)
const display = deriveProfileDisplay(pubkey, relays)
const handle = deriveHandleForPubkey(pubkey)
const score = deriveUserWotScore(pubkey)
@@ -48,7 +55,7 @@
<div class="flex min-w-0 flex-col">
<div class="flex items-center gap-2">
<span class="text-bold overflow-hidden text-ellipsis">
{$profileDisplay}
{$display}
</span>
<WotScore score={$score} active={following} />
</div>
@@ -57,9 +64,9 @@
</div>
</div>
</div>
<ProfileInfo {pubkey} />
<ProfileInfo {pubkey} {url} />
<ModalFooter>
<Button onclick={back} class="btn btn-link">
<Button onclick={back} class="hidden md:btn md:btn-link">
<Icon icon="alt-arrow-left" />
Go back
</Button>
+28 -60
View File
@@ -1,83 +1,51 @@
<script lang="ts">
import {ctx} from "@welshman/lib"
import type {Profile} from "@welshman/util"
import {
getTag,
createEvent,
makeProfile,
editProfile,
createProfile,
isPublishedProfile,
uniqTags,
} from "@welshman/util"
import {Router} from "@welshman/router"
import {pubkey, profilesByPubkey, publishThunk} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import InputProfilePicture from "@lib/components/InputProfilePicture.svelte"
import InfoHandle from "@app/components/InfoHandle.svelte"
import {pushModal, clearModals} from "@app/modal"
import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
import {clearModals} from "@app/modal"
import {pushToast} from "@app/toast"
import {PROTECTED, getMembershipUrls, userMembership} from "@app/state"
const values = $state({...($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 saveEdit = () => {
const relays = ctx.app.router.FromUser().getUrls()
const template = isPublishedProfile(values)
? editProfile($state.snapshot(values))
: createProfile($state.snapshot(values))
const onsubmit = ({profile, shouldBroadcast}: {profile: Profile; shouldBroadcast: boolean}) => {
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)
publishThunk({event, relays})
pushToast({message: "Your profile has been updated!"})
clearModals()
}
let file: File | undefined = $state()
</script>
<form class="col-4" onsubmit={preventDefault(saveEdit)}>
<div class="flex justify-center py-2">
<InputProfilePicture bind:file bind:url={values.picture} />
</div>
<Field>
{#snippet label()}
<p>Username</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="user-circle" />
<input bind:value={values.name} class="grow" type="text" />
</label>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>About You</p>
{/snippet}
{#snippet input()}
<textarea class="textarea textarea-bordered leading-4" rows="3" bind:value={values.about}>
</textarea>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Nostr Address</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="map-point" />
<input bind:value={values.nip05} class="grow" type="text" />
</label>
{/snippet}
{#snippet info()}
<p>
<Button class="link" onclick={() => pushModal(InfoHandle)}>What is a nostr address?</Button>
</p>
{/snippet}
</Field>
<div class="mt-4 flex flex-row items-center justify-between gap-4">
<Button class="btn btn-neutral" onclick={back}>Discard Changes</Button>
<Button type="submit" class="btn btn-primary">Save Changes</Button>
</div>
</form>
<ProfileEditForm {initialValues} {onsubmit}>
{#snippet footer()}
<div class="mt-4 flex flex-row items-center justify-between gap-4">
<Button class="btn btn-neutral" onclick={back}>Discard Changes</Button>
<Button type="submit" class="btn btn-primary">Save Changes</Button>
</div>
{/snippet}
</ProfileEditForm>
+100
View File
@@ -0,0 +1,100 @@
<script lang="ts">
import type {Snippet} from "svelte"
import type {Profile} from "@welshman/util"
import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte"
import InputProfilePicture from "@lib/components/InputProfilePicture.svelte"
import InfoHandle from "@app/components/InfoHandle.svelte"
import {pushModal} from "@app/modal"
type Values = {
profile: Profile
shouldBroadcast: boolean
}
type Props = {
initialValues: Values
onsubmit: (values: Values) => void
hideAddress?: boolean
footer: Snippet
}
const {initialValues, hideAddress, onsubmit, footer}: Props = $props()
const values = $state(initialValues)
const submit = () => onsubmit($state.snapshot(values))
let file: File | undefined = $state()
</script>
<form class="col-4" onsubmit={preventDefault(submit)}>
<div class="flex justify-center py-2">
<InputProfilePicture bind:file bind:url={values.profile.picture} />
</div>
<Field>
{#snippet label()}
<p>Username</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="user-circle" />
<input bind:value={values.profile.name} class="grow" type="text" />
</label>
{/snippet}
{#snippet info()}
What would you like people to call you?
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>About You</p>
{/snippet}
{#snippet input()}
<textarea
class="textarea textarea-bordered leading-4"
rows="5"
bind:value={values.profile.about}></textarea>
{/snippet}
{#snippet info()}
Give a brief introduction to why you're here.
{/snippet}
</Field>
{#if !hideAddress}
<Field>
{#snippet label()}
<p>Nostr Address</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="map-point" />
<input bind:value={values.profile.nip05} class="grow" type="text" />
</label>
{/snippet}
{#snippet info()}
<p>
<Button class="link" onclick={() => pushModal(InfoHandle)}
>What is a nostr address?</Button>
</p>
{/snippet}
</Field>
{/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()}
</form>
+4 -3
View File
@@ -4,7 +4,7 @@
import {feedFromFilter, makeIntersectionFeed, makeRelayFeed} from "@welshman/feeds"
import {NOTE, getReplyTags} 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 {fly} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte"
@@ -19,7 +19,7 @@
let {url, pubkey, events = $bindable([]), hideLoading = false}: Props = $props()
const ctrl = createFeedController({
const ctrl = makeFeedController({
useWindowing: true,
feed: makeIntersectionFeed(
makeRelayFeed(url),
@@ -45,7 +45,8 @@
e => e.id,
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) {
ctrl.load(50)
+9 -3
View File
@@ -1,12 +1,18 @@
<script lang="ts">
import {removeNil} from "@welshman/lib"
import {deriveProfile} from "@welshman/app"
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>
{#if $profile}
<Content event={{content: $profile.about, tags: []}} hideMedia />
<Content event={{content: $profile.about, tags: []}} hideMediaAtDepth={0} />
{/if}
+8 -3
View File
@@ -5,11 +5,16 @@
import ProfileDetail from "@app/components/ProfileDetail.svelte"
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>
<Button onclick={preventDefault(openProfile)} class="link-content">
@<ProfileName {pubkey} />
@<ProfileName {pubkey} {url} />
</Button>
+4 -3
View File
@@ -5,11 +5,12 @@
interface Props {
title: any
subtitle?: string
pubkeys: any
subtitle?: string
url?: string
}
const {subtitle = "", pubkeys, ...restProps}: Props = $props()
const {subtitle = "", pubkeys, url, ...restProps}: Props = $props()
</script>
<div class="column gap-4">
@@ -23,7 +24,7 @@
</ModalHeader>
{#each pubkeys as pubkey (pubkey)}
<div class="card2 bg-alt">
<Profile {pubkey} />
<Profile {pubkey} {url} />
</div>
{/each}
<Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button>
+8 -2
View File
@@ -1,9 +1,15 @@
<script lang="ts">
import {removeNil} from "@welshman/lib"
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>
{$profileDisplay}
+2 -2
View File
@@ -4,7 +4,7 @@
import Button from "@lib/components/Button.svelte"
import {clip} from "@app/toast"
const {code} = $props()
const {code, ...props} = $props()
let canvas: Element | undefined = $state()
let wrapper: Element | undefined = $state()
@@ -26,7 +26,7 @@
})
</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`}>
<canvas
class="rounded-box"
+13 -6
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import {onMount} from "svelte"
import type {Snippet} from "svelte"
import {groupBy, uniq, uniqBy, batch} from "@welshman/lib"
import {REACTION, getTag, REPORT, DELETE} from "@welshman/util"
import {groupBy, uniq, uniqBy, batch, displayList} from "@welshman/lib"
import {REACTION, getReplyFilters, getTag, REPORT, DELETE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {deriveEvents} from "@welshman/store"
import {pubkey, repository, load, displayProfileByPubkey} from "@welshman/app"
import {displayList} from "@lib/util"
import {load} from "@welshman/net"
import {pubkey, repository, displayProfileByPubkey} from "@welshman/app"
import {isMobile, preventDefault, stopPropagation} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import EventReportDetails from "@app/components/EventReportDetails.svelte"
@@ -51,18 +51,25 @@
)
onMount(() => {
const controller = new AbortController()
if (url) {
load({
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[]) => {
load({
relays: [url],
filters: [{kinds: [DELETE], "#e": events.map(e => e.id)}],
filters: getReplyFilters(events, {kinds: [DELETE]}),
})
}),
})
}
return () => {
controller.abort()
}
})
</script>
+1 -1
View File
@@ -29,7 +29,7 @@
>{displayUrl($relay.profile.contact)}</Link>
&bull;
{/if}
{#if $relay?.profile?.supported_nips}
{#if Array.isArray($relay?.profile?.supported_nips)}
<span
class="tooltip cursor-pointer underline"
data-tip="NIPs supported: {$relay.profile.supported_nips.join(', ')}">
+27 -11
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import {Capacitor} from "@capacitor/core"
import {postJson} from "@welshman/lib"
import {isMobile, preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
@@ -8,16 +9,22 @@
import Spinner from "@lib/components/Spinner.svelte"
import LogIn from "@app/components/LogIn.svelte"
import InfoNostr from "@app/components/InfoNostr.svelte"
import SignUpKey from "@app/components/SignUpKey.svelte"
import SignUpSuccess from "@app/components/SignUpSuccess.svelte"
import {pushModal} from "@app/modal"
import {BURROW_URL, PLATFORM_NAME} from "@app/state"
import {BURROW_URL, PLATFORM_NAME, PLATFORM_ACCENT} from "@app/state"
import {pushToast} from "@app/toast"
const ac = window.location.origin
const params = new URLSearchParams({
an: PLATFORM_NAME,
ac: window.location.origin,
at: isMobile ? "android" : "web",
aa: PLATFORM_ACCENT.slice(1),
am: "dark",
asf: "yes",
})
const at = isMobile ? "android" : "web"
const nstart = `https://start.njump.me/?an=Flotilla&at=${at}&ac=${ac}`
const nstart = `https://start.njump.me/?${params.toString()}`
const login = () => pushModal(LogIn)
@@ -37,18 +44,20 @@
}
}
const signup = () => {
const usePassword = () => {
if (BURROW_URL) {
signupPassword()
}
}
const useKey = () => pushModal(SignUpKey)
let email = $state("")
let password = $state("")
let loading = $state(false)
</script>
<form class="column gap-4" onsubmit={preventDefault(signup)}>
<form class="column gap-4" onsubmit={preventDefault(usePassword)}>
<h1 class="heading">Sign up with Nostr</h1>
<p class="m-auto max-w-sm text-center">
{PLATFORM_NAME} is built using the
@@ -89,10 +98,17 @@
</p>
<Divider>Or</Divider>
{/if}
<a href={nstart} class="btn {email || password ? 'btn-neutral' : 'btn-primary'}">
<Icon icon="square-share-line" />
Get going on nstart
</a>
{#if Capacitor.isNativePlatform()}
<Button onclick={useKey} class="btn {email || password ? 'btn-neutral' : 'btn-primary'}">
<Icon icon="key" />
Generate a key
</Button>
{:else}
<a href={nstart} class="btn {email || password ? 'btn-neutral' : 'btn-primary'}">
<Icon icon="square-share-line" />
Create an account on Nstart
</a>
{/if}
<div class="text-sm">
Already have an account?
<Button class="link" onclick={login}>Log in instead</Button>
+82
View File
@@ -0,0 +1,82 @@
<script lang="ts">
import {encrypt} from "nostr-tools/nip49"
import {hexToBytes} from "@noble/hashes/utils"
import {makeSecret} from "@welshman/signer"
import {preventDefault, downloadText} from "@lib/html"
import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import SignUpKeyConfirm from "@app/components/SignUpKeyConfirm.svelte"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
const secret = makeSecret()
const back = () => history.back()
const next = () => {
if (password.length < 12) {
return pushToast({
theme: "error",
message: "Passwords must be at least 12 characters long.",
})
}
const ncryptsec = encrypt(hexToBytes(secret), password)
downloadText("Nostr Secret Key.txt", ncryptsec)
pushModal(SignUpKeyConfirm, {secret, ncryptsec})
}
let password = ""
</script>
<form class="column gap-4" onsubmit={preventDefault(next)}>
<ModalHeader>
{#snippet title()}
<div>Welcome to Nostr!</div>
{/snippet}
</ModalHeader>
<p>
<Link external href="https://nostr.com/" class="link">Nostr</Link> is way to build social apps that
talk to each other. Users own their social identity instead of renting it from a tech company, and
can take it with them.
</p>
<p>
This means that instead of using a password to log in, you generate a <strong
>secret key</strong>
which gives you full control over your account.
</p>
<p>
Keeping this key safe is very important, so we encourage you to download an encrypted copy. To
do this, go ahead and fill in the password you'd like to use to secure your key below.
</p>
<Field>
{#snippet label()}
Password*
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="key" />
<input bind:value={password} class="grow" type="password" />
</label>
{/snippet}
{#snippet info()}
<p>Passwords should be at least 12 characters long. Write this down!</p>
{/snippet}
</Field>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button class="btn btn-primary" type="submit">
Download my key
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
</form>
@@ -0,0 +1,65 @@
<script lang="ts">
import {preventDefault, copyToClipboard} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import SignUpProfile from "@app/components/SignUpProfile.svelte"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
type Props = {
secret: string
ncryptsec: string
}
const {secret, ncryptsec}: Props = $props()
const back = () => history.back()
const copy = () => {
copyToClipboard(ncryptsec)
pushToast({message: "Your secret key has been copied to your clipboard!"})
}
const next = () => {
pushModal(SignUpProfile, {secret})
}
</script>
<form class="column gap-4" onsubmit={preventDefault(next)}>
<ModalHeader>
{#snippet title()}
<div>Download your key</div>
{/snippet}
</ModalHeader>
<p>
Great! We've encrypted your secret key and saved it to your device. If that didn't work, or if
you'd rather save your key somewhere else, you can find the encrypted version below:
</p>
<Field>
{#snippet label()}
Encrypted Secret Key
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="key" />
<input value={ncryptsec} class="ellipsize grow" />
<Button onclick={copy} class="flex items-center">
<Icon icon="copy" />
</Button>
</label>
{/snippet}
</Field>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button class="btn btn-primary" type="submit">
Fill out your profile
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
</form>
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts">
import type {Profile} from "@welshman/util"
import {PROFILE, createProfile, makeProfile, createEvent} from "@welshman/util"
import {loginWithNip01, publishThunk} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
import {INDEXER_RELAYS} from "@app/state"
type Props = {
secret: string
}
const {secret}: Props = $props()
const initialValues = {
profile: makeProfile(),
shouldBroadcast: true,
}
const onsubmit = ({profile, shouldBroadcast}: {profile: Profile; shouldBroadcast: boolean}) => {
const event = createEvent(PROFILE, createProfile(profile))
const relays = shouldBroadcast ? INDEXER_RELAYS : []
loginWithNip01(secret)
publishThunk({event, relays})
}
</script>
<ProfileEditForm hideAddress {initialValues} {onsubmit}>
{#snippet footer()}
<Button type="submit" class="btn btn-primary">Create Account</Button>
{/snippet}
</ProfileEditForm>
+9 -24
View File
@@ -1,10 +1,11 @@
<script lang="ts">
import {displayRelayUrl} from "@welshman/util"
import {parse, renderAsHtml} from "@welshman/content"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte"
import {preventDefault} from "@lib/html"
import {ucFirst} from "@lib/util"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {pushToast} from "@app/toast"
@@ -15,8 +16,8 @@
const back = () => history.back()
const joinRelay = async (claim: string) => {
const error = await attemptRelayAccess(url, claim)
const joinRelay = async () => {
const error = await attemptRelayAccess(url)
if (error) {
return pushToast({theme: "error", message: error})
@@ -33,13 +34,12 @@
loading = true
try {
await joinRelay(claim)
await joinRelay()
} finally {
loading = false
}
}
let claim = $state("")
let loading = $state(false)
</script>
@@ -53,32 +53,17 @@
{/snippet}
</ModalHeader>
<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 class="border-l border-solid border-error pl-4 text-error">
{error}
<p class="bg-alt card2 welshman-content">
{@html renderAsHtml(parse({content: ucFirst(error)}))}
</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>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</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>
<Icon icon="alt-arrow-right" />
</Button>
+6 -15
View File
@@ -1,36 +1,27 @@
<script lang="ts">
import {onMount} from "svelte"
import {goto} from "$app/navigation"
import {ctx, sleep} from "@welshman/lib"
import {sleep} from "@welshman/lib"
import {Pool, AuthStatus} from "@welshman/net"
import {displayRelayUrl} from "@welshman/util"
import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import SpaceVisitConfirm, {confirmSpaceVisit} from "@app/components/SpaceVisitConfirm.svelte"
import {attemptRelayAccess} from "@app/commands"
import {makeSpacePath} from "@app/routes"
import {pushModal} from "@app/modal"
const {url} = $props()
const path = makeSpacePath(url)
const back = () => history.back()
const confirm = () => goto(path, {replaceState: true})
const next = () => {
if (!error && ctx.net.pool.get(url).stats.lastAuth === 0) {
pushModal(Confirm, {
confirm,
message: `This space does not appear to limit who can post to it. This can result
in a large amount of spam or other objectionable content. Continue?`,
})
if (!error && Pool.get().get(url).auth.status === AuthStatus.None) {
pushModal(SpaceVisitConfirm, {url}, {replaceState: true})
} else {
confirm()
confirmSpaceVisit(url)
}
}
+6 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import {onMount} from "svelte"
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 {slide} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte"
@@ -29,7 +29,11 @@
onMount(async () => {
const [[event]] = await Promise.all([
load({filters: [{kinds: [AUTH_INVITE]}], relays: [url]}),
request({
relays: [url],
autoClose: true,
filters: [{kinds: [AUTH_INVITE]}],
}),
sleep(2000),
])
+9 -24
View File
@@ -1,52 +1,37 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {ctx, tryCatch} from "@welshman/lib"
import {tryCatch} from "@welshman/lib"
import {isRelayUrl, normalizeRelayUrl} from "@welshman/util"
import {Pool, AuthStatus} from "@welshman/net"
import {preventDefault} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import InfoRelay from "@app/components/InfoRelay.svelte"
import SpaceJoinConfirm, {confirmSpaceJoin} from "@app/components/SpaceJoinConfirm.svelte"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
import {addSpaceMembership, attemptRelayAccess} from "@app/commands"
import {makeSpacePath} from "@app/routes"
import {attemptRelayAccess} from "@app/commands"
const back = () => history.back()
const confirm = async (url: string) => {
await addSpaceMembership(url)
goto(makeSpacePath(url), {replaceState: true})
pushToast({
message: "Welcome to the space!",
})
}
const joinRelay = async (invite: string) => {
const [raw, claim] = invite.split("|")
const url = normalizeRelayUrl(raw)
const error = await attemptRelayAccess(url, claim)
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) {
pushModal(Confirm, {
confirm: () => confirm(url),
message: `This space does not appear to limit who can post to it. This can result
in a large amount of spam or other objectionable content. Continue?`,
})
if (socket.auth.status === AuthStatus.None) {
pushModal(SpaceJoinConfirm, {url}, {replaceState: true})
} else {
await confirm(url)
await confirmSpaceJoin(url)
}
}
@@ -0,0 +1,32 @@
<script module lang="ts">
import {goto} from "$app/navigation"
import {makeSpacePath} from "@app/routes"
import {addSpaceMembership} from "@app/commands"
import {pushToast} from "@app/toast"
export const confirmSpaceJoin = async (url: string) => {
await addSpaceMembership(url)
goto(makeSpacePath(url), {replaceState: true})
pushToast({
message: "Welcome to the space!",
})
}
</script>
<script lang="ts">
import Confirm from "@lib/components/Confirm.svelte"
type Props = {
url: string
}
const {url}: Props = $props()
const confirm = () => confirmSpaceJoin(url)
</script>
<Confirm
{confirm}
message="This space does not appear to limit who can post to it. This can result in a large amount of spam or other objectionable content. Continue?" />
@@ -0,0 +1,24 @@
<script module lang="ts">
import {goto} from "$app/navigation"
import {makeSpacePath} from "@app/routes"
export const confirmSpaceVisit = (url: string) => {
goto(makeSpacePath(url), {replaceState: true})
}
</script>
<script lang="ts">
import Confirm from "@lib/components/Confirm.svelte"
type Props = {
url: string
}
const {url}: Props = $props()
const confirm = () => confirmSpaceVisit(url)
</script>
<Confirm
{confirm}
message="This space does not appear to limit who can post to it. This can result in a large amount of spam or other objectionable content. Continue?" />

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