Compare commits

...

134 Commits

Author SHA1 Message Date
Jon Staab cee6c3c164 Bump versions 2025-01-28 19:22:57 -08:00
Jon Staab 06d0ae2798 Trim tiptap css 2025-01-28 16:23:19 -08:00
Jon Staab b129ef4242 Add build hash 2025-01-28 14:51:33 -08:00
Jon Staab 48a45f3a3a Add media server settings 2025-01-28 14:44:43 -08:00
Jon Staab ce1fb396e3 Add button to scroll to new messages in channel 2025-01-28 14:19:46 -08:00
Jon Staab e95c57bcb7 Replace nsec.app signup with njump.me 2025-01-28 13:04:37 -08:00
Jon Staab 414f5a5ace Update changelog 2025-01-28 12:33:35 -08:00
Jon Staab a331d24bb1 Bump welshman 2025-01-28 12:28:26 -08:00
Jon Staab fb53e53411 Add reply to long press menu 2025-01-28 09:47:11 -08:00
Jon Staab 1e7e439e3f Bump version 2025-01-28 09:30:17 -08:00
Jon Staab 3368cba1be Bump welshman/app 2025-01-28 09:26:11 -08:00
Jon Staab 4f0579bb7f Fix missing compose input, handle parents differently 2025-01-28 09:23:51 -08:00
Jon Staab 08e80262a4 Bump welshman, rework channel loading 2025-01-28 08:13:20 -08:00
Jon Staab e10b83bed8 Improve data loading a bit 2025-01-24 13:35:09 -08:00
Jon Staab fa17c398ca Make deploy documentation more clear 2025-01-24 09:49:14 -08:00
Jon Staab e0840f24dd Drop support for legacy messages 2025-01-24 09:36:24 -08:00
Jon Staab 8e38271534 Attempt to fix broken android 2025-01-20 08:40:37 -08:00
Jon Staab 86928fc12c Bump welshman 2025-01-17 15:01:05 -08:00
Jon Staab e15fb3ce9c Update app icon 2025-01-17 09:08:55 -08:00
Jon Staab bf1ab5f0ee Bump version 2025-01-17 05:30:08 -08:00
Jon Staab 59568f95f1 Update logo 2025-01-16 15:26:54 -08:00
Jon Staab 75d52e7e17 Outsource terms/privacy 2025-01-16 14:54:43 -08:00
Jon Staab bdb5d3dfaa Update changelog 2025-01-16 08:46:51 -08:00
Jon Staab c387b65460 Bump version to 0.2.3 2025-01-16 08:41:20 -08:00
Jon Staab 01c4219922 Add reviewkey auth bypass, remove note to self 2025-01-15 15:48:39 -08:00
Jon Staab 9ca4440038 Add terms/privacy notice to landing 2025-01-15 14:05:23 -08:00
Jon Staab d6cc414f41 Add terms and privacy 2025-01-15 13:56:04 -08:00
Jon Staab 7ccb2949a9 Update gitignore 2025-01-15 13:41:15 -08:00
Jon Staab 8d4e657af5 Tweak avatar again 2025-01-15 11:42:54 -08:00
Jon Staab 4886650dfa Add mark all read 2025-01-15 11:07:21 -08:00
Jon Staab e36e6093e9 Add avatar fallback 2025-01-15 10:56:56 -08:00
Jon Staab edd6e5c8fc Add send button 2025-01-15 09:10:48 -08:00
Jon Staab be7a42d951 Add reports to channel messages 2025-01-15 07:40:28 -08:00
Jon Staab af91fe129b Accommodate onion urls 2025-01-14 15:58:38 -08:00
Jon Staab 6fcf0e7f12 Fix migration 2025-01-14 15:03:36 -08:00
Jon Staab b6defe59a8 Improve loading and notifications 2025-01-02 16:58:04 -08:00
Jon Staab f618e4e1f3 Fix tiptap styling 2025-01-02 16:12:46 -08:00
Jon Staab 5253980cdc Update changelog 2025-01-02 15:59:51 -08:00
Jon Staab 5931a268cf Bump version 2025-01-02 15:59:01 -08:00
Jon Staab 268028a968 Fix storage access 2025-01-02 15:57:51 -08:00
Jon Staab d6669f42c1 Install sentry cli 2025-01-02 15:22:44 -08:00
Jon Staab 19d69005a1 Modify changelog 2025-01-02 15:20:51 -08:00
Jon Staab 9917970760 Update changelog, bump to 0.2.1 2025-01-02 15:14:28 -08:00
Jon Staab 814c5974c4 Bump welshman, minor version 2025-01-02 15:09:16 -08:00
Jon Staab f5dced433a Fix loading and scrolling 2025-01-02 15:08:16 -08:00
Jon Staab 9e96d5e483 Clean up migrations 2025-01-02 13:39:20 -08:00
Jon Staab 420dfc41f3 Bump welshman 2025-01-02 10:47:34 -08:00
Jon Staab 23ae530cd4 Small fixes/performance improvements 2025-01-02 10:04:28 -08:00
Jon Staab 8dfbc99a34 Fix scroller in room page 2024-12-30 16:49:07 -08:00
Jon Staab 75bca31c14 Tweak connection errors 2024-12-30 15:36:54 -08:00
Jon Staab 0c9109f387 Load messages more aggressively at the top level for all spaces 2024-12-30 15:15:22 -08:00
Jon Staab 7a17dc772f Fix connect call 2024-12-30 09:19:56 -08:00
Jon Staab 7bd98270f8 Temporarily handle old data format for events 2024-12-24 09:26:33 -08:00
Jon Staab c15f57c9a5 Bump version in build.gradle 2024-12-17 10:13:08 -08:00
Jon Staab 0f311c45c0 Add nostr: prefix to editor 2024-12-17 08:42:36 -08:00
Jon Staab 055d539b88 Bump version 2024-12-17 08:42:36 -08:00
hodlbod b8e23c47d4 Merge pull request #89 from deerwhisper2310/master
added missing periods
2024-12-17 08:30:02 -08:00
deerwhisper2310 39c72a61ce added missing periods 2024-12-16 23:07:42 -05:00
Jon Staab 166bd81310 Use userRoomsByUrl 2024-12-16 17:07:52 -08:00
Jon Staab d0565e7c62 Bump welshman 2024-12-16 16:52:08 -08:00
Jon Staab 7ddc1657ad Get rid of subscribePersistent 2024-12-16 16:14:51 -08:00
Jon Staab fe789c461d Infer room/protected tag from parent event in reactions and deletes 2024-12-16 13:33:34 -08:00
Jon Staab cd8d8b548f Add profile detail modal 2024-12-16 12:54:17 -08:00
Jon Staab 3b202b31cb Switch checked from indexedb to localstorage 2024-12-16 11:55:47 -08:00
Jon Staab fd846d41ea Further refine notifications 2024-12-16 11:49:57 -08:00
Jon Staab 3d3ffaf406 Simplify and optimize notifications 2024-12-16 11:26:50 -08:00
Jon Staab 85e5413951 Fix space notifications 2024-12-16 10:30:42 -08:00
Jon Staab 9f3bfd5ac0 Move nip29 check 2024-12-12 14:49:30 -08:00
Jon Staab 9d6531c0d5 Handle join errors prefixed with duplicate: 2024-12-12 14:24:51 -08:00
Jon Staab 77d20966ee Bump welshman 2024-12-12 13:46:32 -08:00
Jon Staab daf5cc84bd Bump welshman 2024-12-11 16:59:16 -08:00
Jon Staab 167cd045f4 Fix squirrely notification badges 2024-12-11 14:11:02 -08:00
Jon Staab c83461688f Small tweaks to room menu 2024-12-11 13:57:46 -08:00
Jon Staab b19881a8a9 Hide loader when no admin posts exist 2024-12-11 13:35:06 -08:00
Jon Staab b6524f4a58 Add join space CTA 2024-12-11 11:25:39 -08:00
Jon Staab 2ee370e78b Fix freshness persistence, optimize pubkey loading 2024-12-11 11:03:22 -08:00
Jon Staab a378ecbad4 Improve loading indicator in channels 2024-12-11 10:31:39 -08:00
Jon Staab 72ced31625 Reduce quote depth 2024-12-10 16:50:38 -08:00
Jon Staab 6f7a1c690f Add e/k tags as well as E/K tags to roots 2024-12-10 16:46:49 -08:00
Jon Staab df42ec9915 Add request utils for complex requests 2024-12-10 16:38:22 -08:00
Jon Staab 19d67783fc Fix legacy message loading 2024-12-10 15:33:36 -08:00
Jon Staab d8c3378e5c Create nip29 group when creating a room 2024-12-10 15:16:54 -08:00
Jon Staab 73c6b9656c Support names for unmanaged groups via kind 10009 2024-12-10 14:14:22 -08:00
Jon Staab 80d44a097a Show lock icon for closed channels 2024-12-10 13:42:26 -08:00
Jon Staab a5dfa02771 Use welshman kinds 2024-12-10 13:07:17 -08:00
Jon Staab 66f3686ef4 Spruce up home page navigation 2024-12-10 13:02:41 -08:00
Jon Staab a65f6f6323 Fix quote relays, add backwards compat for reading legacy messages/threads 2024-12-10 10:49:21 -08:00
Jon Staab 523c54a1f1 Add nip29 join/leave room 2024-12-10 09:44:04 -08:00
Jon Staab 7e3cf94ee8 Account for thunks when figuring out which urls an event is on 2024-12-10 08:59:39 -08:00
Jon Staab 404dc94c34 Display rooms using nip29 meta 2024-12-09 17:06:07 -08:00
Jon Staab ea0e1a6c9a Improve performance a bit 2024-12-09 14:03:59 -08:00
Jon Staab 880093296e Throttle elements on chat page 2024-12-09 12:20:16 -08:00
Jon Staab e17cda1eff Add protected tag 2024-12-09 11:59:42 -08:00
Jon Staab 1e0cb93183 Improve quote rendering 2024-12-05 15:32:27 -08:00
Jon Staab 14cd49caf3 Use new kinds, re work channels 2024-12-05 13:37:15 -08:00
Jon Staab 64916f5d29 Remove chat comments and conversation pane 2024-12-04 15:35:39 -08:00
Jon Staab 7b58cdf855 Tweak login button styles 2024-12-04 15:11:32 -08:00
Jon Staab 2e05eee9e7 Add eject flow 2024-12-04 10:10:41 -08:00
Jon Staab efb0528f76 Speed up initial login 2024-12-03 16:41:25 -08:00
Jon Staab 1ea39c1d56 Add email confirmation and password reset 2024-12-03 15:40:15 -08:00
Jon Staab c2aa829334 Rename LogInPassword 2024-12-03 14:00:15 -08:00
Jon Staab a58fc68235 Add burrow support 2024-12-03 14:00:13 -08:00
Jon Staab 220f26253d Use nip04 for signup 2024-12-03 12:31:31 -08:00
Jon Staab 08fef7aa51 Use new welshman nip46 stuff 2024-12-02 16:21:54 -08:00
Jon Staab b8c77c20cd Merge branch 'master' of github.com:coracle-social/flotilla 2024-12-02 09:52:05 -08:00
Jon Staab aa27a05fa6 Fix weird dotenv error 2024-12-02 09:51:50 -08:00
hodlbod 9a68101a64 Merge pull request #79 from greenart7c3/greenart7c3-patch-1
Fix missing comma after nip44_decrypt
2024-11-27 08:52:33 -08:00
greenart7c3 dd5384f7e4 Fix missing comma after nip44_decrypt 2024-11-27 08:46:31 -03:00
Jon Staab 71d63ed21a Bump welshman 2024-11-26 11:56:23 -08:00
Jon Staab de4e1c8677 Fix thread ellision 2024-11-22 14:46:29 -08:00
Jon Staab e6e1eb8897 Fix menu spacing 2024-11-21 17:21:27 -08:00
Jon Staab 603653574c fix scrolling in sidebar 2024-11-21 14:25:57 -08:00
Jon Staab e83a72b426 Make quotes in channels more minimal 2024-11-21 14:14:46 -08:00
Jon Staab eb5bcd8948 Avoid cutting off emojis in channels 2024-11-21 13:20:19 -08:00
Jon Staab 7c46dfb6bc Add new icon 2024-11-21 11:55:43 -08:00
Jon Staab dcc6f463a7 Make thread replies expandable 2024-11-21 11:52:29 -08:00
Jon Staab 86d082b1ab Re-work thread sorting and loading, fix some display bugs with reaction tooltips, fix thunk status loading indicator 2024-11-21 11:01:34 -08:00
Jon Staab 659403c308 Tweak layout of thread actions 2024-11-20 15:56:24 -08:00
Jon Staab 1c0e680c17 Fix failure to navigate, quote transitions 2024-11-20 08:53:30 -08:00
Jon Staab 05f7d128e4 Add scroller to rooms 2024-11-19 16:15:23 -08:00
Jon Staab dfcb88dcce Fix some spacing issues in content 2024-11-19 14:13:00 -08:00
Jon Staab 5890fb64a5 remove link extension 2024-11-19 14:02:08 -08:00
Jon Staab f52142bc52 Tweak message spacing 2024-11-19 13:42:40 -08:00
Jon Staab f4f60a5333 Listen for new threads, add reply/quote button to channels and chats, better quote handling 2024-11-19 13:24:18 -08:00
Jon Staab 6a646b3240 Avoid attempting to unwrap the same event multiple times in a single page load 2024-11-19 10:25:52 -08:00
Jon Staab e5fd172994 Add step to confirm decrypt before doing it in the background 2024-11-19 10:11:31 -08:00
Jon Staab 7cc2a2f264 Load relay owner notes only from the relay 2024-11-19 09:36:05 -08:00
Jon Staab ad58af8605 Customize nip 46 perms 2024-11-19 09:25:02 -08:00
Jon Staab 5b7985e5d9 Disable LinkExtension 2024-11-19 08:37:52 -08:00
Jon Staab 6ff798f4e8 Fix code blocks 2024-11-19 08:37:09 -08:00
Jon Staab ed738f64c8 Handle failed space auth 2024-11-18 20:31:21 -08:00
Jon Staab cbc4c524c4 Bump welshman 2024-11-18 17:26:50 -08:00
Jon Staab bf599cb190 Fix line height on url preview fail 2024-11-18 16:48:51 -08:00
Jon Staab 06a03f5ab1 Add fallback nav items on mobile 2024-11-18 16:33:20 -08:00
187 changed files with 7185 additions and 5409 deletions
+3
View File
@@ -1,5 +1,8 @@
VITE_DEFAULT_PUBKEYS=fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322 VITE_DEFAULT_PUBKEYS=fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322
VITE_BURROW_URL=
VITE_PLATFORM_URL=https://flotilla.social VITE_PLATFORM_URL=https://flotilla.social
VITE_PLATFORM_TERMS=https://flotilla.social/terms
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
VITE_PLATFORM_NAME=Flotilla VITE_PLATFORM_NAME=Flotilla
VITE_PLATFORM_LOGO=static/flotilla.png VITE_PLATFORM_LOGO=static/flotilla.png
VITE_PLATFORM_RELAY= VITE_PLATFORM_RELAY=
+11 -1
View File
@@ -1,3 +1,13 @@
src/assets src/assets
android
build build
.idea
.gradle
*.png
*.ttf
gradlew*
_app
release
android/capacitor-cordova-android-plugins
android/app/src/androidTest
android/app/src/test
+4 -1
View File
@@ -17,6 +17,9 @@ Thumbs.db
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# Android
.idea
# Generated assets # Generated assets
static/favicon.ico static/favicon.ico
static/pwa-64x64.png static/pwa-64x64.png
@@ -24,5 +27,5 @@ static/pwa-192x192.png
static/pwa-512x512.png static/pwa-512x512.png
static/apple-touch-icon-180x180.png static/apple-touch-icon-180x180.png
static/maskable-icon-512x512.png static/maskable-icon-512x512.png
src/assets src/assets/icons/*.webp
manifest.webmanifest manifest.webmanifest
+49
View File
@@ -0,0 +1,49 @@
# Changelog
# 0.2.6
* Add reply to long-press menu
* Fix @-mentions
* Replace nsec.app signup with njump.me
* Add new messages button in rooms
* Add media server settings
* Add build hash to about page
# 0.2.5
* Improve room and data loading
* Use @welshman/editor
* Drop support for legacy event kinds
* Add support for back button navigation on android
* Remove note to self page (still available via chat)
* Improve chat conversation search
* Change how reply UI works
# 0.2.4
* Update icons
# 0.2.3
* Add NIP 56 reports for messages and threads
* Add ToS and privacy policy
* Add avatar fallback icons
* Add mark as read to chats
* Add send button to chat compose
* Accommodate onion URLs
* Improve loading and notifications
# 0.2.2
* Fix bug with sending messages
# 0.2.1
* Improve performance, as well as scrolling and loading
* Integrate @welshman/editor
* Improve NIP 29 compatibility
* Fix incorrect connection errors
* Refine notifications
* Add room menu to space homepage
* Fix storage bugs
* Add join space CTA
+5 -1
View File
@@ -6,7 +6,11 @@ If you would like to be interoperable with Flotilla, please check out this draft
# Deploy # Deploy
To run your own Flotilla, it's as simple as `npm run build`, then serve the `build` directory. To run your own Flotilla, it's as simple as:
- `npm install`
- `npm run build`
- `npx serve build`
## Environment ## Environment
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "social.flotilla" applicationId "social.flotilla"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 6
versionName "1.0" versionName "0.2.6"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+1
View File
@@ -9,6 +9,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies { dependencies {
implementation project(':capacitor-app')
implementation project(':nostr-signer-capacitor-plugin') implementation project(':nostr-signer-capacitor-plugin')
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 531 B

After

Width:  |  Height:  |  Size: 899 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 916 B

After

Width:  |  Height:  |  Size: 705 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 B

After

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 B

After

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 697 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

@@ -1,9 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar"> <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item> <item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item> <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item> <item name="colorAccent">@color/colorAccent</item>
+1 -1
View File
@@ -7,7 +7,7 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.2.1' classpath 'com.android.tools.build:gradle:8.8.0'
classpath 'com.google.gms:google-services:4.4.0' classpath 'com.google.gms:google-services:4.4.0'
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
+3
View File
@@ -2,5 +2,8 @@
include ':capacitor-android' include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':nostr-signer-capacitor-plugin' include ':nostr-signer-capacitor-plugin'
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/nostr-signer-capacitor-plugin/android') project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/nostr-signer-capacitor-plugin/android')
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+11 -1
View File
@@ -14,9 +14,12 @@ fi
# https://stackoverflow.com/a/69127685/1467342 # https://stackoverflow.com/a/69127685/1467342
eval "$temp_env" eval "$temp_env"
if [[ -z $VITE_BUILD_HASH ]]; then
export VITE_BUILD_HASH=$(git rev-parse --short HEAD)
fi
if [[ $VITE_PLATFORM_LOGO =~ ^https://* ]]; then if [[ $VITE_PLATFORM_LOGO =~ ^https://* ]]; then
curl $VITE_PLATFORM_LOGO > static/logo.png curl $VITE_PLATFORM_LOGO > static/logo.png
cp static/logo.png assets/logo.png
export VITE_PLATFORM_LOGO=static/logo.png export VITE_PLATFORM_LOGO=static/logo.png
fi fi
@@ -28,3 +31,10 @@ perl -i -pe"s|{DESCRIPTION}|$VITE_PLATFORM_DESCRIPTION|g" build/index.html
perl -i -pe"s|{ACCENT}|$VITE_PLATFORM_ACCENT|g" build/index.html perl -i -pe"s|{ACCENT}|$VITE_PLATFORM_ACCENT|g" build/index.html
perl -i -pe"s|{NAME}|$VITE_PLATFORM_NAME|g" build/index.html perl -i -pe"s|{NAME}|$VITE_PLATFORM_NAME|g" build/index.html
perl -i -pe"s|{URL}|$VITE_PLATFORM_URL|g" build/index.html perl -i -pe"s|{URL}|$VITE_PLATFORM_URL|g" build/index.html
npx cap sync
npx @capacitor/assets generate \
--iconBackgroundColor '#eeeeee' \
--iconBackgroundColorDark '#222222' \
--splashBackgroundColor '#ffffff' \
--splashBackgroundColorDark '#191E24'
+3
View File
@@ -4,6 +4,9 @@ const config: CapacitorConfig = {
appId: 'social.flotilla', appId: 'social.flotilla',
appName: 'Flotilla', appName: 'Flotilla',
webDir: 'build' webDir: 'build'
server: {
androidScheme: "https"
},
plugins: { plugins: {
SplashScreen: { SplashScreen: {
androidSplashResourceName: "splash" androidSplashResourceName: "splash"
+3817 -2856
View File
File diff suppressed because it is too large Load Diff
+21 -30
View File
@@ -1,20 +1,24 @@
{ {
"name": "flotilla", "name": "flotilla",
"version": "0.1.0", "version": "0.2.6",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "./build.sh", "build": "./build.sh",
"sourcemaps": "sentry-cli --url https://glitchtip.coracle.social --auth-token $GLITCHTIP_AUTH_TOKEN --api-key $VITE_GLITCHTIP_API_KEY sourcemaps --org coracle --project flotilla --release $(cat package.json|jq -r '.version') upload --url-prefix /_app/immutable/ build/_app/immutable", "sourcemaps": "sentry-cli --url https://glitchtip.coracle.social --auth-token $GLITCHTIP_AUTH_TOKEN --api-key $VITE_GLITCHTIP_API_KEY sourcemaps --org coracle --project flotilla --release $(cat package.json|jq -r '.version') upload --url-prefix /_app/immutable/ build/_app/immutable",
"release:android": "cap sync && cap build android --androidreleasetype APK --signing-type apksigner", "release:android": "cap build android --androidreleasetype APK --signing-type apksigner",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check src && eslint src", "lint": "prettier --check src && eslint src",
"format": "prettier --write src", "format": "prettier --write src",
"prepare": "husky" "prepare": "husky"
}, },
"overrides": {
"@capacitor/core": "^7.0.1"
},
"devDependencies": { "devDependencies": {
"@capacitor/assets": "^3.0.5", "@capacitor/assets": "^3.0.5",
"@sentry/cli": "^2.40.0",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/eslint": "^9.6.0", "@types/eslint": "^9.6.0",
@@ -36,38 +40,28 @@
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@capacitor/android": "^6.1.2", "@capacitor/android": "^7.0.1",
"@capacitor/cli": "^6.1.2", "@capacitor/app": "^7.0.0",
"@capacitor/core": "^6.1.2", "@capacitor/cli": "^6.2.0",
"@capacitor/core": "^7.0.1",
"@noble/curves": "^1.5.0", "@noble/curves": "^1.5.0",
"@noble/hashes": "^1.4.0", "@noble/hashes": "^1.4.0",
"@poppanator/sveltekit-svg": "^4.2.1", "@poppanator/sveltekit-svg": "^4.2.1",
"@sentry/browser": "^8.35.0", "@sentry/browser": "^8.35.0",
"@sveltejs/adapter-static": "^3.0.4", "@sveltejs/adapter-static": "^3.0.4",
"@tiptap/extension-code": "^2.6.6",
"@tiptap/extension-code-block": "^2.6.6",
"@tiptap/extension-document": "^2.6.6",
"@tiptap/extension-dropcursor": "^2.6.6",
"@tiptap/extension-gapcursor": "^2.6.6",
"@tiptap/extension-hard-break": "^2.6.6",
"@tiptap/extension-history": "^2.6.6",
"@tiptap/extension-paragraph": "^2.6.6",
"@tiptap/extension-placeholder": "^2.9.1",
"@tiptap/extension-text": "^2.6.6",
"@tiptap/suggestion": "^2.6.4",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^0.2.6", "@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.6", "@vite-pwa/sveltekit": "^0.6.6",
"@welshman/app": "~0.0.27", "@welshman/app": "~0.0.41",
"@welshman/content": "~0.0.12", "@welshman/content": "~0.0.16",
"@welshman/dvm": "~0.0.10", "@welshman/dvm": "~0.0.14",
"@welshman/feeds": "~0.0.25", "@welshman/editor": "~0.0.10",
"@welshman/lib": "~0.0.26", "@welshman/feeds": "~0.0.30",
"@welshman/net": "~0.0.36", "@welshman/lib": "~0.0.38",
"@welshman/signer": "~0.0.14", "@welshman/net": "~0.0.46",
"@welshman/store": "~0.0.12", "@welshman/signer": "~0.0.20",
"@welshman/util": "~0.0.45", "@welshman/store": "~0.0.15",
"@welshman/util": "~0.0.59",
"daisyui": "^4.12.10", "daisyui": "^4.12.10",
"date-picker-svelte": "^2.13.0", "date-picker-svelte": "^2.13.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
@@ -75,12 +69,9 @@
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"husky": "^9.1.6", "husky": "^9.1.6",
"idb": "^8.0.0", "idb": "^8.0.0",
"nostr-editor": "^0.0.3",
"nostr-signer-capacitor-plugin": "^0.0.3", "nostr-signer-capacitor-plugin": "^0.0.3",
"nostr-tools": "^2.7.2", "nostr-tools": "^2.7.2",
"prettier-plugin-tailwindcss": "^0.6.5", "prettier-plugin-tailwindcss": "^0.6.5",
"qrcode": "^1.5.4", "qrcode": "^1.5.4"
"svelte-tiptap": "^1.1.3",
"throttle-debounce": "^5.0.2"
} }
} }
+46 -29
View File
@@ -40,15 +40,14 @@
:root { :root {
font-family: Lato; font-family: Lato;
}
[data-theme] {
--base-100: oklch(var(--b1)); --base-100: oklch(var(--b1));
--base-200: oklch(var(--b2)); --base-200: oklch(var(--b2));
--base-300: oklch(var(--b3)); --base-300: oklch(var(--b3));
--base-content: oklch(var(--bc)); --base-content: oklch(var(--bc));
--primary: oklch(var(--p)); --primary: oklch(var(--p));
--primary-content: oklch(var(--pc));
--secondary: oklch(var(--s)); --secondary: oklch(var(--s));
--secondary-content: oklch(var(--sc));
} }
.bg-alt, .bg-alt,
@@ -120,6 +119,16 @@
@apply overflow-hidden text-ellipsis; @apply overflow-hidden text-ellipsis;
} }
[data-tip]::before {
@apply ellipsize;
}
@media (max-width: 639px) {
[data-tip]::before {
display: none;
}
}
.content-padding-x { .content-padding-x {
@apply px-4 sm:px-8 md:px-12; @apply px-4 sm:px-8 md:px-12;
} }
@@ -176,45 +185,53 @@
@apply -m-1 min-h-12 p-1; @apply -m-1 min-h-12 p-1;
} }
.tiptap[contenteditable="true"] { .tiptap {
--tiptap-object-bg: var(--base-100);
--tiptap-object-fg: var(--base-content);
--tiptap-active-bg: var(--primary);
--tiptap-active-fg: var(--primary-content);
}
.tiptap-suggestions {
--tiptap-object-bg: var(--base-100);
--tiptap-object-fg: var(--base-content);
--tiptap-active-bg: var(--base-300);
--tiptap-active-fg: var(--base-content);
}
.tiptap {
@apply max-h-[350px] overflow-y-auto p-2 px-4; @apply max-h-[350px] overflow-y-auto p-2 px-4;
} }
.chat-editor .tiptap[contenteditable="true"] { .tiptap p.is-editor-empty:first-child::before {
@apply rounded-box bg-base-300; opacity: 40%;
} }
.input-editor .tiptap[contenteditable="true"] { .chat-editor .tiptap {
@apply input input-bordered h-auto p-[.65rem]; @apply rounded-box bg-base-300 pr-12;
} }
.note-editor .tiptap[contenteditable="true"] { .note-editor .tiptap {
--tiptap-object-bg: var(--base-200);
@apply input input-bordered h-auto min-h-32 rounded-box p-[.65rem] pb-6; @apply input input-bordered h-auto min-h-32 rounded-box p-[.65rem] pb-6;
} }
.tiptap pre code { .input-editor .tiptap {
@apply link-content block w-full; --tiptap-object-bg: var(--base-200);
@apply input input-bordered h-auto p-[.65rem];
} }
.tiptap p code { /* link-content, based on tiptap */
@apply link-content;
}
.link-content, .link-content {
.tiptap [tag] { max-width: 100%;
@apply max-w-full overflow-hidden text-ellipsis whitespace-nowrap rounded bg-neutral px-1 text-neutral-content no-underline; overflow: hidden;
} text-overflow: ellipsis;
white-space: nowrap;
.link-content.link-content-selected { border-radius: 3px;
@apply bg-primary text-primary-content; padding: 0 0.25rem;
} background-color: var(--base-100);
color: var(--base-content);
.tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
opacity: 50%;
} }
/* date input */ /* date input */
+191 -104
View File
@@ -1,13 +1,21 @@
import * as nip19 from "nostr-tools/nip19"
import {get} from "svelte/store" import {get} from "svelte/store"
import {ctx, uniq, sleep, chunk, equals, choice} from "@welshman/lib" import {ctx, sample, uniq, sleep, chunk, equals} from "@welshman/lib"
import { import {
DELETE, DELETE,
REPORT,
PROFILE, PROFILE,
INBOX_RELAYS, INBOX_RELAYS,
RELAYS, RELAYS,
FOLLOWS, FOLLOWS,
REACTION, REACTION,
AUTH_JOIN, AUTH_JOIN,
GROUP_JOIN,
GROUP_LEAVE,
GROUP_CREATE,
GROUP_EDIT_META,
GROUPS,
COMMENT,
isSignedEvent, isSignedEvent,
createEvent, createEvent,
displayProfile, displayProfile,
@@ -15,16 +23,17 @@ import {
makeList, makeList,
addToListPublicly, addToListPublicly,
removeFromListByPredicate, removeFromListByPredicate,
getTag,
getListTags, getListTags,
getRelayTags, getRelayTags,
isShareableRelayUrl, isShareableRelayUrl,
getRelayTagValues, getRelayTagValues,
toNostrURI,
} from "@welshman/util" } from "@welshman/util"
import type {TrustedEvent, EventTemplate, List} from "@welshman/util" import type {TrustedEvent, EventContent, EventTemplate, List} from "@welshman/util"
import type {SubscribeRequestWithHandlers, Subscription} from "@welshman/net" import type {SubscribeRequestWithHandlers} from "@welshman/net"
import {PublishStatus, AuthStatus, SocketStatus, SubscriptionEvent} from "@welshman/net" import {PublishStatus, AuthStatus, SocketStatus} from "@welshman/net"
import {Nip59, makeSecret, stamp, Nip46Broker} from "@welshman/signer" import {Nip59, makeSecret, stamp, Nip46Broker} from "@welshman/signer"
import type {Nip46Handler} from "@welshman/signer"
import { import {
pubkey, pubkey,
signer, signer,
@@ -39,26 +48,29 @@ import {
loadFollows, loadFollows,
loadMutes, loadMutes,
tagEvent, tagEvent,
tagReactionTo, tagEventForReaction,
getRelayUrls, getRelayUrls,
userRelaySelections, userRelaySelections,
userInboxRelaySelections, userInboxRelaySelections,
nip44EncryptToSelf, nip44EncryptToSelf,
loadRelay, loadRelay,
addSession, addSession,
nip46Perms, clearStorage,
subscribe, dropSession,
tagEventForComment,
tagEventForQuote,
} from "@welshman/app" } from "@welshman/app"
import type {Thunk} from "@welshman/app"
import { import {
COMMENT,
tagRoom, tagRoom,
PROTECTED,
userMembership, userMembership,
MEMBERSHIPS,
INDEXER_RELAYS, INDEXER_RELAYS,
NIP46_PERMS,
loadMembership, loadMembership,
loadSettings, loadSettings,
getDefaultPubkeys, getDefaultPubkeys,
getMembershipUrls, userRoomsByUrl,
} from "@app/state" } from "@app/state"
// Utils // Utils
@@ -78,89 +90,108 @@ export const getPubkeyPetname = (pubkey: string) => {
return display return display
} }
export const makeMention = (pubkey: string, hints?: string[]) => [ export const getThunkError = async (thunk: Thunk) => {
"p", const result = await thunk.result
pubkey, const [{status, message}] = Object.values(result) as any
choice(hints || getPubkeyHints(pubkey)),
getPubkeyPetname(pubkey),
]
export const makeIMeta = (url: string, data: Record<string, string>) => [ if (status !== PublishStatus.Success) {
"imeta", return message
`url ${url}`,
...Object.entries(data).map(([k, v]) => [k, v].join(" ")),
]
export const subscribePersistent = (request: SubscribeRequestWithHandlers) => {
let sub: Subscription
let done = false
const start = async () => {
// If the subscription gets closed quickly, don't start flapping
await Promise.all([
sleep(30_000),
new Promise(resolve => {
sub = subscribe(request)
sub.emitter.on(SubscriptionEvent.Complete, resolve)
}),
])
if (!done) {
start()
} }
} }
start() export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => {
if (parent) {
const nevent = nip19.neventEncode({
id: parent.id,
kind: parent.kind,
author: parent.pubkey,
relays: ctx.app.router.Event(parent).limit(3).getUrls(),
})
return () => { tags = [...tags, tagEventForQuote(parent)]
done = true content = toNostrURI(nevent) + "\n\n" + content
sub?.close()
} }
return {content, tags}
} }
// Log in // Log in
export const loginWithNip46 = async (token: string, handler: Nip46Handler) => { export const loginWithNip46 = async ({
const secret = makeSecret() relays,
const broker = Nip46Broker.get({secret, handler}) signerPubkey,
const result = await broker.connect(token, nip46Perms) 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)
if (!result) return false // TODO: remove ack result
if (!["ack", connectSecret].includes(result)) return false
const pubkey = await broker.getPublicKey() const pubkey = await broker.getPublicKey()
if (!pubkey) return false if (!pubkey) return false
addSession({method: "nip46", pubkey, secret, handler}) await loadUserData(pubkey)
const handler = {relays, pubkey: signerPubkey}
addSession({method: "nip46", pubkey, secret: clientSecret, handler})
return true return true
} }
// Log out
export const logout = async () => {
const $pubkey = pubkey.get()
if ($pubkey) {
dropSession($pubkey)
}
await clearStorage()
localStorage.clear()
}
// Loaders // Loaders
export const loadUserData = ( export const loadUserData = (
pubkey: string, pubkey: string,
request: Partial<SubscribeRequestWithHandlers> = {}, request: Partial<SubscribeRequestWithHandlers> = {},
) => { ) => {
const promise = Promise.all([ const promise = Promise.race([
sleep(3000),
Promise.all([
loadInboxRelaySelections(pubkey, request), loadInboxRelaySelections(pubkey, request),
loadMembership(pubkey, request), loadMembership(pubkey, request),
loadSettings(pubkey, request), loadSettings(pubkey, request),
loadProfile(pubkey, request), loadProfile(pubkey, request),
loadFollows(pubkey, request), loadFollows(pubkey, request),
loadMutes(pubkey, request), loadMutes(pubkey, request),
]),
]) ])
// Load followed profiles slowly in the background without clogging other stuff up // 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 () => { promise.then(async () => {
for (const pubkeys of chunk(50, getDefaultPubkeys())) { for (const pubkeys of chunk(50, getDefaultPubkeys())) {
await sleep(300) const relays = sample(1, INDEXER_RELAYS)
await sleep(1000)
for (const pubkey of pubkeys) { for (const pubkey of pubkeys) {
loadMembership(pubkey) loadMembership(pubkey, {relays})
loadProfile(pubkey) loadProfile(pubkey, {relays})
loadFollows(pubkey) loadFollows(pubkey, {relays})
loadMutes(pubkey) loadMutes(pubkey, {relays})
} }
} }
}) })
@@ -185,10 +216,37 @@ export const broadcastUserData = async (relays: string[]) => {
} }
} }
// NIP 29 stuff
export const nip29 = {
createRoom: (url: string, room: string) => {
const event = createEvent(GROUP_CREATE, {tags: [tagRoom(room, url)]})
return publishThunk({event, relays: [url]})
},
editMeta: (url: string, room: string, meta: Record<string, string>) => {
const event = createEvent(GROUP_EDIT_META, {
tags: [tagRoom(room, url), ...Object.entries(meta)],
})
return publishThunk({event, relays: [url]})
},
joinRoom: (url: string, room: string) => {
const event = createEvent(GROUP_JOIN, {tags: [tagRoom(room, url)]})
return publishThunk({event, relays: [url]})
},
leaveRoom: (url: string, room: string) => {
const event = createEvent(GROUP_LEAVE, {tags: [tagRoom(room, url)]})
return publishThunk({event, relays: [url]})
},
}
// List updates // List updates
export const addSpaceMembership = async (url: string) => { export const addSpaceMembership = async (url: string) => {
const list = get(userMembership) || makeList({kind: MEMBERSHIPS}) const list = get(userMembership) || makeList({kind: GROUPS})
const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf) const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf)
const relays = uniq([...ctx.app.router.FromUser().getUrls(), ...getRelayTagValues(event.tags)]) const relays = uniq([...ctx.app.router.FromUser().getUrls(), ...getRelayTagValues(event.tags)])
@@ -196,7 +254,7 @@ export const addSpaceMembership = async (url: string) => {
} }
export const removeSpaceMembership = async (url: string) => { export const removeSpaceMembership = async (url: string) => {
const list = get(userMembership) || makeList({kind: MEMBERSHIPS}) const list = get(userMembership) || makeList({kind: GROUPS})
const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf) const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([ const relays = uniq([
@@ -208,17 +266,21 @@ export const removeSpaceMembership = async (url: string) => {
return publishThunk({event, relays}) return publishThunk({event, relays})
} }
export const addRoomMembership = async (url: string, room: string) => { export const addRoomMembership = async (url: string, room: string, name: string) => {
const list = get(userMembership) || makeList({kind: MEMBERSHIPS}) const list = get(userMembership) || makeList({kind: GROUPS})
const event = await addToListPublicly(list, tagRoom(room, url)).reconcile(nip44EncryptToSelf) const newTags = [
["r", url],
["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([...ctx.app.router.FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays}) return publishThunk({event, relays})
} }
export const removeRoomMembership = async (url: string, room: string) => { export const removeRoomMembership = async (url: string, room: string) => {
const list = get(userMembership) || makeList({kind: MEMBERSHIPS}) const list = get(userMembership) || makeList({kind: GROUPS})
const pred = (t: string[]) => equals(tagRoom(room, url), t) const pred = (t: string[]) => equals(["group", room, url], t.slice(0, 3))
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf) const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([ const relays = uniq([
url, url,
@@ -247,7 +309,7 @@ export const setRelayPolicy = (url: string, read: boolean, write: boolean) => {
url, url,
...INDEXER_RELAYS, ...INDEXER_RELAYS,
...ctx.app.router.FromUser().getUrls(), ...ctx.app.router.FromUser().getUrls(),
...getMembershipUrls(userMembership.get()), ...userRoomsByUrl.get().keys(),
], ],
}) })
} }
@@ -268,7 +330,7 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
relays: [ relays: [
...INDEXER_RELAYS, ...INDEXER_RELAYS,
...ctx.app.router.FromUser().getUrls(), ...ctx.app.router.FromUser().getUrls(),
...getMembershipUrls(userMembership.get()), ...userRoomsByUrl.get().keys(),
], ],
}) })
} }
@@ -288,15 +350,19 @@ export const checkRelayAccess = async (url: string, claim = "") => {
const result = await thunk.result const result = await thunk.result
if (result[url].status !== PublishStatus.Success) { if (result[url].status === PublishStatus.Failure) {
const message = const message =
connection.auth.message?.replace(/^.*: /, "") || connection.auth.message?.replace(/^.*: /, "") ||
result[url].message?.replace(/^.*: /, "") || result[url].message?.replace(/^.*: /, "") ||
"join request rejected" "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 `Failed to join relay (${message})`
} }
} }
}
export const checkRelayProfile = async (url: string) => { export const checkRelayProfile = async (url: string) => {
const relay = await loadRelay(url) const relay = await loadRelay(url)
@@ -330,10 +396,14 @@ export const checkRelayAuth = async (url: string, timeout = 3000) => {
} }
export const attemptRelayAccess = async (url: string, claim = "") => { export const attemptRelayAccess = async (url: string, claim = "") => {
const checks = [checkRelayProfile, checkRelayConnection, checkRelayAccess, checkRelayAuth] const checks = [
() => checkRelayConnection(url),
() => checkRelayAccess(url, claim),
() => checkRelayAuth(url),
]
for (const check of checks) { for (const check of checks) {
const error = await check(url) const error = await check()
if (error) { if (error) {
return error return error
@@ -365,55 +435,72 @@ export const sendWrapped = async ({
) )
} }
export const makeDelete = ({event}: {event: TrustedEvent}) => {
const tags = [["k", String(event.kind)], ...tagEvent(event)]
const groupTag = getTag("h", event.tags)
if (groupTag) {
tags.push(PROTECTED)
tags.push(groupTag)
}
return createEvent(DELETE, {tags})
}
export const publishDelete = ({relays, event}: {relays: string[]; event: TrustedEvent}) =>
publishThunk({event: makeDelete({event}), relays})
export type ReportParams = {
event: TrustedEvent
content: string
reason: string
}
export const makeReport = ({event, reason, content}: ReportParams) => {
const tags = [
["p", event.pubkey],
["e", event.id, reason],
]
return createEvent(REPORT, {content, tags})
}
export const publishReport = ({
relays,
event,
reason,
content,
}: ReportParams & {relays: string[]}) =>
publishThunk({event: makeReport({event, reason, content}), relays})
export type ReactionParams = { export type ReactionParams = {
event: TrustedEvent event: TrustedEvent
content: string content: string
tags?: string[][]
} }
export const makeReaction = ({event, content, tags = []}: ReactionParams) => export const makeReaction = ({event, content}: ReactionParams) => {
createEvent(REACTION, {content, tags: [...tags, ...tagReactionTo(event)]}) const tags = tagEventForReaction(event)
const groupTag = getTag("h", event.tags)
if (groupTag) {
tags.push(PROTECTED)
tags.push(groupTag)
}
return createEvent(REACTION, {content, tags})
}
export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) => export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) =>
publishThunk({event: makeReaction(params), relays}) publishThunk({event: makeReaction(params), relays})
export type ReplyParams = { export type CommentParams = {
event: TrustedEvent event: TrustedEvent
content: string content: string
tags?: string[][] tags?: string[][]
} }
export const makeComment = ({event, content, tags = []}: ReplyParams) => { export const makeComment = ({event, content, tags = []}: CommentParams) =>
const seenRoots = new Set<string>() createEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event)]})
for (const [raw, ...tag] of event.tags.filter(t => t[0].match(/^(k|e|a|i)$/i))) { export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
const T = raw.toUpperCase()
const t = raw.toLowerCase()
if (seenRoots.has(T)) {
tags.push([t, ...tag])
} else {
tags.push([T, ...tag])
seenRoots.add(T)
}
}
if (seenRoots.size === 0) {
tags.push(["K", String(event.kind)])
tags.push(["E", event.id])
} else {
tags.push(["k", String(event.kind)])
tags.push(["e", event.id])
}
return createEvent(COMMENT, {content, tags})
}
export const publishComment = ({relays, ...params}: ReplyParams & {relays: string[]}) =>
publishThunk({event: makeComment(params), relays}) publishThunk({event: makeComment(params), relays})
export const makeDelete = ({event}: {event: TrustedEvent}) =>
createEvent(DELETE, {tags: [["k", String(event.kind)], ...tagEvent(event)]})
export const publishDelete = ({relays, event}: {relays: string[]; event: TrustedEvent}) =>
publishThunk({event: makeDelete({event}), relays})
+20 -1
View File
@@ -4,7 +4,26 @@
import Landing from "@app/components/Landing.svelte" import Landing from "@app/components/Landing.svelte"
import Toast from "@app/components/Toast.svelte" import Toast from "@app/components/Toast.svelte"
import PrimaryNav from "@app/components/PrimaryNav.svelte" import PrimaryNav from "@app/components/PrimaryNav.svelte"
import {modals} from "@app/modal" import EmailConfirm from "@app/components/EmailConfirm.svelte"
import PasswordReset from "@app/components/PasswordReset.svelte"
import {BURROW_URL} from "@app/state"
import {modals, pushModal} from "@app/modal"
if (BURROW_URL && !$pubkey) {
if ($page.url.pathname === "/confirm-email") {
pushModal(EmailConfirm, {
email: $page.url.searchParams.get("email"),
confirm_token: $page.url.searchParams.get("confirm_token"),
})
}
if ($page.url.pathname === "/reset-password") {
pushModal(PasswordReset, {
email: $page.url.searchParams.get("email"),
reset_token: $page.url.searchParams.get("reset_token"),
})
}
}
</script> </script>
<div class="flex h-screen overflow-hidden"> <div class="flex h-screen overflow-hidden">
+30 -27
View File
@@ -1,54 +1,50 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import type {Readable} from "svelte/store" import {writable} from "svelte/store"
import {createEditor, type Editor, EditorContent} from "svelte-tiptap" import {EditorContent} from "svelte-tiptap"
import {isMobile} from "@lib/html" import {isMobile} from "@lib/html"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import {getEditorOptions, getEditorTags} from "@lib/editor" import {makeEditor} from "@app/editor"
import {getPubkeyHints} from "@app/commands"
export let onSubmit export let onSubmit: any
export let content = "" export let content = ""
let editor: Readable<Editor> export const focus = () => $editor.chain().focus().run()
const uploading = writable(false)
const uploadFiles = () => $editor!.chain().selectFiles().run()
const submit = () => { const submit = () => {
if ($loading) return if ($uploading) return
onSubmit({ const content = $editor!.getText({blockSeparator: "\n"}).trim()
content: $editor.getText({blockSeparator: "\n"}), const tags = $editor!.storage.nostr.getEditorTags()
tags: getEditorTags($editor),
})
$editor.chain().clearContent().run() if (!content) return
onSubmit({content, tags})
$editor!.chain().clearContent().run()
} }
$: loading = $editor?.storage.fileUpload.loading const editor = makeEditor({autofocus: !isMobile, submit, uploading, aggressive: true})
onMount(() => { onMount(() => {
editor = createEditor( $editor!.chain().setContent(content).run()
getEditorOptions({
submit,
getPubkeyHints,
submitOnEnter: true,
autofocus: !isMobile,
}),
)
$editor.commands.setContent(content)
}) })
</script> </script>
<form <form
class="relative z-feature flex gap-2 p-2" class="relative z-feature flex gap-2 p-2"
on:submit|preventDefault={$loading ? undefined : submit}> on:submit|preventDefault={$uploading ? undefined : submit}>
<Button <Button
data-tip="Add an image" data-tip="Add an image"
class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200" class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200"
disabled={$loading} disabled={$uploading}
on:click={$editor.commands.selectFiles}> on:click={uploadFiles}>
{#if $loading} {#if $uploading}
<span class="loading loading-spinner loading-xs"></span> <span class="loading loading-spinner loading-xs"></span>
{:else} {:else}
<Icon icon="gallery-send" /> <Icon icon="gallery-send" />
@@ -57,4 +53,11 @@
<div class="chat-editor flex-grow overflow-hidden"> <div class="chat-editor flex-grow overflow-hidden">
<EditorContent editor={$editor} /> <EditorContent editor={$editor} />
</div> </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}
on:click={submit}>
<Icon icon="plain" />
</Button>
</form> </form>
@@ -0,0 +1,23 @@
<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 Content from "@app/components/Content.svelte"
export let event: TrustedEvent
export let clear: () => void
</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">Replying to @{displayProfileByPubkey(event.pubkey)}</p>
{#key event.id}
<Content {event} hideMedia minLength={100} maxLength={300} expandMode="disabled" />
{/key}
<Button class="absolute right-2 top-2 cursor-pointer" on:click={clear}>
<Icon icon="close-circle" />
</Button>
</div>
@@ -1,36 +0,0 @@
<script lang="ts">
import {sortBy, append} from "@welshman/lib"
import type {EventContent, TrustedEvent} from "@welshman/util"
import {repository} from "@welshman/app"
import {deriveEvents} from "@welshman/store"
import ChannelMessage from "@app/components/ChannelMessage.svelte"
import ChannelCompose from "@app/components/ChannelCompose.svelte"
import {tagRoom, COMMENT} from "@app/state"
import {publishComment} from "@app/commands"
export let url, room, event: TrustedEvent
const replies = deriveEvents(repository, {
filters: [{kinds: [COMMENT], "#E": [event.id]}],
})
const onSubmit = ({content, tags}: EventContent) =>
publishComment({
event,
content,
tags: append(tagRoom(room, url), tags),
relays: [url],
})
</script>
<div class="col-2">
<div class="overflow-auto pt-3">
<ChannelMessage {url} {room} {event} showPubkey isHead inert />
{#each sortBy(e => e.created_at, $replies) as reply (reply.id)}
<ChannelMessage {url} {room} event={reply} showPubkey inert />
{/each}
</div>
<div class="bottom-0 left-0 right-0">
<ChannelCompose {onSubmit} />
</div>
</div>
+39 -45
View File
@@ -1,52 +1,48 @@
<script lang="ts"> <script lang="ts">
import {readable} from "svelte/store"
import {hash} from "@welshman/lib" import {hash} from "@welshman/lib"
import {now} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import { import {
thunks, thunks,
pubkey,
deriveProfile, deriveProfile,
deriveProfileDisplay, deriveProfileDisplay,
formatTimestampAsDate,
formatTimestampAsTime, formatTimestampAsTime,
pubkey,
} from "@welshman/app" } from "@welshman/app"
import {isMobile} from "@lib/html" import {isMobile} from "@lib/html"
import LongPress from "@lib/components/LongPress.svelte" import LongPress from "@lib/components/LongPress.svelte"
import Avatar from "@lib/components/Avatar.svelte" import Avatar from "@lib/components/Avatar.svelte"
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 Content from "@app/components/Content.svelte"
import ThunkStatus from "@app/components/ThunkStatus.svelte" import ThunkStatus from "@app/components/ThunkStatus.svelte"
import ReplySummary from "@app/components/ReplySummary.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte" import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ChannelConversation from "@app/components/ChannelConversation.svelte"
import ChannelMessageEmojiButton from "@app/components/ChannelMessageEmojiButton.svelte" import ChannelMessageEmojiButton from "@app/components/ChannelMessageEmojiButton.svelte"
import ChannelMessageMenuButton from "@app/components/ChannelMessageMenuButton.svelte" import ChannelMessageMenuButton from "@app/components/ChannelMessageMenuButton.svelte"
import ChannelMessageMenuMobile from "@app/components/ChannelMessageMenuMobile.svelte" import ChannelMessageMenuMobile from "@app/components/ChannelMessageMenuMobile.svelte"
import {colors, tagRoom, deriveEvent, pubkeyLink} from "@app/state" import {colors} from "@app/state"
import {publishDelete, publishReaction} from "@app/commands" import {publishDelete, publishReaction} from "@app/commands"
import {pushDrawer, pushModal} from "@app/modal" import {pushModal} from "@app/modal"
export let url, room export let url, room
export let event: TrustedEvent export let event: TrustedEvent
export let replyTo: any = undefined
export let showPubkey = false export let showPubkey = false
export let isHead = false
export let inert = false export let inert = false
const thunk = $thunks[event.id] const thunk = $thunks[event.id]
const today = formatTimestampAsDate(now())
const profile = deriveProfile(event.pubkey) const profile = deriveProfile(event.pubkey)
const profileDisplay = deriveProfileDisplay(event.pubkey) const profileDisplay = deriveProfileDisplay(event.pubkey)
const rootTag = event.tags.find(t => t[0].match(/^e$/i))
const rootId = rootTag?.[1]
const rootHints = [rootTag?.[2]].filter(Boolean) as string[]
const rootEvent = rootId ? deriveEvent(rootId, rootHints) : readable(null)
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length] const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
const onClick = () => { const reply = () => replyTo(event)
const root = $rootEvent || event
pushDrawer(ChannelConversation, {url, room, event: root}) const onLongPress = () => pushModal(ChannelMessageMenuMobile, {url, event, reply})
}
const onLongPress = () => pushModal(ChannelMessageMenuMobile, {url, event}) const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
const onReactionClick = (content: string, events: TrustedEvent[]) => { const onReactionClick = (content: string, events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey) const reaction = events.find(e => e.pubkey === $pubkey)
@@ -54,62 +50,60 @@
if (reaction) { if (reaction) {
publishDelete({relays: [url], event: reaction}) publishDelete({relays: [url], event: reaction})
} else { } else {
publishReaction({ publishReaction({event, content, relays: [url]})
event,
content,
relays: [url],
tags: [tagRoom(room, url)],
})
} }
} }
</script> </script>
<LongPress <LongPress
on:click={isMobile || inert ? null : onClick} data-event={event.id}
onLongPress={inert ? null : onLongPress} onLongPress={inert ? null : onLongPress}
class="group relative flex w-full flex-col gap-1 p-2 text-left transition-colors {inert class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left">
? 'hover:bg-base-300'
: ''}">
<div class="flex w-full gap-3 overflow-auto"> <div class="flex w-full gap-3 overflow-auto">
{#if showPubkey} {#if showPubkey}
<Link external href={pubkeyLink(event.pubkey)} class="flex items-start"> <Button on:click={openProfile} class="flex items-start">
<Avatar src={$profile?.picture} class="border border-solid border-base-content" size={10} /> <Avatar src={$profile?.picture} class="border border-solid border-base-content" size={8} />
</Link> </Button>
{:else} {:else}
<div class="w-10 min-w-10 max-w-10" /> <div class="w-8 min-w-8 max-w-8" />
{/if} {/if}
<div class="-mt-1 min-w-0 flex-grow pr-1"> <div class="min-w-0 flex-grow pr-1">
{#if showPubkey} {#if showPubkey}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Link <Button on:click={openProfile} class="text-sm font-bold" style="color: {colorValue}">
external
href={pubkeyLink(event.pubkey)}
class="text-sm font-bold"
style="color: {colorValue}">
{$profileDisplay} {$profileDisplay}
</Link> </Button>
<span class="text-xs opacity-50">{formatTimestampAsTime(event.created_at)}</span> <span class="text-xs opacity-50">
{#if formatTimestampAsDate(event.created_at) === today}
Today
{:else}
{formatTimestampAsDate(event.created_at)}
{/if}
at {formatTimestampAsTime(event.created_at)}
</span>
</div> </div>
{/if} {/if}
<div class="text-sm"> <div class="text-sm">
<Content {event} /> <Content {event} quoteProps={{minimal: true, relays: [url]}} />
{#if thunk} {#if thunk}
<ThunkStatus {thunk} class="mt-2" /> <ThunkStatus {thunk} class="mt-2" />
{/if} {/if}
</div> </div>
</div> </div>
</div> </div>
<div class="row-2 ml-12"> <div class="row-2 ml-10 mt-1">
{#if !isHead} <ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-right" />
<ReplySummary relays={[url]} {event} on:click={onClick} />
{/if}
<ReactionSummary relays={[url]} {event} {onReactionClick} />
</div> </div>
<button <button
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all" class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile} class:group-hover:opacity-100={!isMobile}
on:click|stopPropagation> on:click|stopPropagation>
<ChannelMessageEmojiButton {url} {room} {event} /> <ChannelMessageEmojiButton {url} {room} {event} />
{#if replyTo}
<Button class="btn join-item btn-xs" on:click={reply}>
<Icon icon="reply" size={4} />
</Button>
{/if}
<ChannelMessageMenuButton {url} {event} /> <ChannelMessageMenuButton {url} {event} />
</button> </button>
</LongPress> </LongPress>
@@ -1,19 +1,17 @@
<script lang="ts"> <script lang="ts">
import {noop} from "@welshman/lib"
import type {NativeEmoji} from "emoji-picker-element/shared" import type {NativeEmoji} from "emoji-picker-element/shared"
import EmojiButton from "@lib/components/EmojiButton.svelte" import EmojiButton from "@lib/components/EmojiButton.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import {tagRoom} from "@app/state"
import {publishReaction} from "@app/commands" import {publishReaction} from "@app/commands"
export let url, room, event export let url, room, event
// Tell svelte-check to shut up
noop(room)
const onEmoji = (emoji: NativeEmoji) => const onEmoji = (emoji: NativeEmoji) =>
publishReaction({ publishReaction({event, relays: [url], content: emoji.unicode})
event,
relays: [url],
content: emoji.unicode,
tags: [tagRoom(room, url)],
})
</script> </script>
<EmojiButton {onEmoji} class="btn join-item btn-xs"> <EmojiButton {onEmoji} class="btn join-item btn-xs">
@@ -3,6 +3,7 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import EventInfo from "@app/components/EventInfo.svelte" import EventInfo from "@app/components/EventInfo.svelte"
import EventReport from "@app/components/EventReport.svelte"
import ConfirmDelete from "@app/components/ConfirmDelete.svelte" import ConfirmDelete from "@app/components/ConfirmDelete.svelte"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
@@ -10,6 +11,11 @@
export let event export let event
export let onClick export let onClick
const report = () => {
onClick()
pushModal(EventReport, {url, event})
}
const showInfo = () => { const showInfo = () => {
onClick() onClick()
pushModal(EventInfo, {event}) pushModal(EventInfo, {event})
@@ -35,5 +41,12 @@
Delete Message Delete Message
</Button> </Button>
</li> </li>
{:else}
<li>
<Button class="text-error" on:click={report}>
<Icon size={4} icon="danger" />
Report Content
</Button>
</li>
{/if} {/if}
</ul> </ul>
@@ -5,30 +5,25 @@
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte" import EmojiPicker from "@lib/components/EmojiPicker.svelte"
import EventInfo from "@app/components/EventInfo.svelte" import EventInfo from "@app/components/EventInfo.svelte"
import ChannelConversation from "@app/components/ChannelConversation.svelte"
import ConfirmDelete from "@app/components/ConfirmDelete.svelte" import ConfirmDelete from "@app/components/ConfirmDelete.svelte"
import {publishReaction} from "@app/commands" import {publishReaction} from "@app/commands"
import {pushModal, pushDrawer} from "@app/modal" import {pushModal} from "@app/modal"
import {tagRoom} from "@app/state"
export let url export let url
export let room
export let event export let event
export let reply
const onEmoji = (emoji: NativeEmoji) => { const onEmoji = (emoji: NativeEmoji) => {
history.back() history.back()
publishReaction({ publishReaction({event, relays: [url], content: emoji.unicode})
event,
relays: [url],
content: emoji.unicode,
tags: [tagRoom(room, url)],
})
} }
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true}) const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
const showConversation = () => const sendReply = () => {
pushDrawer(ChannelConversation, {url, room, event}, {replaceState: true}) history.back()
reply()
}
const showInfo = () => pushModal(EventInfo, {event}, {replaceState: true}) const showInfo = () => pushModal(EventInfo, {event}, {replaceState: true})
@@ -40,9 +35,9 @@
<Icon size={4} icon="smile-circle" /> <Icon size={4} icon="smile-circle" />
Send Reaction Send Reaction
</Button> </Button>
<Button class="btn btn-neutral w-full" on:click={showConversation}> <Button class="btn btn-neutral w-full" on:click={sendReply}>
<Icon size={4} icon="reply" /> <Icon size={4} icon="reply" />
View Conversation Send Reply
</Button> </Button>
<Button class="btn btn-neutral" on:click={showInfo}> <Button class="btn btn-neutral" on:click={showInfo}>
<Icon size={4} icon="code-2" /> <Icon size={4} icon="code-2" />
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts">
import {GENERAL, channelsById, makeChannelId} from "@app/state"
export let url
export let room
</script>
{#if room === GENERAL}
general
{:else}
{$channelsById.get(makeChannelId(url, room))?.name || room}
{/if}
+30 -17
View File
@@ -13,13 +13,7 @@
import {int, nthNe, MINUTE, sortBy, remove} from "@welshman/lib" import {int, nthNe, MINUTE, sortBy, remove} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {createEvent, DIRECT_MESSAGE, INBOX_RELAYS} from "@welshman/util" import {createEvent, DIRECT_MESSAGE, INBOX_RELAYS} from "@welshman/util"
import { import {pubkey, formatTimestampAsDate, inboxRelaySelectionsByPubkey, load} from "@welshman/app"
pubkey,
formatTimestampAsDate,
inboxRelaySelectionsByPubkey,
load,
tagPubkey,
} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
@@ -29,12 +23,14 @@
import ProfileName from "@app/components/ProfileName.svelte" import ProfileName from "@app/components/ProfileName.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte" import ProfileCircles from "@app/components/ProfileCircles.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ProfileList from "@app/components/ProfileList.svelte" import ProfileList from "@app/components/ProfileList.svelte"
import ChatMessage from "@app/components/ChatMessage.svelte" import ChatMessage from "@app/components/ChatMessage.svelte"
import ChatCompose from "@app/components/ChannelCompose.svelte" import ChatCompose from "@app/components/ChannelCompose.svelte"
import {userSettingValues, deriveChat, splitChatId, PLATFORM_NAME, pubkeyLink} from "@app/state" import ChatComposeParent from "@app/components/ChannelComposeParent.svelte"
import {userSettingValues, deriveChat, splitChatId, PLATFORM_NAME} from "@app/state"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {sendWrapped} from "@app/commands" import {sendWrapped, prependParent} from "@app/commands"
export let id export let id
@@ -52,19 +48,32 @@
const showMembers = () => const showMembers = () =>
pushModal(ProfileList, {pubkeys: others, title: `People in this conversation`}) pushModal(ProfileList, {pubkeys: others, title: `People in this conversation`})
const onSubmit = async ({content, ...params}: EventContent) => { const replyTo = (event: TrustedEvent) => {
// Remove p tags since they result in forking the conversation parent = event
const tags = [...params.tags.filter(nthNe(0, "p")), ...remove($pubkey!, pubkeys).map(tagPubkey)] compose.focus()
}
const clearParent = () => {
parent = undefined
}
const onSubmit = async ({content, tags}: EventContent) => {
await sendWrapped({ await sendWrapped({
pubkeys, pubkeys,
template: createEvent(DIRECT_MESSAGE, {content, tags}), template: createEvent(
DIRECT_MESSAGE,
prependParent(parent, {content, tags: tags.filter(nthNe(0, "p"))}),
),
delay: $userSettingValues.send_delay, delay: $userSettingValues.send_delay,
}) })
clearParent()
} }
let loading = true let loading = true
let parent: TrustedEvent | undefined
let elements: Element[] = [] let elements: Element[] = []
let compose: ChatCompose
$: { $: {
elements = [] elements = []
@@ -112,10 +121,11 @@
<div slot="title" class="flex flex-col gap-1 sm:flex-row sm:gap-2"> <div slot="title" class="flex flex-col gap-1 sm:flex-row sm:gap-2">
{#if others.length === 1} {#if others.length === 1}
{@const pubkey = others[0]} {@const pubkey = others[0]}
<Link external href={pubkeyLink(pubkey)} class="row-2"> {@const onClick = () => pushModal(ProfileDetail, {pubkey})}
<Button on:click={onClick} class="row-2">
<ProfileCircle {pubkey} size={5} /> <ProfileCircle {pubkey} size={5} />
<ProfileName {pubkey} /> <ProfileName {pubkey} />
</Link> </Button>
{:else} {:else}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<ProfileCircles pubkeys={others} size={5} /> <ProfileCircles pubkeys={others} size={5} />
@@ -170,7 +180,7 @@
{#if type === "date"} {#if type === "date"}
<Divider>{value}</Divider> <Divider>{value}</Divider>
{:else} {:else}
<ChatMessage event={assertEvent(value)} {pubkeys} {showPubkey} /> <ChatMessage event={assertEvent(value)} {pubkeys} {showPubkey} {replyTo} />
{/if} {/if}
{/each} {/each}
<p <p
@@ -185,5 +195,8 @@
<slot name="info" /> <slot name="info" />
</p> </p>
</div> </div>
<ChatCompose {onSubmit} /> {#if parent}
<ChatComposeParent event={parent} clear={clearParent} />
{/if}
<ChatCompose bind:this={compose} {onSubmit} />
</div> </div>
+64
View File
@@ -0,0 +1,64 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {WRAP} from "@welshman/util"
import {repository} from "@welshman/app"
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 {canDecrypt, PLATFORM_NAME, ensureUnwrapped} from "@app/state"
import {clearModals} from "@app/modal"
export let next
let loading = false
const enableChat = async () => {
canDecrypt.set(true)
for (const event of repository.query([{kinds: [WRAP]}])) {
ensureUnwrapped(event)
}
clearModals()
goto(next)
}
const submit = async () => {
loading = true
try {
await enableChat()
} finally {
loading = false
}
}
const back = () => history.back()
</script>
<form class="column gap-4" on:submit|preventDefault={submit}>
<ModalHeader>
<div slot="title">Enable Messages</div>
<div slot="info">Do you want to enable direct messages?</div>
</ModalHeader>
<p>
By default, direct messages are disabled, since loading them requires
{PLATFORM_NAME} to download and decrypt a lot of data.
</p>
<p>
If you'd like to enable them, please make sure your signer is set up to to auto-approve requests
to decrypt data.
</p>
<ModalFooter>
<Button class="btn btn-link" on:click={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Enable Messages</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
</form>
+7 -5
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {page} from "$app/stores" import {page} from "$app/stores"
import {remove, assoc} from "@welshman/lib" import {remove} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {pubkey, loadInboxRelaySelections} from "@welshman/app" import {pubkey, loadInboxRelaySelections} from "@welshman/app"
import {fade} from "@lib/transition" import {fade} from "@lib/transition"
@@ -10,7 +10,7 @@
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte" import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {makeChatPath} from "@app/routes" import {makeChatPath} from "@app/routes"
import {CHAT_FILTERS, deriveNotification} from "@app/notifications" import {notifications} from "@app/notifications"
export let id: string export let id: string
export let pubkeys: string[] export let pubkeys: string[]
@@ -19,7 +19,6 @@
const others = remove($pubkey!, pubkeys) const others = remove($pubkey!, pubkeys)
const active = $page.params.chat === id const active = $page.params.chat === id
const path = makeChatPath(pubkeys) const path = makeChatPath(pubkeys)
const notification = deriveNotification(path, CHAT_FILTERS.map(assoc("authors", pubkeys)))
onMount(() => { onMount(() => {
for (const pk of others) { for (const pk of others) {
@@ -35,7 +34,10 @@
<div class="flex flex-col justify-start gap-1"> <div class="flex flex-col justify-start gap-1">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2"> <div class="flex min-w-0 items-center gap-2">
{#if others.length === 1} {#if others.length === 0}
<ProfileCircle pubkey={$pubkey} size={5} />
Note to self
{:else if others.length === 1}
<ProfileCircle pubkey={others[0]} size={5} /> <ProfileCircle pubkey={others[0]} size={5} />
<ProfileName pubkey={others[0]} /> <ProfileName pubkey={others[0]} />
{:else} {:else}
@@ -47,7 +49,7 @@
</p> </p>
{/if} {/if}
</div> </div>
{#if !active && $notification} {#if !active && $notifications.has(path)}
<div class="h-2 w-2 rounded-full bg-primary" transition:fade /> <div class="h-2 w-2 rounded-full bg-primary" transition:fade />
{/if} {/if}
</div> </div>
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ChatStart from "@app/components/ChatStart.svelte"
import {setChecked} from "@app/notifications"
import {pushModal} from "@app/modal"
const startChat = () => pushModal(ChatStart, {}, {replaceState: true})
const markAsRead = () => {
setChecked("/chat/*")
history.back()
}
</script>
<div class="col-2">
<Button class="btn btn-primary" on:click={startChat}>
<Icon size={4} icon="add-circle" />
Start chat
</Button>
<Button class="btn btn-neutral" on:click={markAsRead}>
<Icon size={4} icon="check-circle" />
Mark all read
</Button>
</div>
+24 -18
View File
@@ -11,25 +11,27 @@
} from "@welshman/app" } from "@welshman/app"
import {isMobile} from "@lib/html" import {isMobile} from "@lib/html"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte" import Button from "@lib/components/Button.svelte"
import Tippy from "@lib/components/Tippy.svelte" import Tippy from "@lib/components/Tippy.svelte"
import LongPress from "@lib/components/LongPress.svelte" import LongPress from "@lib/components/LongPress.svelte"
import Avatar from "@lib/components/Avatar.svelte" import Avatar from "@lib/components/Avatar.svelte"
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
import ReplySummary from "@app/components/ReplySummary.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte" import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkStatus from "@app/components/ThunkStatus.svelte" import ThunkStatus from "@app/components/ThunkStatus.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte" import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte" import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte"
import {colors, pubkeyLink} from "@app/state" import {colors} from "@app/state"
import {makeDelete, makeReaction, sendWrapped} from "@app/commands" import {makeDelete, makeReaction, sendWrapped} from "@app/commands"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
export let event: TrustedEvent export let event: TrustedEvent
export let replyTo: any = undefined
export let pubkeys: string[] export let pubkeys: string[]
export let showPubkey = false export let showPubkey = false
const thunk = $thunks[event.id] const thunk = $thunks[event.id]
const isOwn = event.pubkey === $pubkey
const profile = deriveProfile(event.pubkey) const profile = deriveProfile(event.pubkey)
const profileDisplay = deriveProfileDisplay(event.pubkey) const profileDisplay = deriveProfileDisplay(event.pubkey)
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length] const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
@@ -41,6 +43,8 @@
await sendWrapped({template, pubkeys}) await sendWrapped({template, pubkeys})
} }
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys}) const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys})
const togglePopover = () => { const togglePopover = () => {
@@ -59,14 +63,15 @@
<ThunkStatus {thunk} class="mt-1" /> <ThunkStatus {thunk} class="mt-1" />
{/if} {/if}
<div <div
data-event={event.id}
class="group chat flex items-center justify-end gap-1 px-2" class="group chat flex items-center justify-end gap-1 px-2"
class:chat-start={event.pubkey !== $pubkey} class:chat-start={!isOwn}
class:flex-row-reverse={event.pubkey !== $pubkey} class:flex-row-reverse={!isOwn}
class:chat-end={event.pubkey === $pubkey}> class:chat-end={isOwn}>
<Tippy <Tippy
bind:popover bind:popover
component={ChatMessageMenu} component={ChatMessageMenu}
props={{event, pubkeys, popover}} props={{event, pubkeys, popover, replyTo}}
params={{ params={{
interactive: true, interactive: true,
trigger: "manual", trigger: "manual",
@@ -85,28 +90,30 @@
<Icon icon="menu-dots" size={4} /> <Icon icon="menu-dots" size={4} />
</button> </button>
</Tippy> </Tippy>
<div class="flex min-w-0 flex-col" class:items-end={event.pubkey === $pubkey}> <div class="flex min-w-0 flex-col" class:items-end={isOwn}>
<LongPress <LongPress
class="bg-alt chat-bubble mx-1 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl" class="bg-alt chat-bubble mx-1 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl"
onLongPress={showMobileMenu}> onLongPress={showMobileMenu}>
{#if showPubkey && event.pubkey !== $pubkey} {#if showPubkey}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Link external href={pubkeyLink(event.pubkey)} class="flex items-center gap-1"> {#if !isOwn}
<Button on:click={openProfile} class="flex items-center gap-1">
<Avatar <Avatar
src={$profile?.picture} src={$profile?.picture}
class="border border-solid border-base-content" class="border border-solid border-base-content"
size={4} /> size={4} />
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Link <Button
external on:click={openProfile}
href={pubkeyLink(event.pubkey)}
class="text-sm font-bold" class="text-sm font-bold"
style="color: {colorValue}"> style="color: {colorValue}">
{$profileDisplay} {$profileDisplay}
</Link> </Button>
</div> </div>
</Link> </Button>
<span class="text-xs opacity-50">{formatTimestampAsTime(event.created_at)}</span> {/if}
<span class="whitespace-nowrap text-xs opacity-50"
>{formatTimestampAsTime(event.created_at)}</span>
</div> </div>
{/if} {/if}
<div class="text-sm"> <div class="text-sm">
@@ -114,8 +121,7 @@
</div> </div>
</LongPress> </LongPress>
<div class="row-2 z-feature -mt-1 ml-4"> <div class="row-2 z-feature -mt-1 ml-4">
<ReplySummary {event} /> <ReactionSummary {event} {onReactionClick} noTooltip />
<ReactionSummary {event} {onReactionClick} />
</div> </div>
</div> </div>
</div> </div>
@@ -8,6 +8,9 @@
export let event export let event
export let pubkeys export let pubkeys
export let popover export let popover
export let replyTo
const reply = () => replyTo(event)
const showInfo = () => { const showInfo = () => {
popover.hide() popover.hide()
@@ -17,6 +20,11 @@
<div class="join border border-solid border-neutral text-xs"> <div class="join border border-solid border-neutral text-xs">
<ChatMessageEmojiButton {event} {pubkeys} /> <ChatMessageEmojiButton {event} {pubkeys} />
{#if replyTo}
<Button class="btn join-item btn-xs" on:click={reply}>
<Icon size={4} icon="reply" />
</Button>
{/if}
<Button class="btn join-item btn-xs" on:click={showInfo}> <Button class="btn join-item btn-xs" on:click={showInfo}>
<Icon size={4} icon="code-2" /> <Icon size={4} icon="code-2" />
</Button> </Button>
+43 -17
View File
@@ -4,7 +4,7 @@
import { import {
parse, parse,
truncate, truncate,
render as renderParsed, renderAsHtml,
isText, isText,
isTopic, isTopic,
isCode, isCode,
@@ -36,6 +36,7 @@
export let showEntire = false export let showEntire = false
export let hideMedia = false export let hideMedia = false
export let expandMode = "block" export let expandMode = "block"
export let quoteProps: Record<string, any> = {}
export let depth = 0 export let depth = 0
const fullContent = parse(event) const fullContent = parse(event)
@@ -44,18 +45,38 @@
showEntire = true showEntire = true
} }
const isBoundary = (i: number) => { const isBlock = (i: number) => {
const parsed = fullContent[i] const parsed = fullContent[i]
if (!parsed || isNewline(parsed)) return true if (!parsed || hideMedia) return false
if (isText(parsed)) return parsed.value.match(/^\s+$/)
if (isLink(parsed) && $userSettingValues.show_media && isStartOrEnd(i)) {
return true
}
if ((isEvent(parsed) || isAddress(parsed)) && isStartOrEnd(i) && depth < 1) {
return true
}
return false return false
} }
const isStartAndEnd = (i: number) => Boolean(isBoundary(i - 1) && isBoundary(i + 1)) const isBoundary = (i: number) => {
const parsed = fullContent[i]
const isStartOrEnd = (i: number) => Boolean(isBoundary(i - 1) || isBoundary(i + 1)) if (!parsed || isNewline(parsed)) return true
if (isText(parsed)) return Boolean(parsed.value.match(/^\s+$/))
return false
}
const isStart = (i: number) => isBoundary(i - 1)
const isEnd = (i: number) => isBoundary(i + 1)
const isStartAndEnd = (i: number) => isStart(i) && isEnd(i)
const isStartOrEnd = (i: number) => isStart(i) || isEnd(i)
const ignoreWarning = () => { const ignoreWarning = () => {
warning = null warning = null
@@ -72,7 +93,7 @@
mediaLength: hideMedia ? 20 : 200, mediaLength: hideMedia ? 20 : 200,
}) })
$: hasEllipsis = shortContent.find(isEllipsis) $: hasEllipsis = shortContent.some(isEllipsis)
$: expandInline = hasEllipsis && expandMode === "inline" $: expandInline = hasEllipsis && expandMode === "inline"
$: expandBlock = hasEllipsis && expandMode === "block" $: expandBlock = hasEllipsis && expandMode === "block"
</script> </script>
@@ -92,15 +113,17 @@
style={expandBlock ? "mask-image: linear-gradient(0deg, transparent 0px, black 100px)" : ""}> style={expandBlock ? "mask-image: linear-gradient(0deg, transparent 0px, black 100px)" : ""}>
{#each shortContent as parsed, i} {#each shortContent as parsed, i}
{#if isNewline(parsed)} {#if isNewline(parsed)}
<ContentNewline value={parsed.value} /> <ContentNewline value={parsed.value.slice(isBlock(i - 1) ? 1 : 0)} />
{:else if isTopic(parsed)} {:else if isTopic(parsed)}
<ContentTopic value={parsed.value} /> <ContentTopic value={parsed.value} />
{:else if isCode(parsed)} {:else if isCode(parsed)}
<ContentCode value={parsed.value} isBlock={isStartAndEnd(i)} /> <ContentCode
value={parsed.value}
isBlock={isStartAndEnd(i) || parsed.value.includes("\n")} />
{:else if isCashu(parsed) || isInvoice(parsed)} {:else if isCashu(parsed) || isInvoice(parsed)}
<ContentToken value={parsed.value} /> <ContentToken value={parsed.value} />
{:else if isLink(parsed)} {:else if isLink(parsed)}
{#if isStartOrEnd(i) && !hideMedia && $userSettingValues.show_media} {#if isBlock(i)}
<ContentLinkBlock value={parsed.value} /> <ContentLinkBlock value={parsed.value} />
{:else} {:else}
<ContentLinkInline value={parsed.value} /> <ContentLinkInline value={parsed.value} />
@@ -108,10 +131,10 @@
{:else if isProfile(parsed)} {:else if isProfile(parsed)}
<ContentMention value={parsed.value} /> <ContentMention value={parsed.value} />
{:else if isEvent(parsed) || isAddress(parsed)} {:else if isEvent(parsed) || isAddress(parsed)}
{#if isStartOrEnd(i) && depth < 2 && !hideMedia} {#if isBlock(i)}
<ContentQuote value={parsed.value} {depth} {event}> <ContentQuote {...quoteProps} value={parsed.value} {depth} {event}>
<div slot="note-content" let:event> <div slot="note-content" let:event>
<svelte:self {hideMedia} {event} depth={depth + 1} /> <svelte:self {quoteProps} {hideMedia} {event} depth={depth + 1} />
</div> </div>
</ContentQuote> </ContentQuote>
{:else} {:else}
@@ -123,16 +146,19 @@
</Link> </Link>
{/if} {/if}
{:else if isEllipsis(parsed) && expandInline} {:else if isEllipsis(parsed) && expandInline}
{@html renderParsed(parsed)} {@html renderAsHtml(parsed)}
<button type="button" class="text-sm underline"> Read more </button> <button type="button" class="text-sm underline"> Read more </button>
{:else} {:else}
{@html renderParsed(parsed)} {@html renderAsHtml(parsed)}
{/if} {/if}
{/each} {/each}
</div> </div>
{#if expandBlock} {#if expandBlock}
<div class="relative z-feature -mt-6 flex justify-center bg-gradient-to-t from-base-100 py-2"> <div class="relative z-feature -mt-6 flex justify-center py-2">
<button type="button" class="btn" on:click|stopPropagation|preventDefault={expand}> <button
type="button"
class="btn btn-neutral"
on:click|stopPropagation|preventDefault={expand}>
See more See more
</button> </button>
</div> </div>
+4 -2
View File
@@ -3,6 +3,8 @@
export let isBlock export let isBlock
</script> </script>
<code class="link-content w-full" class:block={isBlock}> <code
{value} class="w-full overflow-auto whitespace-pre rounded bg-neutral px-1 text-neutral-content"
class:block={isBlock}>
{value.trim()}
</code> </code>
+3 -3
View File
@@ -22,7 +22,7 @@
const expand = () => pushModal(ContentLinkDetail, {url}, {fullscreen: true}) const expand = () => pushModal(ContentLinkDetail, {url}, {fullscreen: true})
</script> </script>
<Link external href={url} class="my-2 flex"> <Link external href={url} class="my-2 block">
<div class="overflow-hidden rounded-box leading-[0]"> <div class="overflow-hidden rounded-box leading-[0]">
{#if url.match(/\.(mov|webm|mp4)$/)} {#if url.match(/\.(mov|webm|mp4)$/)}
<video controls src={url} class="max-h-96 object-contain object-center"> <video controls src={url} class="max-h-96 object-contain object-center">
@@ -30,7 +30,7 @@
</video> </video>
{:else if url.match(/\.(jpe?g|png|gif|webp)$/)} {:else if url.match(/\.(jpe?g|png|gif|webp)$/)}
<button type="button" on:click|stopPropagation|preventDefault={expand}> <button type="button" on:click|stopPropagation|preventDefault={expand}>
<img alt="Link preview" src={imgproxy(url)} class="m-auto max-h-96" /> <img alt="Link preview" src={imgproxy(url)} class="m-auto max-h-96 rounded-box" />
</button> </button>
{:else} {:else}
{#await loadPreview()} {#await loadPreview()}
@@ -54,7 +54,7 @@
{/if} {/if}
</div> </div>
{:catch} {:catch}
<p class="bg-alt p-12 text-center"> <p class="bg-alt p-12 text-center leading-normal">
Unable to load a preview for {url} Unable to load a preview for {url}
</p> </p>
{/await} {/await}
+7 -4
View File
@@ -1,14 +1,17 @@
<script lang="ts"> <script lang="ts">
import {displayProfile} from "@welshman/util" import {displayProfile} from "@welshman/util"
import {deriveProfile} from "@welshman/app" import {deriveProfile} from "@welshman/app"
import Link from "@lib/components/Link.svelte" import Button from "@lib/components/Button.svelte"
import {pubkeyLink} from "@app/state" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal"
export let value export let value
const profile = deriveProfile(value.pubkey) const profile = deriveProfile(value.pubkey)
const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey})
</script> </script>
<Link external href={pubkeyLink(value.pubkey)} class="link-content"> <Button on:click={openProfile} class="link-content">
@{displayProfile($profile)} @{displayProfile($profile)}
</Link> </Button>
+83 -24
View File
@@ -1,46 +1,105 @@
<script lang="ts"> <script lang="ts">
import {nip19} from "nostr-tools" import {nip19} from "nostr-tools"
import {goto} from "$app/navigation"
import {ctx, nthEq} from "@welshman/lib" import {ctx, nthEq} from "@welshman/lib"
import {Address} from "@welshman/util" import {tracker, repository} from "@welshman/app"
import type {TrustedEvent} from "@welshman/util" import {Address, DIRECT_MESSAGE, MESSAGE, THREAD} from "@welshman/util"
import Link from "@lib/components/Link.svelte" import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import NoteCard from "@app/components/NoteCard.svelte" import NoteCard from "@app/components/NoteCard.svelte"
import {deriveEvent, entityLink, MESSAGE, THREAD} from "@app/state" import {deriveEvent, entityLink, ROOM} from "@app/state"
import {makeThreadPath} from "@app/routes" import {makeThreadPath, makeRoomPath} from "@app/routes"
export let value export let value
export let event export let event
export let depth = 0 export let depth = 0
export let relays: string[] = []
export let minimal = false
const {id, identifier, kind, pubkey, relays: relayHints = []} = value const {id, identifier, kind, pubkey, relays: relayHints = []} = value
const addr = new Address(kind, pubkey, identifier) const idOrAddress = id || new Address(kind, pubkey, identifier).toString()
const idOrAddress = id || addr.toString() const mergedRelays = [
const relays = ctx.app.router.Quote(event, idOrAddress, relayHints).getUrls() ...relays,
const quote = deriveEvent(idOrAddress, relays) ...ctx.app.router.Quote(event, idOrAddress, relayHints).getUrls(),
const entity = id ? nip19.neventEncode({id, relays}) : addr.toNaddr() ]
const quote = deriveEvent(idOrAddress, mergedRelays)
const entity = id
? nip19.neventEncode({id, relays: mergedRelays})
: new Address(kind, pubkey, identifier, mergedRelays).toNaddr()
const getLocalHref = (e: TrustedEvent) => { const scrollToEvent = (id: string) => {
const url = e.tags.find(nthEq(0, "~"))?.[2] const element = document.querySelector(`[data-event="${id}"]`) as any
if (!url) return if (element) {
if ([MESSAGE, THREAD].includes(e.kind)) return makeThreadPath(url, e.id) element.scrollIntoView({behavior: "smooth"})
element.style =
"filter: brightness(1.5); transition-property: all; transition-duration: 400ms;"
const kind = e.tags.find(nthEq(0, "K"))?.[1] setTimeout(() => {
const id = e.tags.find(nthEq(0, "E"))?.[1] element.style = "transition-property: all; transition-duration: 300ms;"
}, 800)
if (!id || !kind) return setTimeout(() => {
if ([MESSAGE, THREAD].includes(parseInt(kind))) return makeThreadPath(url, id) element.style = ""
}, 800 + 400)
} }
// If we found this event on a relay that the user is a member of, redirect internally return Boolean(element)
$: localHref = $quote ? getLocalHref($quote) : null }
$: href = localHref || entityLink(entity)
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)
}
return Boolean(event)
}
const onClick = (e: Event) => {
if ($quote) {
if ($quote.kind === DIRECT_MESSAGE) {
return scrollToEvent($quote.id)
}
const [url] = tracker.getRelays($quote.id)
const room = $quote.tags.find(nthEq(0, ROOM))?.[1]
if (url && room) {
if ($quote.kind === THREAD) {
return goto(makeThreadPath(url, $quote.id))
}
if ($quote.kind === MESSAGE) {
return scrollToEvent($quote.id) || openMessage(url, room, $quote.id)
}
const kind = $quote.tags.find(nthEq(0, "K"))?.[1]
const id = $quote.tags.find(nthEq(0, "E"))?.[1]
if (id && kind) {
if (parseInt(kind) === THREAD) {
return goto(makeThreadPath(url, id))
}
if (parseInt(kind) === MESSAGE) {
return scrollToEvent(id) || openMessage(url, room, id)
}
}
}
}
window.open(entityLink(entity))
}
</script> </script>
<Link external={!localHref} {href} class="my-2 block max-w-full text-left"> <Button class="my-2 block max-w-full text-left" on:click={onClick}>
{#if $quote} {#if $quote}
<NoteCard event={$quote} class="bg-alt rounded-box p-4"> <NoteCard {minimal} event={$quote} class="bg-alt rounded-box p-4">
<slot name="note-content" event={$quote} {depth} /> <slot name="note-content" event={$quote} {depth} />
</NoteCard> </NoteCard>
{:else} {:else}
@@ -48,4 +107,4 @@
<Spinner loading>Loading event...</Spinner> <Spinner loading>Loading event...</Spinner>
</div> </div>
{/if} {/if}
</Link> </Button>
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
import {onMount} from "svelte"
import {postJson, sleep} from "@welshman/lib"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import LogInPassword from "@app/components/LogInPassword.svelte"
import {pushModal} from "@app/modal"
import {BURROW_URL} from "@app/state"
export let email
export let confirm_token
const login = () => {
pushModal(LogInPassword, {email}, {path: "/"})
}
let error: string
let loading = true
onMount(async () => {
const [res] = await Promise.all([
postJson(BURROW_URL + "/user/confirm-email", {email, confirm_token}),
sleep(2000),
])
error = res.error
loading = false
})
</script>
<div class="column gap-4">
<h1 class="heading">
{#if loading}
Just a second...
{:else if error}
Oops!
{:else}
Success!
{/if}
</h1>
<p class="m-auto max-w-sm text-center">
<Spinner {loading}>
{#if loading}
Hang tight, we're checking your confirmation link.
{:else if error}
Looks like something went wrong. {error}
{:else}
You're all set - click below to log in.
{/if}
</Spinner>
</p>
<Button class="btn btn-primary" on:click={login} disabled={loading}>Continue to Login</Button>
</div>
+16 -21
View File
@@ -1,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {EditorContent} from "svelte-tiptap"
import type {Readable} from "svelte/store" import {writable} from "svelte/store"
import {createEditor, type Editor, EditorContent} from "svelte-tiptap"
import {randomId} from "@welshman/lib" import {randomId} from "@welshman/lib"
import {createEvent, EVENT_DATE, EVENT_TIME} from "@welshman/util" import {createEvent, EVENT_TIME} from "@welshman/util"
import {publishThunk, dateToSeconds} from "@welshman/app" import {publishThunk, dateToSeconds} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte" import Field from "@lib/components/Field.svelte"
@@ -11,16 +10,18 @@
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte" import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import {getPubkeyHints} from "@app/commands" import {PROTECTED} from "@app/state"
import {getEditorOptions, getEditorTags} from "@lib/editor" import {makeEditor} from "@app/editor"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
export let url export let url
const uploading = writable(false)
const back = () => history.back() const back = () => history.back()
const submit = () => { const submit = () => {
if ($loading) return if ($uploading) return
if (!title) { if (!title) {
return pushToast({ return pushToast({
@@ -36,16 +37,16 @@
}) })
} }
const kind = isAllDay ? EVENT_DATE : EVENT_TIME const event = createEvent(EVENT_TIME, {
const event = createEvent(kind, { content: $editor.getText({blockSeparator: "\n"}).trim(),
content: $editor.getText({blockSeparator: "\n"}),
tags: [ tags: [
["d", randomId()], ["d", randomId()],
["title", title], ["title", title],
["location", location], ["location", location],
["start", dateToSeconds(start).toString()], ["start", dateToSeconds(start).toString()],
["end", dateToSeconds(end).toString()], ["end", dateToSeconds(end).toString()],
...getEditorTags($editor), ...$editor.storage.nostr.getEditorTags(),
PROTECTED,
], ],
}) })
@@ -53,18 +54,12 @@
history.back() history.back()
} }
let editor: Readable<Editor> const editor = makeEditor({submit, uploading})
const isAllDay = false
let title = "" let title = ""
let location = "" let location = ""
let start: Date let start: Date
let end: Date let end: Date
$: loading = $editor?.storage.fileUpload.loading
onMount(() => {
editor = createEditor(getEditorOptions({submit, getPubkeyHints}))
})
</script> </script>
<form class="column gap-4" on:submit|preventDefault={submit}> <form class="column gap-4" on:submit|preventDefault={submit}>
@@ -89,8 +84,8 @@
<Button <Button
data-tip="Add an image" data-tip="Add an image"
class="center btn tooltip" class="center btn tooltip"
on:click={$editor.commands.selectFiles}> on:click={() => $editor.chain().selectFiles().run()}>
{#if $loading} {#if $uploading}
<span class="loading loading-spinner loading-xs"></span> <span class="loading loading-spinner loading-xs"></span>
{:else} {:else}
<Icon icon="gallery-send" /> <Icon icon="gallery-send" />
+7 -5
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import {nip19} from "nostr-tools" import {nip19} from "nostr-tools"
import {ctx} from "@welshman/lib"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import FieldInline from "@lib/components/FieldInline.svelte" import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -8,10 +9,11 @@
export let event export let event
const note1 = nip19.noteEncode(event.id) const relays = ctx.app.router.Event(event).getUrls()
const nevent1 = nip19.neventEncode({...event, relays})
const npub1 = nip19.npubEncode(event.pubkey) const npub1 = nip19.npubEncode(event.pubkey)
const json = JSON.stringify(event, null, 2) const json = JSON.stringify(event, null, 2)
const copyId = () => clip(note1) const copyLink = () => clip(nevent1)
const copyPubkey = () => clip(npub1) const copyPubkey = () => clip(npub1)
const copyJson = () => clip(json) const copyJson = () => clip(json)
</script> </script>
@@ -22,11 +24,11 @@
<div slot="info">The full details of this event are shown below.</div> <div slot="info">The full details of this event are shown below.</div>
</ModalHeader> </ModalHeader>
<FieldInline> <FieldInline>
<p slot="label">Event ID</p> <p slot="label">Event Link</p>
<label class="input input-bordered flex w-full items-center gap-2" slot="input"> <label class="input input-bordered flex w-full items-center gap-2" slot="input">
<Icon icon="file" /> <Icon icon="file" />
<input type="text" class="ellipsize min-w-0 grow" value={note1} /> <input type="text" class="ellipsize min-w-0 grow" value={nevent1} />
<Button on:click={copyId} class="flex items-center"> <Button on:click={copyLink} class="flex items-center">
<Icon icon="copy" /> <Icon icon="copy" />
</Button> </Button>
</label> </label>
+4 -6
View File
@@ -1,17 +1,13 @@
<script lang="ts"> <script lang="ts">
import {fromPairs} from "@welshman/lib" import {fromPairs} from "@welshman/lib"
import {secondsToDate, getLocale, formatTimestamp, formatTimestampAsDate} from "@welshman/app" import {formatTimestamp, formatTimestampAsDate, formatTimestampAsTime} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
export let event export let event
const timeFmt = new Intl.DateTimeFormat(getLocale(), {timeStyle: "short"})
$: meta = fromPairs(event.tags) as Record<string, string> $: meta = fromPairs(event.tags) as Record<string, string>
$: end = parseInt(meta.end) $: end = parseInt(meta.end)
$: start = parseInt(meta.start) $: start = parseInt(meta.start)
$: startDate = secondsToDate(start)
$: endDate = secondsToDate(end)
$: startDateDisplay = formatTimestampAsDate(start) $: startDateDisplay = formatTimestampAsDate(start)
$: endDateDisplay = formatTimestampAsDate(end) $: endDateDisplay = formatTimestampAsDate(end)
$: isSingleDay = startDateDisplay === endDateDisplay $: isSingleDay = startDateDisplay === endDateDisplay
@@ -21,6 +17,8 @@
<span>{meta.title || meta.name}</span> <span>{meta.title || meta.name}</span>
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
<Icon icon="clock-circle" size={4} /> <Icon icon="clock-circle" size={4} />
{timeFmt.format(startDate)}{isSingleDay ? timeFmt.format(endDate) : formatTimestamp(end)} {formatTimestampAsTime(start)}{isSingleDay
? formatTimestampAsTime(end)
: formatTimestamp(end)}
</div> </div>
</div> </div>
+73
View File
@@ -0,0 +1,73 @@
<script lang="ts">
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 {pushToast} from "@app/toast"
import {publishReport} from "@app/commands"
export let url
export let event
const back = () => history.back()
const confirm = async () => {
if (!reason) {
return pushToast({
theme: "error",
message: "Please select a reason for your report.",
})
}
loading = true
await publishReport({event, reason: reason.toLowerCase(), content, relays: [url]})
loading = false
history.back()
return pushToast({message: "Your report has been sent!"})
}
let reason = ""
let content = ""
let loading = false
</script>
<form class="column gap-4" on:submit|preventDefault={confirm}>
<ModalHeader>
<div slot="title">Report Content</div>
<div slot="info">Flag inappropriate content.</div>
</ModalHeader>
<Field>
<p slot="label">Reason*</p>
<select slot="input" class="select select-bordered" bind:value={reason}>
<option disabled selected>Choose a reason</option>
<option>Nudity</option>
<option>Malware</option>
<option>Profanity</option>
<option>Illegal</option>
<option>Spam</option>
<option>Impersonation</option>
<option>Other</option>
</select>
<p slot="info">Please select a reason for your report.</p>
</Field>
<Field>
<p slot="label">Details</p>
<textarea slot="input" class="textarea textarea-bordered" bind:value={content} />
<p slot="info">Please provide any additional details relevant to your report.</p>
</Field>
<ModalFooter>
<Button class="btn btn-link" on:click={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Send Report</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
</form>
@@ -0,0 +1,55 @@
<script lang="ts">
import {getTag, REPORT} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {deriveEvents} from "@welshman/store"
import {pubkey, repository} from "@welshman/app"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import Button from "@lib/components/Button.svelte"
import Profile from "@app/components/Profile.svelte"
import {publishDelete} from "@app/commands"
export let url
export let event
const reports = deriveEvents(repository, {
filters: [{kinds: [REPORT], "#e": [event.id]}],
})
const back = () => history.back()
const deleteReport = (report: TrustedEvent) => {
publishDelete({event: report, relays: [url]})
if ($reports.length === 0) {
history.back()
}
}
const getReason = (tags: string[][]) => getTag("e", tags)?.[2] || "other"
</script>
<div class="column gap-4">
<ModalHeader>
<div slot="title">Report Details</div>
<div slot="info">All reports for this event are shown below.</div>
</ModalHeader>
{#each $reports as report (report.id)}
{@const reason = getReason(report.tags)}
{@const remove = () => deleteReport(report)}
<div class="column gap-2">
<div class="flex justify-between">
<div>
<Profile pubkey={report.pubkey} />
<span>Reported this event as "{reason}"</span>
</div>
{#if report.pubkey === $pubkey}
<Button class="btn-default btn" on:click={remove}>Delete Report</Button>
{/if}
</div>
{#if report.content}
<p>"{report.content}"</p>
{/if}
</div>
{/each}
<Button class="btn btn-primary" on:click={back}>Got it</Button>
</div>
+31 -10
View File
@@ -1,8 +1,17 @@
<script lang="ts"> <script lang="ts">
import {session} from "@welshman/app"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileEject from "@app/components/ProfileEject.svelte"
import {PLATFORM_NAME} from "@app/state" import {PLATFORM_NAME} from "@app/state"
import {pushModal} from "@app/modal"
const back = () => history.back()
const startEject = () => pushModal(ProfileEject)
</script> </script>
<div class="column gap-4"> <div class="column gap-4">
@@ -10,21 +19,33 @@
<div slot="title">What is a private key?</div> <div slot="title">What is a private key?</div>
</ModalHeader> </ModalHeader>
<p> <p>
Most software keeps track of users by giving them a username and password. This gives the Most online services keep track of users by giving them a username and password. This gives the
service service <strong>total control</strong> over their users, allowing them to ban them at any time, or
<strong>total control</strong> over their users, allowing them to ban them at any time, or sell their sell their activity.
activity.
</p> </p>
<p> <p>
On <Link external href="https://nostr.com/">Nostr</Link>, <strong>you</strong> control your own On <Link external href="https://nostr.com/">Nostr</Link>, <strong>you</strong> control your own
identity and social data, through the magic of crytography. The basic idea is that you have a identity and social data, through the magic of crytography. The basic idea is that you have a
<strong>public key</strong>, which acts as your user id, and a <strong>private key</strong> which <strong>public key</strong>, which acts as your user id, and a
allows you to authenticate any message you send. <strong>private key</strong> which allows you to prove your identity.
</p> </p>
{#if $session?.email}
<p> <p>
It's very important to keep private keys safe, but this can sometimes be confusing for It's very important to keep private keys safe, but this can sometimes be tricky, which is why {PLATFORM_NAME}
newcomers. This is why {PLATFORM_NAME} supports <strong>remote signer</strong> login. These services supports a traditional account-based login for new users.
can store your keys securely for you, giving you access using a username and password.
</p> </p>
<Button class="btn btn-primary" on:click={() => history.back()}>Got it</Button> <p>If you'd like to switch to self-custody, please click below to get started.</p>
<ModalFooter>
<Button class="btn btn-link" on:click={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button class="btn btn-primary" on:click={startEject}>
<Icon icon="check-circle" />
I want to hold my own keys
</Button>
</ModalFooter>
{:else}
<Button class="btn btn-primary" on:click={back}>Got it</Button>
{/if}
</div> </div>
+7 -1
View File
@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Link from "@lib/components/Link.svelte"
import Dialog from "@lib/components/Dialog.svelte" import Dialog from "@lib/components/Dialog.svelte"
import CardButton from "@lib/components/CardButton.svelte" import CardButton from "@lib/components/CardButton.svelte"
import LogIn from "@app/components/LogIn.svelte" import LogIn from "@app/components/LogIn.svelte"
import SignUp from "@app/components/SignUp.svelte" import SignUp from "@app/components/SignUp.svelte"
import {PLATFORM_NAME} from "@app/state" import {PLATFORM_TERMS, PLATFORM_PRIVACY, PLATFORM_NAME} from "@app/state"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
const logIn = () => pushModal(LogIn) const logIn = () => pushModal(LogIn)
@@ -33,5 +34,10 @@
<div slot="info">Just a few questions and you'll be on your way.</div> <div slot="info">Just a few questions and you'll be on your way.</div>
</CardButton> </CardButton>
</Button> </Button>
<p class="text-center text-xs opacity-75">
By using {PLATFORM_NAME}, you consent to our
<Link external class="link" href={PLATFORM_TERMS}>Terms of Service</Link> and
<Link external class="link" href={PLATFORM_PRIVACY}>Privacy Policy</Link>.
</p>
</div> </div>
</Dialog> </Dialog>
+40 -19
View File
@@ -9,8 +9,9 @@
import SignUp from "@app/components/SignUp.svelte" import SignUp from "@app/components/SignUp.svelte"
import InfoNostr from "@app/components/InfoNostr.svelte" import InfoNostr from "@app/components/InfoNostr.svelte"
import LogInBunker from "@app/components/LogInBunker.svelte" import LogInBunker from "@app/components/LogInBunker.svelte"
import LogInPassword from "@app/components/LogInPassword.svelte"
import {pushModal, clearModals} from "@app/modal" import {pushModal, clearModals} from "@app/modal"
import {PLATFORM_NAME} from "@app/state" import {PLATFORM_NAME, BURROW_URL} from "@app/state"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {loadUserData} from "@app/commands" import {loadUserData} from "@app/commands"
import {setChecked} from "@app/notifications" import {setChecked} from "@app/notifications"
@@ -18,28 +19,27 @@
const signUp = () => pushModal(SignUp) const signUp = () => pushModal(SignUp)
const withLoading = const withLoading =
(cb: (...args: any[]) => any) => (s: string, cb: (...args: any[]) => any) =>
async (...args: any[]) => { async (...args: any[]) => {
loading = true loading = s
try { try {
await cb(...args) await cb(...args)
} finally { } finally {
loading = false loading = undefined
} }
} }
const onSuccess = async (session: Session, relays: string[] = []) => { const onSuccess = async (session: Session, relays: string[] = []) => {
addSession(session)
await loadUserData(session.pubkey, {relays}) await loadUserData(session.pubkey, {relays})
addSession(session)
pushToast({message: "Successfully logged in!"}) pushToast({message: "Successfully logged in!"})
setChecked("*") setChecked("*")
clearModals() clearModals()
} }
const loginWithNip07 = withLoading(async () => { const loginWithNip07 = withLoading("nip07", async () => {
const pubkey = await getNip07()?.getPublicKey() const pubkey = await getNip07()?.getPublicKey()
if (pubkey) { if (pubkey) {
@@ -52,7 +52,7 @@
} }
}) })
const loginWithSigner = withLoading(async (app: any) => { const loginWithNip55 = withLoading("nip55", async (app: any) => {
const signer = new Nip55Signer(app.packageName) const signer = new Nip55Signer(app.packageName)
const pubkey = await signer.getPubkey() const pubkey = await signer.getPubkey()
@@ -66,19 +66,18 @@
} }
}) })
const loginWithPassword = () => pushModal(LogInPassword)
const loginWithBunker = () => pushModal(LogInBunker) const loginWithBunker = () => pushModal(LogInBunker)
let loading = false
let signers: any[] = [] let signers: any[] = []
let hasNativeSigner = Boolean(getNip07()) let loading: string | undefined
$: hasSigner = getNip07() || signers.length > 0
onMount(async () => { onMount(async () => {
if (Capacitor.isNativePlatform()) { if (Capacitor.isNativePlatform()) {
signers = await getNip55() signers = await getNip55()
if (signers.length > 0) {
hasNativeSigner = true
}
} }
}) })
</script> </script>
@@ -92,7 +91,7 @@
</p> </p>
{#if getNip07()} {#if getNip07()}
<Button disabled={loading} on:click={loginWithNip07} class="btn btn-primary"> <Button disabled={loading} on:click={loginWithNip07} class="btn btn-primary">
{#if loading} {#if loading === "nip07"}
<span class="loading loading-spinner mr-3" /> <span class="loading loading-spinner mr-3" />
{:else} {:else}
<Icon icon="widget" /> <Icon icon="widget" />
@@ -101,8 +100,8 @@
</Button> </Button>
{/if} {/if}
{#each signers as app} {#each signers as app}
<Button disabled={loading} class="btn btn-primary" on:click={() => loginWithSigner(app)}> <Button disabled={loading} class="btn btn-primary" on:click={() => loginWithNip55(app)}>
{#if loading} {#if loading === "nip55"}
<span class="loading loading-spinner mr-3" /> <span class="loading loading-spinner mr-3" />
{:else} {:else}
<img src={app.iconUrl} alt={app.name} width="20" height="20" /> <img src={app.iconUrl} alt={app.name} width="20" height="20" />
@@ -110,21 +109,43 @@
Log in with {app.name} Log in with {app.name}
</Button> </Button>
{/each} {/each}
{#if BURROW_URL && !hasSigner}
<Button disabled={loading} on:click={loginWithPassword} class="btn btn-primary">
{#if loading === "password"}
<span class="loading loading-spinner mr-3" />
{:else}
<Icon icon="key" />
{/if}
Log in with Password
</Button>
{/if}
<Button <Button
disabled={loading} disabled={loading}
on:click={loginWithBunker} on:click={loginWithBunker}
class="btn {hasNativeSigner ? 'btn-neutral' : 'btn-primary'}"> class="btn {hasSigner || BURROW_URL ? 'btn-neutral' : 'btn-primary'}">
<Icon icon="cpu" /> <Icon icon="cpu" />
Log in with Remote Signer Log in with Remote Signer
</Button> </Button>
{#if BURROW_URL && hasSigner}
<Button disabled={loading} on:click={loginWithPassword} class="btn">
{#if loading === "password"}
<span class="loading loading-spinner mr-3" />
{:else}
<Icon icon="key" />
{/if}
Log in with Password
</Button>
{/if}
{#if !hasSigner || !BURROW_URL}
<Link <Link
external external
disabled={loading} disabled={loading}
href="https://nostrapps.com#signers" href="https://nostrapps.com#signers"
class="btn {hasNativeSigner ? '' : 'btn-neutral'}"> class="btn {hasSigner || BURROW_URL ? '' : 'btn-neutral'}">
<Icon icon="compass" /> <Icon icon="compass" />
Browse Signer Apps Browse Signer Apps
</Link> </Link>
{/if}
<div class="text-sm"> <div class="text-sm">
Need an account? Need an account?
<Button class="link" on:click={signUp}>Register instead</Button> <Button class="link" on:click={signUp}>Register instead</Button>
+65 -34
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {onDestroy} from "svelte" import {onMount, onDestroy} from "svelte"
import {Nip46Broker} from "@welshman/signer" import {Nip46Broker, getPubkey, makeSecret} from "@welshman/signer"
import {nip46Perms, addSession} from "@welshman/app" import {addSession} from "@welshman/app"
import {slideAndFade} from "@lib/transition" import {slideAndFade} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -15,29 +15,24 @@
import {pushModal, clearModals} from "@app/modal" import {pushModal, clearModals} from "@app/modal"
import {setChecked} from "@app/notifications" import {setChecked} from "@app/notifications"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {PLATFORM_URL, PLATFORM_NAME, PLATFORM_LOGO, SIGNER_RELAYS} from "@app/state" import {NIP46_PERMS, PLATFORM_URL, PLATFORM_NAME, PLATFORM_LOGO, SIGNER_RELAYS} from "@app/state"
const back = () => history.back() const clientSecret = makeSecret()
const abortController = new AbortController() const abortController = new AbortController()
const init = Nip46Broker.initiate({ const broker = Nip46Broker.get({clientSecret, relays: SIGNER_RELAYS})
perms: nip46Perms,
url: PLATFORM_URL, const back = () => history.back()
name: PLATFORM_NAME,
relays: SIGNER_RELAYS,
image: PLATFORM_LOGO,
abortController,
})
const onSubmit = async () => { const onSubmit = async () => {
const {pubkey, token, relays} = Nip46Broker.parseBunkerLink(bunker) const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(input)
if (loading) { if (loading) {
return return
} }
if (!pubkey || relays.length === 0) { if (!signerPubkey || relays.length === 0) {
return pushToast({ return pushToast({
theme: "error", theme: "error",
message: "Sorry, it looks like that's an invalid bunker link.", message: "Sorry, it looks like that's an invalid bunker link.",
@@ -47,16 +42,16 @@
loading = true loading = true
try { try {
if (!(await loginWithNip46(token, {pubkey, relays}))) { const success = await loginWithNip46({connectSecret, clientSecret, signerPubkey, relays})
if (success) {
abortController.abort()
} else {
return pushToast({ return pushToast({
theme: "error", theme: "error",
message: "Something went wrong, please try again!", message: "Something went wrong, please try again!",
}) })
} }
abortController.abort()
await loadUserData(pubkey)
} finally { } finally {
loading = false loading = false
} }
@@ -64,21 +59,57 @@
clearModals() clearModals()
} }
let bunker = "" let url = ""
let input = ""
let loading = false let loading = false
init.result.then(async pubkey => { $: {
if (pubkey) { // For testing and for play store reviewers
loading = true if (input === "reviewkey") {
const secret = makeSecret()
addSession({ addSession({method: "nip01", secret, pubkey: getPubkey(secret)})
pubkey, }
method: "nip46", }
secret: init.clientSecret,
handler: {pubkey, relays: SIGNER_RELAYS}, onMount(async () => {
url = await broker.makeNostrconnectUrl({
perms: NIP46_PERMS,
url: PLATFORM_URL,
name: PLATFORM_NAME,
image: PLATFORM_LOGO,
}) })
await loadUserData(pubkey) 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("*") setChecked("*")
clearModals() clearModals()
@@ -97,16 +128,16 @@
Connect your signer by scanning the QR code below or pasting a bunker link. Connect your signer by scanning the QR code below or pasting a bunker link.
</div> </div>
</ModalHeader> </ModalHeader>
{#if !loading} {#if !loading && url}
<div class="w-xs m-auto" out:slideAndFade> <div class="w-xs m-auto" out:slideAndFade>
<QRCode code={init.nostrconnect} /> <QRCode code={url} />
</div> </div>
{/if} {/if}
<Field> <Field>
<p slot="label">Bunker Link*</p> <p slot="label">Bunker Link*</p>
<label class="input input-bordered flex w-full items-center gap-2" slot="input"> <label class="input input-bordered flex w-full items-center gap-2" slot="input">
<Icon icon="cpu" /> <Icon icon="cpu" />
<input disabled={loading} bind:value={bunker} class="grow" placeholder="bunker://" /> <input disabled={loading} bind:value={input} class="grow" placeholder="bunker://" />
</label> </label>
<p slot="info"> <p slot="info">
A login link provided by a nostr signing app. A login link provided by a nostr signing app.
@@ -118,7 +149,7 @@
<Icon icon="alt-arrow-left" /> <Icon icon="alt-arrow-left" />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary" disabled={loading || !bunker}> <Button type="submit" class="btn btn-primary" disabled={loading || !input}>
<Spinner {loading}>Next</Spinner> <Spinner {loading}>Next</Spinner>
<Icon icon="alt-arrow-right" /> <Icon icon="alt-arrow-right" />
</Button> </Button>

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