Compare commits

..

191 Commits

Author SHA1 Message Date
Jon Staab 3e832af3e4 Update version 2025-07-17 15:41:30 -07:00
Jon Staab 84b8650fa4 Shorten goals title for mobile 2025-07-17 15:40:07 -07:00
Jon Staab 83abb5aa94 Fix chat notifications so they take nip 29 into account 2025-07-17 15:35:49 -07:00
Jon Staab a12eddb47b Fix zaps on mobile 2025-07-17 15:29:26 -07:00
Jon Staab c87166247c Update zapstore.yaml 2025-07-17 15:21:05 -07:00
Jon Staab 037c8cb41b Disable zaps on ios 2025-07-17 14:39:59 -07:00
Jon Staab 79de2e1176 Bump version 2025-07-17 14:30:18 -07:00
Jon Staab d4b026a3ad Add zaps to threads/events 2025-07-15 15:56:55 -07:00
Jon Staab 00f383ff2e Add qr scanning for wallet connect 2025-07-15 15:49:26 -07:00
Jon Staab 6f6bb508db Handle invalid bunker url, update synced stores 2025-07-15 11:34:29 -07:00
Jon Staab e2a0672ca5 load messages in general on room relay 2025-07-09 14:28:07 -07:00
Jon Staab e2a5fe7a79 Fix sidebar overflow 2025-07-09 14:22:59 -07:00
Jon Staab 5d02ae75dc Bump welshman 2025-07-09 14:00:42 -07:00
Jon Staab 2460bbbc83 Fix balance coming from webln 2025-07-09 13:19:33 -07:00
Jon Staab 084d8d931b Load relay selections whenever we see a new pubkey 2025-07-09 09:17:45 -07:00
Jon Staab 6ee4ac1a89 Add funding goals 2025-07-07 15:28:36 -07:00
Jon Staab 1d07097350 Fix some zap bugs 2025-07-07 13:58:43 -07:00
Jon Staab 63d6b362c7 Remove info missing rooms 2025-07-07 12:46:17 -07:00
Jon Staab bfed277ea9 Add zaps 2025-07-04 06:22:19 -07:00
Jon Staab 9e8aa2ef3a show and copy npub 2025-07-02 16:48:44 -07:00
Jon Staab 4bbc0878f7 Bump apple version, add vapid key 2025-07-01 12:53:09 -07:00
Jon Staab 16a3ba2a9b Bump version 2025-06-30 11:08:42 -07:00
Jon Staab 7c11eb8947 Allow mark all as read on desktop 2025-06-30 11:03:02 -07:00
Jon Staab 6bdc8d4d9f Space alerts dialog 2025-06-30 10:41:42 -07:00
Jon Staab b9048936ba Tweak alerts button layout 2025-06-30 09:38:43 -07:00
Jon Staab b9620f4443 Add claim to alert add 2025-06-27 14:36:09 -07:00
Jon Staab f2249fe592 Handle conversations with no room 2025-06-27 09:44:01 -07:00
Jon Staab fd42a0e8d4 Clear badge when opening app 2025-06-27 09:41:30 -07:00
Jon Staab 37d52ba35f Show latest note as conversation 2025-06-27 09:00:43 -07:00
Jon Staab 3037323dc0 Add support for ios push notifications 2025-06-27 08:33:31 -07:00
Jon Staab 5301ef876d Fix notification badge for global chat 2025-06-24 17:36:14 -07:00
Jon Staab aa054d8b1a Fix ContentMention display 2025-06-24 17:23:07 -07:00
Jon Staab 3655790e5f Add fcm push notifications 2025-06-24 14:27:16 -07:00
Jon Staab 6cca823ed4 Get web push working 2025-06-23 11:16:25 -07:00
Jon Staab 18a383edab Update alert form to include push notifications 2025-06-19 10:01:16 -07:00
Jon Staab 43da7d628e Replace bunker with claim on alerts page 2025-06-18 17:02:32 -07:00
Jon Staab 2fae3ca248 Fix broadcasting user profiles when protected 2025-06-16 16:56:59 -07:00
Jon Staab d99ada44f5 Show link url if no title is available 2025-06-16 11:45:05 -07:00
Jon Staab cb0119b9b8 Update welshman 2025-06-16 10:12:24 -07:00
Jon Staab dac9ef8e4e Move some stuff to welshman, broadcast profile updates 2025-06-13 15:17:20 -07:00
Jon Staab 528917b90e Fix sort order of thread comments 2025-06-13 10:14:12 -07:00
Jon Staab a22db78967 rename createEvent to makeEvent 2025-06-10 13:35:57 -07:00
Jon Staab 5718510779 Bump versions 2025-06-09 15:29:13 -07:00
Jon Staab f877dc7fbe Add chat quick link 2025-06-09 15:27:00 -07:00
Jon Staab df03fb1116 Remove old docker workflow 2025-06-09 15:20:09 -07:00
Jon Staab 7455b49f8d Bump version 2025-06-09 15:15:58 -07:00
Jon Staab ae00eb0b9c Bump welshman and nostr-editor 2025-06-09 15:04:51 -07:00
Jon Staab b82e434c70 Try turning ci off 2025-06-09 14:02:06 -07:00
Jon Staab 576c9c2c95 Bump welshman 2025-06-09 13:48:46 -07:00
Jon Staab cef046b3ae Increase maxLength for recent activity 2025-06-09 13:48:46 -07:00
Jon Staab 18ae6f6044 Use new editor uploads 2025-06-09 13:48:46 -07:00
Jon Staab 664f3c01e0 Bump nostr-tools 2025-06-09 13:48:46 -07:00
Jon Staab 15e82c4e41 Link to frith in SpaceCreateExternal 2025-06-09 13:48:46 -07:00
Jon Staab 397ecf773e Show warning for non-nip29 relays 2025-06-09 13:48:46 -07:00
Jon Staab 45397e7fb8 Show image link if image fails to load 2025-06-09 13:48:46 -07:00
Jon Staab 11aa841241 Add profile room list 2025-06-09 13:48:46 -07:00
Jon Staab cc1c18d55f Update context file 2025-06-09 13:48:46 -07:00
Jon Staab e3fbd69e6e Tweak profiles/search 2025-06-09 13:48:46 -07:00
Jon Staab ac756bf266 tweak relay status component 2025-06-09 13:48:46 -07:00
Jon Staab 8e28ff13e9 Generalize goToMessage 2025-06-09 13:48:46 -07:00
Jon Staab d8b87db784 Flesh out recent activity component 2025-06-09 13:48:46 -07:00
Jon Staab 0b8c6c4a49 Add claude context file and mock up recent activity 2025-06-09 13:48:46 -07:00
Jon Staab 9f4f468bf0 Add tos and pp links 2025-06-09 13:48:46 -07:00
Jon Staab 7563dff621 Move relay status to its own component 2025-06-09 13:48:46 -07:00
Jon Staab f782898b62 Factor space recent activity into its own component 2025-06-09 13:48:46 -07:00
Jon Staab d0601400cd Move space quick links to its own file 2025-06-09 13:48:45 -07:00
Jon Staab d262da39e5 Tweak room search and owner display 2025-06-09 13:48:45 -07:00
Jon Staab 7d617d8399 Add latest note from admin to relay dashboard 2025-06-09 13:48:45 -07:00
Jon Staab d2b7db18af Show socket status on space dashboard 2025-06-09 13:48:45 -07:00
Jon Staab 89c2690254 Add new room dashboard layout 2025-06-09 13:48:45 -07:00
Jon Staab 34945d1c42 Fix chat menu item active state 2025-06-09 13:48:45 -07:00
Jon Staab 43b207c4dc Use kind 15 to send images in DMs 2025-06-09 13:48:45 -07:00
Jon Staab 55efb3fdfd Use encrypted uploads 2025-06-09 13:48:45 -07:00
Jon Staab c4a1ad2e33 Ignore roocode 2025-06-09 13:48:45 -07:00
Jon Staab fd8442c632 Remove space blossom detection 2025-06-09 13:48:45 -07:00
Jon Staab e0875eb9b9 Add minimal style for quotes of chat messages 2025-06-09 13:48:45 -07:00
Jon Staab 962ac7d80c Support copying and pasting npubs better 2025-06-09 13:48:45 -07:00
Jon Staab 5338ee11bc Update changelog 2025-06-09 13:48:45 -07:00
Jon Staab 6d2e9a037d Allow for multiple platform relays 2025-06-09 13:48:45 -07:00
Jon Staab ac8530bd9a Add non-nip29 chat, add leave room 2025-06-09 13:48:45 -07:00
Jon Staab f7d11cf124 Improve group membership detection 2025-06-09 13:48:45 -07:00
Jon Staab 72d85e5740 Unnest nip29 commands 2025-06-09 13:48:45 -07:00
Jon Staab e57b5721f6 Detect nip29 support for create room button 2025-06-09 13:48:45 -07:00
Jon Staab 4ba6c72459 Remove unmanaged groups 2025-06-09 13:48:45 -07:00
Jon Staab c33698c662 Remove general room 2025-06-09 13:48:45 -07:00
Jon Staab cf4e40c4cf Add github action to publish dockerfile 2025-06-09 13:48:45 -07:00
Jon Staab 664da505cd Improve forms for entering invite codes 2025-05-27 15:00:30 -07:00
Jon Staab 573d4e3cfb Add support for customizable accent content color 2025-05-19 16:56:19 -07:00
Jon Staab b2dc41f25b Re-arrange the readme 2025-05-19 15:17:26 -07:00
Jon Staab b3bc0e4957 Add github action to publish dockerfile 2025-05-19 11:54:19 -07:00
Jon Staab 0e79e5b9cc Add dockerfile 2025-05-15 16:20:46 -07:00
Jon Staab 34c7bfcffb Get rid of overries, bump welshman to lockstep versioning 2025-05-15 15:52:17 -07:00
Jon Staab fd9fee8f50 Add indexer, maybe improve safe area support 2025-05-15 10:15:41 -07:00
Jon Staab b14c3ab345 Bump version 2025-05-14 13:52:37 -07:00
Jon Staab 823058e335 Add setting for font size 2025-05-13 14:31:34 -07:00
Jon Staab 60ec6924f3 Fix thunks status layout 2025-05-13 10:35:42 -07:00
Jon Staab 18fc895fcb Tweak navigation to improve white labeled instances 2025-05-13 10:14:20 -07:00
Jon Staab 42295159a0 Update remove-pnpm-overrides to use package version of welshman (hack) 2025-05-13 09:06:53 -07:00
Jon Staab db408ac30d Stop propagation on thunk status 2025-05-12 15:35:13 -07:00
Jon Staab 1ced5689c3 Bump version 2025-05-12 15:19:38 -07:00
Jon Staab 263a803875 Add custom emoji parsing and display 2025-05-12 15:10:24 -07:00
Jon Staab 58afb8fa0c Bump editor 2025-05-12 11:17:13 -07:00
Jon Staab 4aaa19ea1b Apply theme to body so popovers get themed too, make selected popover item more clear 2025-05-12 10:03:29 -07:00
Jon Staab 2f9010cd13 Ignore unnecessary error 2025-05-12 09:01:13 -07:00
Jon Staab 12fcdfcd4f Add light theme and secondary color 2025-05-12 08:48:54 -07:00
Jon Staab 317ab57ed2 Use env instead of env.local 2025-05-12 08:27:46 -07:00
Jon Staab 52ef67740a Move default env to env.template, fix notifier relay/pubkey 2025-05-12 08:27:07 -07:00
Jon Staab 68ebd32e15 Bump welshman 2025-05-09 12:41:02 -07:00
Jon Staab e94aa3c119 Bump version, fix new messages thing 2025-05-09 12:26:05 -07:00
Jon Staab 4d10fe7cc0 Handle broken supported_nips 2025-05-08 11:16:02 -07:00
Jon Staab 841928783b Re-introduce safe inset areas 2025-05-08 11:05:27 -07:00
Jon Staab 6e5e1a0846 Remove safe area inset stuff to re-apply later 2025-05-08 09:11:10 -07:00
Jon Staab d57f4747a6 Tweak errors so that actionable links are rendered 2025-05-07 15:04:35 -07:00
Jon Staab 94a0077b09 Use non-singleton broker 2025-05-07 13:53:58 -07:00
Jon Staab f2eb04adff Bump version 2025-05-07 09:12:17 -07:00
Jon Staab d4d5979a35 Fix missing room images and room overflow in nav 2025-05-07 09:11:00 -07:00
Jon Staab dde6e54657 Add build in production script 2025-05-06 18:26:48 -07:00
Jon Staab 698a7513b8 Tweak some gradle stuff 2025-05-06 18:07:30 -07:00
Jon Staab ea3f5a6779 Bump version 2025-05-06 17:06:18 -07:00
Jon Staab f5fce8e2e7 Bump welshman and signer plugin 2025-05-06 10:34:14 -07:00
Jon Staab 46b5c01c49 Allow use of cleartext relays on native 2025-05-06 09:50:05 -07:00
Jon Staab dd069329ee Add timezone and locale to alerts 2025-05-05 15:39:07 -07:00
Jon Staab c1b52b66ff Use lib version of date functions 2025-05-05 10:11:02 -07:00
Jon Staab 5873e8aa60 Fix modal stuff 2025-04-29 15:20:40 -07:00
Jon Staab c582082816 Fix link detail for authenticated images 2025-04-29 12:30:01 -07:00
Jon Staab 6ddba63ff9 Use space as blossom server if supported 2025-04-29 12:26:29 -07:00
Jon Staab 5a7750a91b Use user blossom server list for settings, add InputList 2025-04-29 11:04:39 -07:00
Jon Staab 8c71b7d9b9 Update welshman 2025-04-29 09:56:52 -07:00
Jon Staab b5a28c71ad Support auth-protected images 2025-04-28 15:46:48 -07:00
Jon Staab ccdd18a863 Fill in default email for alerts 2025-04-28 12:28:39 -07:00
Jon Staab 2244ecad9b Update alerts to use new anchor 2025-04-28 09:48:09 -07:00
Jon Staab da2457da9f Use new relay getters 2025-04-25 10:41:38 -07:00
Jon Staab c18b29e7d6 Update welshman stuff, fix bug in makeFeed 2025-04-24 12:35:41 -07:00
Jon Staab 3a954201ce Tweak boot, stop saving alert events 2025-04-23 11:05:28 -07:00
Jon Staab c8bc8ee8bf Fix thunk indicator 2025-04-16 14:19:09 -07:00
Jon Staab 8c3e52ce8c Update storage adapters 2025-04-16 14:08:58 -07:00
Jon Staab 303b8967e9 Remove aliases, their time has not yet come 2025-04-16 10:36:21 -07:00
Jon Staab f3debe6c02 Use new ALIAS kind 2025-04-15 15:45:48 -07:00
Jon Staab 374ca7f265 Add per-url aliases 2025-04-15 15:07:54 -07:00
Jon Staab 91689e5b90 Optionally protect profiles 2025-04-15 09:36:59 -07:00
Jon Staab a64eaba45c Fix modal flash 2025-04-14 17:13:16 -07:00
Jon Staab 394a1e7d30 Update to new thunk stuff 2025-04-14 16:50:48 -07:00
Jon Staab d5b1fab1e7 Tweak data loading 2025-04-11 14:44:27 -07:00
Jon Staab 10a1e6e640 Update welshman session stuff 2025-04-11 11:51:15 -07:00
Jon Staab 84af4d2d8e Update welshman stuff again 2025-04-11 09:27:19 -07:00
Jon Staab acddff79f0 Improve loading a bit 2025-04-11 08:41:50 -07:00
Jon Staab 489707b9b2 Switch to pnpm, use new welshman stuff 2025-04-09 15:32:35 -07:00
Jon Staab 33902dbefe Make calendar window smaller to avoid tag limits 2025-04-03 15:56:37 -07:00
Jon Staab 1b318a7a52 Fix reactions on mobile 2025-04-03 15:40:52 -07:00
Jon Staab b6a4b38d14 Make relays configurable 2025-04-03 15:35:56 -07:00
Jon Staab a3eb6d52c0 Fix nip46 signer connect 2025-03-24 12:40:47 -07:00
Jon Staab d2c537d275 Refactor login, pass bunker to alerts 2025-03-20 13:00:07 -07:00
Jon Staab 9eefd6600d Add handler for alerts 2025-03-20 09:38:57 -07:00
Jon Staab ad034b1641 Tweak layout css 2025-03-19 11:22:57 -07:00
Jon Staab d94860014c Fix chat spacing 2025-03-19 09:56:00 -07:00
Jon Staab 33af39ee93 Add calendar event editing 2025-03-18 15:36:52 -07:00
Jon Staab 1d56a2193d Clean up calendar header 2025-03-17 09:53:14 -07:00
Jon Staab 75905e4652 Take a guess at fixing android keyboard issue 2025-03-07 09:01:47 -08:00
Jon Staab d07b9cde5f Tweak spacing 2025-03-04 17:39:54 -08:00
Jon Staab d8a9cc5a7e Fix sizing for big chat inputs 2025-03-04 12:53:38 -08:00
Jon Staab 863d11352f Bump versions 2025-03-04 11:28:41 -08:00
Jon Staab b4cc770cdf Update changelog 2025-03-04 11:20:24 -08:00
Jon Staab 901e56a625 Tweak settings page, hide alerts 2025-03-04 10:58:14 -08:00
Jon Staab 479fed34f7 Fix chat layout on ios 2025-03-04 10:52:27 -08:00
Jon Staab 81d7b08aed Fix profile suggestions 2025-03-04 10:47:58 -08:00
Jon Staab a582b1ea73 Apply layout changes to chat 2025-03-04 10:20:06 -08:00
Jon Staab 1c0b2a09df ellipsize page bar title 2025-03-04 10:00:24 -08:00
Jon Staab 3a42a1b560 Rework css on room view to avoid losing input visibility 2025-03-04 09:49:56 -08:00
Jon Staab db203bf00d Move page bar closer to top of screen 2025-03-03 17:10:17 -08:00
Jon Staab ffb36af734 Make analytics and error reporting optional 2025-03-03 15:09:58 -08:00
Jon Staab b399fa8dcc Replace long press with tap target 2025-03-03 13:59:38 -08:00
Jon Staab 5bba5959f7 Attempt to fix keyboard placement, wait for connection 2025-03-03 13:23:44 -08:00
Jon Staab 2ad65e394e Remember user minute selection 2025-03-03 13:04:12 -08:00
Jon Staab 345b20bf5d Fix nevent hints for url-specific stuff 2025-03-03 12:10:47 -08:00
Jon Staab b9fb251b32 Randomize subscription minute 2025-02-27 09:33:40 -08:00
Jon Staab dd9a9c0df2 Add status to alert items 2025-02-25 13:36:32 -08:00
Jon Staab 115b5f9fbe Extend timeout for setChecked 2025-02-24 13:40:26 -08:00
Jon Staab 3ad7dcfeb4 Ignore some files 2025-02-19 11:13:38 -08:00
Jon Staab 60d107aed2 Fix some state stuff, snapshot things in the right places 2025-02-18 17:15:41 -08:00
Jon Staab 08d8d45ecb Refactor confirm to avoid passing closures 2025-02-18 09:03:10 -08:00
Jon Staab c40e8ce1a7 Fix reactions on mobile 2025-02-17 17:33:21 -08:00
Jon Staab 993bf8d2e6 Bump gradle build number 2025-02-14 16:20:16 -08:00
Jon Staab c3c65c3970 Use in-app onboarding on all native platforms 2025-02-14 16:07:40 -08:00
Jon Staab a5b868cd56 Update changelog 2025-02-14 16:02:11 -08:00
Jon Staab 8fcc56a408 Bump version 2025-02-14 16:00:18 -08:00
Jon Staab c8dfbc936b Spruce up nstart, add profile deletion 2025-02-14 15:59:20 -08:00
Jon Staab f1e76a1ed1 Bump versions, limit key generation to ios 2025-02-14 12:18:52 -08:00
Jon Staab 6ecc3e6770 Improve discover page 2025-02-14 12:14:00 -08:00
Jon Staab b05c408977 Move loadUserData to requests 2025-02-14 11:12:19 -08:00
Jon Staab e484c3cb00 Bump versions 2025-02-14 10:56:00 -08:00
Jon Staab 69d0e11ba4 Add nip 01 login flow to mobile 2025-02-14 10:53:36 -08:00
224 changed files with 17999 additions and 18297 deletions
+2
View File
@@ -3,4 +3,6 @@
--ignore-dir=build
--ignore-dir=ios/DerivedData
--ignore-dir=ios/App/App/public
--ignore-file=match:.svg
--ignore-file=match:package-lock.json
+4
View File
@@ -0,0 +1,4 @@
node_modules
android
ios
build
-12
View File
@@ -1,12 +0,0 @@
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_TERMS=https://flotilla.social/terms
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
VITE_PLATFORM_NAME=Flotilla
VITE_PLATFORM_LOGO=static/flotilla.png
VITE_PLATFORM_RELAY=
VITE_PLATFORM_ACCENT="#7161FF"
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities."
VITE_GLITCHTIP_API_KEY=
GLITCHTIP_AUTH_TOKEN=
+18
View File
@@ -0,0 +1,18 @@
VITE_DEFAULT_PUBKEYS=06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71,266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed,6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e,76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa,7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3
VITE_BURROW_URL=
VITE_PLATFORM_URL=https://flotilla.social
VITE_PLATFORM_TERMS=https://flotilla.social/terms
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
VITE_PLATFORM_NAME=Flotilla
VITE_PLATFORM_LOGO=static/flotilla.png
VITE_PLATFORM_RELAYS=
VITE_PLATFORM_ACCENT="#7161FF"
VITE_PLATFORM_SECONDARY="#EB5E28"
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities."
VITE_INDEXER_RELAYS=wss://purplepag.es/,wss://relay.damus.io/,wss://relay.nostr.band/,wss://indexer.coracle.social/
VITE_SIGNER_RELAYS=wss://relay.nsec.app/,wss://bucket.coracle.social/
VITE_NOTIFIER_PUBKEY=27b7c2ed89ef78322114225ea3ebf5f72c7767c2528d4d0c1854d039c00085df
VITE_NOTIFIER_RELAY=wss://anchor.coracle.social/
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
VITE_GLITCHTIP_API_KEY=
GLITCHTIP_AUTH_TOKEN=
+1 -1
View File
@@ -1 +1 @@
package-lock.json -diff
pnpm-lock.yaml -diff
+59
View File
@@ -0,0 +1,59 @@
name: Docker
on:
push:
branches: ['master']
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker-container
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v2
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
+4 -1
View File
@@ -1,5 +1,5 @@
# Env
.env.local
.env
# Vite
vite.config.js.timestamp-*
@@ -56,8 +56,11 @@ out/
.gradle/
local.properties
proguard/
google-services.json
GoogleService-Info.plist
# IDEs and editors
.roo
.idea/
.vscode/
+8 -2
View File
@@ -1,2 +1,8 @@
npm run lint
npm run check
pnpm run lint
pnpm run check
if [[ ! -z $(cat package.json | grep 'link:') ]]; then
echo "Some packages are linked to local files!"
exit 1
fi
+116
View File
@@ -1,5 +1,121 @@
# Changelog
# 1.2.2
* Fix phantom chat notifications
* Fix zaps on mobile
# 1.2.1
* Add zaps to chat, threads, and events
* Add funding goals
* Add NWC support
* Add wallet settings page
* Handle invalid bunker url
* Fix sidebar overflow
* Fix profile npub display
# 1.2.0
* Fix sort order of thread comments
* Fix link display when no title is available
* Fix making profiles non-protected
* Replace bunker url with relay claims for notifier auth
* Add push notifications on all platforms
* Add "mark all as read" on desktop
* Re-design space dashboard
# 1.1.1
* Add chat quick link
# 1.1.0
* Add better theming support
* Improve forms for entering invite codes
* Rely more heavily on NIP 29 for rooms
* Support multiple platform relays
* Remove default general room
* Remove room tag from threads/calendars
* Show pubkey on profile detail
* Support pasting pubkey into chat start dialog
* Add minimal style for quoted messages
# 1.0.4
* Fix thunk status click handler
* Remove duplicate dependencies
* Improve navigation on white-labeled instances
* Add setting for font size
# 1.0.3
* Add light theme
* Use correct alerts server
* Ignore relay errors for claims
* Fix inline code blocks
* Add custom emoji parsing and display
# 1.0.2
* Fix add relay button
* Fix safe inset areas
* Better rendering for errors from relays
* Improve remote signer login
# 1.0.1
* Fix relay images in nav
* Fix relay nav overflow
# 1.0.0
* Add alerts via Anchor
* Fix nip46 signer connect
* Allow use of cleartext relays on native builds
* Fix some modal state bugs caused by svelte 5
* Detect blossom support on community relays
* Use user blossom server list in settings
* Fix some feed bugs
* Improve thunk indicator
* Update storage adapters
* Fix modal flash
* Switch to pnpm
* Improve calendar windowing
# 0.2.14
* Add calendar event editing
# 0.2.13
* Fix android keyboard issue
# 0.2.12
* Fix keyboard covering chat input
* Fix thread replies
* Make error reporting and analytics optional
* Replace long press with tap target
* Fix time input
* Fix nevent hints for url-specific stuff
* Fix confirm and reactions on mobile
* Add reply to chat on mobile
* Fix profile suggestions
# 0.2.11
* Add in-app signup flow on ios
* Add profile deletion
# 0.2.10
* Improve space discovery
# 0.2.9
* Add NIP 01 signup flow on mobile
# 0.2.8
* Show spinner when joining a room
+26
View File
@@ -0,0 +1,26 @@
## Project Overview
Flotilla is a Discord-like Nostr client that operates on the concept of "relays as groups/spaces." Built with SvelteKit 2.5 and Svelte 5, it provides messaging, threads, calendar events, and social features across Nostr relays.
## Important Patterns
### Finding Code
- Prefer navigating from one file to the next following imports when possible
- If search is necessary, use `ack`, not `grep` or `rg`.
### Nostr Event Handling
- Prefer seconds to milliseconds when handling nostr events.
### Styling Conventions
- When styling html, prefer flex/gap classes over margin or space-y classes.
### Room/space memberships
Memberships are surfaced as "bookmarks" to the user.
```typescript
import {membershipsByPubkey, getMembershipUrls} from '@app/state'
const spaces = getMembershipUrls($membershipsByPubkey.get(pubkey))
const rooms = getMembershipRooms($membershipsByPubkey.get(pubkey))
```
+24
View File
@@ -0,0 +1,24 @@
FROM node:20-slim
# Install pnpm
RUN npm install -g pnpm@latest
# Set working directory
WORKDIR /app
# Copy package files
COPY package.json pnpm-lock.yaml ./
# Install dependencies
RUN pnpm i
# Copy the rest of the application
COPY . .
# Build the application
ENV NODE_OPTIONS=--max_old_space_size=16384
RUN pnpm run build
# Default to serving the build directory
CMD ["npx", "serve", "build"]
+17 -80
View File
@@ -2,25 +2,17 @@
A discord-like nostr client based on the idea of "relays as groups".
If you would like to be interoperable with Flotilla, please check out this draft NIP: https://github.com/coracle-social/nips/blob/relay-chat/xx.md
# Deploy
To run your own Flotilla, it's as simple as:
- `npm install`
- `npm run build`
- `npx serve build`
If you would like to be interoperable with Flotilla, please check out this guide: https://habla.news/u/hodlbod@coracle.social/1741286140797
## Environment
You can also optionally create an `.env.local` file and populate it with the following environment variables (see `.env` for examples):
You can also optionally create an `.env` file and populate it with the following environment variables (see `.env` for examples):
- `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust.
- `VITE_PLATFORM_URL` - The url where the app will be hosted. This is only used for build-time population of meta tags.
- `VITE_PLATFORM_NAME` - The name of the app
- `VITE_PLATFORM_LOGO` - A logo url for the app
- `VITE_PLATFORM_RELAY` - A relay url that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the platform relay the home page.
- `VITE_PLATFORM_RELAYS` - A list of comma-separated relay urls that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the first platform relay the home page.
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
- `VITE_PLATFORM_DESCRIPTION` - A description of the app
- `VITE_GLITCHTIP_API_KEY` - A Sentry DSN for use with glitchtip (error reporting)
@@ -28,84 +20,29 @@ You can also optionally create an `.env.local` file and populate it with the fol
If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer.
## Nginx/TLS (optional)
## Development
If you'd like to set up flotilla on a server you control, you'll want to set up a reverse proxy and provision a TSL certificate for the domain you'll be using. You should also make sure to add swap to your server.
Run `pnpm run dev` to get a dev server, and `pnpm run check:watch` to watch for typescript errors. When you're ready to commit, a pre-commit hook will run to lint and typecheck your work.
There will be some parts of the following templates, for example `<SERVER NAME>`, which you'll need to fill in before running the code.
## Deployment
First, create an `A` record with your DNS provider pointing to the IP of your server. This will allow certbot to create your certificate later.
Next install `nginx`, `git`, and `certbot`. If you're on a debian- or ubuntu-based distro, run `sudo apt-get update && sudo apt-get install nginx git certbot python3-certbot-nginx`.
Now, create a new user where your code will be stored, clone the repository, fill in your `.env.local` file, and build the app.
To run your own Flotilla, it's as simple as:
```sh
# Replace with your password
PASSWORD=<YOUR PASSWORD HERE>
# Add the user and set a password
adduser flotilla
echo flotilla:$PASSWORD | chpasswd
# Login as flotilla
sudo su flotilla
# Go to flotilla's home directory
cd ~
# Install nvm, yarn, clone repos
wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
# Update PATH
. ~/.bashrc
# Clone repository and install dependencies
git clone https://github.com/coracle-social/flotilla.git
cd ~/flotilla
nvm install
nvm use
npm i
# Optionally create and populate .env.local to suit your use case
# Build the app
NODE_OPTIONS=--max_old_space_size=16384 npm run build
# Exit back to root
exit
pnpm install
pnpm run build
npx serve build
```
Once you've exited back to root, you can set up nginx. Place the following in a file named after your domain in the `/etc/nginx/sites-available` directory, for example, `flotilla.example.com`. This should match the `A` record you registered above.
Or, if you prefer to use a container:
```conf
server {
listen 80;
server_name <SERVER NAME>;
root /home/flotilla/flotilla/build;
index index.html;
location / {
try_files $uri /index.html;
}
}
```sh
podman run -d -p 3000:3000 ghcr.io/coracle-social/flotilla:latest
```
Now you can run `certbot`, which will provision a TLS certificate for your domain and update your nginx configuration.
Alternatively, you can copy the build files into a directory of your choice and serve it yourself:
```sh
mkdir ./mount
podman run -v ./mount:/app/mount ghcr.io/coracle-social/flotilla:latest bash -c 'cp -r build/* mount'
```
certbot --nginx -d <SERVER NAME>
```
Now, enable the site and restart nginx. If you want to be careful, run `nginx -t` before restarting nginx.
```
ln -s /etc/nginx/sites-{available,enabled}/<SERVER NAME>
service nginx restart
```
Now, visit your domain. You should be all set up!
# Development
Run `npm run dev` to get a dev server, and `npm run check:watch` to watch for typescript errors. When you're ready to commit, run `npm run format && npm run lint` and fix any errors that come up.
+4 -4
View File
@@ -5,10 +5,10 @@ android {
compileSdk rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "social.flotilla"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 9
versionName "0.2.7"
minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion
versionCode 23
versionName "1.2.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+4
View File
@@ -9,7 +9,11 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-community-safe-area')
implementation project(':capacitor-app')
implementation project(':capacitor-keyboard')
implementation project(':capacitor-push-notifications')
implementation project(':capawesome-capacitor-badge')
implementation project(':nostr-signer-capacitor-plugin')
}
+4 -1
View File
@@ -6,12 +6,14 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
@@ -32,4 +34,5 @@
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
</manifest>
+15 -3
View File
@@ -1,9 +1,21 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/android/capacitor')
include ':capacitor-community-safe-area'
project(':capacitor-community-safe-area').projectDir = new File('../node_modules/.pnpm/@capacitor-community+safe-area@7.0.0-alpha.1_@capacitor+core@7.2.0/node_modules/@capacitor-community/safe-area/android')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app/android')
include ':capacitor-keyboard'
project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard/android')
include ':capacitor-push-notifications'
project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/push-notifications/android')
include ':capawesome-capacitor-badge'
project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@7.0.1_@capacitor+core@7.2.0/node_modules/@capawesome/capacitor-badge/android')
include ':nostr-signer-capacitor-plugin'
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/nostr-signer-capacitor-plugin/android')
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin/android')
+4 -2
View File
@@ -3,7 +3,9 @@ ext {
compileSdkVersion = 35
targetSdkVersion = 35
androidxActivityVersion = '1.9.2'
androidxAppCompatVersion = '1.7.0'
//https://github.com/ionic-team/capacitor/issues/7866
// androidxAppCompatVersion = '1.7.0'
androidxAppCompatVersion = '1.6.1'
androidxCoordinatorLayoutVersion = '1.2.0'
androidxCoreVersion = '1.15.0'
androidxFragmentVersion = '1.8.4'
@@ -13,4 +15,4 @@ ext {
androidxJunitVersion = '1.2.1'
androidxEspressoCoreVersion = '3.6.1'
cordovaAndroidVersion = '10.1.1'
}
}
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -e
# Fetch tags and set to env vars
git fetch --prune --unshallow --tags || true
git describe --tags --abbrev=0 || true
export VITE_BUILD_VERSION=$RENDER_GIT_COMMIT
export VITE_BUILD_HASH=$RENDER_GIT_COMMIT
# Install dependencies
CI=0 pnpm i
# Rebuild sharp
pnpm rebuild
# The build runs out of memory at times
NODE_OPTIONS=--max_old_space_size=16384 pnpm run build
+4 -4
View File
@@ -2,12 +2,12 @@
temp_env=$(declare -p -x)
if [ -f .env ]; then
source .env
if [ -f .env.template ]; then
source .env.template
fi
if [ -f .env.local ]; then
source .env.local
if [ -f .env ]; then
source .env
fi
# Avoid overwriting env vars provided directly
+10 -2
View File
@@ -10,11 +10,19 @@ const config: CapacitorConfig = {
plugins: {
SplashScreen: {
androidSplashResourceName: "splash"
}
},
Keyboard: {
style: "DARK",
resizeOnFullScreen: true,
},
Badge: {
persist: true,
autoClear: true
},
},
// Use this for live reload https://capacitorjs.com/docs/guides/live-reload
// server: {
// url: "http://192.168.1.250:1847",
// url: "http://192.168.1.115:1847",
// cleartext: true
// },
};
+8 -4
View File
@@ -18,6 +18,7 @@
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
051414282E0CC28400BE0BC8 /* Flotilla Chat.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Flotilla Chat.entitlements"; sourceTree = "<group>"; };
1F53EE54954731A2328CBC4B /* Pods-Flotilla Chat.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Flotilla Chat.release.xcconfig"; path = "Pods/Target Support Files/Pods-Flotilla Chat/Pods-Flotilla Chat.release.xcconfig"; sourceTree = "<group>"; };
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
@@ -57,6 +58,7 @@
504EC2FB1FED79650016851F = {
isa = PBXGroup;
children = (
051414282E0CC28400BE0BC8 /* Flotilla Chat.entitlements */,
504EC3061FED79650016851F /* App */,
504EC3051FED79650016851F /* Products */,
7F8756D8B27F46E3366F6CEA /* Pods */,
@@ -349,16 +351,17 @@
baseConfigurationReference = 7B9FA71C362B734D9F965709 /* Pods-Flotilla Chat.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
CURRENT_PROJECT_VERSION = 16;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.2.8;
MARKETING_VERSION = 1.2.2;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -374,16 +377,17 @@
baseConfigurationReference = 1F53EE54954731A2328CBC4B /* Pods-Flotilla Chat.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
CURRENT_PROJECT_VERSION = 16;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.2.8;
MARKETING_VERSION = 1.2.2;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
+9
View File
@@ -46,4 +46,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
// Enable push notifications https://capacitorjs.com/docs/apis/push-notifications
NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: deviceToken)
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
// Enable push notifications https://capacitorjs.com/docs/apis/push-notifications
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
}
}
+4
View File
@@ -49,5 +49,9 @@
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
</dict>
</plist>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>
+9 -5
View File
@@ -1,4 +1,4 @@
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
require_relative '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '14.0'
use_frameworks!
@@ -9,10 +9,14 @@ use_frameworks!
install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/nostr-signer-capacitor-plugin'
pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios'
pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@7.0.0-alpha.1_@capacitor+core@7.2.0/node_modules/@capacitor-community/safe-area'
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app'
pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard'
pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/push-notifications'
pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@7.0.1_@capacitor+core@7.2.0/node_modules/@capawesome/capacitor-badge'
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin'
end
target 'Flotilla Chat' do
-15453
View File
File diff suppressed because it is too large Load Diff
+36 -16
View File
@@ -1,6 +1,6 @@
{
"name": "flotilla",
"version": "0.2.8",
"version": "1.2.2",
"private": true,
"scripts": {
"dev": "vite dev",
@@ -15,6 +15,7 @@
},
"devDependencies": {
"@capacitor/assets": "^3.0.5",
"@eslint/js": "^9.26.0",
"@sentry/cli": "^2.40.0",
"@sveltejs/kit": "^2.5.27",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
@@ -37,29 +38,36 @@
},
"type": "module",
"dependencies": {
"@capacitor-community/safe-area": "7.0.0-alpha.1",
"@capacitor/android": "^7.0.0",
"@capacitor/app": "^7.0.0",
"@capacitor/cli": "^7.0.0",
"@capacitor/core": "^7.0.1",
"@capacitor/ios": "^7.0.0",
"@noble/curves": "^1.5.0",
"@noble/hashes": "^1.4.0",
"@capacitor/keyboard": "^7.0.0",
"@capacitor/push-notifications": "^7.0.1",
"@capawesome/capacitor-badge": "^7.0.1",
"@getalby/sdk": "^5.1.0",
"@poppanator/sveltekit-svg": "^4.2.1",
"@sentry/browser": "^8.35.0",
"@sveltejs/adapter-static": "^3.0.4",
"@tiptap/core": "^2.12.0",
"@types/qrcode": "^1.5.5",
"@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.6",
"@welshman/app": "~0.0.42",
"@welshman/content": "~0.0.18",
"@welshman/dvm": "~0.0.14",
"@welshman/editor": "~0.0.15",
"@welshman/feeds": "~0.0.30",
"@welshman/lib": "~0.0.41",
"@welshman/net": "~0.0.47",
"@welshman/signer": "~0.0.20",
"@welshman/store": "~0.0.16",
"@welshman/util": "~0.0.61",
"@welshman/app": "^0.4.0",
"@welshman/content": "^0.4.0",
"@welshman/editor": "^0.4.0",
"@welshman/feeds": "^0.4.0",
"@welshman/lib": "^0.4.0",
"@welshman/net": "^0.4.0",
"@welshman/relay": "^0.4.0",
"@welshman/router": "^0.4.0",
"@welshman/signer": "^0.4.0",
"@welshman/store": "^0.4.0",
"@welshman/util": "^0.4.0",
"compressorjs": "^1.2.1",
"daisyui": "^4.12.10",
"date-picker-svelte": "^2.13.0",
"dotenv": "^16.4.5",
@@ -67,9 +75,21 @@
"fuse.js": "^7.0.0",
"husky": "^9.1.6",
"idb": "^8.0.0",
"nostr-signer-capacitor-plugin": "coracle-social/nostr-signer-capacitor-plugin#9fbe4f8",
"nostr-tools": "^2.7.2",
"nostr-signer-capacitor-plugin": "^0.0.4",
"nostr-tools": "^2.14.2",
"prettier-plugin-tailwindcss": "^0.6.5",
"qrcode": "^1.5.4"
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"throttle-debounce": "^5.0.2",
"tippy.js": "^6.3.7"
},
"pnpm": {
"ignoredBuiltDependencies": [
"@sentry/cli",
"esbuild"
],
"onlyBuiltDependencies": [
"sharp"
]
}
}
+10090
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,8 +1,8 @@
import dotenv from "dotenv"
import {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config"
dotenv.config({path: ".env.local"})
dotenv.config({path: ".env"})
dotenv.config({path: ".env.template"})
export default defineConfig({
preset,
+120 -38
View File
@@ -1,3 +1,5 @@
@import "@welshman/editor/index.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -44,6 +46,14 @@
:root {
font-family: Lato;
--sait: env(safe-area-inset-top);
--saib: env(safe-area-inset-bottom);
--sail: env(safe-area-inset-left);
--sair: env(safe-area-inset-right);
}
[data-theme] {
@apply bg-base-300;
--base-100: oklch(var(--b1));
--base-200: oklch(var(--b2));
--base-300: oklch(var(--b3));
@@ -54,56 +64,80 @@
--secondary-content: oklch(var(--sc));
}
:root,
body,
html {
@apply bg-base-300;
}
/* safe area insets */
/* ios */
@layer components {
.pt-sai {
padding-top: var(--sait);
}
.sait {
padding-top: env(safe-area-inset-top);
}
.pr-sai {
padding-right: var(--sair);
}
.sair {
padding-right: env(safe-area-inset-right);
}
.pb-sai {
padding-bottom: var(--saib);
}
.saib {
padding-bottom: env(safe-area-inset-bottom);
}
.pl-sai {
padding-left: var(--sail);
}
.sail {
padding-left: env(safe-area-inset-left);
}
.px-sai {
@apply pl-sai pr-sai;
}
.saix {
@apply sail sair;
}
.py-sai {
@apply pt-sai pb-sai;
}
.saiy {
@apply sait saib;
}
.p-sai {
@apply py-sai px-sai;
}
.sai {
@apply saiy saix;
}
.mt-sai {
padding-top: var(--sait);
}
.top-sai {
top: env(safe-area-inset-top);
}
.mr-sai {
padding-right: var(--sair);
}
.right-sai {
right: env(safe-area-inset-right);
}
.mb-sai {
padding-bottom: var(--saib);
}
.bottom-sai {
bottom: env(safe-area-inset-bottom);
}
.ml-sai {
padding-left: var(--sail);
}
.left-sai {
left: env(safe-area-inset-left);
.mx-sai {
@apply ml-sai mr-sai;
}
.my-sai {
@apply mt-sai mb-sai;
}
.m-sai {
@apply my-sai mx-sai;
}
.top-sai {
top: var(--sait);
}
.right-sai {
right: var(--sair);
}
.bottom-sai {
bottom: var(--saib);
}
.left-sai {
left: var(--sail);
}
}
/* utilities */
@@ -257,6 +291,14 @@ html {
--tiptap-active-fg: var(--base-content);
}
.tiptap-suggestions__item {
@apply border-l-2 border-solid border-base-100;
}
.tiptap-suggestions__selected {
@apply border-primary;
}
.tiptap {
@apply max-h-[350px] overflow-y-auto p-2 px-4;
}
@@ -292,6 +334,16 @@ html {
color: var(--base-content);
}
/* content rendered by welshman/content */
.welshman-content a {
@apply link;
}
.welshman-content-error a {
@apply underline;
}
/* date input */
.picker {
@@ -323,3 +375,33 @@ emoji-picker {
--input-font-color: var(--base-content);
--outline-color: var(--base-100);
}
/* progress */
progress[value]::-webkit-progress-value {
transition: width 0.5s;
}
/* content width for fixed elements */
.cw {
@apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))];
}
.cw-full {
@apply w-full md:left-[4rem] md:w-[calc(100%-4rem-var(--sair))];
}
.cb {
@apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)];
}
/* chat view */
.chat__compose {
@apply cb cw fixed;
}
.chat__scroll-down {
@apply fixed bottom-28 right-4 md:bottom-16;
}
+3 -1
View File
@@ -2,7 +2,9 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="theme-color" content="{ACCENT}" />
<meta name="description" content="{DESCRIPTION}" />
<meta name="og:url" content="{URL}" />
+2 -1
View File
@@ -1,6 +1,7 @@
/* eslint prefer-rest-params: 0 */
import {page} from "$app/stores"
import {getSetting} from "@app/state"
const w = window as any
@@ -12,7 +13,7 @@ w.plausible =
export const setupAnalytics = () => {
page.subscribe($page => {
if ($page.route) {
if ($page.route && getSetting("report_usage")) {
w.plausible("pageview", {u: $page.route.id})
}
})
+170 -212
View File
@@ -1,6 +1,9 @@
import {nwc} from "@getalby/sdk"
import * as nip19 from "nostr-tools/nip19"
import {get} from "svelte/store"
import {ctx, sample, uniq, sleep, chunk, equals} from "@welshman/lib"
import {randomId, flatten, poll, uniq, equals, TIMEZONE, LOCALE} from "@welshman/lib"
import type {Feed} from "@welshman/feeds"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {
DELETE,
REPORT,
@@ -10,14 +13,14 @@ import {
FOLLOWS,
REACTION,
AUTH_JOIN,
GROUP_JOIN,
GROUP_LEAVE,
GROUP_CREATE,
GROUP_EDIT_META,
GROUPS,
ROOMS,
COMMENT,
ALERT_EMAIL,
ALERT_WEB,
ALERT_IOS,
ALERT_ANDROID,
isSignedEvent,
createEvent,
makeEvent,
displayProfile,
normalizeRelayUrl,
makeList,
@@ -26,50 +29,40 @@ import {
getTag,
getListTags,
getRelayTags,
isShareableRelayUrl,
getRelayTagValues,
toNostrURI,
getRelaysFromList,
RelayMode,
} from "@welshman/util"
import type {TrustedEvent, EventContent, EventTemplate, List} from "@welshman/util"
import type {SubscribeRequestWithHandlers} from "@welshman/net"
import {PublishStatus, AuthStatus, SocketStatus} from "@welshman/net"
import {Nip59, makeSecret, stamp, Nip46Broker} from "@welshman/signer"
import {Pool, AuthStatus, SocketStatus} from "@welshman/net"
import {Router} from "@welshman/router"
import {
pubkey,
signer,
repository,
publishThunk,
publishThunks,
loadProfile,
loadInboxRelaySelections,
profilesByPubkey,
relaySelectionsByPubkey,
getWriteRelayUrls,
loadFollows,
loadMutes,
tagEvent,
tagEventForReaction,
getRelayUrls,
userRelaySelections,
userInboxRelaySelections,
nip44EncryptToSelf,
loadRelay,
addSession,
clearStorage,
dropSession,
tagEventForComment,
tagEventForQuote,
getThunkError,
} from "@welshman/app"
import type {Thunk} from "@welshman/app"
import {
tagRoom,
wallet,
getWebLn,
PROTECTED,
userMembership,
INDEXER_RELAYS,
NIP46_PERMS,
loadMembership,
loadSettings,
getDefaultPubkeys,
NOTIFIER_PUBKEY,
NOTIFIER_RELAY,
userRoomsByUrl,
} from "@app/state"
@@ -77,7 +70,7 @@ import {
export const getPubkeyHints = (pubkey: string) => {
const selections = relaySelectionsByPubkey.get().get(pubkey)
const relays = selections ? getWriteRelayUrls(selections) : []
const relays = selections ? getRelaysFromList(selections, RelayMode.Write) : []
const hints = relays.length ? relays : INDEXER_RELAYS
return hints
@@ -90,22 +83,13 @@ export const getPubkeyPetname = (pubkey: string) => {
return display
}
export const getThunkError = async (thunk: Thunk) => {
const result = await thunk.result
const [{status, message}] = Object.values(result) as any
if (status !== PublishStatus.Success) {
return message
}
}
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(),
relays: Router.get().Event(parent).limit(3).getUrls(),
})
tags = [...tags, tagEventForQuote(parent)]
@@ -115,38 +99,6 @@ export const prependParent = (parent: TrustedEvent | undefined, {content, tags}:
return {content, tags}
}
// Log in
export const loginWithNip46 = async ({
relays,
signerPubkey,
clientSecret = makeSecret(),
connectSecret = "",
}: {
relays: string[]
signerPubkey: string
clientSecret?: string
connectSecret?: string
}) => {
const broker = Nip46Broker.get({relays, clientSecret, signerPubkey})
const result = await broker.connect(connectSecret, NIP46_PERMS)
// TODO: remove ack result
if (!["ack", connectSecret].includes(result)) return false
const pubkey = await broker.getPublicKey()
if (!pubkey) return false
await loadUserData(pubkey)
const handler = {relays, pubkey: signerPubkey}
addSession({method: "nip46", pubkey, secret: clientSecret, handler})
return true
}
// Log out
export const logout = async () => {
@@ -161,47 +113,6 @@ export const logout = async () => {
localStorage.clear()
}
// Loaders
export const loadUserData = (
pubkey: string,
request: Partial<SubscribeRequestWithHandlers> = {},
) => {
const promise = Promise.race([
sleep(3000),
Promise.all([
loadInboxRelaySelections(pubkey, request),
loadMembership(pubkey, request),
loadSettings(pubkey, request),
loadProfile(pubkey, request),
loadFollows(pubkey, request),
loadMutes(pubkey, request),
]),
])
// Load followed profiles slowly in the background without clogging other stuff up. Only use a single
// indexer relay to avoid too many redundant validations, which slow things down and eat bandwidth
promise.then(async () => {
for (const pubkeys of chunk(50, getDefaultPubkeys())) {
const relays = sample(1, INDEXER_RELAYS)
await sleep(1000)
for (const pubkey of pubkeys) {
loadMembership(pubkey, {relays})
loadProfile(pubkey, {relays})
loadFollows(pubkey, {relays})
loadMutes(pubkey, {relays})
}
}
})
return promise
}
export const discoverRelays = (lists: List[]) =>
Promise.all(uniq(lists.flatMap(getRelayUrls)).filter(isShareableRelayUrl).map(loadRelay))
// Synchronization
export const broadcastUserData = async (relays: string[]) => {
@@ -216,77 +127,42 @@ 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
export const addSpaceMembership = async (url: string) => {
const list = get(userMembership) || makeList({kind: GROUPS})
const list = get(userMembership) || makeList({kind: ROOMS})
const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf)
const relays = uniq([...ctx.app.router.FromUser().getUrls(), ...getRelayTagValues(event.tags)])
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays})
}
export const removeSpaceMembership = async (url: string) => {
const list = get(userMembership) || makeList({kind: GROUPS})
const list = get(userMembership) || makeList({kind: ROOMS})
const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([
url,
...ctx.app.router.FromUser().getUrls(),
...getRelayTagValues(event.tags),
])
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays})
}
export const addRoomMembership = async (url: string, room: string, name: string) => {
const list = get(userMembership) || makeList({kind: GROUPS})
export const addRoomMembership = async (url: string, room: string) => {
const list = get(userMembership) || makeList({kind: ROOMS})
const newTags = [
["r", url],
["group", room, url, name],
["group", room, url],
]
const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf)
const relays = uniq([...ctx.app.router.FromUser().getUrls(), ...getRelayTagValues(event.tags)])
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays})
}
export const removeRoomMembership = async (url: string, room: string) => {
const list = get(userMembership) || makeList({kind: GROUPS})
const list = get(userMembership) || makeList({kind: ROOMS})
const pred = (t: string[]) => equals(["group", room, url], t.slice(0, 3))
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([
url,
...ctx.app.router.FromUser().getUrls(),
...getRelayTagValues(event.tags),
])
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays})
}
@@ -304,11 +180,11 @@ export const setRelayPolicy = (url: string, read: boolean, write: boolean) => {
}
return publishThunk({
event: createEvent(list.kind, {tags}),
event: makeEvent(list.kind, {tags}),
relays: [
url,
...INDEXER_RELAYS,
...ctx.app.router.FromUser().getUrls(),
...Router.get().FromUser().getUrls(),
...userRoomsByUrl.get().keys(),
],
})
@@ -318,7 +194,7 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
const list = get(userInboxRelaySelections) || makeList({kind: INBOX_RELAYS})
// Only update inbox policies if they already exist or we're adding them
if (enabled || getRelayUrls(list).includes(url)) {
if (enabled || getRelaysFromList(list).includes(url)) {
const tags = getRelayTags(getListTags(list)).filter(t => normalizeRelayUrl(t[1]) !== url)
if (enabled) {
@@ -326,10 +202,10 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
}
return publishThunk({
event: createEvent(list.kind, {tags}),
event: makeEvent(list.kind, {tags}),
relays: [
...INDEXER_RELAYS,
...ctx.app.router.FromUser().getUrls(),
...Router.get().FromUser().getUrls(),
...userRoomsByUrl.get().keys(),
],
})
@@ -338,29 +214,40 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
// Relay access
export const checkRelayAccess = async (url: string, claim = "") => {
const connection = ctx.net.pool.get(url)
export const attemptAuth = (url: string) =>
Pool.get()
.get(url)
.auth.attemptAuth(e => signer.get()?.sign(e))
await connection.auth.attempt(5000)
export const checkRelayAccess = async (url: string, claim = "") => {
const socket = Pool.get().get(url)
await attemptAuth(url)
const thunk = publishThunk({
event: createEvent(AUTH_JOIN, {tags: [["claim", claim]]}),
event: makeEvent(AUTH_JOIN, {tags: [["claim", claim]]}),
relays: [url],
})
const result = await thunk.result
const error = await getThunkError(thunk)
if (result[url].status === PublishStatus.Failure) {
if (error) {
const message =
connection.auth.message?.replace(/^.*: /, "") ||
result[url].message?.replace(/^.*: /, "") ||
socket.auth.details?.replace(/^\w+: /, "") ||
error?.replace(/^\w+: /, "") ||
"join request rejected"
// If it's a strict NIP 29 relay don't worry about requesting access
// TODO: remove this if relay29 ever gets less strict
if (message !== "missing group (`h`) tag") {
return `Failed to join relay (${message})`
}
if (message === "missing group (`h`) tag") return
// Ignore messages about the relay ignoring ours
if (error?.startsWith("mute: ")) return
// Ignore rejected empty claims
if (!claim && error?.includes("invite code")) return
return message
}
}
@@ -373,25 +260,30 @@ export const checkRelayProfile = async (url: string) => {
}
export const checkRelayConnection = async (url: string) => {
const connection = ctx.net.pool.get(url)
const socket = Pool.get().get(url)
await connection.socket.open()
socket.attemptToOpen()
if (connection.socket.status !== SocketStatus.Open) {
await poll({
signal: AbortSignal.timeout(3000),
condition: () => socket.status === SocketStatus.Open,
})
if (socket.status !== SocketStatus.Open) {
return `Failed to connect`
}
}
export const checkRelayAuth = async (url: string, timeout = 3000) => {
const connection = ctx.net.pool.get(url)
const socket = Pool.get().get(url)
const okStatuses = [AuthStatus.None, AuthStatus.Ok]
await connection.auth.attempt(timeout)
await attemptAuth(url)
// Only raise an error if it's not a timeout.
// If it is, odds are the problem is with our signer, not the relay
if (!okStatuses.includes(connection.auth.status) && connection.auth.message) {
return `Failed to authenticate (${connection.auth.message})`
if (!okStatuses.includes(socket.auth.status) && socket.auth.details) {
return `Failed to authenticate (${socket.auth.details})`
}
}
@@ -413,42 +305,26 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
// Actions
export const sendWrapped = async ({
template,
pubkeys,
delay,
}: {
template: EventTemplate
pubkeys: string[]
delay?: number
}) => {
const nip59 = Nip59.fromSigner(signer.get()!)
return publishThunks(
await Promise.all(
uniq(pubkeys).map(async recipient => ({
event: await nip59.wrap(recipient, stamp(template)),
relays: ctx.app.router.PubkeyInbox(recipient).getUrls(),
delay,
})),
),
)
}
export const makeDelete = ({event}: {event: TrustedEvent}) => {
const tags = [["k", String(event.kind)], ...tagEvent(event)]
export const makeDelete = ({event, tags = []}: {event: TrustedEvent; tags?: string[][]}) => {
const thisTags = [["k", String(event.kind)], ...tagEvent(event), ...tags]
const groupTag = getTag("h", event.tags)
if (groupTag) {
tags.push(PROTECTED)
tags.push(groupTag)
thisTags.push(PROTECTED, groupTag)
}
return createEvent(DELETE, {tags})
return makeEvent(DELETE, {tags: thisTags})
}
export const publishDelete = ({relays, event}: {relays: string[]; event: TrustedEvent}) =>
publishThunk({event: makeDelete({event}), relays})
export const publishDelete = ({
relays,
event,
tags = [],
}: {
relays: string[]
event: TrustedEvent
tags?: string[][]
}) => publishThunk({event: makeDelete({event, tags}), relays})
export type ReportParams = {
event: TrustedEvent
@@ -462,7 +338,7 @@ export const makeReport = ({event, reason, content}: ReportParams) => {
["e", event.id, reason],
]
return createEvent(REPORT, {content, tags})
return makeEvent(REPORT, {content, tags})
}
export const publishReport = ({
@@ -476,10 +352,12 @@ export const publishReport = ({
export type ReactionParams = {
event: TrustedEvent
content: string
tags?: string[][]
}
export const makeReaction = ({event, content}: ReactionParams) => {
const tags = tagEventForReaction(event)
export const makeReaction = ({content, event, tags: paramTags = []}: ReactionParams) => {
const tags = [...paramTags, ...tagEventForReaction(event)]
const groupTag = getTag("h", event.tags)
if (groupTag) {
@@ -487,7 +365,7 @@ export const makeReaction = ({event, content}: ReactionParams) => {
tags.push(groupTag)
}
return createEvent(REACTION, {content, tags})
return makeEvent(REACTION, {content, tags})
}
export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) =>
@@ -500,7 +378,87 @@ export type CommentParams = {
}
export const makeComment = ({event, content, tags = []}: CommentParams) =>
createEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event)]})
makeEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event)]})
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
publishThunk({event: makeComment(params), relays})
export type AlertParams = {
feed: Feed
description: string
claims: Record<string, string>
email?: {
cron: string
email: string
handler: string[]
}
web?: {
endpoint: string
p256dh: string
auth: string
}
ios?: {
device_token: string
bundle_identifier: string
}
android?: {
device_token: string
}
}
export const makeAlert = async (params: AlertParams) => {
const tags = [
["feed", JSON.stringify(params.feed)],
["locale", LOCALE],
["timezone", TIMEZONE],
["description", params.description],
]
for (const [relay, claim] of Object.entries(params.claims)) {
tags.push(["claim", relay, claim])
}
let kind: number
if (params.email) {
kind = ALERT_EMAIL
tags.push(...Object.entries(params.email).map(flatten))
} else if (params.web) {
kind = ALERT_WEB
tags.push(...Object.entries(params.web).map(flatten))
} else if (params.ios) {
kind = ALERT_IOS
tags.push(...Object.entries(params.ios).map(flatten))
} else if (params.android) {
kind = ALERT_ANDROID
tags.push(...Object.entries(params.android).map(flatten))
} else {
throw new Error("Alert has invalid params")
}
return makeEvent(kind, {
content: await signer.get().nip44.encrypt(NOTIFIER_PUBKEY, JSON.stringify(tags)),
tags: [
["d", randomId()],
["p", NOTIFIER_PUBKEY],
],
})
}
export const publishAlert = async (params: AlertParams) =>
publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]})
export const payInvoice = async (invoice: string) => {
const $wallet = get(wallet)
if (!$wallet) {
throw new Error("No wallet is connected")
}
if ($wallet.type === "nwc") {
return new nwc.NWCClient($wallet.info).payInvoice({invoice})
} else if ($wallet.type === "webln") {
return getWebLn()
.enable()
.then(() => getWebLn().sendPayment(invoice))
}
}
+297
View File
@@ -0,0 +1,297 @@
<script lang="ts">
import {onMount} from "svelte"
import {preventDefault} from "@lib/html"
import {decrypt} from "@welshman/signer"
import {randomInt, parseJson, fromPairs, displayList, TIMEZONE, identity} from "@welshman/lib"
import {
displayRelayUrl,
getTagValue,
getAddress,
THREAD,
MESSAGE,
EVENT_TIME,
COMMENT,
} from "@welshman/util"
import type {Filter} from "@welshman/util"
import {makeIntersectionFeed, makeRelayFeed, feedFromFilters} from "@welshman/feeds"
import {pubkey, signer, getThunkError} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {
alerts,
getMembershipUrls,
userMembership,
NOTIFIER_PUBKEY,
NOTIFIER_RELAY,
} from "@app/state"
import {loadAlertStatuses, requestRelayClaim} from "@app/requests"
import {publishAlert, attemptAuth} from "@app/commands"
import type {AlertParams} from "@app/commands"
import {platform, platformName, canSendPushNotifications, getPushInfo} from "@app/push"
import {pushToast} from "@app/toast"
type Props = {
url?: string
channel?: string
notifyChat?: boolean
notifyThreads?: boolean
notifyCalendar?: boolean
hideSpaceField?: boolean
}
let {
url = "",
channel = "email",
notifyChat = true,
notifyThreads = true,
notifyCalendar = true,
hideSpaceField = false,
}: Props = $props()
const timezoneOffset = parseInt(TIMEZONE.slice(3)) / 100
const minute = randomInt(0, 59)
const hour = (17 - timezoneOffset) % 24
const WEEKLY = `0 ${minute} ${hour} * * 1`
const DAILY = `0 ${minute} ${hour} * * *`
let loading = $state(false)
let cron = $state(WEEKLY)
let claim = $state("")
let email = $state($alerts.map(a => getTagValue("email", a.tags)).filter(identity)[0] || "")
const back = () => history.back()
const submit = async () => {
if (channel === "email" && !email.includes("@")) {
return pushToast({
theme: "error",
message: "Please provide an email address",
})
}
if (!url) {
return pushToast({
theme: "error",
message: "Please select a space",
})
}
if (!notifyThreads && !notifyCalendar && !notifyChat) {
return pushToast({
theme: "error",
message: "Please select something to be notified about",
})
}
const filters: Filter[] = []
const display: string[] = []
if (notifyThreads) {
display.push("threads")
filters.push({kinds: [THREAD]})
filters.push({kinds: [COMMENT], "#k": [String(THREAD)]})
}
if (notifyCalendar) {
display.push("calendar events")
filters.push({kinds: [EVENT_TIME]})
filters.push({kinds: [COMMENT], "#k": [String(EVENT_TIME)]})
}
if (notifyChat) {
display.push("chat")
filters.push({kinds: [MESSAGE]})
}
loading = true
try {
const claims = claim ? {[url]: claim} : {}
const feed = makeIntersectionFeed(feedFromFilters(filters), makeRelayFeed(url))
const description = `for ${displayList(display)} on ${displayRelayUrl(url)}`
const params: AlertParams = {feed, claims, description}
if (channel === "email") {
const cadence = cron?.endsWith("1") ? "Weekly" : "Daily"
params.description = `${cadence} alert ${description}, sent via email.`
params.email = {
cron,
email,
handler: [
"31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1737058597050",
"wss://relay.nostr.band/",
"web",
],
}
} else {
try {
// @ts-ignore
params[platform] = await getPushInfo()
params.description = `${platformName} push notification ${description}.`
} catch (e: any) {
return pushToast({
theme: "error",
message: String(e),
})
}
}
// If we don't do this we'll get an event rejection
await attemptAuth(NOTIFIER_RELAY)
const thunk = await publishAlert(params)
const error = await getThunkError(thunk)
if (error) {
return pushToast({
theme: "error",
message: `Failed to send your alert to the notification server (${error}).`,
})
}
// Fetch our new status to make sure it's active
const address = getAddress(thunk.event)
const statusEvents = await loadAlertStatuses($pubkey!)
const statusEvent = statusEvents.find(event => getTagValue("d", event.tags) === address)
const statusTags = statusEvent
? parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, statusEvent.content))
: []
const {status = "error", message = "Your alert was not activated"}: Record<string, string> =
fromPairs(statusTags)
if (status === "error") {
return pushToast({theme: "error", message})
}
pushToast({message: "Your alert has been successfully created!"})
back()
} finally {
loading = false
}
}
onMount(() => {
if (!canSendPushNotifications()) {
channel = "email"
}
if (url) {
requestRelayClaim(url).then(code => {
if (code) {
claim = code
}
})
}
})
</script>
<form class="column gap-4" onsubmit={preventDefault(submit)}>
<ModalHeader>
{#snippet title()}
Add an Alert
{/snippet}
</ModalHeader>
{#if canSendPushNotifications()}
<FieldInline>
{#snippet label()}
<p>Alert Type*</p>
{/snippet}
{#snippet input()}
<select bind:value={channel} class="select select-bordered">
<option value="email">Email Digest</option>
<option value="push">Push Notification</option>
</select>
{/snippet}
</FieldInline>
{/if}
{#if channel === "email"}
<FieldInline>
{#snippet label()}
<p>Email Address*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<input placeholder="email@example.com" bind:value={email} />
</label>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Frequency*</p>
{/snippet}
{#snippet input()}
<select bind:value={cron} class="select select-bordered">
<option value={WEEKLY}>Weekly</option>
<option value={DAILY}>Daily</option>
</select>
{/snippet}
</FieldInline>
{/if}
{#if !hideSpaceField}
<FieldInline>
{#snippet label()}
<p>Space*</p>
{/snippet}
{#snippet input()}
<select bind:value={url} class="select select-bordered">
<option value="" disabled selected>Choose a space URL</option>
{#each getMembershipUrls($userMembership) as url (url)}
<option value={url}>{displayRelayUrl(url)}</option>
{/each}
</select>
{/snippet}
</FieldInline>
{/if}
<FieldInline>
{#snippet label()}
<p>Notifications*</p>
{/snippet}
{#snippet input()}
<div class="flex items-center justify-end gap-4">
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyThreads} />
Threads
</span>
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyCalendar} />
Calendar
</span>
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyChat} />
Chat
</span>
</div>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Invite Code</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<input bind:value={claim} />
</label>
{/snippet}
{#snippet info()}
<p>
To get notifications from private spaces, please provide an invite code which grants access
to the space.
</p>
{/snippet}
</FieldInline>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Confirm</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
</form>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import Confirm from "@lib/components/Confirm.svelte"
import type {Alert} from "@app/state"
import {NOTIFIER_RELAY, NOTIFIER_PUBKEY} from "@app/state"
import {publishDelete} from "@app/commands"
import {pushToast} from "@app/toast"
type Props = {
alert: Alert
}
const {alert}: Props = $props()
const confirm = () => {
publishDelete({event: alert.event, relays: [NOTIFIER_RELAY], tags: [["p", NOTIFIER_PUBKEY]]})
pushToast({message: "Your alert has been deleted!"})
history.back()
}
</script>
<Confirm {confirm} message="You'll no longer receive messages for this alert." />
+69
View File
@@ -0,0 +1,69 @@
<script lang="ts">
import {parseJson} from "@welshman/lib"
import {displayFeeds} from "@welshman/feeds"
import {getAddress, getTagValue, getTagValues} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import AlertDelete from "@app/components/AlertDelete.svelte"
import type {Alert} from "@app/state"
import {deriveAlertStatus} from "@app/state"
import {pushModal} from "@app/modal"
type Props = {
alert: Alert
}
const {alert}: Props = $props()
const status = deriveAlertStatus(getAddress(alert.event))
const cron = $derived(getTagValue("cron", alert.tags))
const channel = $derived(getTagValue("channel", alert.tags))
const feeds = $derived(getTagValues("feed", alert.tags))
const description = $derived(
getTagValue("description", alert.tags) ||
[
`${cron?.endsWith("1") ? "Weekly" : "Daily"} alert for events`,
displayFeeds(feeds.map(parseJson)),
`sent via ${channel}.`,
].join(" "),
)
const startDelete = () => pushModal(AlertDelete, {alert})
</script>
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4">
<Button class="py-1" onclick={startDelete}>
<Icon icon="trash-bin-2" />
</Button>
<div class="flex-inline gap-1">{description}</div>
</div>
{#if $status}
{@const statusText = getTagValue("status", $status.tags) || "error"}
{#if statusText === "ok"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content px-3 py-1 text-sm"
data-tip={getTagValue("message", $status.tags)}>
Active
</span>
{:else if statusText === "pending"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content border-yellow-500 px-3 py-1 text-sm text-yellow-500"
data-tip={getTagValue("message", $status.tags)}>
Pending
</span>
{:else}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip={getTagValue("message", $status.tags)}>
{statusText.replace("-", " ").replace(/^(.)/, x => x.toUpperCase())}
</span>
{/if}
{:else}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip="The notification server did not respond to your request.">
Inactive
</span>
{/if}
</div>
+43
View File
@@ -0,0 +1,43 @@
<script lang="ts">
import {getTagValue} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import AlertAdd from "@app/components/AlertAdd.svelte"
import AlertItem from "@app/components/AlertItem.svelte"
import {pushModal} from "@app/modal"
import {alerts} from "@app/state"
type Props = {
url?: string
channel?: string
hideSpaceField?: boolean
}
const {url = "", channel = "push", hideSpaceField = false}: Props = $props()
const startAlert = () => pushModal(AlertAdd, {url, channel, hideSpaceField})
const filteredAlerts = $derived(
url ? $alerts.filter(a => getTagValue("feed", a.tags)?.includes(url)) : $alerts,
)
</script>
<div class="card2 bg-alt flex flex-col gap-6 shadow-xl">
<div class="flex items-center justify-between">
<strong class="flex items-center gap-3">
<Icon icon="inbox" />
Alerts
</strong>
<Button class="btn btn-primary btn-sm" onclick={startAlert}>
<Icon icon="add-circle" />
Add Alert
</Button>
</div>
<div class="col-4">
{#each filteredAlerts as alert (alert.event.id)}
<AlertItem {alert} />
{:else}
<p class="text-center opacity-75 py-12">Nothing here yet!</p>
{/each}
</div>
</div>
+79
View File
@@ -0,0 +1,79 @@
<script module lang="ts">
import type {Nip46ResponseWithResult} from "@welshman/signer"
import {Nip46Broker, makeSecret} from "@welshman/signer"
import {NIP46_PERMS, PLATFORM_URL, PLATFORM_NAME, PLATFORM_LOGO, SIGNER_RELAYS} from "@app/state"
export class BunkerConnectController {
url = $state("")
bunker = $state("")
loading = $state(false)
clientSecret = makeSecret()
abortController = new AbortController()
broker = new Nip46Broker({clientSecret: this.clientSecret, relays: SIGNER_RELAYS})
onNostrConnect: (response: Nip46ResponseWithResult) => void
constructor({onNostrConnect}: {onNostrConnect: (response: Nip46ResponseWithResult) => void}) {
this.onNostrConnect = onNostrConnect
}
async start() {
this.url = await this.broker.makeNostrconnectUrl({
perms: NIP46_PERMS,
url: PLATFORM_URL,
name: PLATFORM_NAME,
image: PLATFORM_LOGO,
})
let response
try {
response = await this.broker.waitForNostrconnect(this.url, this.abortController.signal)
} catch (errorResponse: any) {
if (errorResponse?.error) {
pushToast({
theme: "error",
message: `Received error from signer: ${errorResponse.error}`,
})
} else if (errorResponse) {
console.error(errorResponse)
}
}
if (response) {
this.loading = true
this.onNostrConnect(response)
}
}
stop() {
this.broker.cleanup()
this.abortController.abort()
}
}
</script>
<script lang="ts">
import {onMount, onDestroy} from "svelte"
import {slideAndFade} from "@lib/transition"
import QRCode from "@app/components/QRCode.svelte"
import {pushToast} from "@app/toast"
type Props = {
controller: BunkerConnectController
}
const {controller}: Props = $props()
onMount(() => {
controller.start()
})
onDestroy(() => {
controller.stop()
})
</script>
{#if controller.url}
<div class="flex justify-center" out:slideAndFade>
<QRCode code={controller.url} />
</div>
{/if}
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts">
import {pushModal} from "@app/modal"
import InfoBunker from "@app/components/InfoBunker.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte"
type Props = {
bunker: string
loading: boolean
}
let {loading, bunker = $bindable("")}: Props = $props()
</script>
<Field>
{#snippet label()}
<p>Bunker Link*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="cpu" />
<input disabled={loading} bind:value={bunker} class="grow" placeholder="bunker://" />
</label>
{/snippet}
{#snippet info()}
<p>
A login link provided by a nostr signing app.
<Button class="link" onclick={() => pushModal(InfoBunker)}>What is a bunker link?</Button>
</p>
{/snippet}
</Field>
+23 -11
View File
@@ -1,12 +1,16 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
import {publishDelete, publishReaction} from "@app/commands"
import {makeCalendarPath} from "@app/routes"
import {pushModal} from "@app/modal"
const {
url,
@@ -20,24 +24,32 @@
const path = makeCalendarPath(url, event.id)
const onReactionClick = (content: string, events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey)
const editEvent = () => pushModal(CalendarEventEdit, {url, event})
if (reaction) {
publishDelete({relays: [url], event: reaction})
} else {
publishReaction({event, content, relays: [url]})
}
}
const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
const createReaction = (template: EventContent) =>
publishReaction({...template, event, relays: [url]})
</script>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2">
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-left" />
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Event" />
<EventActions {url} {event} noun="Event">
{#snippet customActions()}
{#if event.pubkey === $pubkey}
<li>
<Button onclick={editEvent}>
<Icon size={4} icon="pen" />
Edit Event
</Button>
</li>
{/if}
{/snippet}
</EventActions>
</div>
</div>
+16 -157
View File
@@ -1,164 +1,23 @@
<script lang="ts">
import {writable} from "svelte/store"
import {randomId, HOUR} from "@welshman/lib"
import {createEvent, EVENT_TIME} from "@welshman/util"
import {publishThunk} from "@welshman/app"
import {preventDefault} from "@lib/html"
import {daysBetween} from "@lib/util"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {PROTECTED, GENERAL, tagRoom} from "@app/state"
import {makeEditor} from "@app/editor"
import {pushToast} from "@app/toast"
import CalendarEventForm from "@app/components/CalendarEventForm.svelte"
const {url} = $props()
const uploading = writable(false)
const back = () => history.back()
const submit = () => {
if ($uploading) return
if (!title) {
return pushToast({
theme: "error",
message: "Please provide a title.",
})
}
if (!start || !end) {
return pushToast({
theme: "error",
message: "Please provide start and end times.",
})
}
if (start >= end) {
return pushToast({
theme: "error",
message: "End time must be later than start time.",
})
}
const event = createEvent(EVENT_TIME, {
content: editor.getText({blockSeparator: "\n"}).trim(),
tags: [
["d", randomId()],
["title", title],
["location", location],
["start", start.toString()],
["end", end.toString()],
...daysBetween(start, end).map(D => ["D", D.toString()]),
...editor.storage.nostr.getEditorTags(),
tagRoom(GENERAL, url),
PROTECTED,
],
})
pushToast({message: "Your event has been published!"})
publishThunk({event, relays: [url]})
history.back()
type Props = {
url: string
}
const editor = makeEditor({submit, uploading})
let title = $state("")
let location = $state("")
let start: number | undefined = $state()
let end: number | undefined = $state()
let endDirty = false
$effect(() => {
if (!endDirty && start) {
end = start + HOUR
} else if (end) {
endDirty = true
}
})
const {url}: Props = $props()
</script>
<form class="column gap-4" onsubmit={preventDefault(submit)}>
<ModalHeader>
{#snippet title()}
<div>Create an Event</div>
{/snippet}
{#snippet info()}
<div>Invite other group members to events online or in real life.</div>
{/snippet}
</ModalHeader>
<Field>
{#snippet label()}
<p>Title*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<input bind:value={title} class="grow" type="text" />
</label>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Summary</p>
{/snippet}
{#snippet input()}
<div class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
<div class="input-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
<Button
data-tip="Add an image"
class="center btn tooltip"
onclick={() => editor.chain().selectFiles().run()}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="gallery-send" />
{/if}
</Button>
</div>
{/snippet}
</Field>
<Field>
{#snippet label()}
Start*
{/snippet}
{#snippet input()}
<DateTimeInput bind:value={start} />
{/snippet}
</Field>
<Field>
{#snippet label()}
End*
{/snippet}
{#snippet input()}
<DateTimeInput bind:value={end} />
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Location (optional)</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="map-point" />
<input bind:value={location} class="grow" type="text" />
</label>
{/snippet}
</Field>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={$uploading}>
<Spinner loading={$uploading}>Create Event</Spinner>
</Button>
</ModalFooter>
</form>
<CalendarEventForm {url}>
{#snippet header()}
<ModalHeader>
{#snippet title()}
<div>Create an Event</div>
{/snippet}
{#snippet info()}
<div>Invite other group members to events online or in real life.</div>
{/snippet}
</ModalHeader>
{/snippet}
</CalendarEventForm>
+6 -5
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import {fromPairs} from "@welshman/lib"
import {fromPairs, LOCALE, secondsToDate} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {LOCALE, secondsToDate} from "@welshman/app"
type Props = {
event: TrustedEvent
@@ -13,7 +12,9 @@
</script>
<div
class="flex h-14 w-14 flex-col items-center justify-center gap-1 rounded-box border border-solid border-base-content p-2 sm:h-24 sm:w-24">
<span class="sm:text-lg">{Intl.DateTimeFormat(LOCALE, {month: "short"}).format(startDate)}</span>
<span class="sm:text-4xl">{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(startDate)}</span>
class="hidden h-32 w-32 min-w-32 flex-col items-center justify-center gap-1 rounded-box bg-base-300 p-2 sm:flex">
<strong>{Intl.DateTimeFormat(LOCALE, {month: "short"}).format(startDate)}</strong>
<span class="text-4xl">{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(startDate)}</span>
<span class="text-xs opacity-75"
>{Intl.DateTimeFormat(LOCALE, {weekday: "long"}).format(startDate)}</span>
</div>
@@ -0,0 +1,35 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {getTagValue} from "@welshman/util"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import CalendarEventForm from "@app/components/CalendarEventForm.svelte"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const initialValues = {
d: getTagValue("d", event.tags)!,
title: getTagValue("title", event.tags)!,
location: getTagValue("location", event.tags)!,
start: parseInt(getTagValue("start", event.tags)!),
end: parseInt(getTagValue("end", event.tags)!),
content: event.content,
}
</script>
<CalendarEventForm {url} {initialValues}>
{#snippet header()}
<ModalHeader>
{#snippet title()}
<div>Edit this Event</div>
{/snippet}
{#snippet info()}
<div>Invite other group members to events online or in real life.</div>
{/snippet}
</ModalHeader>
{/snippet}
</CalendarEventForm>
+170
View File
@@ -0,0 +1,170 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {writable} from "svelte/store"
import {randomId, HOUR} from "@welshman/lib"
import {makeEvent, EVENT_TIME} from "@welshman/util"
import {publishThunk} from "@welshman/app"
import {preventDefault} from "@lib/html"
import {daysBetween} from "@lib/util"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {PROTECTED} from "@app/state"
import {makeEditor} from "@app/editor"
import {pushToast} from "@app/toast"
type Props = {
url: string
header: Snippet
initialValues?: {
d: string
title: string
content: string
location: string
start: number
end: number
}
}
const {url, header, initialValues}: Props = $props()
const uploading = writable(false)
const back = () => history.back()
const selectFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const submit = async () => {
if ($uploading) return
if (!title) {
return pushToast({
theme: "error",
message: "Please provide a title.",
})
}
if (!start || !end) {
return pushToast({
theme: "error",
message: "Please provide start and end times.",
})
}
if (start >= end) {
return pushToast({
theme: "error",
message: "End time must be later than start time.",
})
}
const ed = await editor
const event = makeEvent(EVENT_TIME, {
content: ed.getText({blockSeparator: "\n"}).trim(),
tags: [
["d", initialValues?.d || randomId()],
["title", title],
["location", location || ""],
["start", start.toString()],
["end", end.toString()],
...daysBetween(start, end).map(D => ["D", D.toString()]),
...ed.storage.nostr.getEditorTags(),
PROTECTED,
],
})
pushToast({message: "Your event has been saved!"})
publishThunk({event, relays: [url]})
history.back()
}
const content = initialValues?.content || ""
const editor = makeEditor({url, submit, uploading, content})
let title = $state(initialValues?.title || "")
let location = $state(initialValues?.location || "")
let start: number | undefined = $state(initialValues?.start)
let end: number | undefined = $state(initialValues?.end)
let endDirty = Boolean(initialValues?.end)
$effect(() => {
if (!endDirty && start) {
end = start + HOUR
} else if (end) {
endDirty = true
}
})
</script>
<form novalidate class="column gap-4" onsubmit={preventDefault(submit)}>
{@render header()}
<Field>
{#snippet label()}
<p>Title*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<input bind:value={title} class="grow" type="text" />
</label>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Summary</p>
{/snippet}
{#snippet input()}
<div class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
<div class="input-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
<Button data-tip="Add an image" class="center btn tooltip" onclick={selectFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="gallery-send" />
{/if}
</Button>
</div>
{/snippet}
</Field>
<Field>
{#snippet label()}
Start*
{/snippet}
{#snippet input()}
<DateTimeInput bind:value={start} />
{/snippet}
</Field>
<Field>
{#snippet label()}
End*
{/snippet}
{#snippet input()}
<DateTimeInput bind:value={end} />
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Location (optional)</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="map-point" />
<input bind:value={location} class="grow" type="text" />
</label>
{/snippet}
</Field>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={$uploading}>
<Spinner loading={$uploading}>Save Event</Spinner>
</Button>
</ModalFooter>
</form>
+15 -6
View File
@@ -1,8 +1,12 @@
<script lang="ts">
import {fromPairs} from "@welshman/lib"
import {
fromPairs,
formatTimestamp,
formatTimestampAsDate,
formatTimestampAsTime,
} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import {formatTimestamp, formatTimestampAsDate, formatTimestampAsTime} from "@welshman/app"
type Props = {
event: TrustedEvent
@@ -17,8 +21,13 @@
const isSingleDay = $derived(startDateDisplay === endDateDisplay)
</script>
<p class="text-xl">{meta.title || meta.name}</p>
<div class="flex items-center gap-2 text-sm">
<Icon icon="clock-circle" size={4} />
{formatTimestampAsTime(start)}{isSingleDay ? formatTimestampAsTime(end) : formatTimestamp(end)}
<div class="flex flex-grow flex-wrap justify-between gap-2">
<p class="text-xl">{meta.title || meta.name}</p>
<div class="flex items-center gap-2 text-sm">
<Icon icon="clock-circle" size={4} />
<span class="sm:hidden">{formatTimestampAsDate(start)}</span>
{formatTimestampAsTime(start)}{isSingleDay
? formatTimestampAsTime(end)
: formatTimestamp(end)}
</div>
</div>
+2 -4
View File
@@ -15,12 +15,10 @@
</script>
<Link class="col-3 card2 bg-alt w-full cursor-pointer" href={makeCalendarPath(url, event.id)}>
<div class="flex items-center justify-between gap-2">
<CalendarEventHeader {event} />
</div>
<CalendarEventHeader {event} />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75">
Posted by <ProfileLink pubkey={event.pubkey} />
Posted by <ProfileLink pubkey={event.pubkey} {url} />
</span>
<CalendarEventActions showActivity {url} {event} />
</div>
+12 -9
View File
@@ -6,19 +6,22 @@
type Props = {
event: TrustedEvent
url: string
}
const {event}: Props = $props()
const {event, url}: Props = $props()
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
</script>
<span>
Posted by <ProfileLink pubkey={event.pubkey} />
</span>
{#if meta.location}
<span></span>
<div class="flex min-w-0 flex-col gap-1 text-sm opacity-75">
<span class="flex items-center gap-1">
<Icon icon="map-point" size={4} />
{meta.location}
<Icon icon="user-circle" size={4} />
Posted by <ProfileLink pubkey={event.pubkey} {url} />
</span>
{/if}
{#if meta.location}
<span class="flex items-start gap-1">
<Icon icon="map-point" class="mt-[2px]" size={4} />
<span class="break-words">{meta.location}</span>
</span>
{/if}
</div>
+13 -10
View File
@@ -1,39 +1,42 @@
<script lang="ts">
import {writable} from "svelte/store"
import type {EventContent} from "@welshman/util"
import {isMobile, preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
interface Props {
onSubmit: any
type Props = {
url?: string
onSubmit: (event: EventContent) => void
}
const {onSubmit}: Props = $props()
const {onSubmit, url}: Props = $props()
const autofocus = !isMobile
const uploading = writable(false)
export const focus = () => editor.chain().focus().run()
export const focus = () => editor.then(ed => ed.chain().focus().run())
const uploadFiles = () => editor.chain().selectFiles().run()
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const submit = () => {
const submit = async () => {
if ($uploading) return
const content = editor.getText({blockSeparator: "\n"}).trim()
const tags = editor.storage.nostr.getEditorTags()
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = ed.storage.nostr.getEditorTags()
if (!content) return
onSubmit({content, tags})
editor.chain().clearContent().run()
ed.chain().clearContent().run()
}
const editor = makeEditor({autofocus, submit, uploading, aggressive: true})
const editor = makeEditor({url, autofocus, submit, uploading, aggressive: true})
</script>
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
+43 -46
View File
@@ -1,17 +1,9 @@
<script lang="ts">
import {hash} from "@welshman/lib"
import {now} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {
thunks,
pubkey,
deriveProfile,
deriveProfileDisplay,
formatTimestampAsDate,
formatTimestampAsTime,
} from "@welshman/app"
import {hash, now, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {thunks, deriveProfile, deriveProfileDisplay} from "@welshman/app"
import {isMobile} from "@lib/html"
import LongPress from "@lib/components/LongPress.svelte"
import TapTarget from "@lib/components/TapTarget.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -19,50 +11,45 @@
import ThunkStatus from "@app/components/ThunkStatus.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ChannelMessageZapButton from "@app/components/ChannelMessageZapButton.svelte"
import ChannelMessageEmojiButton from "@app/components/ChannelMessageEmojiButton.svelte"
import ChannelMessageMenuButton from "@app/components/ChannelMessageMenuButton.svelte"
import ChannelMessageMenuMobile from "@app/components/ChannelMessageMenuMobile.svelte"
import {colors} from "@app/state"
import {colors, ENABLE_ZAPS} from "@app/state"
import {publishDelete, publishReaction} from "@app/commands"
import {pushModal} from "@app/modal"
interface Props {
url: any
room: any
url: string
event: TrustedEvent
replyTo?: any
replyTo?: (event: TrustedEvent) => void
showPubkey?: boolean
inert?: boolean
}
const {url, room, event, replyTo = undefined, showPubkey = false, inert = false}: Props = $props()
const {url, event, replyTo = undefined, showPubkey = false, inert = false}: Props = $props()
const thunk = $thunks[event.id]
const today = formatTimestampAsDate(now())
const profile = deriveProfile(event.pubkey)
const profileDisplay = deriveProfileDisplay(event.pubkey)
const profile = deriveProfile(event.pubkey, [url])
const profileDisplay = deriveProfileDisplay(event.pubkey, [url])
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
const reply = () => replyTo(event)
const reply = () => replyTo!(event)
const onLongPress = () => pushModal(ChannelMessageMenuMobile, {url, event, reply})
const onTap = () => pushModal(ChannelMessageMenuMobile, {url, event, reply})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
const onReactionClick = (content: string, events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey)
const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
if (reaction) {
publishDelete({relays: [url], event: reaction})
} else {
publishReaction({event, content, relays: [url]})
}
}
const createReaction = (template: EventContent) =>
publishReaction({...template, event, relays: [url]})
</script>
<LongPress
<TapTarget
data-event={event.id}
onLongPress={inert ? null : onLongPress}
onTap={inert ? null : onTap}
class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left">
<div class="flex w-full gap-3 overflow-auto">
{#if showPubkey}
@@ -89,7 +76,7 @@
</div>
{/if}
<div class="text-sm">
<Content {event} relays={[url]} />
<Content minimalQuote {event} {url} />
{#if thunk}
<ThunkStatus {thunk} class="mt-2" />
{/if}
@@ -97,17 +84,27 @@
</div>
</div>
<div class="row-2 ml-10 mt-1">
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-right" />
<ReactionSummary
{url}
{event}
{deleteReaction}
{createReaction}
reactionClass="tooltip-right" />
</div>
<button
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}>
<ChannelMessageEmojiButton {url} {room} {event} />
{#if replyTo}
<Button class="btn join-item btn-xs" onclick={reply}>
<Icon icon="reply" size={4} />
</Button>
{/if}
<ChannelMessageMenuButton {url} {event} />
</button>
</LongPress>
{#if !isMobile}
<button
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}>
{#if ENABLE_ZAPS}
<ChannelMessageZapButton {url} {event} />
{/if}
<ChannelMessageEmojiButton {url} {event} />
{#if replyTo}
<Button class="btn join-item btn-xs" onclick={reply}>
<Icon icon="reply" size={4} />
</Button>
{/if}
<ChannelMessageMenuButton {url} {event} />
</button>
{/if}
</TapTarget>
@@ -1,14 +1,10 @@
<script lang="ts">
import {noop} from "@welshman/lib"
import type {NativeEmoji} from "emoji-picker-element/shared"
import EmojiButton from "@lib/components/EmojiButton.svelte"
import Icon from "@lib/components/Icon.svelte"
import {publishReaction} from "@app/commands"
const {url, room, event} = $props()
// Tell svelte-check to shut up
noop(room)
const {url, event} = $props()
const onEmoji = (emoji: NativeEmoji) =>
publishReaction({event, relays: [url], content: emoji.unicode})
+3 -3
View File
@@ -4,7 +4,7 @@
import Icon from "@lib/components/Icon.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import EventReport from "@app/components/EventReport.svelte"
import ConfirmDelete from "@app/components/ConfirmDelete.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {pushModal} from "@app/modal"
const {url, event, onClick} = $props()
@@ -16,12 +16,12 @@
const showInfo = () => {
onClick()
pushModal(EventInfo, {event})
pushModal(EventInfo, {url, event})
}
const showDelete = () => {
onClick()
pushModal(ConfirmDelete, {url, event})
pushModal(EventDeleteConfirm, {url, event})
}
</script>
@@ -1,20 +1,29 @@
<script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import ConfirmDelete from "@app/components/ConfirmDelete.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {ENABLE_ZAPS} from "@app/state"
import {publishReaction} from "@app/commands"
import {pushModal} from "@app/modal"
const {url, event, reply} = $props()
type Props = {
url: string
event: TrustedEvent
reply: () => void
}
const onEmoji = (emoji: NativeEmoji) => {
const {url, event, reply}: Props = $props()
const onEmoji = ((event: TrustedEvent, url: string, emoji: NativeEmoji) => {
history.back()
publishReaction({event, relays: [url], content: emoji.unicode})
}
}).bind(undefined, event, url)
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
@@ -23,9 +32,9 @@
reply()
}
const showInfo = () => pushModal(EventInfo, {event}, {replaceState: true})
const showInfo = () => pushModal(EventInfo, {url, event}, {replaceState: true})
const showDelete = () => pushModal(ConfirmDelete, {url, event})
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
</script>
<div class="col-2">
@@ -33,6 +42,12 @@
<Icon size={4} icon="smile-circle" />
Send Reaction
</Button>
{#if ENABLE_ZAPS}
<ZapButton replaceState {url} {event} class="btn btn-secondary w-full">
<Icon size={4} icon="bolt" />
Send Zap
</ZapButton>
{/if}
<Button class="btn btn-neutral w-full" onclick={sendReply}>
<Icon size={4} icon="reply" />
Send Reply
@@ -0,0 +1,10 @@
<script lang="ts">
import Icon from "@lib/components/Icon.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
const {url, event} = $props()
</script>
<ZapButton {url} {event} class="btn join-item btn-xs">
<Icon icon="bolt" size={4} />
</ZapButton>
+2 -6
View File
@@ -1,11 +1,7 @@
<script lang="ts">
import {GENERAL, channelsById, makeChannelId} from "@app/state"
import {channelsById, makeChannelId} from "@app/state"
const {url, room} = $props()
</script>
{#if room === GENERAL}
general
{:else}
{$channelsById.get(makeChannelId(url, room))?.name || room}
{/if}
{$channelsById.get(makeChannelId(url, room))?.name || room}
+215 -121
View File
@@ -1,20 +1,40 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {onMount} from "svelte"
import {int, nthNe, MINUTE, sortBy, remove} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {createEvent, DIRECT_MESSAGE, INBOX_RELAYS} from "@welshman/util"
import {
int,
ms,
partition,
spec,
nthEq,
nthNe,
MINUTE,
sortBy,
remove,
formatTimestampAsDate,
} from "@welshman/lib"
import type {TrustedEvent, EventTemplate, EventContent} from "@welshman/util"
import {parse, isLink} from "@welshman/content"
import {
makeEvent,
tagsFromIMeta,
getTags,
DIRECT_MESSAGE,
DIRECT_MESSAGE_FILE,
INBOX_RELAYS,
} from "@welshman/util"
import {
pubkey,
tagPubkey,
formatTimestampAsDate,
sendWrapped,
loadUsingOutbox,
inboxRelaySelectionsByPubkey,
load,
} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte"
import Button from "@lib/components/Button.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
@@ -23,27 +43,30 @@
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ProfileList from "@app/components/ProfileList.svelte"
import ChatMessage from "@app/components/ChatMessage.svelte"
import ChatCompose from "@app/components/ChannelCompose.svelte"
import ChatComposeParent from "@app/components/ChannelComposeParent.svelte"
import {userSettingValues, deriveChat, splitChatId, PLATFORM_NAME} from "@app/state"
import ChatCompose from "@app/components/ChatCompose.svelte"
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
import {
INDEXER_RELAYS,
userSettingValues,
deriveChat,
splitChatId,
PLATFORM_NAME,
} from "@app/state"
import {pushModal} from "@app/modal"
import {sendWrapped, prependParent} from "@app/commands"
import {prependParent} from "@app/commands"
const {
id,
info,
}: {
type Props = {
id: string
info?: Snippet
} = $props()
}
const {id, info}: Props = $props()
const chat = deriveChat(id)
const pubkeys = splitChatId(id)
const others = remove($pubkey!, pubkeys)
const missingInboxes = $derived(pubkeys.filter(pk => !$inboxRelaySelectionsByPubkey.has(pk)))
const assertEvent = (e: any) => e as TrustedEvent
const showMembers = () =>
pushModal(ProfileList, {pubkeys: others, title: `People in this conversation`})
@@ -57,14 +80,53 @@
}
const onSubmit = async (params: EventContent) => {
// Remove p tags since they result in forking the conversation
const tags = [...params.tags.filter(nthNe(0, "p")), ...remove($pubkey!, pubkeys).map(tagPubkey)]
const ptags = remove($pubkey!, pubkeys).map(tagPubkey)
await sendWrapped({
pubkeys,
template: createEvent(DIRECT_MESSAGE, prependParent(parent, {...params, tags})),
delay: $userSettingValues.send_delay,
})
// Remove p tags since they result in forking the conversation
params.tags = params.tags.filter(nthNe(0, "p"))
// Add our reply quote to content
params = prependParent(parent, params)
const [imetaTags, tags] = partition(nthEq(0, "imeta"), params.tags)
const imetas = getTags("imeta", imetaTags).map(tagsFromIMeta)
const templates: EventTemplate[] = []
const buffer = []
const addTemplate = (kind: number, content: string, tags: string[][]) => {
content = content.trim()
if (content) {
templates.push(makeEvent(kind, {content, tags: [...tags, ...ptags]}))
}
}
for (const p of parse(params)) {
const imeta = isLink(p)
? imetas.find(tags => tags.find(spec(["url", p.value.url.toString()])))
: undefined
if (isLink(p) && imeta) {
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
addTemplate(
DIRECT_MESSAGE_FILE,
p.value.url.toString(),
imeta.slice(1).filter(nthNe(0, "url")),
)
} else {
buffer.push(p.raw)
}
}
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
// Split the message into multiple pieces so that we can use kind 15 to send images per nip 17
// Sleep 1 second between each one to make sure timestamps are distinct
for (let i = 0; i < templates.length; i++) {
const template = templates[i]
await sendWrapped({pubkeys, template, delay: $userSettingValues.send_delay + ms(i)})
}
clearParent()
}
@@ -72,6 +134,8 @@
let loading = $state(true)
let compose: ChatCompose | undefined = $state()
let parent: TrustedEvent | undefined = $state()
let chatCompose: HTMLElement | undefined = $state()
let dynamicPadding: HTMLElement | undefined = $state()
const elements = $derived.by(() => {
const elements = []
@@ -105,7 +169,27 @@
onMount(() => {
// Don't use loadInboxRelaySelection because we want to force reload
load({filters: [{kinds: [INBOX_RELAYS], authors: others}]})
for (const pubkey of others) {
loadUsingOutbox({
pubkey,
kind: INBOX_RELAYS,
relays: INDEXER_RELAYS,
})
}
const observer = new ResizeObserver(() => {
if (dynamicPadding && chatCompose) {
dynamicPadding.style.minHeight = `${chatCompose.offsetHeight}px`
}
})
observer.observe(chatCompose!)
observer.observe(dynamicPadding!)
return () => {
observer.unobserve(chatCompose!)
observer.unobserve(dynamicPadding!)
}
})
setTimeout(() => {
@@ -113,106 +197,116 @@
}, 5000)
</script>
<div class="relative flex h-full w-full flex-col">
{#if others.length > 0}
<PageBar>
{#snippet title()}
<div class="flex flex-col gap-1 sm:flex-row sm:gap-2">
{#if others.length === 1}
{@const pubkey = others[0]}
{@const onClick = () => pushModal(ProfileDetail, {pubkey})}
<Button onclick={onClick} class="row-2">
<ProfileCircle {pubkey} size={5} />
<ProfileName {pubkey} />
</Button>
{:else}
<div class="flex items-center gap-2">
<ProfileCircles pubkeys={others} size={5} />
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
<ProfileName pubkey={others[0]} />
and
{#if others.length === 2}
<ProfileName pubkey={others[1]} />
{:else}
{others.length - 1}
{others.length > 2 ? "others" : "other"}
{/if}
</p>
</div>
{#if others.length > 2}
<Button onclick={showMembers} class="btn btn-link hidden sm:block"
>Show all members</Button>
{/if}
{/if}
<PageBar>
{#snippet title()}
<div class="flex flex-col gap-1 sm:flex-row sm:gap-2">
{#if others.length === 0}
<div class="row-2">
<ProfileCircle pubkey={$pubkey!} size={5} />
<ProfileName pubkey={$pubkey!} />
</div>
{/snippet}
{#snippet action()}
<div>
{#if remove($pubkey, missingInboxes).length > 0}
{@const count = remove($pubkey, missingInboxes).length}
{@const label = count > 1 ? "inboxes are" : "inbox is"}
<div
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer"
data-tip="{count} {label} not configured.">
<Icon icon="danger" />
{count}
</div>
{/if}
</div>
{/snippet}
</PageBar>
{/if}
<div class="-mt-2 flex flex-grow flex-col-reverse overflow-auto py-2">
{#if missingInboxes.includes($pubkey!)}
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error">
<Icon icon="danger" />
Your inbox is not configured.
</p>
<p>
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please visit
your <Link class="link" href="/settings/relays">relay settings page</Link> to set up your
inbox.
</p>
</div>
</div>
{:else if missingInboxes.length > 0}
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error">
<Icon icon="danger" />
{missingInboxes.length}
{missingInboxes.length > 1 ? "inboxes are" : "inbox is"} not configured.
</p>
<p>
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please make
sure everyone in this conversation has set up their inbox relays.
</p>
</div>
</div>
{/if}
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "date"}
<Divider>{value}</Divider>
{:else if others.length === 1}
{@const pubkey = others[0]}
{@const onClick = () => pushModal(ProfileDetail, {pubkey})}
<Button onclick={onClick} class="row-2">
<ProfileCircle {pubkey} size={5} />
<ProfileName {pubkey} />
</Button>
{:else}
<ChatMessage event={assertEvent(value)} {pubkeys} {showPubkey} {replyTo} />
{/if}
{/each}
<p
class="m-auto flex h-10 max-w-sm flex-col items-center justify-center gap-4 py-20 text-center">
<Spinner {loading}>
{#if loading}
Looking for messages...
{:else}
End of message history
<div class="flex items-center gap-2">
<ProfileCircles pubkeys={others} size={5} />
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
<ProfileName pubkey={others[0]} />
and
{#if others.length === 2}
<ProfileName pubkey={others[1]} />
{:else}
{others.length - 1}
{others.length > 2 ? "others" : "other"}
{/if}
</p>
</div>
{#if others.length > 2}
<Button onclick={showMembers} class="btn btn-link hidden sm:block"
>Show all members</Button>
{/if}
</Spinner>
{@render info?.()}
</p>
</div>
{#if parent}
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
</div>
{/snippet}
{#snippet action()}
<div>
{#if remove($pubkey, missingInboxes).length > 0}
{@const count = remove($pubkey, missingInboxes).length}
{@const label = count > 1 ? "inboxes are" : "inbox is"}
<div
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer"
data-tip="{count} {label} not configured.">
<Icon icon="danger" />
{count}
</div>
{/if}
</div>
{/snippet}
</PageBar>
<PageContent class="flex flex-col-reverse gap-2 pt-4">
<div bind:this={dynamicPadding}></div>
{#if missingInboxes.includes($pubkey!)}
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error">
<Icon icon="danger" />
Your inbox is not configured.
</p>
<p>
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please visit
your <Link class="link" href="/settings/relays">relay settings page</Link> to set up your inbox.
</p>
</div>
</div>
{:else if missingInboxes.length > 0}
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error">
<Icon icon="danger" />
{missingInboxes.length}
{missingInboxes.length > 1 ? "inboxes are" : "inbox is"} not configured.
</p>
<p>
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please make
sure everyone in this conversation has set up their inbox relays.
</p>
</div>
</div>
{/if}
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "date"}
<Divider>{value}</Divider>
{:else}
<ChatMessage
event={$state.snapshot(value as TrustedEvent)}
{pubkeys}
{showPubkey}
{replyTo} />
{/if}
{/each}
<p class="m-auto flex h-10 max-w-sm flex-col items-center justify-center gap-4 py-20 text-center">
<Spinner {loading}>
{#if loading}
Looking for messages...
{:else}
End of message history
{/if}
</Spinner>
{@render info?.()}
</p>
</PageContent>
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
<div>
{#if parent}
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
</div>
<ChatCompose bind:this={compose} {onSubmit} />
</div>
+70
View File
@@ -0,0 +1,70 @@
<script lang="ts">
import {writable} from "svelte/store"
import type {EventContent} from "@welshman/util"
import {isMobile, preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
type Props = {
url?: string
onSubmit: (event: EventContent) => void
}
const {onSubmit, url}: Props = $props()
const autofocus = !isMobile
const uploading = writable(false)
export const focus = () => editor.then(ed => ed.chain().focus().run())
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const submit = async () => {
if ($uploading) return
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = ed.storage.nostr.getEditorTags()
if (!content) return
onSubmit({content, tags})
ed.chain().clearContent().run()
}
const editor = makeEditor({
url,
autofocus,
submit,
uploading,
aggressive: true,
})
</script>
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
<Button
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"
disabled={$uploading}
onclick={uploadFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="gallery-send" />
{/if}
</Button>
<div class="chat-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
<Button
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full"
disabled={$uploading}
onclick={submit}>
<Icon icon="plain" />
</Button>
</form>
@@ -0,0 +1,35 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {displayProfileByPubkey} from "@welshman/app"
import {slide} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
const {
verb,
event,
clear,
}: {
verb: string
event: TrustedEvent
clear: () => void
} = $props()
</script>
<div
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8 text-xs"
transition:slide>
<p class="text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p>
{#key event.id}
<NoteContent
{event}
hideMediaAtDepth={0}
minLength={100}
maxLength={300}
expandMode="disabled" />
{/key}
<Button class="absolute right-2 top-2 cursor-pointer" onclick={clear}>
<Icon icon="close-circle" />
</Button>
</div>
+3 -1
View File
@@ -13,6 +13,8 @@
const {next} = $props()
const nextUrl = $state.snapshot(next)
let loading = $state(false)
const enableChat = async () => {
@@ -23,7 +25,7 @@
}
clearModals()
goto(next)
goto(nextUrl)
}
const submit = async () => {
+2 -2
View File
@@ -22,7 +22,7 @@
const {...props}: Props = $props()
const others = remove($pubkey!, props.pubkeys)
const active = $page.params.chat === props.id
const active = $derived($page.params.chat === props.id)
const path = makeChatPath(props.pubkeys)
onMount(() => {
@@ -40,7 +40,7 @@
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
{#if others.length === 0}
<ProfileCircle pubkey={$pubkey} size={5} />
<ProfileCircle pubkey={$pubkey!} size={5} />
Note to self
{:else if others.length === 1}
<ProfileCircle pubkey={others[0]} size={5} />
+44 -47
View File
@@ -1,19 +1,13 @@
<script lang="ts">
import {type Instance} from "tippy.js"
import {hash} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {
thunks,
deriveProfile,
deriveProfileDisplay,
formatTimestampAsTime,
pubkey,
} from "@welshman/app"
import {hash, formatTimestampAsTime} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {thunks, pubkey, deriveProfile, deriveProfileDisplay, sendWrapped} from "@welshman/app"
import {isMobile} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import LongPress from "@lib/components/LongPress.svelte"
import TapTarget from "@lib/components/TapTarget.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import Content from "@app/components/Content.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
@@ -22,17 +16,17 @@
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte"
import {colors} from "@app/state"
import {makeDelete, makeReaction, sendWrapped} from "@app/commands"
import {makeDelete, makeReaction} from "@app/commands"
import {pushModal} from "@app/modal"
interface Props {
event: TrustedEvent
replyTo?: any
replyTo: (event: TrustedEvent) => void
pubkeys: string[]
showPubkey?: boolean
}
const {event, replyTo = undefined, pubkeys, showPubkey = false}: Props = $props()
const {event, replyTo, pubkeys, showPubkey = false}: Props = $props()
const thunk = $thunks[event.id]
const isOwn = event.pubkey === $pubkey
@@ -40,16 +34,17 @@
const profileDisplay = deriveProfileDisplay(event.pubkey)
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
const onReactionClick = async (content: string, events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey)
const template = reaction ? makeDelete({event: reaction}) : makeReaction({event, content})
const reply = () => replyTo(event)
await sendWrapped({template, pubkeys})
}
const deleteReaction = (event: TrustedEvent) =>
sendWrapped({template: makeDelete({event}), pubkeys})
const createReaction = (template: EventContent) =>
sendWrapped({template: makeReaction({event, ...template}), pubkeys})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys})
const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys, reply})
const togglePopover = () => {
if (popoverIsVisible) {
@@ -72,32 +67,34 @@
class:chat-start={!isOwn}
class:flex-row-reverse={!isOwn}
class:chat-end={isOwn}>
<Tippy
bind:popover
component={ChatMessageMenu}
props={{event, pubkeys, popover, replyTo}}
params={{
interactive: true,
trigger: "manual",
onShow() {
popoverIsVisible = true
},
onHidden() {
popoverIsVisible = false
},
}}>
<button
type="button"
class="opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}
onclick={togglePopover}>
<Icon icon="menu-dots" size={4} />
</button>
</Tippy>
{#if !isMobile}
<Tippy
bind:popover
component={ChatMessageMenu}
props={{event, pubkeys, popover, replyTo}}
params={{
interactive: true,
trigger: "manual",
onShow() {
popoverIsVisible = true
},
onHidden() {
popoverIsVisible = false
},
}}>
<button
type="button"
class="opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}
onclick={togglePopover}>
<Icon icon="menu-dots" size={4} />
</button>
</Tippy>
{/if}
<div class="flex min-w-0 flex-col" class:items-end={isOwn}>
<LongPress
class="bg-alt chat-bubble mx-1 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl"
onLongPress={showMobileMenu}>
<TapTarget
class="bg-alt chat-bubble mx-1 mb-2 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl"
onTap={showMobileMenu}>
{#if showPubkey}
<div class="flex items-center gap-2">
{#if !isOwn}
@@ -120,9 +117,9 @@
<div class="text-sm">
<Content showEntire {event} />
</div>
</LongPress>
<div class="row-2 z-feature -mt-1 ml-4">
<ReactionSummary {event} {onReactionClick} noTooltip />
</TapTarget>
<div class="row-2 z-feature -mt-4 ml-4">
<ReactionSummary {event} {deleteReaction} {createReaction} noTooltip />
</div>
</div>
</div>
@@ -1,9 +1,10 @@
<script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import {sendWrapped} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte"
import {makeReaction, sendWrapped} from "@app/commands"
import {makeReaction} from "@app/commands"
interface Props {
event: TrustedEvent
@@ -1,22 +1,35 @@
<script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import {sendWrapped} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import {makeReaction, sendWrapped} from "@app/commands"
import {makeReaction} from "@app/commands"
import {pushModal} from "@app/modal"
import {clip} from "@app/toast"
const {event, pubkeys} = $props()
const onEmoji = (emoji: NativeEmoji) => {
history.back()
sendWrapped({template: makeReaction({event, content: emoji.unicode}), pubkeys})
type Props = {
pubkeys: string[]
event: TrustedEvent
reply: () => void
}
const {event, pubkeys, reply}: Props = $props()
const onEmoji = ((event: TrustedEvent, pubkeys: string[], emoji: NativeEmoji) => {
history.back()
sendWrapped({template: makeReaction({event, content: emoji.unicode}), pubkeys})
}).bind(undefined, event, pubkeys)
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
const sendReply = () => {
history.back()
reply()
}
const copyText = () => {
history.back()
clip(event.content)
@@ -30,6 +43,10 @@
<Icon size={4} icon="smile-circle" />
Send Reaction
</Button>
<Button class="btn btn-neutral w-full" onclick={sendReply}>
<Icon size={4} icon="reply" />
Send Reply
</Button>
<Button class="btn btn-neutral w-full" onclick={copyText}>
<Icon size={4} icon="copy" />
Copy Text
+35 -1
View File
@@ -1,5 +1,10 @@
<script lang="ts">
import * as nip19 from "nostr-tools/nip19"
import {onMount} from "svelte"
import {writable} from "svelte/store"
import {goto} from "$app/navigation"
import {tryCatch, uniq} from "@welshman/lib"
import {fromNostrURI} from "@welshman/util"
import {pubkey} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Field from "@lib/components/Field.svelte"
@@ -14,7 +19,36 @@
const onSubmit = () => goto(makeChatPath([...pubkeys, $pubkey!]))
const addPubkey = (pubkey: string) => {
pubkeys = uniq([...pubkeys, pubkey])
term.set("")
}
const term = writable("")
let pubkeys: string[] = $state([])
onMount(() => {
return term.subscribe(t => {
if (t.match(/^[0-9a-f]{64}$/)) {
addPubkey(t)
}
if (t.match(/^(nostr:)?(npub1|nprofile1)/)) {
tryCatch(() => {
const {type, data} = nip19.decode(fromNostrURI(t))
if (type === "npub") {
addPubkey(data)
}
if (type === "nprofile") {
addPubkey(data.pubkey)
}
})
}
})
})
</script>
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
@@ -28,7 +62,7 @@
</ModalHeader>
<Field>
{#snippet input()}
<ProfileMultiSelect autofocus bind:value={pubkeys} />
<ProfileMultiSelect autofocus bind:value={pubkeys} {term} />
{/snippet}
</Field>
<ModalFooter>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import type {TrustedEvent, EventContent} from "@welshman/util"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import {publishDelete, publishReaction} from "@app/commands"
import {makeThreadPath} from "@app/routes"
interface Props {
url: any
event: any
showActivity?: boolean
}
const {url, event, showActivity = false}: Props = $props()
const path = makeThreadPath(url, event.id)
const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
const createReaction = (template: EventContent) =>
publishReaction({...template, event, relays: [url]})
</script>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2">
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Comment" />
</div>
</div>
+17 -5
View File
@@ -6,6 +6,7 @@
truncate,
renderAsHtml,
isText,
isEmoji,
isTopic,
isCode,
isCashu,
@@ -22,6 +23,7 @@
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ContentToken from "@app/components/ContentToken.svelte"
import ContentEmoji from "@app/components/ContentEmoji.svelte"
import ContentCode from "@app/components/ContentCode.svelte"
import ContentLinkInline from "@app/components/ContentLinkInline.svelte"
import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte"
@@ -38,8 +40,9 @@
showEntire?: boolean
hideMediaAtDepth?: number
expandMode?: string
relays?: string[]
minimalQuote?: boolean
depth?: number
url?: string
}
let {
@@ -49,8 +52,9 @@
showEntire = $bindable(false),
hideMediaAtDepth = 1,
expandMode = "block",
relays = [],
minimalQuote = false,
depth = 0,
url,
}: Props = $props()
const fullContent = parse(event)
@@ -133,6 +137,8 @@
<ContentNewline value={parsed.value} />
{:else if isTopic(parsed)}
<ContentTopic value={parsed.value} />
{:else if isEmoji(parsed)}
<ContentEmoji value={parsed.value} />
{:else if isCode(parsed)}
<ContentCode
value={parsed.value}
@@ -141,15 +147,21 @@
<ContentToken value={parsed.value} />
{:else if isLink(parsed)}
{#if isBlock(i)}
<ContentLinkBlock value={parsed.value} />
<ContentLinkBlock value={parsed.value} {event} />
{:else}
<ContentLinkInline value={parsed.value} />
{/if}
{:else if isProfile(parsed)}
<ContentMention value={parsed.value} />
<ContentMention value={parsed.value} {url} />
{:else if isEvent(parsed) || isAddress(parsed)}
{#if isBlock(i)}
<ContentQuote {depth} {relays} {hideMediaAtDepth} value={parsed.value} {event} />
<ContentQuote
{depth}
{url}
{hideMediaAtDepth}
value={parsed.value}
{event}
minimal={minimalQuote} />
{:else}
<Link
external
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import type {ParsedEmojiValue} from "@welshman/content"
import {imgproxy} from "@app/state"
export let value: ParsedEmojiValue
const alt = `:${value.name}:`
</script>
{#if value.url}
<img
{alt}
src={imgproxy(value.url, {w: 24, h: 24})}
class="-mt-0.5 inline h-[1em] min-w-[1em] align-middle" />
{:else}
{alt}
{/if}
+12 -13
View File
@@ -1,12 +1,13 @@
<script lang="ts">
import {ellipsize, postJson} from "@welshman/lib"
import {ellipsize, displayUrl, postJson} from "@welshman/lib"
import {dufflepud, imgproxy} from "@app/state"
import {preventDefault, stopPropagation} from "@lib/html"
import Link from "@lib/components/Link.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
import {pushModal} from "@app/modal"
const {value} = $props()
const {value, event} = $props()
let hideImage = $state(false)
@@ -26,18 +27,18 @@
hideImage = true
}
const expand = () => pushModal(ContentLinkDetail, {url}, {fullscreen: true})
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
</script>
<Link external href={url} class="my-2 block">
<div class="overflow-hidden rounded-box leading-[0]">
<div class="overflow-hidden rounded-box">
{#if url.match(/\.(mov|webm|mp4)$/)}
<video controls src={url} class="max-h-96 object-contain object-center">
<track kind="captions" />
</video>
{:else if url.match(/\.(jpe?g|png|gif|webp)$/)}
<button type="button" onclick={stopPropagation(preventDefault(expand))}>
<img alt="Link preview" src={imgproxy(url)} class="m-auto max-h-96 rounded-box" />
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-box" />
</button>
{:else}
{#await loadPreview()}
@@ -51,15 +52,13 @@
alt="Link preview"
onerror={onError}
src={imgproxy(preview.image)}
class="bg-alt max-h-72 object-contain object-center" />
{/if}
{#if preview.title}
<div class="flex flex-col gap-2 p-4">
<strong class="overflow-hidden text-ellipsis whitespace-nowrap"
>{preview.title}</strong>
<p>{ellipsize(preview.description, 140)}</p>
</div>
class="bg-alt max-h-72 rounded-t-box object-contain object-center" />
{/if}
<div class="flex flex-col gap-2 p-4">
<strong class="overflow-hidden text-ellipsis whitespace-nowrap"
>{preview.title || displayUrl(url)}</strong>
<p>{ellipsize(preview.description, 140)}</p>
</div>
</div>
{:catch}
<p class="bg-alt p-12 text-center leading-normal">
@@ -0,0 +1,52 @@
<script lang="ts">
import {onMount, onDestroy} from "svelte"
import {displayUrl} from "@welshman/lib"
import {getTags, decryptFile, getTagValue, tagsFromIMeta} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import {imgproxy} from "@app/state"
const {value, event, ...props} = $props()
const url = value.url.toString()
const meta =
getTags("imeta", event.tags)
.map(tagsFromIMeta)
.find(meta => getTagValue("url", meta) === url) || event.tags
const key = getTagValue("decryption-key", meta)
const nonce = getTagValue("decryption-nonce", meta)
const algorithm = getTagValue("encryption-algorithm", meta)
const onError = () => {
hasError = true
}
let hasError = $state(false)
let src = $state(imgproxy(url))
onMount(async () => {
if (algorithm === "aes-gcm" && key && nonce) {
const response = await fetch(url)
if (response.ok) {
const ciphertext = new Uint8Array(await response.arrayBuffer())
const decryptedData = await decryptFile({ciphertext, key, nonce, algorithm})
src = URL.createObjectURL(new Blob([decryptedData]))
}
}
})
onDestroy(() => {
URL.revokeObjectURL(src)
})
</script>
{#if hasError}
<a href={url} class="link-content whitespace-nowrap">
<Icon icon="link-round" size={3} class="inline-block" />
{displayUrl(url)}
</a>
{:else}
<img alt="" {src} onerror={onError} {...props} />
{/if}
+3 -3
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
import Button from "@lib/components/Button.svelte"
import {imgproxy} from "@app/state"
const {url} = $props()
const {value, event} = $props()
const back = () => history.back()
</script>
<Button class="m-auto h-screen w-screen cursor-pointer p-4" onclick={back}>
<img alt="" src={imgproxy(url)} class="m-auto max-h-full max-w-full rounded-box" />
<ContentLinkBlockImage {value} {event} class="m-auto max-h-full max-w-full rounded-box" />
</Button>
+12 -6
View File
@@ -1,17 +1,23 @@
<script lang="ts">
import {displayProfile} from "@welshman/util"
import {deriveProfile} from "@welshman/app"
import {removeNil} from "@welshman/lib"
import type {ProfilePointer} from "@welshman/content"
import {deriveProfileDisplay} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal"
const {value} = $props()
type Props = {
value: ProfilePointer
url?: string
}
const profile = deriveProfile(value.pubkey)
const {value, url}: Props = $props()
const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey})
const display = deriveProfileDisplay(value.pubkey, removeNil([url]))
const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey, url})
</script>
<Button onclick={openProfile} class="link-content">
@{displayProfile($profile)}
@{$display}
</Button>
+37 -88
View File
@@ -1,112 +1,61 @@
<script lang="ts">
import {nip19} from "nostr-tools"
import {goto} from "$app/navigation"
import {ctx, nthEq} from "@welshman/lib"
import {tracker, repository} from "@welshman/app"
import {Address, DIRECT_MESSAGE, MESSAGE, THREAD, EVENT_TIME} from "@welshman/util"
import * as nip19 from "nostr-tools/nip19"
import {Router} from "@welshman/router"
import type {TrustedEvent} from "@welshman/util"
import {Address, MESSAGE} from "@welshman/util"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import {deriveEvent, entityLink, ROOM} from "@app/state"
import {makeThreadPath, makeCalendarPath, makeRoomPath} from "@app/routes"
import {deriveEvent, entityLink} from "@app/state"
import {goToEvent} from "@app/routes"
const {value, event, depth, hideMediaAtDepth, relays = []} = $props()
type Props = {
value: any
hideMediaAtDepth: number
event: TrustedEvent
depth: number
url?: string
minimal?: boolean
}
const {id, identifier, kind, pubkey, relays: relayHints = []} = value
const {value, event, depth, hideMediaAtDepth, url, minimal}: Props = $props()
const {id, identifier, kind, pubkey, relays = []} = value
const idOrAddress = id || new Address(kind, pubkey, identifier).toString()
const mergedRelays = [
...relays,
...ctx.app.router.Quote(event, idOrAddress, relayHints).getUrls(),
]
const mergedRelays = Router.get().Quote(event, idOrAddress, relays).getUrls()
if (url) {
mergedRelays.push(url)
}
const quote = deriveEvent(idOrAddress, mergedRelays)
const entity = id
? nip19.neventEncode({id, relays: mergedRelays})
: new Address(kind, pubkey, identifier, mergedRelays).toNaddr()
const scrollToEvent = (id: string) => {
const element = document.querySelector(`[data-event="${id}"]`) as any
if (element) {
element.scrollIntoView({behavior: "smooth"})
element.style =
"filter: brightness(1.5); transition-property: all; transition-duration: 400ms;"
setTimeout(() => {
element.style = "transition-property: all; transition-duration: 300ms;"
}, 800)
setTimeout(() => {
element.style = ""
}, 800 + 400)
}
return Boolean(element)
}
const openMessage = (url: string, room: string, id: string) => {
const event = repository.getEvent(id)
if (event) {
goto(makeRoomPath(url, room))
// TODO: if the event doesn't immediately load, this won't work. Scroll up until it's found
setTimeout(() => scrollToEvent(id), 300)
}
return Boolean(event)
}
const onclick = () => {
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 === EVENT_TIME) {
return goto(makeCalendarPath(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) === EVENT_TIME) {
return goto(makeCalendarPath(url, id))
}
if (parseInt(kind) === MESSAGE) {
return scrollToEvent(id) || openMessage(url, room, id)
}
}
}
goToEvent($quote)
} else {
window.open(entityLink(entity))
}
window.open(entityLink(entity))
}
</script>
<Button class="my-2 block max-w-full text-left" {onclick}>
{#if $quote}
<NoteCard event={$quote} class="bg-alt rounded-box p-4">
<NoteContent {hideMediaAtDepth} {relays} event={$quote} depth={depth + 1} />
</NoteCard>
{#if minimal && $quote.kind === MESSAGE}
<div
class="border-l-2 border-solid border-l-primary py-1 pl-2 opacity-90"
style="background-color: color-mix(in srgb, var(--primary) 10%, var(--base-300) 90%);">
<NoteContent {hideMediaAtDepth} {url} event={$quote} depth={depth + 1} />
</div>
{:else}
<NoteCard event={$quote} {url} class="bg-alt rounded-box p-4">
<NoteContent {hideMediaAtDepth} {url} event={$quote} depth={depth + 1} />
</NoteCard>
{/if}
{:else}
<div class="rounded-box p-4">
<Spinner loading>Loading event...</Spinner>
@@ -0,0 +1,74 @@
<script lang="ts">
import {formatTimestamp} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Content from "@app/components/Content.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {goToEvent} from "@app/routes"
import {displayChannel} from "@app/state"
type Props = {
url: string
room?: string
events: TrustedEvent[]
latest: TrustedEvent
earliest: TrustedEvent
participants: string[]
}
const {url, room, events, latest, earliest, participants}: Props = $props()
</script>
<Button class="card2 bg-alt" onclick={() => goToEvent(earliest)}>
<div class="flex flex-col gap-3">
<div class="flex items-start gap-3">
<ProfileCircle pubkey={earliest.pubkey} size={10} />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 text-sm opacity-70">
{#if room}
<span class="font-medium text-blue-400">
#{displayChannel(url, room)}
</span>
<span class="opacity-50"></span>
{/if}
<span>{formatTimestamp(earliest.created_at)}</span>
</div>
<Content minimalQuote minLength={100} maxLength={400} event={earliest} />
</div>
</div>
<div class="ml-13 flex items-center justify-between">
<div class="flex gap-1">
<Icon icon="alt-arrow-left" />
<span class="text-sm opacity-70">
{events.length}
{events.length === 1 ? "message" : "messages"}
</span>
</div>
<div class="flex gap-2">
<ProfileCircles pubkeys={participants} size={6} />
<span class="text-sm opacity-70">
{participants.length}
{participants.length === 1 ? "participant" : "participants"}
</span>
</div>
</div>
{#if latest !== earliest}
<Button class="card2 bg-alt" onclick={() => goToEvent(latest)}>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-sm opacity-70">
<ProfileCircle pubkey={latest.pubkey} size={5} />
<span class="font-medium">Latest reply:</span>
</div>
<span class="text-xs opacity-50">
{formatTimestamp(latest.created_at)}
</span>
</div>
<Content minimalQuote minLength={100} maxLength={400} event={latest} />
</div>
</Button>
{/if}
</div>
</Button>
+15 -7
View File
@@ -1,23 +1,26 @@
<script lang="ts">
import type {Snippet} from "svelte"
import type {Instance} from "tippy.js"
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import Button from "@lib/components/Button.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte"
import EventMenu from "@app/components/EventMenu.svelte"
import {ENABLE_ZAPS} from "@app/state"
import {publishReaction} from "@app/commands"
const {
url,
noun,
event,
}: {
type Props = {
url: string
noun: string
event: TrustedEvent
} = $props()
hideZap?: boolean
customActions?: Snippet
}
const {url, noun, event, hideZap, customActions}: Props = $props()
const showPopover = () => popover?.show()
@@ -30,13 +33,18 @@
</script>
<Button class="join rounded-full">
{#if ENABLE_ZAPS && !hideZap}
<ZapButton {url} {event} class="btn join-item btn-neutral btn-xs">
<Icon icon="bolt" size={4} />
</ZapButton>
{/if}
<EmojiButton {onEmoji} class="btn join-item btn-neutral btn-xs">
<Icon icon="smile-circle" size={4} />
</EmojiButton>
<Tippy
bind:popover
component={EventMenu}
props={{url, noun, event, onClick: hidePopover}}
props={{url, noun, event, customActions, onClick: hidePopover}}
params={{trigger: "manual", interactive: true}}>
<Button class="btn join-item btn-neutral btn-xs" onclick={showPopover}>
<Icon icon="menu-dots" size={4} />
+3 -2
View File
@@ -1,10 +1,11 @@
<script lang="ts">
import {onMount} from "svelte"
import {max} from "@welshman/lib"
import {max, formatTimestampRelative} from "@welshman/lib"
import {COMMENT} from "@welshman/util"
import {load} from "@welshman/net"
import {deriveEvents} from "@welshman/store"
import type {TrustedEvent} from "@welshman/util"
import {formatTimestampRelative, repository, load} from "@welshman/app"
import {repository} from "@welshman/app"
import {notifications} from "@app/notifications"
import Icon from "@lib/components/Icon.svelte"
@@ -1,14 +1,18 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import Confirm from "@lib/components/Confirm.svelte"
import {publishDelete} from "@app/commands"
import {clearModals} from "@app/modal"
const {url, event} = $props()
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const confirm = async () => {
const snapshot = $state.snapshot(event)
await publishDelete({event: snapshot, relays: [url]})
await publishDelete({event, relays: [url]})
clearModals()
}
+10 -4
View File
@@ -1,15 +1,21 @@
<script lang="ts">
import {nip19} from "nostr-tools"
import {ctx} from "@welshman/lib"
import * as nip19 from "nostr-tools/nip19"
import {Router} from "@welshman/router"
import type {TrustedEvent} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import {clip} from "@app/toast"
const {event} = $props()
type Props = {
url?: string
event: TrustedEvent
}
const relays = ctx.app.router.Event(event).getUrls()
const {url, event}: Props = $props()
const relays = url ? [url] : Router.get().Event(event).getUrls()
const nevent1 = nip19.neventEncode({...event, relays})
const npub1 = nip19.npubEncode(event.pubkey)
const json = JSON.stringify(event, null, 2)
+20 -25
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import {onMount} from "svelte"
import type {Snippet} from "svelte"
import type {TrustedEvent} from "@welshman/util"
import {COMMENT} from "@welshman/util"
import {pubkey} from "@welshman/app"
@@ -7,45 +9,37 @@
import EventInfo from "@app/components/EventInfo.svelte"
import EventReport from "@app/components/EventReport.svelte"
import EventShare from "@app/components/EventShare.svelte"
import ConfirmDelete from "@app/components/ConfirmDelete.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {pushModal} from "@app/modal"
const {
url,
noun,
event,
onClick,
}: {
type Props = {
url: string
noun: string
event: TrustedEvent
onClick: () => void
} = $props()
customActions?: Snippet
}
const {url, noun, event, onClick, customActions}: Props = $props()
const isRoot = event.kind !== COMMENT
const report = () => {
onClick()
pushModal(EventReport, {url, event})
}
const report = () => pushModal(EventReport, {url, event})
const showInfo = () => {
onClick()
pushModal(EventInfo, {event})
}
const showInfo = () => pushModal(EventInfo, {url, event})
const share = () => {
onClick()
pushModal(EventShare, {url, event})
}
const share = () => pushModal(EventShare, {url, event})
const showDelete = () => {
onClick()
pushModal(ConfirmDelete, {url, event})
}
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-xl">
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-xl" bind:this={ul}>
{#if isRoot}
<li>
<Button onclick={share}>
@@ -60,6 +54,7 @@
{noun} Details
</Button>
</li>
{@render customActions?.()}
{#if event.pubkey === $pubkey}
<li>
<Button onclick={showDelete} class="text-error">
+51 -25
View File
@@ -1,13 +1,14 @@
<script lang="ts">
import {onMount} from "svelte"
import {writable} from "svelte/store"
import {isMobile, preventDefault} from "@lib/html"
import {fly, slideAndFade} from "@lib/transition"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {publishComment} from "@app/commands"
import {tagRoom, GENERAL, PROTECTED} from "@app/state"
import {PROTECTED} from "@app/state"
import {makeEditor} from "@app/editor"
import {pushToast} from "@app/toast"
@@ -15,11 +16,14 @@
const uploading = writable(false)
const submit = () => {
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => {
if ($uploading) return
const content = editor.getText({blockSeparator: "\n"}).trim()
const tags = [...editor.storage.nostr.getEditorTags(), tagRoom(GENERAL, url), PROTECTED]
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = [...ed.storage.nostr.getEditorTags(), PROTECTED]
if (!content) {
return pushToast({
@@ -31,31 +35,53 @@
onSubmit(publishComment({event, content, tags, relays: [url]}))
}
const editor = makeEditor({submit, uploading, autofocus: !isMobile})
const editor = makeEditor({url, submit, uploading, autofocus: !isMobile})
let form: HTMLElement
let spacer: HTMLElement
onMount(() => {
setTimeout(() => {
spacer.scrollIntoView({block: "end", behavior: "smooth"})
})
const observer = new ResizeObserver(() => {
spacer!.style.minHeight = `${form!.offsetHeight}px`
})
observer.observe(form!)
return () => {
observer.unobserve(form!)
}
})
</script>
<div bind:this={spacer}></div>
<form
in:fly
out:slideAndFade
bind:this={form}
onsubmit={preventDefault(submit)}
class="card2 sticky bottom-2 z-feature mx-2 mt-4 bg-neutral">
<div class="relative">
<div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} />
class="cb cw fixed z-feature -mx-2 pt-3">
<div class="card2 mx-2 my-2 bg-neutral">
<div class="relative">
<div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
<Button
data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2"
onclick={selectFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="paperclip" size={3} />
{/if}
</Button>
</div>
<Button
data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2"
onclick={editor.commands.selectFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="paperclip" size={3} />
{/if}
</Button>
<ModalFooter>
<Button class="btn btn-link" onclick={onClose}>Cancel</Button>
<Button type="submit" class="btn btn-primary">Post Reply</Button>
</ModalFooter>
</div>
<ModalFooter>
<Button class="btn btn-link" onclick={onClose}>Cancel</Button>
<Button type="submit" class="btn btn-primary">Post Reply</Button>
</ModalFooter>
</form>
+1 -1
View File
@@ -42,7 +42,7 @@
<div class="column gap-2">
<div class="flex justify-between">
<div>
<Profile pubkey={report.pubkey} />
<Profile pubkey={report.pubkey} {url} />
<span>Reported this event as "{reason}"</span>
</div>
{#if report.pubkey === $pubkey}
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import type {TrustedEvent, EventContent} from "@welshman/util"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import {publishDelete, publishReaction} from "@app/commands"
import {makeGoalPath} from "@app/routes"
interface Props {
url: any
event: any
showActivity?: boolean
}
const {url, event, showActivity = false}: Props = $props()
const path = makeGoalPath(url, event.id)
const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
const createReaction = (template: EventContent) =>
publishReaction({...template, event, relays: [url]})
</script>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2">
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} hideZap noun="Goal" />
</div>
</div>
+146
View File
@@ -0,0 +1,146 @@
<script lang="ts">
import {writable} from "svelte/store"
import {makeEvent, ZAP_GOAL} from "@welshman/util"
import {publishThunk} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {pushToast} from "@app/toast"
import {PROTECTED} from "@app/state"
import {makeEditor} from "@app/editor"
const {url} = $props()
const uploading = writable(false)
const back = () => history.back()
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => {
if ($uploading) return
if (!content) {
return pushToast({
theme: "error",
message: "Please provide a title for your funding goal.",
})
}
const ed = await editor
const summary = ed.getText({blockSeparator: "\n"}).trim()
if (!summary.trim()) {
return pushToast({
theme: "error",
message: "Please provide details about your funding goal.",
})
}
const tags = [
...ed.storage.nostr.getEditorTags(),
["summary", summary],
["amount", String(amount)],
["relays", url],
PROTECTED,
]
publishThunk({
relays: [url],
event: makeEvent(ZAP_GOAL, {content, tags}),
})
history.back()
}
const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"})
let content = $state("")
let amount = $state(1000)
</script>
<form class="column gap-4" onsubmit={preventDefault(submit)}>
<ModalHeader>
{#snippet title()}
<div>Create a Funding Goal</div>
{/snippet}
{#snippet info()}
<div>Request contributions for your fundraiser.</div>
{/snippet}
</ModalHeader>
<div class="col-8 relative">
<Field>
{#snippet label()}
<p>Title*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus={!isMobile}
bind:value={content}
class="grow"
type="text"
placeholder="What do funds go towards?" />
</label>
{/snippet}
</Field>
<div class="relative">
<Field>
{#snippet label()}
<p>Details*</p>
{/snippet}
{#snippet input()}
<div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
{/snippet}
</Field>
<Button
data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2"
onclick={selectFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="paperclip" size={3} />
{/if}
</Button>
</div>
<div class="flex flex-col gap-1">
<FieldInline>
{#snippet label()}
Goal Amount (sats)*
{/snippet}
{#snippet input()}
<div class="flex flex-grow justify-end">
<label class="input input-bordered flex items-center gap-2">
<Icon icon="bolt" />
<input bind:value={amount} type="number" class="w-28" />
<p class="opacity-50">sats</p>
</label>
</div>
{/snippet}
</FieldInline>
<input
class="range range-primary -mt-2"
type="range"
min="1000"
max="100000"
step="1000"
bind:value={amount} />
</div>
</div>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary">Create Goal</Button>
</ModalFooter>
</form>
+36
View File
@@ -0,0 +1,36 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {getTagValue} from "@welshman/util"
import Link from "@lib/components/Link.svelte"
import Content from "@app/components/Content.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import GoalActions from "@app/components/GoalActions.svelte"
import GoalSummary from "@app/components/GoalSummary.svelte"
import {makeGoalPath} from "@app/routes"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const summary = getTagValue("summary", event.tags)
</script>
<Link class="col-2 card2 bg-alt w-full cursor-pointer" href={makeGoalPath(url, event.id)}>
<p class="text-2xl">{event.content}</p>
<Content
event={{content: summary, tags: event.tags}}
{url}
expandMode="inline"
minLength={50}
maxLength={300} />
<GoalSummary {url} {event} />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75">
Posted by <ProfileLink pubkey={event.pubkey} {url} />
</span>
<GoalActions showActivity {url} {event} />
</div>
</Link>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
import {now, DAY, uniq, sum} from "@welshman/lib"
import type {Zap, TrustedEvent} from "@welshman/util"
import {getTagValue, fromMsats, ZAP_RESPONSE} from "@welshman/util"
import {deriveEventsMapped} from "@welshman/store"
import {repository, getValidZap} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const zaps = deriveEventsMapped<Zap>(repository, {
filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}],
itemToEvent: item => item.response,
eventToItem: (response: TrustedEvent) => getValidZap(response, event),
})
const goalAmount = parseInt(getTagValue("amount", event.tags) || "0")
const zapAmount = $derived(fromMsats(sum($zaps.map(zap => zap.invoiceAmount))))
const contributorsCount = $derived(uniq($zaps.map(zap => zap.request.pubkey)).length)
const daysOld = Math.ceil((now() - event.created_at) / DAY)
</script>
<div class="card2 bg-alt flex flex-col gap-8">
<div class="flex gap-8">
<div>
<p class="text-xl text-primary">{zapAmount} sats</p>
<p class="text-sm opacity-75">funded of {goalAmount} sats</p>
</div>
<div>
<p class="text-xl">{contributorsCount}</p>
<p class="text-sm opacity-75">{contributorsCount === 1 ? "contributor" : "contributors"}</p>
</div>
<div>
<p class="text-xl">{daysOld}</p>
<p class="text-sm opacity-75">{daysOld === 1 ? "day" : "days"} old</p>
</div>
</div>
<progress class="progress progress-primary" value={zapAmount} max={goalAmount}></progress>
<ZapButton {url} {event} class="btn btn-primary lg:m-auto lg:px-20">
<Icon icon="bolt" />
Contribute to this goal
</ZapButton>
</div>
+36
View File
@@ -0,0 +1,36 @@
<script lang="ts">
import {deriveZapperForPubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
const {pubkey} = $props()
const zapper = deriveZapperForPubkey(pubkey)
const back = () => history.back()
</script>
<div class="column gap-4">
<ModalHeader>
{#snippet title()}
<div>Unable to Zap</div>
{/snippet}
</ModalHeader>
<p>
Zapping <ProfileLink {pubkey} class="!text-primary" /> isn't possible right now because
{#if $zapper}
their zap receiver isn't correctly set up.
{:else}
they don't currently have a zap receiver set up.
{/if}
</p>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
</ModalFooter>
</div>
+5 -5
View File
@@ -2,7 +2,7 @@
import {onMount} from "svelte"
import {Capacitor} from "@capacitor/core"
import {getNip07, getNip55, Nip55Signer} from "@welshman/signer"
import {addSession, type Session} from "@welshman/app"
import {addSession, type Session, makeNip07Session, makeNip55Session} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
@@ -13,7 +13,7 @@
import {pushModal, clearModals} from "@app/modal"
import {PLATFORM_NAME, BURROW_URL} from "@app/state"
import {pushToast} from "@app/toast"
import {loadUserData} from "@app/commands"
import {loadUserData} from "@app/requests"
import {setChecked} from "@app/notifications"
let signers: any[] = $state([])
@@ -24,7 +24,7 @@
const signUp = () => pushModal(SignUp)
const onSuccess = async (session: Session, relays: string[] = []) => {
await loadUserData(session.pubkey, {relays})
await loadUserData(session.pubkey, relays)
addSession(session)
pushToast({message: "Successfully logged in!"})
@@ -39,7 +39,7 @@
const pubkey = await getNip07()?.getPublicKey()
if (pubkey) {
await onSuccess({method: "nip07", pubkey})
await onSuccess(makeNip07Session(pubkey))
} else {
pushToast({
theme: "error",
@@ -59,7 +59,7 @@
const pubkey = await signer.getPubkey()
if (pubkey) {
await onSuccess({method: "nip55", pubkey, signer: app.packageName})
await onSuccess(makeNip55Session(pubkey, app.packageName))
} else {
pushToast({
theme: "error",
+64 -115
View File
@@ -1,125 +1,91 @@
<script lang="ts">
import {onMount, onDestroy} from "svelte"
import {Nip46Broker, getPubkey, makeSecret} from "@welshman/signer"
import {addSession} from "@welshman/app"
import type {Nip46ResponseWithResult} from "@welshman/signer"
import {Nip46Broker, makeSecret} from "@welshman/signer"
import {loginWithNip01, loginWithNip46} from "@welshman/app"
import {preventDefault} from "@lib/html"
import {slideAndFade} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import QRCode from "@app/components/QRCode.svelte"
import InfoBunker from "@app/components/InfoBunker.svelte"
import {loginWithNip46, loadUserData} from "@app/commands"
import {pushModal, clearModals} from "@app/modal"
import BunkerConnect, {BunkerConnectController} from "@app/components/BunkerConnect.svelte"
import BunkerUrl from "@app/components/BunkerUrl.svelte"
import {loadUserData} from "@app/requests"
import {clearModals} from "@app/modal"
import {setChecked} from "@app/notifications"
import {pushToast} from "@app/toast"
import {NIP46_PERMS, PLATFORM_URL, PLATFORM_NAME, PLATFORM_LOGO, SIGNER_RELAYS} from "@app/state"
const clientSecret = makeSecret()
const abortController = new AbortController()
const broker = Nip46Broker.get({clientSecret, relays: SIGNER_RELAYS})
import {SIGNER_RELAYS, NIP46_PERMS} from "@app/state"
const back = () => history.back()
const controller = new BunkerConnectController({
onNostrConnect: async (response: Nip46ResponseWithResult) => {
const pubkey = await controller.broker.getPublicKey()
await loadUserData(pubkey)
loginWithNip46(pubkey, controller.clientSecret, response.event.pubkey, SIGNER_RELAYS)
setChecked("*")
clearModals()
},
})
const onSubmit = async () => {
const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(bunker)
if (loading) {
return
}
if (!signerPubkey || relays.length === 0) {
return pushToast({
theme: "error",
message: "Sorry, it looks like that's an invalid bunker link.",
})
}
loading = true
if (controller.loading) return
try {
const success = await loginWithNip46({connectSecret, clientSecret, signerPubkey, relays})
const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(controller.bunker)
if (success) {
abortController.abort()
console.log({signerPubkey, connectSecret, relays})
if (!signerPubkey || relays.length === 0) {
return pushToast({
theme: "error",
message: "Sorry, it looks like that's an invalid bunker link.",
})
}
controller.loading = true
const {clientSecret} = controller
const broker = new Nip46Broker({relays, clientSecret, signerPubkey})
const result = await broker.connect(connectSecret, NIP46_PERMS)
const pubkey = await broker.getPublicKey()
// TODO: remove ack result
if (pubkey && ["ack", connectSecret].includes(result)) {
broker.cleanup()
controller.stop()
await loadUserData(pubkey)
loginWithNip46(pubkey, clientSecret, signerPubkey, relays)
} else {
return pushToast({
theme: "error",
message: "Something went wrong, please try again!",
})
}
} catch (e) {
console.error(e)
return pushToast({
theme: "error",
message: "Something went wrong, please try again!",
})
} finally {
loading = false
controller.loading = false
}
clearModals()
}
let url = $state("")
let bunker = $state("")
let loading = $state(false)
$effect(() => {
// For testing and for play store reviewers
if (bunker === "reviewkey") {
const secret = makeSecret()
addSession({method: "nip01", secret, pubkey: getPubkey(secret)})
if (controller.bunker === "reviewkey") {
loginWithNip01(makeSecret())
}
})
onMount(async () => {
url = await broker.makeNostrconnectUrl({
perms: NIP46_PERMS,
url: PLATFORM_URL,
name: PLATFORM_NAME,
image: PLATFORM_LOGO,
})
let response
try {
response = await broker.waitForNostrconnect(url, abortController)
} catch (errorResponse: any) {
if (errorResponse?.error) {
pushToast({
theme: "error",
message: `Received error from signer: ${errorResponse.error}`,
})
} else if (errorResponse) {
console.error(errorResponse)
}
}
if (response) {
loading = true
const userPubkey = await broker.getPublicKey()
await loadUserData(userPubkey)
addSession({
method: "nip46",
pubkey: userPubkey,
secret: clientSecret,
handler: {
pubkey: response.event.pubkey,
relays: SIGNER_RELAYS,
},
})
setChecked("*")
clearModals()
}
})
onDestroy(() => {
abortController.abort()
})
</script>
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
@@ -131,35 +97,18 @@
<div>Connect your signer by scanning the QR code below or pasting a bunker link.</div>
{/snippet}
</ModalHeader>
{#if !loading && url}
<div class="flex justify-center" out:slideAndFade>
<QRCode code={url} />
</div>
{/if}
<Field>
{#snippet label()}
<p>Bunker Link*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="cpu" />
<input disabled={loading} bind:value={bunker} class="grow" placeholder="bunker://" />
</label>
{/snippet}
{#snippet info()}
<p>
A login link provided by a nostr signing app.
<Button class="link" onclick={() => pushModal(InfoBunker)}>What is a bunker link?</Button>
</p>
{/snippet}
</Field>
<BunkerConnect {controller} />
<BunkerUrl loading={controller.loading} bind:bunker={controller.bunker} />
<ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}>
<Button class="btn btn-link" onclick={back} disabled={controller.loading}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading || !bunker}>
<Spinner {loading}>Next</Spinner>
<Button
type="submit"
class="btn btn-primary"
disabled={controller.loading || !controller.bunker}>
<Spinner loading={controller.loading}>Next</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
+9 -14
View File
@@ -3,7 +3,7 @@
import {postJson, stripProtocol} from "@welshman/lib"
import {Nip46Broker, makeSecret} from "@welshman/signer"
import {normalizeRelayUrl} from "@welshman/util"
import {addSession} from "@welshman/app"
import {addSession, makeNip46Session} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
@@ -12,7 +12,7 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import PasswordResetRequest from "@app/components/PasswordResetRequest.svelte"
import {loadUserData} from "@app/commands"
import {loadUserData} from "@app/requests"
import {clearModals, pushModal} from "@app/modal"
import {setChecked} from "@app/notifications"
import {pushToast} from "@app/toast"
@@ -34,7 +34,7 @@
? [normalizeRelayUrl("ws://" + stripProtocol(BURROW_URL))]
: [normalizeRelayUrl(BURROW_URL)]
const broker = Nip46Broker.get({clientSecret, relays})
const broker = new Nip46Broker({clientSecret, relays})
const back = () => history.back()
@@ -68,7 +68,7 @@
let response
try {
response = await broker.waitForNostrconnect(url, abortController)
response = await broker.waitForNostrconnect(url, abortController.signal)
} catch (errorResponse: any) {
if (errorResponse?.error) {
pushToast({
@@ -83,18 +83,13 @@
if (response) {
loading = true
const userPubkey = await broker.getPublicKey()
const pubkey = await broker.getPublicKey()
const session = makeNip46Session(pubkey, clientSecret, response.event.pubkey, relays)
await loadUserData(userPubkey)
addSession({
email,
method: "nip46",
pubkey: userPubkey,
secret: clientSecret,
handler: {pubkey: response.event.pubkey, relays},
})
await loadUserData(pubkey)
addSession({...session, email})
broker.cleanup()
setChecked("*")
clearModals()
}
+15
View File
@@ -0,0 +1,15 @@
<script lang="ts">
import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte"
type Props = {
urls: string[]
}
const {urls}: Props = $props()
</script>
<div class="column menu gap-2">
{#each urls as url (url)}
<MenuSpacesItem {url} />
{/each}
</div>
+72 -30
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import {onMount} from "svelte"
import {displayRelayUrl, GROUP_META} from "@welshman/util"
import {displayRelayUrl, getTagValue} from "@welshman/util"
import {deriveRelay} from "@welshman/app"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -12,26 +13,34 @@
import SpaceExit from "@app/components/SpaceExit.svelte"
import SpaceJoin from "@app/components/SpaceJoin.svelte"
import ProfileList from "@app/components/ProfileList.svelte"
import AlertAdd from "@app/components/AlertAdd.svelte"
import Alerts from "@app/components/Alerts.svelte"
import RoomCreate from "@app/components/RoomCreate.svelte"
import MenuSpaceRoomItem from "@app/components/MenuSpaceRoomItem.svelte"
import {
ENABLE_ZAPS,
userRoomsByUrl,
hasMembershipUrl,
memberships,
deriveUserRooms,
deriveOtherRooms,
hasNip29,
alerts,
} from "@app/state"
import {notifications} from "@app/notifications"
import {pullConservatively} from "@app/requests"
import {pushModal} from "@app/modal"
import {makeSpacePath} from "@app/routes"
const {url} = $props()
const relay = deriveRelay(url)
const chatPath = makeSpacePath(url, "chat")
const goalsPath = makeSpacePath(url, "goals")
const threadsPath = makeSpacePath(url, "threads")
const calendarPath = makeSpacePath(url, "calendar")
const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url)
const hasAlerts = $derived($alerts.some(a => getTagValue("feed", a.tags)?.includes(url)))
const openMenu = () => {
showMenu = true
@@ -44,7 +53,7 @@
const showMembers = () =>
pushModal(
ProfileList,
{pubkeys: members, title: `Members of`, subtitle: displayRelayUrl(url)},
{url, pubkeys: members, title: `Members of`, subtitle: displayRelayUrl(url)},
{replaceState},
)
@@ -56,6 +65,13 @@
const addRoom = () => pushModal(RoomCreate, {url}, {replaceState})
const manageAlerts = () => {
const component = hasAlerts ? Alerts : AlertAdd
const params = {url, channel: "push", hideSpaceField: true}
pushModal(component, params, {replaceState})
}
let showMenu = $state(false)
let replaceState = $state(false)
let element: Element | undefined = $state()
@@ -66,22 +82,23 @@
onMount(() => {
replaceState = Boolean(element?.closest(".drawer"))
pullConservatively({relays: [url], filters: [{kinds: [GROUP_META]}]})
})
</script>
<div bind:this={element}>
<SecondaryNavSection class="max-h-screen">
<div bind:this={element} class="flex h-full flex-col justify-between">
<SecondaryNavSection>
<div>
<SecondaryNavItem class="w-full !justify-between" onclick={openMenu}>
<strong class="ellipsize">{displayRelayUrl(url)}</strong>
<strong class="ellipsize flex items-center gap-3">
{displayRelayUrl(url)}
</strong>
<Icon icon="alt-arrow-down" />
</SecondaryNavItem>
{#if showMenu}
<Popover hideOnClick onClose={toggleMenu}>
<ul
transition:fly
class="menu absolute z-popover mt-2 w-full rounded-box bg-base-100 p-2 shadow-xl">
class="menu absolute z-popover mt-2 w-full gap-1 rounded-box bg-base-100 p-2 shadow-xl">
<li>
<Button onclick={showMembers}>
<Icon icon="user-rounded" />
@@ -111,10 +128,18 @@
</Popover>
{/if}
</div>
<div class="flex min-h-0 flex-col gap-1 overflow-auto">
<div class="flex max-h-[calc(100vh-150px)] min-h-0 flex-col gap-1 overflow-auto">
<SecondaryNavItem {replaceState} href={makeSpacePath(url)}>
<Icon icon="home-smile" /> Home
</SecondaryNavItem>
{#if ENABLE_ZAPS}
<SecondaryNavItem
{replaceState}
href={goalsPath}
notification={$notifications.has(goalsPath)}>
<Icon icon="star-fall-minimalistic-2" /> Goals
</SecondaryNavItem>
{/if}
<SecondaryNavItem
{replaceState}
href={threadsPath}
@@ -127,28 +152,45 @@
notification={$notifications.has(calendarPath)}>
<Icon icon="calendar-minimalistic" /> Calendar
</SecondaryNavItem>
<div class="h-2"></div>
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{#each $userRooms as room, i (room)}
<MenuSpaceRoomItem {replaceState} notify {url} {room} />
{/each}
{#if $otherRooms.length > 0}
<div class="h-2"></div>
<SecondaryNavHeader>
{#if $userRooms.length > 0}
Other Rooms
{:else}
Rooms
{/if}
</SecondaryNavHeader>
{#if hasNip29($relay)}
{#if $userRooms.length > 0}
<div class="h-2"></div>
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{/if}
{#each $userRooms as room, i (room)}
<MenuSpaceRoomItem {replaceState} notify {url} {room} />
{/each}
{#if $otherRooms.length > 0}
<div class="h-2"></div>
<SecondaryNavHeader>
{#if $userRooms.length > 0}
Other Rooms
{:else}
Rooms
{/if}
</SecondaryNavHeader>
{/if}
{#each $otherRooms as room, i (room)}
<MenuSpaceRoomItem {replaceState} {url} {room} />
{/each}
<SecondaryNavItem {replaceState} onclick={addRoom}>
<Icon icon="add-circle" />
Create room
</SecondaryNavItem>
{:else}
<SecondaryNavItem
{replaceState}
href={chatPath}
notification={$notifications.has(chatPath)}>
<Icon icon="chat-round" /> Chat
</SecondaryNavItem>
{/if}
{#each $otherRooms as room, i (room)}
<MenuSpaceRoomItem {replaceState} {url} {room} />
{/each}
<SecondaryNavItem {replaceState} onclick={addRoom}>
<Icon icon="add-circle" />
Create room
</SecondaryNavItem>
</div>
</SecondaryNavSection>
<div class="p-4">
<button class="btn btn-neutral btn-sm w-full" onclick={manageAlerts}>
<Icon icon="bell" />
Manage Alerts
</button>
</div>
</div>
+2 -2
View File
@@ -3,7 +3,7 @@
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import {makeRoomPath} from "@app/routes"
import {deriveChannel, channelIsLocked} from "@app/state"
import {deriveChannel} from "@app/state"
import {notifications} from "@app/notifications"
interface Props {
@@ -23,7 +23,7 @@
href={path}
{replaceState}
notification={notify ? $notifications.has(path) : false}>
{#if channelIsLocked($channel)}
{#if $channel?.closed || $channel?.private}
<Icon icon="lock" size={4} />
{:else}
<Icon icon="hashtag" />
+11 -12
View File
@@ -5,23 +5,22 @@
import CardButton from "@lib/components/CardButton.svelte"
import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte"
import SpaceAdd from "@app/components/SpaceAdd.svelte"
import {userRoomsByUrl, PLATFORM_RELAY} from "@app/state"
import {userRoomsByUrl, PLATFORM_RELAYS} from "@app/state"
import {pushModal} from "@app/modal"
const addSpace = () => pushModal(SpaceAdd)
</script>
<div class="column menu gap-2">
{#if PLATFORM_RELAY}
<MenuSpacesItem url={PLATFORM_RELAY} />
<Divider />
{:else if $userRoomsByUrl.size > 0}
{#each $userRoomsByUrl.keys() as url (url)}
<MenuSpacesItem {url} />
{/each}
<Divider />
{/if}
{#if !PLATFORM_RELAY}
{#each PLATFORM_RELAYS as url (url)}
<MenuSpacesItem {url} />
{:else}
{#if $userRoomsByUrl.size > 0}
{#each $userRoomsByUrl.keys() as url (url)}
<MenuSpacesItem {url} />
{/each}
<Divider />
{/if}
<Button onclick={addSpace}>
<CardButton>
{#snippet icon()}
@@ -35,5 +34,5 @@
{/snippet}
</CardButton>
</Button>
{/if}
{/each}
</div>
+3 -10
View File
@@ -10,26 +10,19 @@
}
}
let modal: any = $state()
const hash = $derived($page.url.hash.slice(1))
const hashIsValid = $derived(Boolean($modals[hash]))
$effect(() => {
if ($modals[hash]) {
modal = $modals[hash]
}
})
const modal = $derived($modals[hash])
</script>
<svelte:window onkeydown={onKeyDown} />
{#if hashIsValid && modal?.options?.drawer}
{#if modal?.options?.drawer}
<Drawer onClose={clearModals} {...modal.options}>
{#key modal.id}
<modal.component {...modal.props} />
{/key}
</Drawer>
{:else if hashIsValid && modal}
{:else if modal}
<Dialog onClose={clearModals} {...modal.options}>
{#key modal.id}
<modal.component {...modal.props} />
+9 -6
View File
@@ -1,11 +1,12 @@
<script lang="ts">
import cx from "classnames"
import type {Snippet} from "svelte"
import {nip19} from "nostr-tools"
import {ctx} from "@welshman/lib"
import * as nip19 from "nostr-tools/nip19"
import {formatTimestamp} from "@welshman/lib"
import {getListTags, getPubkeyTagValues} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {formatTimestamp, userMutes} from "@welshman/app"
import {Router} from "@welshman/router"
import {userMutes} from "@welshman/app"
import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -18,16 +19,18 @@
children,
minimal = false,
hideProfile = false,
url,
...restProps
}: {
event: TrustedEvent
children: Snippet
minimal?: boolean
hideProfile?: boolean
url?: string
class?: string
} = $props()
const relays = ctx.app.router.Event(event).getUrls()
const relays = Router.get().Event(event).getUrls()
const nevent = nip19.neventEncode({id: event.id, relays})
const ignoreMute = () => {
@@ -50,9 +53,9 @@
<div class="flex justify-between gap-2">
{#if !hideProfile}
{#if minimal}
@<ProfileName pubkey={event.pubkey} />
@<ProfileName pubkey={event.pubkey} {url} />
{:else}
<Profile pubkey={event.pubkey} />
<Profile pubkey={event.pubkey} {url} />
{/if}
{/if}
<Link
+1 -3
View File
@@ -12,9 +12,7 @@
<div class="flex items-start gap-4">
<CalendarEventDate event={props.event} />
<div class="flex flex-grow flex-col">
<div class="flex flex-grow flex-wrap justify-between gap-2">
<CalendarEventHeader event={props.event} />
</div>
<CalendarEventHeader event={props.event} />
<div class="flex py-2 opacity-50">
<div class="h-px flex-grow bg-base-content opacity-25"></div>
</div>
+6 -12
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import {pubkey} from "@welshman/app"
import type {TrustedEvent, EventContent} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
@@ -11,24 +10,19 @@
const {url, event} = $props()
const onReactionClick = (content: string, events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey)
const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
if (reaction) {
publishDelete({relays: [url], event: reaction})
} else {
publishReaction({event, content, relays: [url]})
}
}
const createReaction = (template: EventContent) =>
publishReaction({...template, event, relays: [url]})
const onEmoji = (emoji: NativeEmoji) =>
publishReaction({event, content: emoji.unicode, relays: [url]})
</script>
<NoteCard {event} class="card2 bg-alt">
<NoteCard {event} {url} class="card2 bg-alt">
<NoteContent {event} expandMode="inline" />
<div class="flex w-full justify-between gap-2">
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-right">
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-right">
<EmojiButton {onEmoji} class="btn btn-neutral btn-xs h-[26px] rounded-box">
<Icon icon="smile-circle" size={4} />
</EmojiButton>
+22 -36
View File
@@ -1,48 +1,34 @@
<script lang="ts">
import {onMount} from "svelte"
import {ctx} from "@welshman/lib"
import type {Filter} from "@welshman/util"
import {deriveEvents} from "@welshman/store"
import {repository, load, loadRelaySelections, formatTimestampRelative} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import Profile from "@app/components/Profile.svelte"
import ProfileInfo from "@app/components/ProfileInfo.svelte"
import {makeChatPath} from "@app/routes"
import ProfileBadges from "@app/components/ProfileBadges.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal"
const {pubkey} = $props()
type Props = {
pubkey: string
url?: string
}
const filters: Filter[] = [{authors: [pubkey], limit: 1}]
const events = deriveEvents(repository, {filters})
const {pubkey, url}: Props = $props()
onMount(async () => {
// Make sure we have their relay selections before we load their posts
await loadRelaySelections(pubkey)
// Load at least one note, regardless of time frame
load({
filters: [{authors: [pubkey], limit: 1}],
relays: ctx.app.router.FromPubkeys([pubkey]).getUrls(),
})
})
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
</script>
<div class="card2 bg-alt col-2 shadow-xl">
<div class="card2 bg-alt flex flex-col gap-4 shadow-xl">
<div class="flex justify-between">
<Profile {pubkey} />
<Link class="btn btn-primary hidden sm:flex" href={makeChatPath([pubkey])}>
<Icon icon="letter" />
Start a Chat
</Link>
<Profile {pubkey} {url} />
<Button onclick={openProfile} class="btn btn-primary hidden sm:flex">
<Icon icon="user-circle" />
View Profile
</Button>
</div>
<ProfileInfo {pubkey} />
{#if $events.length > 0}
<div class="bg-alt badge badge-neutral border-none">
Last active {formatTimestampRelative($events[0].created_at)}
</div>
{/if}
<Link class="btn btn-primary sm:hidden" href={makeChatPath([pubkey])}>
<Icon icon="letter" />
Start a Chat
</Link>
<ProfileInfo {pubkey} {url} />
<ProfileBadges {pubkey} {url} />
<Button onclick={openProfile} class="btn btn-primary sm:hidden">
<Icon icon="user-circle" />
View Profile
</Button>
</div>
+50 -17
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import {splitAt} from "@welshman/lib"
import {userProfile} from "@welshman/app"
import Avatar from "@lib/components/Avatar.svelte"
import Divider from "@lib/components/Divider.svelte"
@@ -8,50 +10,76 @@
import SpaceAdd from "@app/components/SpaceAdd.svelte"
import ChatEnable from "@app/components/ChatEnable.svelte"
import MenuSpaces from "@app/components/MenuSpaces.svelte"
import MenuOtherSpaces from "@app/components/MenuOtherSpaces.svelte"
import MenuSettings from "@app/components/MenuSettings.svelte"
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
import {userRoomsByUrl, canDecrypt, PLATFORM_RELAY, PLATFORM_LOGO} from "@app/state"
import {userRoomsByUrl, canDecrypt, PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/state"
import {pushModal} from "@app/modal"
import {makeSpacePath} from "@app/routes"
import {notifications} from "@app/notifications"
interface Props {
children?: import("svelte").Snippet
type Props = {
children?: Snippet
}
const {children}: Props = $props()
const addSpace = () => pushModal(SpaceAdd)
const showSpacesMenu = () => (spacePaths.length > 0 ? pushModal(MenuSpaces) : pushModal(SpaceAdd))
const showSpacesMenu = () => (spaceUrls.length > 0 ? pushModal(MenuSpaces) : pushModal(SpaceAdd))
const showOtherSpacesMenu = () => pushModal(MenuOtherSpaces, {urls: secondarySpaceUrls})
const showSettingsMenu = () => pushModal(MenuSettings)
const openChat = () => ($canDecrypt ? goto("/chat") : pushModal(ChatEnable, {next: "/chat"}))
const hasNotification = (url: string) => {
const path = makeSpacePath(url)
return !$page.url.pathname.startsWith(path) && $notifications.has(path)
}
let windowHeight = $state(0)
const itemHeight = 56
const navPadding = 6 * itemHeight
const itemLimit = $derived((windowHeight - navPadding) / itemHeight)
const spaceUrls = $derived(Array.from($userRoomsByUrl.keys()))
const spacePaths = $derived(spaceUrls.map(url => makeSpacePath(url)))
const anySpaceNotifications = $derived(
spacePaths.some(path => !$page.url.pathname.startsWith(path) && $notifications.has(path)),
)
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, spaceUrls))
const anySpaceNotifications = $derived(spaceUrls.some(hasNotification))
const otherSpaceNotifications = $derived(secondarySpaceUrls.some(hasNotification))
</script>
<div class="sail sait saib relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block">
<svelte:window bind:innerHeight={windowHeight} />
<div
class="ml-sai mt-sai mb-sai relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block">
<div class="flex h-full flex-col justify-between">
<div>
{#if PLATFORM_RELAY}
<PrimaryNavItemSpace url={PLATFORM_RELAY} />
{#each PLATFORM_RELAYS as url (url)}
<PrimaryNavItemSpace {url} />
{:else}
<PrimaryNavItem title="Home" href="/home" class="tooltip-right">
<Avatar src={PLATFORM_LOGO} class="!h-10 !w-10" />
</PrimaryNavItem>
<Divider />
{#each spaceUrls as url (url)}
{#each primarySpaceUrls as url (url)}
<PrimaryNavItemSpace {url} />
{/each}
{#if secondarySpaceUrls.length > 0}
<PrimaryNavItem
title="Other Spaces"
class="tooltip-right"
onclick={showOtherSpacesMenu}
notification={otherSpaceNotifications}>
<Avatar icon="widget" class="!h-10 !w-10" />
</PrimaryNavItem>
{/if}
<PrimaryNavItem title="Add Space" onclick={addSpace} class="tooltip-right">
<Avatar icon="settings-minimalistic" class="!h-10 !w-10" />
</PrimaryNavItem>
{/if}
{/each}
</div>
<div>
<PrimaryNavItem
@@ -78,7 +106,7 @@
{@render children?.()}
<!-- a little extra something for ios -->
<div class="fixed bottom-0 left-0 right-0 z-nav h-14 bg-base-100 md:hidden"></div>
<div class="fixed bottom-0 left-0 right-0 z-nav h-[var(--saib)] bg-base-100 md:hidden"></div>
<div
class="border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
<div class="content-padding-x content-sizing flex justify-between px-2">
@@ -92,9 +120,14 @@
notification={$notifications.has("/chat")}>
<Avatar icon="letter" class="!h-10 !w-10" />
</PrimaryNavItem>
<PrimaryNavItem title="Spaces" onclick={showSpacesMenu} notification={anySpaceNotifications}>
<Avatar icon="settings-minimalistic" class="!h-10 !w-10" />
</PrimaryNavItem>
{#if PLATFORM_RELAYS.length !== 1}
<PrimaryNavItem
title="Spaces"
onclick={showSpacesMenu}
notification={anySpaceNotifications}>
<Avatar icon="settings-minimalistic" class="!h-10 !w-10" />
</PrimaryNavItem>
{/if}
</div>
<PrimaryNavItem title="Settings" onclick={showSettingsMenu}>
<Avatar icon="settings" src={$userProfile?.picture} class="!h-10 !w-10" />
+36 -20
View File
@@ -1,47 +1,63 @@
<script lang="ts">
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
import * as nip19 from "nostr-tools/nip19"
import {removeNil} from "@welshman/lib"
import {displayPubkey} from "@welshman/util"
import {
session,
userFollows,
deriveUserWotScore,
deriveProfile,
deriveHandleForPubkey,
displayHandle,
deriveProfile,
deriveProfileDisplay,
} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import WotScore from "@lib/components/WotScore.svelte"
import WotScore from "@app/components/WotScore.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal"
import {clip} from "@app/toast"
const {pubkey} = $props()
type Props = {
pubkey: string
url?: string
showPubkey?: boolean
avatarSize?: number
}
const profile = deriveProfile(pubkey)
const profileDisplay = deriveProfileDisplay(pubkey)
const {pubkey, url, showPubkey, avatarSize = 10}: Props = $props()
const relays = removeNil([url])
const profile = deriveProfile(pubkey, relays)
const profileDisplay = deriveProfileDisplay(pubkey, relays)
const handle = deriveHandleForPubkey(pubkey)
const score = deriveUserWotScore(pubkey)
const openProfile = () => pushModal(ProfileDetail, {pubkey})
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
const following = $derived(
pubkey === $session!.pubkey || getPubkeyTagValues(getListTags($userFollows)).includes(pubkey),
)
const copyPubkey = () => clip(nip19.npubEncode(pubkey))
</script>
<div class="flex max-w-full gap-3">
<div class="flex max-w-full items-start gap-3">
<Button onclick={openProfile} class="py-1">
<Avatar src={$profile?.picture} size={10} />
<Avatar src={$profile?.picture} size={avatarSize} />
</Button>
<div class="flex min-w-0 flex-col">
<div class="flex items-center gap-2">
<Button onclick={openProfile} class="text-bold overflow-hidden text-ellipsis">
{$profileDisplay}
</Button>
<WotScore score={$score} active={following} />
</div>
<div class="overflow-hidden text-ellipsis text-sm opacity-75">
{$handle ? displayHandle($handle) : displayPubkey(pubkey)}
<WotScore {pubkey} />
</div>
{#if $handle}
<div class="overflow-hidden text-ellipsis text-sm opacity-75">
{displayHandle($handle)}
</div>
{/if}
{#if showPubkey}
<div class="flex items-center gap-1 overflow-hidden text-ellipsis text-xs opacity-60">
{displayPubkey(pubkey)}
<Button onclick={copyPubkey} class="pt-1">
<Icon size={3} icon="copy" />
</Button>
</div>
{/if}
</div>
</div>
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import {onMount} from "svelte"
import {load} from "@welshman/net"
import {Router} from "@welshman/router"
import type {Filter} from "@welshman/util"
import {deriveEvents} from "@welshman/store"
import {formatTimestampRelative} from "@welshman/lib"
import {NOTE, ROOMS, MESSAGE, THREAD, COMMENT, getRelayTags, getListTags} from "@welshman/util"
import {repository, loadRelaySelections} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import ProfileSpaces from "@app/components/ProfileSpaces.svelte"
import {membershipsByPubkey} from "@app/state"
import {goToEvent} from "@app/routes"
import {pushModal} from "@app/modal"
type Props = {
pubkey: string
url?: string
}
const {pubkey, url}: Props = $props()
const filters: Filter[] = [{authors: [pubkey], limit: 1}]
const events = deriveEvents(repository, {filters})
const membership = $derived($membershipsByPubkey.get(pubkey))
const relays = $derived(getRelayTags(getListTags(membership)))
const viewEvent = () => goToEvent($events[0]!)
const openSpaces = () => pushModal(ProfileSpaces, {pubkey, url})
onMount(async () => {
// Make sure we have their relay selections before we load their posts
await loadRelaySelections(pubkey)
// Load groups and at least one note, regardless of time frame
load({
filters: [
{authors: [pubkey], kinds: [ROOMS]},
{authors: [pubkey], limit: 1, kinds: [NOTE, MESSAGE, THREAD, COMMENT]},
],
relays: Router.get().FromPubkeys([pubkey]).getUrls(),
})
})
</script>
<div class="flex flex-wrap gap-2">
{#if $events.length > 0}
<Button onclick={viewEvent} class="badge badge-neutral">
Last active {formatTimestampRelative($events[0].created_at)}
</Button>
{/if}
{#if relays.length > 0}
<Button onclick={openSpaces} class="badge badge-neutral">
{relays.length}
{relays.length === 1 ? "space" : "spaces"}
</Button>
{/if}
</div>

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