Compare commits

..

121 Commits

Author SHA1 Message Date
Jon Staab ab21008f34 Add etag for immutable assets 2026-05-12 08:46:50 -07:00
Jon Staab 0998639d59 Push to gitea package registry 2026-05-11 13:49:37 -07:00
Jon Staab eccde07d06 Fix dockerfile again 2026-05-11 13:20:47 -07:00
Jon Staab 770cdc5f13 Reduce extra space on android when keyboard is open 2026-05-11 12:44:44 -07:00
Jon Staab 6bafb62414 Fix docker build 2026-05-11 12:28:42 -07:00
Jon Staab 6ce0fbbbe6 Make recommended ios changes 2026-05-11 10:02:14 -07:00
Jon Staab 8fe42e6f22 Update version 2026-05-11 09:54:22 -07:00
Jon Staab 47a6209730 Bump welshman 2026-05-11 09:20:17 -07:00
Jon Staab 24d3f867f8 Improve space search 2026-05-07 12:53:49 -07:00
Jon Staab 9db60374e4 Make sure to always show date on calendar events when embedded in chat 2026-05-06 17:24:29 -07:00
Jon Staab 8ef4b21dab Allow sharing something to chat without a message 2026-05-06 17:17:55 -07:00
Jon Staab 8f56812dd1 Fix undefined chat draft key 2026-05-06 16:56:31 -07:00
Jon Staab 3833cb093d Attempt to fix wrapping on relay summary on ios 2026-05-06 16:39:01 -07:00
Jon Staab 94db65b85e Bump welshman, add email rendering support 2026-05-06 13:47:30 -07:00
Jon Staab 6f731e48d2 Hide keyboard on app resume 2026-05-06 12:48:15 -07:00
Jon Staab 99fe0e543c Avoid capturing stale cleanup function in chat 2026-05-06 09:31:28 -07:00
Jon Staab c6b0799b2a Remove cv class from chat since new messages weren't rendering in Safari 2026-05-06 09:25:29 -07:00
Jon Staab 861f2286db Remove unnecessary tooltip, fix chat padding on mobile 2026-05-06 09:01:01 -07:00
Jon Staab 9af3e3b2e9 Fix relay badge overflow 2026-05-05 09:11:12 -07:00
Jon Staab 341c1b45b2 Stop publishing join requests every time we open a space 2026-05-05 09:09:28 -07:00
Jon Staab 89f5d8cdf5 Fix pasting into event summary 2026-05-04 16:15:21 -07:00
Jon Staab ca3270437d Highlight active space 2026-05-04 16:11:30 -07:00
Khushvendra bbbc6f7363 fix(metadata): add case-insensitive HTML title fallback parsing for invite links (#248)
Co-authored-by: 1amKhush <khushvendras99@gmail.com>
Co-committed-by: 1amKhush <khushvendras99@gmail.com>
2026-05-04 21:02:56 +00:00
Jon Staab 8a0abacf6f Fix padding on page content on small screens 2026-05-01 13:24:42 -07:00
Jon Staab 976ccdabd4 fix: include MESSAGE kind and local matches in space search 2026-04-28 14:44:16 -07:00
Jon Staab 99b26680b6 feat: rework hosting page to 2+1 architecture (#231) 2026-04-28 14:42:09 -07:00
Jon Staab c5be477855 fix: bundle emoji-picker data locally for Capacitor Android
The emoji grid wasn't rendering on Android because emoji-picker-element
defaults to fetching its data.json from jsdelivr, and CapacitorHttp's
patched fetch breaks the library's ETag-based revalidation flow. Bundle
emoji-picker-element-data via Vite's ?url import so the JSON ships as a
same-origin asset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:15:12 -07:00
DeveshSingh 32c1501e9c feat: add progress bar to signup flow (#234)
Co-authored-by: deveshanim3 <deveshsingh6986@gmail.com>
Co-committed-by: deveshanim3 <deveshsingh6986@gmail.com>
2026-04-23 15:35:59 +00:00
DeveshSingh 463837e7d4 fix: restore consistent input field sizing and alignment in FieldInline (#235) (#238)
Co-authored-by: deveshanim3 <deveshsingh6986@gmail.com>
Co-committed-by: deveshanim3 <deveshsingh6986@gmail.com>
2026-04-20 16:26:44 +00:00
Shreyas2004wagh d74f142cdd Fix relay auth privacy toggle (#240)
Co-authored-by: Shreyas2004wagh <shreyaswagh2004@gmail.com>
Co-committed-by: Shreyas2004wagh <shreyaswagh2004@gmail.com>
2026-04-20 16:25:52 +00:00
Jon Staab 53954aae89 classnames tweak 2026-04-17 16:06:00 -07:00
userAdityaa 24aa62a503 chore: carify Pomade login errors with actionable invalid vs network messaging (#233)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-17 23:00:03 +00:00
Jon Staab 2618bb9c63 Fix centered layout 2026-04-17 14:58:59 -07:00
Prat_09 32a31045ef fix: Improve toggle switch placement in settings screen (#208) (#232)
Co-authored-by: Pratyush Mohanty <prat_09@noreply.coracle.social>
Co-committed-by: Pratyush Mohanty <prat_09@noreply.coracle.social>
2026-04-17 21:58:52 +00:00
DeveshSingh 56edad77a8 fix: added logic for password requirements on signup (#230)
Co-authored-by: deveshanim3 <deveshsingh6986@gmail.com>
Co-committed-by: deveshanim3 <deveshsingh6986@gmail.com>
2026-04-17 19:43:27 +00:00
priyanshu_bharti fdb604e350 Use type=email for signup/login email inputs (#225) (#228)
Co-authored-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
2026-04-17 18:55:04 +00:00
DeveshSingh 3c66dfd83c fix/wrong-message-offline (#222)
Co-authored-by: deveshanim3 <deveshsingh6986@gmail.com>
Co-committed-by: deveshanim3 <deveshsingh6986@gmail.com>
2026-04-17 18:24:55 +00:00
userAdityaa 81633b0a1e fix: vertical alignment of emoji and overflow buttons in shared event action row (#219)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-17 15:22:40 +00:00
Khushvendra 4a967de184 fix(chat): suppress programmatic scroll while user is scrolling (#132) (#216)
Co-authored-by: 1amKhush <khushvendras99@gmail.com>
Co-committed-by: 1amKhush <khushvendras99@gmail.com>
2026-04-16 23:20:17 +00:00
DeveshSingh 59961cbdb5 fix: supported nip overflow in SpaceRelayStatus.svelte (#215)
Co-authored-by: deveshanim3 <deveshsingh6986@gmail.com>
Co-committed-by: deveshanim3 <deveshsingh6986@gmail.com>
2026-04-16 21:36:14 +00:00
Jon Staab 95d9d8bf23 Bump version 2026-04-16 14:10:50 -07:00
Jon Staab 2fd9741a2b Fix safe area inset for chat fab 2026-04-16 14:08:25 -07:00
Jon Staab fe9c325580 Update universal links 2026-04-16 13:50:13 -07:00
Jon Staab 61e93d4071 Update changelog, bump version 2026-04-16 11:40:24 -07:00
Jon Staab 1e4a4e43dc remove dead virtualization code 2026-04-16 11:39:11 -07:00
Jon Staab e1a7b051bd Use welshman kinds 2026-04-16 11:34:59 -07:00
sakshamjain 7a7af58f5c feat: add native share support for space invites 2026-04-16 10:16:12 -07:00
Jon Staab 016ae86d50 Stop sending duplicate requests per room 2026-04-16 10:03:01 -07:00
Jon Staab 2bff060a5e Add thumbnail url 2026-04-16 10:03:01 -07:00
userAdityaa 68231504d0 fix: modal close button stacking above emoji picker on mobile (#211)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-16 15:38:25 +00:00
DeveshSingh 0658a8ee44 bug: fixed calender modal stacking issue (#209)
Co-authored-by: deveshanim3 <deveshsingh6986@gmail.com>
Co-committed-by: deveshanim3 <deveshsingh6986@gmail.com>
2026-04-16 14:55:37 +00:00
priyanshu_bharti 43fb3d35e6 Fix #202 slow-network invite timeout handling (#207)
Co-authored-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
2026-04-15 22:01:00 +00:00
Khushvendra 4cc1cc95ca Fix voice call cold-start timeout and preserve custom timeout message (#174) (#203)
Co-authored-by: 1amKhush <khushvendras99@gmail.com>
Co-committed-by: 1amKhush <khushvendras99@gmail.com>
2026-04-15 20:37:03 +00:00
Prat_09 964ef441ec Update relay description (#195) (#197)
Co-authored-by: Pratyush Mohanty <prat_09@noreply.coracle.social>
Co-committed-by: Pratyush Mohanty <prat_09@noreply.coracle.social>
2026-04-14 15:09:46 +00:00
priyanshu_bharti 796f37d320 Make space reordering discoverable with smoother drag animation (#171)
Co-authored-by: Priyanshu Bharti <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshu Bharti <bhartipriyanshustm@gmail.com>
2026-04-13 22:38:02 +00:00
nayan9617 b46fd94578 Use relay-provided member lists as source of truth (#191)
Co-authored-by: Nayan Patidar <nayan9617@noreply.coracle.social>
Co-committed-by: Nayan Patidar <nayan9617@noreply.coracle.social>
2026-04-13 21:12:49 +00:00
Jon Staab bdc8e75640 Fix search input width 2026-04-13 12:08:11 -07:00
Jon Staab ef08821796 remove VirtualItem 2026-04-13 10:35:26 -07:00
Jon Staab 9f386f6968 remove redundant room syncing logic 2026-04-11 10:20:50 -07:00
Khushvendra ec0b6a99e2 add room mentions and clickable room/relay refs 2026-04-11 10:13:23 -07:00
Khushvendra f6d9e52c6e fix: support native clipboard image paste on mobile (#181)
Co-authored-by: Khushvendra <khushvendras99@gmail.com>
Co-committed-by: Khushvendra <khushvendras99@gmail.com>
2026-04-11 16:38:06 +00:00
Jon Staab 90f86b833d Handle quotes in RoomItem. Fixes #188 2026-04-10 15:27:36 -07:00
userAdityaa 29bb33c26c publish kind 9 quote after room content creation for cross-client interoperability 2026-04-10 14:20:24 -07:00
Jon Staab c740bd21d4 Fix space page layout on Android by adding visible prop to SecondaryNav 2026-04-10 13:26:14 -07:00
Jon Staab 1d92709c76 perf: task-fix-list-virtualization changes 2026-04-10 12:44:01 -07:00
Jon Staab a42e1df1a7 Fix feed pagination logic 2026-04-10 12:40:28 -07:00
Jon Staab e33beee17d perf: task-fix-raf-derived-to-effect changes 2026-04-10 12:30:25 -07:00
Jon Staab b10ea04cb3 Fix Android push fallback: show all notifications, retry on failure 2026-04-10 12:23:32 -07:00
priyanshu_bharti e8c94177ca Support Aegis URL scheme for NIP-46 login (#161)
Co-authored-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
2026-04-10 19:04:34 +00:00
Jon Staab f1f2083c88 Remove unnecessary snapshots, format 2026-04-10 11:09:26 -07:00
Jon Staab f42889c3c2 Improve performance #182:
increase profile timer and chat search throttle delays
reduce GC pressure in derived stores
use requestIdleCallback for non-critical storage writes
batch repository update processing in feeds
2026-04-10 10:39:38 -07:00
Jon Staab a75e1f96eb Add .claude to gitignore 2026-04-10 10:14:01 -07:00
priyanshu_bharti 85c5293082 Raise message size limit in chat (#186)
Co-authored-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
2026-04-10 16:37:23 +00:00
Jon Staab 37efa6a62c Bump pomade 2026-04-10 09:24:22 -07:00
userAdityaa 1d5f91fb6c fix: realtime updates for room members and admins (#178)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-09 21:05:30 +00:00
userAdityaa ef18655776 make close button / backdrop work on direct invite link page (#177)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-09 20:03:08 +00:00
sakshamjain b786e858d9 correct inverted arrow icon in advanced section toggle (#180)
Co-authored-by: Saksham Jain <reach2saksham2004@gmail.com>
Co-committed-by: Saksham Jain <reach2saksham2004@gmail.com>
2026-04-09 19:57:15 +00:00
mplorentz f4ebc4e99e Video in calls (#135)
#135

This PR adds basic video functionality to our voice rooms. Again I followed the Discord UX for inspiration, so all video calls start as voice-only calls that gracefully upgrade (and downgrade) when someone turns on a video or starts screen sharing.

When a video feed is detected the Room page will change to display a grid of feeds. The grid logic is very basic, that's definitely an area to improve in the future. You can open the chat part of the room with a new button on the VoiceWidget - on the desktop layout this creates a split view with video on the left and chat on the right, but on mobile it switches to chat fullscreen. I also added a little pin icon you can use to focus on a single video feed (useful for screen sharing). There is a lot of tailwind I don't understand here, but it seems to work well enough.

I moved voice.ts into a new `call` folder and moved some of its stores into `call/stores.ts` which allowed me to keep most of the video logic in `call/video.ts`. It's not a perfect encapsulation as voice.ts does subscribe to some of the hooks for the livekit calls and passes some of the signals onto `video.ts`. This could probably be broken up better but for this PR I'd rather not focus on making it perfect if that's ok. Partly for the sake of time but also because I envision another PR that renames/reorganizes things and I think a larger UX evaluation is necessary and should include real user feedback. I'm not confident tha""t the Voice Room concept as a whole will stick going forward. Maybe all rooms in a livekit enabled server should be able to host a call (like a slack huddle), maybe users want to be able to schedule calls as events, or even have them start with an ad-hoc set of participants completely outside of a NIP-29 group, etc.

Co-authored-by: mplorentz <mplorentz@noreply.gitea.coracle.social>
Reviewed-on: coracle/flotilla#135
Co-authored-by: Matt Lorentz <mplorentz@noreply.coracle.social>
Co-committed-by: Matt Lorentz <mplorentz@noreply.coracle.social>
2026-04-08 17:10:20 +00:00
Jon Staab 65ca8a7fd8 Remove follow graph building 2026-04-08 09:46:56 -07:00
nayan9617 7f1e98dcb2 Fix fallback pull race after abort (#167)
Co-authored-by: nayan9617 <nayanp4925@gmail.com>
Co-committed-by: nayan9617 <nayanp4925@gmail.com>
2026-04-08 16:43:04 +00:00
priyanshu_bharti 4c19ee823b 73-video-thumbnails (#142)
This PR implements video thumbnails for `.mov`, `.webm`, and `.mp4` files in the `ContentLinkBlock` component.

Changes:
- Added the `poster` attribute to the `<video>` tag.
- Set the poster source to `{url}#t=1` to capture a clear preview frame at the 1-second mark.
- Verified locally that thumbnails are now correctly displayed instead of a black/empty box.

Closes #73

Reviewed-on: coracle/flotilla#142
Co-authored-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
2026-04-08 16:07:11 +00:00
Jon Staab 8e2dd8b278 Upgrade daisyui/tailwind 2026-04-07 15:31:35 -07:00
Jon Staab 8d35b3aad2 Chat tweaks 2026-04-07 10:40:45 -07:00
Prat_09 613cad31c0 add start chat FAB (#152)
Co-authored-by: Pratyush Mohanty <prat_09@noreply.coracle.social>
Co-committed-by: Pratyush Mohanty <prat_09@noreply.coracle.social>
2026-04-07 17:02:40 +00:00
Jon Staab 3779a90f26 Tweak to chat item menu buttons 2026-04-07 09:51:25 -07:00
theAnuragMishra 7470f28f31 fix spacing around messages (#159)
Co-authored-by: theAnuragMishra <theanuragmishra@noreply.coracle.social>
Co-committed-by: theAnuragMishra <theanuragmishra@noreply.coracle.social>
2026-04-07 16:50:53 +00:00
hodlbod 17fb4e780b Clean up drafts implementation (#164) 2026-04-07 13:06:29 +00:00
userAdityaa 30c2a6ef79 persist drafts in memory (#155)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-07 12:06:29 +00:00
Jon Staab 0547e9513f Small css tweak 2026-04-06 09:21:59 -07:00
nayan9617 70e5172f1b fix/tooltip-clipping (#156)
Co-authored-by: nayan9617 <nayanp4925@gmail.com>
Co-committed-by: nayan9617 <nayanp4925@gmail.com>
2026-04-06 16:14:08 +00:00
Jon Staab 61c568a112 Formatting 2026-04-06 09:09:01 -07:00
Jon Staab ae2ba6f44d Tweak toast close button 2026-04-06 09:08:26 -07:00
Jon Staab f84006fbe4 Tweak button on profile page 2026-04-06 09:08:26 -07:00
priyanshu_bharti fed34a2747 show space name on hover in primary nav (#129) (#136)
Co-authored-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
2026-04-06 16:08:02 +00:00
userAdityaa 80df16f97b feat: redesign toast notifications for UX (#148)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-04 16:45:49 +00:00
junaiddshaukat 18cb245599 Remove room/space leave indications (#149)
Co-authored-by: junaiddshaukat <junaiddshaukat@noreply.coracle.social>
Co-committed-by: junaiddshaukat <junaiddshaukat@noreply.coracle.social>
2026-04-04 16:28:11 +00:00
Jon Staab fd6cc84be6 Simplify chat compose layout 2026-04-04 09:02:52 -07:00
Jon Staab 9311cab3b2 Move away from fixed positioned page elements because they act squirrely on android 2026-04-03 17:16:47 -07:00
userAdityaa fceccf47be fix(ui): hide report badge for non-admin users (#147)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-03 23:54:20 +00:00
Jon Staab fe20fbfd28 Add polls 2026-04-03 10:56:00 -07:00
junaiddshaukat 4f3a2a1660 Add space search to recent activity page (#59) (#119)
Co-authored-by: junaiddshaukat <junaiddshaukat@noreply.coracle.social>
Co-committed-by: junaiddshaukat <junaiddshaukat@noreply.coracle.social>
2026-04-03 16:58:35 +00:00
Jon Staab 1c8457a4bf Fix notification badge on mobile nav 2026-04-02 16:47:32 -07:00
Jon Staab 8710043a02 Fix env conventions again 2026-04-02 14:01:09 -07:00
Jon Staab dc46b42cb6 Fix platform logo 2026-04-02 13:49:01 -07:00
Jon Staab 2f1972e70a Add contributing file 2026-04-02 13:31:37 -07:00
mplorentz c5fcf12165 Fix error toast when failing to join room. (#113)
Co-authored-by: Matt Lorentz <mplorentz@noreply.coracle.social>
Co-committed-by: Matt Lorentz <mplorentz@noreply.coracle.social>
2026-04-02 19:35:46 +00:00
mplorentz 61ed632579 Change audio devices in call (#112)
Co-authored-by: Matt Lorentz <mplorentz@noreply.coracle.social>
Co-committed-by: Matt Lorentz <mplorentz@noreply.coracle.social>
2026-04-02 19:33:48 +00:00
Jon Staab 86f4b75c52 Merge subs to avoid hitting limits 2026-04-02 11:49:26 -07:00
bhavishy2801 b26ab916d5 feat: use NIP-50 relay-side search with scope selection (#114)
Co-authored-by: Bhavishy <bhavishyrocker2801@gmail.com>
Co-committed-by: Bhavishy <bhavishyrocker2801@gmail.com>
2026-04-02 18:49:18 +00:00
nayan9617 c882198206 fix: respect VITE_PLATFORM_LOGO with fallback (#116)
Co-authored-by: nayan9617 <nayanp4925@gmail.com>
Co-committed-by: nayan9617 <nayanp4925@gmail.com>
2026-04-02 17:52:44 +00:00
Jon Staab 4aef27ffd5 Fix xcode version 2026-04-02 07:08:16 -07:00
Jon Staab cf4e3f5fc6 Bump version 2026-04-02 07:05:34 -07:00
Jon Staab 57eb919c83 Bump welshman to fix poll behavior 2026-04-02 07:02:57 -07:00
Jon Staab 85cfaf2bc9 Remove redundant join space button 2026-04-02 06:29:37 -07:00
Jon Staab 25a69a8191 Small tweaks 2026-04-01 14:07:29 -07:00
Jon Staab 6feeb23b1f Bump welshman 2026-04-01 11:39:05 -07:00
Jon Staab 4b92ffe3c5 remove duplicate spaces button 2026-04-01 11:33:56 -07:00
Jon Staab 823a9c3271 Combine discover and space list into a single page 2026-03-31 14:24:09 -07:00
Jon Staab fe89df2aa3 Fix some chat related bugs 2026-03-31 11:25:59 -07:00
Jon Staab 97ff8ff802 Bump version 2026-03-31 09:55:04 -07:00
Jon Staab a10a9e7043 Bump pomade 2026-03-31 09:53:30 -07:00
198 changed files with 6582 additions and 2742 deletions
+1 -2
View File
@@ -4,9 +4,8 @@ ios
build
# Git
.git
.gitignore
# Env files (keep .env for build; exclude local overrides)
.env.local
.env.*.local
.env.*.local
+2
View File
@@ -15,8 +15,10 @@ VITE_PUSH_BRIDGE=wss://npb.coracle.social/
VITE_BLOCKED_RELAYS=brb.io,relay.nostr.band,nostr.mutinywallet.com,feeds.nostr.band,nostr.zbd.gg,wot.utxo.one,blastr.f7z.xyz,relay.current.fyi
VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom
VITE_DEFAULT_SEARCH_RELAYS=relay.ditto.pub,antiprimal.net,relay.vertexlab.io
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub
VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
VITE_THUMBNAIL_URL=https://vthumbs.coracle.social
VITE_GLITCHTIP_API_KEY=
GLITCHTIP_AUTH_TOKEN=
+2 -1
View File
@@ -1,4 +1,5 @@
src/assets
.claude
target
build
.idea
@@ -13,4 +14,4 @@ ios/App/Pods/
android/capacitor-cordova-android-plugins
android/app/src/androidTest
android/app/src/test
node_modules
+4 -4
View File
@@ -5,8 +5,8 @@ on:
branches: [master]
env:
REGISTRY: ghcr.io
IMAGE_NAME: coracle-social/flotilla
REGISTRY: gitea.coracle.social
IMAGE_NAME: coracle/flotilla
jobs:
build-and-push-image:
@@ -23,8 +23,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
username: hodlbod
password: ${{ secrets.PACKAGE_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
+4 -1
View File
@@ -1,6 +1,6 @@
# Env
.env
.env.local
.env.*.local
# Vite
vite.config.js.timestamp-*
@@ -28,6 +28,7 @@ node_modules/
.pnpm-store/
build/
.svelte-kit/
.next/
# Rust/Tauri
*target/
@@ -69,7 +70,9 @@ GoogleService-Info.plist
.roo
.idea/
.vscode/
.claude/
# OS generated
.DS_Store
Thumbs.db
package-lock.json
+69
View File
@@ -1,5 +1,74 @@
# Changelog
# 1.8.0
* Fix relay badge overflow
* Suppress programmatic scroll when user is scrolling
* Fix vertical alignment of emoji and overflow buttons in shared event action row
* Use type=email for signup/login email inputs, validate password
* Improve toggle switch placement on settings screens
* Fix relay auth privacy toggle
* Improve field layout
* Add progress bar to signup flow
* Bundle emojis properly
* Rework hosting page
* Fix padding on pages on small screens
* Add richer link preview support
* Fix pasting into event summary
* Publish fewer join/claim requests
* Fix new messages not rendering in safari
* Avoid capturing stale cleanup function in chat
* Hide keyboard on app resume
* Add email rendering support
* Fix bunker login
* Fix undefined chat draft key
* Allow sharing to chat without a message
* Make sure to show date on calendar events when embedded
* Improve space search
# 1.7.4
* Fix safe area inset for FAB
# 1.7.3
* Add native share support for space invites
* Stop sending duplicate requests per room
* Add more robust thumbnail url generation
* Make space reordering discoverable with smoother drag animation
* Improve relay member list
* Add room mentions and clickable room/relay refs
* Support native clipboard image paste on mobile
* publish kind 9 quote after room content creation for cross-client interoperability
* Improve feed pagination logic and performance
* Support Aegis URL scheme for NIP-46 login
* Various UI and bug fixes
* Raise message size limit in chat
* Fix realtime updates for room members and admins
* Add video to calls
* Remove follow graph building
* Add start chat FAB
* Add drafts
* Redesign toast notifications
* Remove room/space leave indications
* Hide report badge for non-admin users
* Add polls
* Add search to recent activity page
* Fix notification badge on mobile nav
* Change audio devices in call
# 1.7.2
* Fix race condition in nip 46
* Remove duplicate spaces button
* Combine discover and space list pages
* Fix some chat related bugs
* Fix bug with joining spaces
# 1.7.1
* Fix pomade registration fallback in case of offline signer
# 1.7.0
* Enable email/password login
+56
View File
@@ -0,0 +1,56 @@
## Project Overview
Flotilla is a Nostr "relays as groups" community chat client. It implements NIP-29 (relay-based groups) to create Discord-like spaces (servers) and rooms (channels).
Please visit our [issue tracker](https://gitea.coracle.social/coracle/flotilla/issues) to contribute. Any new issues should be opened without a milestone, label, or project and the project owners will triage them.
### Milestones
Milestones indicate how soon a given task should be tackled.
- [Current](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=1) issues are immediately actionable.
- [Next](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=2) means an issue is blocked.
- [Future](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=3) means we're deferring work until a later date.
### Labels
- [Design](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=15) issues need design work before being implemented. This might take the form of a high-quality mockup, wireframes, user flows, or just a couple notes about where things go, depending on the nature of the task.
- [Dev](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=16) issues are ready to be implemented. Most of the work will be related to architecting and writing code.
- [Easy](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=14) issues have no dependencies, and are scoped quite narrowly.
- [Priority](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=6) issues include bugs and urgent feature requests. These should get attention first if possible, although sometimes long-standing performance issues or subtle bugs might end up here for a while.
- [Ideas](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=13) are for things that aren't scoped out yet, or which need protocol work before getting designed or implemented.
### Projects
Issues may or may not have a project. Projects are used to group issues thematically just for organization.
## Coding conventions
There are a few conventions that are helpful to know right out of the gate.
- Most nostr protocol functionality is implemented via the [welshman library](https://welshman.coracle.social/)
- Use Svelte 4 **stores** rather than runes for all state outside UI components
- Most global state flows through Welshman's `repository` (unidirectional)
- Query state using `deriveEventsMapped` or `deriveProfile` etc
- Events are published via `publishThunk`, which allows for optimistic UI updates during signing/pow generation.
- Components should have minimal props - e.g. instead of passing a whole `relay` through, pass its `url`.
- Use `AbortController` when possible instead of request ids
- Use `undefined` or optional properties instead of `null`
- Do not use `any`. If there are type errors related to `unknown`, they are likely because the upstream definition of the data is incorrect.
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
- When dynamically building classes, use `cx` from `classnames`.
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates.
## Contributing Workflow
To contribute, do the following:
- Find or create an issue and assign yourself (comment instead if you're not able to self-assign)
- If the issue is a design task, attach or link out to any mockups/wireframes/flowcharts
- Once a design task is completed, a maintainer will remove the `design` label and add the `dev` label
- If the issue is a development task, fork the repository and create a branch prefixed by the issue number, e.g. `105-deep-links`
- Before requesting a review, be sure to review any agent-generated code, run the pre-commit hooks, and test the changes.
- Open a PR and request a review. A maintainer will get back to you with requested changes, or will merge the PR.
- Keep your PR up-to-date by rebasing frequently on `dev`. Avoid force-pushing to `dev`.
- PRs are rebased, squashed, and merged to keep commit history simple.
- An issue may have multiple PRs. Once complete, it can be closed.
+15 -20
View File
@@ -1,32 +1,27 @@
# Stage 1: Build
# Uses .env from build context for config (logo, branding, etc.)
# Optional: docker build --build-arg VITE_BUILD_HASH=$(git rev-parse --short HEAD) -t flotilla .
# Build and run the Flotilla web server.
#
# docker build -t flotilla .
# docker run -p 3000:3000 flotilla
#
# Pass --build-arg VITE_BUILD_HASH=$(git rev-parse --short HEAD) to stamp the build.
# A .env in the build context is picked up by build.sh for branding config.
FROM node:20-bookworm AS builder
FROM node:22-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends curl
RUN npm install -g pnpm@latest
RUN npm install -g pnpm@10.33.0
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm i
# Copy everything (including .env when present) - build.sh will source it
COPY . .
ARG VITE_BUILD_HASH
ENV VITE_BUILD_HASH=${VITE_BUILD_HASH}
RUN pnpm i --frozen-lockfile
ENV NODE_OPTIONS=--max_old_space_size=16384
COPY . .
RUN pnpm run build
FROM node:20-alpine
EXPOSE 3000
WORKDIR /app
# Copy only the built output - no source, no .env, no dev deps
COPY --from=builder /app/build ./build
CMD ["npx", "serve", "-s", "build"]
CMD ["node", "server.js"]
+6 -4
View File
@@ -16,11 +16,13 @@ You can also optionally create an `.env.local` file and populate it with the fol
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
- `VITE_PLATFORM_DESCRIPTION` - A description of the app
These values **won't** be used for a built version. Instead, env variables should be provided to `build.sh` directly or to the built container.
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.
## Development
See [CONTRIBUTING.md](AGENTS.md).
See [CONTRIBUTING.md](CONTRIBUTING.md).
## Deployment
@@ -29,18 +31,18 @@ To run your own Flotilla, it's as simple as:
```sh
pnpm install
pnpm run build
npx serve -s build
pnpm run start
```
Or, if you prefer to use a container:
```sh
podman run -d -p 3000:3000 ghcr.io/coracle-social/flotilla:latest
docker run -d -p 3000:3000 gitea.coracle.social/coracle/flotilla:latest
```
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'
docker run -v ./mount:/app/mount gitea.coracle.social/coracle/flotilla:latest bash -c 'cp -r build/* mount'
```
+2 -2
View File
@@ -8,8 +8,8 @@ android {
applicationId "social.flotilla"
minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion
versionCode 42
versionName "1.7.0"
versionCode 47
versionName "1.8.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+2
View File
@@ -12,10 +12,12 @@ dependencies {
implementation project(':aparajita-capacitor-secure-storage')
implementation project(':capacitor-community-safe-area')
implementation project(':capacitor-app')
implementation project(':capacitor-clipboard')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-keyboard')
implementation project(':capacitor-preferences')
implementation project(':capacitor-push-notifications')
implementation project(':capacitor-share')
implementation project(':capawesome-capacitor-android-dark-mode-support')
implementation project(':capawesome-capacitor-badge')
implementation project(':nostr-signer-capacitor-plugin')
+3
View File
@@ -44,4 +44,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
</manifest>
@@ -7,6 +7,7 @@ import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.OutOfQuotaPolicy
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import com.getcapacitor.JSObject
@@ -76,6 +77,7 @@ class AndroidPushFallbackPlugin : Plugin() {
val immediate = OneTimeWorkRequest.Builder(AndroidPushFallbackWorker::class.java)
.setConstraints(constraints)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
workManager.enqueueUniquePeriodicWork(
@@ -43,7 +43,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
private const val TAG = "PushFallback"
private const val CHANNEL_ID = "flotilla_fallback"
private const val CURSOR_PREFIX = "androidPushFallback.cursor."
private const val SOCKET_TIMEOUT_SECONDS = 20L
private const val SOCKET_TIMEOUT_SECONDS = 30L
private const val REJECTED = "__REJECTED__"
private const val KIND_RELAY_AUTH = 22242
private const val KIND_NIP46_RPC = 24133
@@ -72,6 +72,8 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
}
override fun doWork(): Result {
Log.i(TAG, "doWork() started")
if (isAppInForeground()) {
return Result.success()
}
@@ -88,7 +90,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
val activeSince = state.optLong("activeSince", 0L)
val seen = mutableSetOf<String>()
var latestPair: Pair<String, JSONObject>? = null
val newEvents = mutableListOf<Pair<String, JSONObject>>()
for (sub in subscriptions) {
val cursorKey = CURSOR_PREFIX + sub.relay + ":" + sub.key
@@ -102,23 +104,19 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
for (event in result.events) {
val id = event.optString("id", "")
if (id.isNotEmpty() && seen.add(id)) {
val createdAt = event.optLong("created_at", 0L)
if (latestPair == null || createdAt > latestPair!!.second.optLong("created_at", 0L)) {
latestPair = Pair(sub.relay, event)
}
newEvents.add(Pair(sub.relay, event))
}
}
}
if (latestPair != null) {
val (relay, event) = latestPair!!
for ((relay, event) in newEvents) {
postNotification(relay, event)
}
return Result.success()
} catch (e: Exception) {
Log.e(TAG, "Worker failed", e)
return Result.success()
return Result.retry()
} finally {
pool.closeAll()
client.dispatcher.executorService.shutdown()
@@ -214,7 +212,8 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
.setContentIntent(pendingIntent)
.build()
NotificationManagerCompat.from(context).notify(1, notification)
val notificationId = id.hashCode().let { if (it == 0) 1 else it }
NotificationManagerCompat.from(context).notify(notificationId, notification)
}
private fun matchesFilter(filter: JSONObject, event: JSONObject): Boolean {
+6
View File
@@ -11,6 +11,9 @@ project(':capacitor-community-safe-area').projectDir = new File('../node_modules
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app/android')
include ':capacitor-clipboard'
project(':capacitor-clipboard').projectDir = new File('../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/clipboard/android')
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem/android')
@@ -23,6 +26,9 @@ project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@
include ':capacitor-push-notifications'
project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications/android')
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/share/android')
include ':capawesome-capacitor-android-dark-mode-support'
project(':capawesome-capacitor-android-dark-mode-support').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-android-dark-mode-support@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-android-dark-mode-support/android')
+2 -5
View File
@@ -2,11 +2,8 @@
temp_env=$(declare -p -x)
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
+29 -11
View File
@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 48;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@@ -131,8 +131,9 @@
504EC2FC1FED79650016851F /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 920;
LastUpgradeCheck = 920;
LastUpgradeCheck = 2630;
TargetAttributes = {
504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2;
@@ -257,6 +258,7 @@
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
@@ -264,8 +266,10 @@
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
@@ -275,8 +279,10 @@
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = S26U9DYW3A;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
@@ -295,6 +301,7 @@
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
@@ -314,6 +321,7 @@
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
@@ -321,8 +329,10 @@
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
@@ -332,8 +342,10 @@
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = S26U9DYW3A;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@@ -345,7 +357,9 @@
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES;
};
name = Release;
@@ -358,14 +372,16 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 33;
DEVELOPMENT_TEAM = S26U9DYW3A;
CURRENT_PROJECT_VERSION = 38;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.7.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.8.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -385,14 +401,16 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 33;
DEVELOPMENT_TEAM = S26U9DYW3A;
CURRENT_PROJECT_VERSION = 38;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.7.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.8.0;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
+3 -1
View File
@@ -24,8 +24,10 @@
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Flotilla uses the camera when you enable it in a voice room.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Flotilla uses the microphone for voice chat in rooms.</string>
<string>Flotilla uses the microphone when you enable it in a voice room.</string>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
+2
View File
@@ -14,10 +14,12 @@ def capacitor_pods
pod 'AparajitaCapacitorSecureStorage', :path => '../../node_modules/.pnpm/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage'
pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor-community/safe-area'
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app'
pod 'CapacitorClipboard', :path => '../../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/clipboard'
pod 'CapacitorFilesystem', :path => '../../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem'
pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/keyboard'
pod 'CapacitorPreferences', :path => '../../node_modules/.pnpm/@capacitor+preferences@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/preferences'
pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications'
pod 'CapacitorShare', :path => '../../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/share'
pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge'
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_2704ecccfd05fcfb1ad8852744422b7c/node_modules/nostr-signer-capacitor-plugin'
end
+26 -16
View File
@@ -1,10 +1,11 @@
{
"name": "flotilla",
"version": "1.7.0",
"version": "1.8.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "./build.sh",
"start": "node server.js",
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
@@ -22,6 +23,7 @@
"@eslint/js": "^9.39.2",
"@sveltejs/kit": "^2.50.1",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@tailwindcss/postcss": "^4.2.2",
"@tauri-apps/cli": "^2.9.6",
"@types/eslint": "^9.6.1",
"autoprefixer": "^10.4.23",
@@ -35,7 +37,7 @@
"prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.48.0",
"svelte-check": "^4.3.5",
"tailwindcss": "^3.4.19",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.53.1",
"vite": "^5.4.21"
@@ -47,47 +49,54 @@
"@capacitor/android": "^8.0.1",
"@capacitor/app": "^8.0.0",
"@capacitor/cli": "^8.0.1",
"@capacitor/clipboard": "^8.0.1",
"@capacitor/core": "^8.0.1",
"@capacitor/filesystem": "^8.1.0",
"@capacitor/ios": "^8.0.1",
"@capacitor/keyboard": "^8.0.0",
"@capacitor/preferences": "^8.0.0",
"@capacitor/push-notifications": "^8.0.0",
"@capacitor/share": "^8.0.1",
"@capawesome/capacitor-android-dark-mode-support": "^8.0.0",
"@capawesome/capacitor-badge": "^8.0.0",
"@getalby/lightning-tools": "^6.1.0",
"@getalby/sdk": "^5.1.2",
"@hono/node-server": "^2.0.0",
"@noble/curves": "^1.9.7",
"@pomade/core": "^0.2.1",
"@pomade/core": "^0.2.3",
"@poppanator/sveltekit-svg": "^4.2.1",
"@sveltejs/adapter-static": "^3.0.10",
"@tiptap/core": "^2.27.2",
"@tiptap/pm": "^2.27.2",
"@types/qrcode": "^1.5.6",
"@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.8",
"@welshman/app": "^0.8.10",
"@welshman/content": "^0.8.10",
"@welshman/editor": "^0.8.10",
"@welshman/feeds": "^0.8.10",
"@welshman/lib": "^0.8.10",
"@welshman/net": "^0.8.10",
"@welshman/router": "^0.8.10",
"@welshman/signer": "^0.8.10",
"@welshman/store": "^0.8.10",
"@welshman/util": "^0.8.10",
"@welshman/app": "^0.8.15",
"@welshman/content": "^0.8.15",
"@welshman/editor": "^0.8.15",
"@welshman/feeds": "^0.8.15",
"@welshman/lib": "^0.8.15",
"@welshman/net": "^0.8.15",
"@welshman/router": "^0.8.15",
"@welshman/signer": "^0.8.15",
"@welshman/store": "^0.8.15",
"@welshman/util": "^0.8.15",
"cheerio": "^1.2.0",
"compressorjs-next": "^1.1.2",
"daisyui": "^4.12.24",
"daisyui": "^5.5.19",
"date-picker-svelte": "^2.17.0",
"dotenv": "^16.6.1",
"emoji-picker-element": "^1.28.1",
"emoji-picker-element-data": "^1.8.0",
"fuse.js": "^7.1.0",
"hono": "^4.12.15",
"husky": "^9.1.7",
"idb": "^8.0.3",
"livekit-client": "^2.17.2",
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
"nostr-tools": "^2.19.4",
"prettier-plugin-tailwindcss": "^0.6.14",
"prettier-plugin-tailwindcss": "^0.7.2",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"throttle-debounce": "^5.0.2",
@@ -104,5 +113,6 @@
"overrides": {
"sharp": "0.35.0-rc.0"
}
}
},
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
}
+712 -483
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -1,6 +1,5 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
"@tailwindcss/postcss": {},
},
}
+1 -1
View File
@@ -2,7 +2,7 @@ import dotenv from "dotenv"
import {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config"
dotenv.config({path: ".env.local"})
dotenv.config({path: ".env.template"})
dotenv.config({path: ".env"})
export default defineConfig({
preset,
+288
View File
@@ -0,0 +1,288 @@
import path from "node:path"
import {promises as fs} from "node:fs"
import {fileURLToPath} from "node:url"
import "dotenv/config"
import {serve} from "@hono/node-server"
import {serveStatic} from "@hono/node-server/serve-static"
import {loadRelay} from "@welshman/app"
import {displayRelayUrl, normalizeRelayUrl} from "@welshman/util"
import {load} from "cheerio"
import {Hono} from "hono"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const BUILD_DIR = path.join(__dirname, "build")
const INDEX_PATH = path.join(BUILD_DIR, "index.html")
const PORT = parseInt(process.env.PORT || "", 10) || 3000
const HOST = process.env.HOST || "0.0.0.0"
let TEMPLATE_HTML = ""
try {
TEMPLATE_HTML = await fs.readFile(INDEX_PATH, "utf8")
} catch (error) {
console.error(`Unable to read ${INDEX_PATH}. Run "pnpm run build" first.`)
process.exit(1)
}
const PLATFORM_NAME = process.env.VITE_PLATFORM_NAME
const PLATFORM_DESCRIPTION = process.env.VITE_PLATFORM_DESCRIPTION
// Match client-side decode logic
const decodeRelay = url => {
try {
return normalizeRelayUrl(decodeURIComponent(url))
} catch {
return undefined
}
}
const requestUrlFromContext = context => {
const requestUrl = new URL(context.req.url)
const forwardedProto = context.req.header("x-forwarded-proto")?.split(",")[0]?.trim()
const forwardedHost = context.req.header("x-forwarded-host")?.split(",")[0]?.trim()
if (forwardedProto === "http" || forwardedProto === "https") {
requestUrl.protocol = `${forwardedProto}:`
}
if (forwardedHost) {
requestUrl.host = forwardedHost
}
return requestUrl
}
const fetchRelayMeta = async relayUrl => {
if (!relayUrl) return undefined
try {
return await loadRelay(normalizeRelayUrl(relayUrl))
} catch (err) {
console.error(`Failed to fetch relay metadata for ${relayUrl}:`, err)
return undefined
}
}
const buildDefaultImage = requestUrl => {
return new URL("/maskable-icon-512x512.png", requestUrl.origin).toString()
}
const getMetadataForInvite = async (url, match) => {
const relayParam = url.searchParams.get("r")
if (!relayParam) return undefined
const relayMetadata = await fetchRelayMeta(relayParam)
if (!relayMetadata) return undefined
const relayDisplay = displayRelayUrl(relayParam)
const spaceName = relayMetadata.name
const relayDescription = relayMetadata.description
const title = spaceName
? `Invite to ${spaceName} on ${PLATFORM_NAME}`
: `Invite to a Space on ${PLATFORM_NAME}`
const parts = []
if (spaceName) {
parts.push(`You are invited to join ${spaceName} on ${PLATFORM_NAME}.`)
} else {
parts.push(`You are invited to join a space on ${PLATFORM_NAME}.`)
}
if (relayDisplay) parts.push(`Relay: ${relayDisplay}.`)
if (relayDescription) parts.push(relayDescription)
else parts.push(PLATFORM_DESCRIPTION)
const description = parts.join(" ")
const image =
relayMetadata.icon ||
relayMetadata.picture ||
relayMetadata.image ||
buildDefaultImage(url)
return {
title,
description,
image,
url: url.toString(),
site: url.origin,
}
}
const getMetadataForSpace = async (url, match) => {
const relayParam = decodeRelay(match[1])
if (!relayParam) return undefined
const relayMetadata = await fetchRelayMeta(relayParam)
if (!relayMetadata) return undefined
const spaceName = relayMetadata.name || displayRelayUrl(relayParam)
return {
title: `${spaceName} on ${PLATFORM_NAME}`,
description: relayMetadata.description || PLATFORM_DESCRIPTION,
image:
relayMetadata.icon ||
relayMetadata.picture ||
relayMetadata.image ||
buildDefaultImage(url),
url: url.toString(),
site: url.origin,
}
}
const getMetadataForSpaceSection = async (url, match) => {
const spaceMeta = await getMetadataForSpace(url, match)
if (!spaceMeta) return undefined
const section = match[2]
const sectionName = section.charAt(0).toUpperCase() + section.slice(1)
spaceMeta.title = `${sectionName} on ${spaceMeta.title}`
return spaceMeta
}
const getMetadataForSpaceItem = async (url, match) => {
const spaceMeta = await getMetadataForSpace(url, match)
if (!spaceMeta) return undefined
const section = match[2]
let itemType = "Item"
if (section === "calendar") itemType = "Event"
if (section === "threads") itemType = "Thread"
if (section === "polls") itemType = "Poll"
if (section === "goals") itemType = "Goal"
if (section === "classifieds") itemType = "Listing"
spaceMeta.title = `${itemType} on ${spaceMeta.title}`
return spaceMeta
}
const getMetadataForRoom = async (url, match) => {
const spaceMeta = await getMetadataForSpace(url, match)
if (!spaceMeta) return undefined
// Room metadata requires fetching from Nostr, which can be added later.
spaceMeta.title = `Room on ${spaceMeta.title}`
return spaceMeta
}
const routes = [
[/^\/join\/?$/, getMetadataForInvite],
[/^\/spaces\/([^/]+)\/(calendar|chat|threads|polls|goals|classifieds|recent)\/?$/, getMetadataForSpaceSection],
[/^\/spaces\/([^/]+)\/(calendar|threads|polls|goals|classifieds)\/([^/]+)\/?$/, getMetadataForSpaceItem],
[/^\/spaces\/([^/]+)\/([^/]+)\/?$/, getMetadataForRoom],
[/^\/spaces\/([^/]+)\/?$/, getMetadataForSpace],
]
const getMetadataForRoute = async url => {
for (const [regex, getMetadata] of routes) {
const match = url.pathname.match(regex)
if (match) {
try {
return await getMetadata(url, match)
} catch (err) {
console.error(`Error generating metadata for route ${url.pathname}:`, err)
return undefined
}
}
}
return undefined
}
const injectMeta = metadata => {
const $ = load(TEMPLATE_HTML)
if (metadata.title) {
$("title").text(metadata.title)
$('meta[property="og:title"]').attr("content", metadata.title)
$('meta[name="twitter:title"]').attr("content", metadata.title)
}
if (metadata.description) {
$('meta[name="description"]').attr("content", metadata.description)
$('meta[property="og:description"]').attr("content", metadata.description)
$('meta[name="twitter:description"]').attr("content", metadata.description)
}
if (metadata.image) {
$('meta[property="og:image"]').attr("content", metadata.image)
$('meta[name="twitter:image"]').attr("content", metadata.image)
}
if (metadata.url) {
$('meta[property="og:url"]').attr("content", metadata.url)
$('meta[name="twitter:site"]').attr("content", metadata.site)
$('meta[name="twitter:url"]').attr("content", metadata.url)
$('link[rel="canonical"]').attr("href", metadata.url)
}
return $.html()
}
const app = new Hono()
// Only allow GET and HEAD requests
app.use("*", async (context, next) => {
const method = context.req.method
if (method !== "GET" && method !== "HEAD") {
return context.text("Method Not Allowed", 405, {Allow: "GET, HEAD"})
}
await next()
})
// Serve static assets with appropriate caching
app.use(
"*",
serveStatic({
root: BUILD_DIR,
onFound: (filePath, context) => {
const isImmutable = filePath.split(path.sep).join("/").includes("/_app/immutable/")
const cacheControl =
path.basename(filePath) === "index.html"
? "no-cache"
: isImmutable
? "public, max-age=31536000, immutable"
: "public, max-age=3600"
context.header("Cache-Control", cacheControl)
// Immutable assets are content-hashed by Vite, so the filename is itself a
// stable content identifier. Exposing it as an ETag lets clients that
// revalidate explicitly (e.g. emoji-picker-element checks its data source
// on every load) skip re-downloading large files when nothing changed.
if (isImmutable) {
context.header("ETag", `"${path.basename(filePath)}"`)
}
},
}),
)
// SPA fallback for routes that don't match static files
app.get("*", async context => {
const requestUrl = requestUrlFromContext(context)
// If the path has an extension, it's likely a missing static asset, not an SPA route
if (path.extname(requestUrl.pathname)) {
return context.text("Not found", 404)
}
const metadata = await getMetadataForRoute(requestUrl)
const html = metadata ? injectMeta(metadata) : TEMPLATE_HTML
return context.html(html, 200, {
"Cache-Control": metadata ? "no-store" : "no-cache",
})
})
serve(
{
fetch: app.fetch,
hostname: HOST,
port: PORT,
},
() => {
console.log(`Flotilla server running on http://${HOST}:${PORT}`)
},
)
+275 -266
View File
@@ -1,46 +1,6 @@
@import "@welshman/editor/index.css";
@import "tailwindcss";
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Fonts */
@font-face {
font-family: "Satoshis";
font-style: normal;
font-weight: 400;
src:
local(""),
url("/fonts/Satoshi Symbol.ttf") format("truetype");
}
@font-face {
font-family: "Lato";
font-style: normal;
font-weight: 400;
src:
local(""),
url("/fonts/Lato-Regular.ttf") format("truetype");
}
@font-face {
font-family: "Lato";
font-style: bold;
font-weight: 600;
src:
local(""),
url("/fonts/Lato-Bold.ttf") format("truetype");
}
@font-face {
font-family: "Lato";
font-style: italic;
font-weight: 400;
src:
local(""),
url("/fonts/Italic.ttf") format("truetype");
}
@config "../tailwind.config.js";
/* root */
@@ -52,98 +12,245 @@
--sair: var(--safe-area-inset-right, 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));
--base-content: oklch(var(--bc));
--primary: oklch(var(--p));
--primary-content: oklch(var(--pc));
--secondary: oklch(var(--s));
--secondary-content: oklch(var(--sc));
--neutral: oklch(var(--n));
--neutral-content: oklch(var(--nc));
@utility pt-sai {
padding-top: var(--sait);
}
.mobile [data-tip]::before {
display: none !important;
@utility pr-sai {
padding-right: var(--sair);
}
/* safe area insets */
@utility pb-sai {
padding-bottom: var(--saib);
}
@layer components {
.pt-sai {
padding-top: var(--sait);
@utility pl-sai {
padding-left: var(--sail);
}
@utility px-sai {
@apply pl-sai pr-sai;
}
@utility py-sai {
@apply pt-sai pb-sai;
}
@utility p-sai {
@apply py-sai px-sai;
}
@utility mt-sai {
margin-top: var(--sait);
}
@utility mr-sai {
margin-right: var(--sair);
}
@utility mb-sai {
margin-bottom: var(--saib);
}
@utility ml-sai {
margin-left: var(--sail);
}
@utility mx-sai {
@apply ml-sai mr-sai;
}
@utility my-sai {
@apply mt-sai mb-sai;
}
@utility m-sai {
@apply my-sai mx-sai;
}
@utility top-sai {
top: var(--sait);
}
@utility right-sai {
right: var(--sair);
}
@utility bottom-sai {
bottom: var(--saib);
}
@utility left-sai {
left: var(--sail);
}
@utility card2 {
@apply rounded-box text-base-content p-4 sm:p-6;
}
@utility column {
@apply flex flex-col;
}
@utility center {
@apply flex items-center justify-center;
}
@utility row-2 {
@apply flex items-center gap-2;
}
@utility row-3 {
@apply flex items-center gap-3;
}
@utility row-4 {
@apply flex items-center gap-4;
}
@utility col-2 {
@apply flex flex-col gap-2;
}
@utility col-3 {
@apply flex flex-col gap-3;
}
@utility col-4 {
@apply flex flex-col gap-4;
}
@utility col-8 {
@apply flex flex-col gap-8;
}
@utility ellipsize {
@apply overflow-hidden text-ellipsis;
}
@utility content-padding-x {
@apply px-4 sm:px-8 md:px-12;
}
@utility content-padding-t {
@apply pt-4 sm:pt-8 md:pt-12;
}
@utility content-padding-b {
@apply pb-4 sm:pb-8 md:pb-12;
}
@utility content-padding-y {
@apply pt-4 pb-4 sm:pt-8 sm:pb-8 md:pt-12 md:pb-12;
}
@utility content-sizing {
@apply m-auto w-full max-w-3xl;
}
@utility content {
@apply m-auto w-full max-w-3xl px-4 pt-4 pb-4 sm:px-8 sm:pt-8 sm:pb-8 md:px-12 md:pt-12 md:pb-12;
}
@utility heading {
@apply text-center text-2xl;
}
@utility subheading {
@apply text-center text-xl;
}
@utility superheading {
@apply text-center text-4xl;
}
@utility link {
@apply text-primary cursor-pointer underline;
}
/* content visibility */
@utility cv {
content-visibility: auto;
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@layer utilities {
/* Fonts */
@font-face {
font-family: "Satoshis";
font-style: normal;
font-weight: 400;
src:
local(""),
url("/fonts/Satoshi Symbol.ttf") format("truetype");
}
.pr-sai {
padding-right: var(--sair);
@font-face {
font-family: "Lato";
font-style: normal;
font-weight: 400;
src:
local(""),
url("/fonts/Lato-Regular.ttf") format("truetype");
}
.pb-sai {
padding-bottom: var(--saib);
@font-face {
font-family: "Lato";
font-style: bold;
font-weight: 600;
src:
local(""),
url("/fonts/Lato-Bold.ttf") format("truetype");
}
.pl-sai {
padding-left: var(--sail);
@font-face {
font-family: "Lato";
font-style: italic;
font-weight: 400;
src:
local(""),
url("/fonts/Italic.ttf") format("truetype");
}
.px-sai {
@apply pl-sai pr-sai;
/* root */
:root {
font-family: Lato;
text-size-adjust: 100%;
--sait: var(--safe-area-inset-top, env(safe-area-inset-top));
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
--sail: var(--safe-area-inset-left, env(safe-area-inset-left));
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
}
.py-sai {
@apply pt-sai pb-sai;
[data-theme] {
@apply bg-base-300;
}
.p-sai {
@apply py-sai px-sai;
.mobile [data-tip]::before {
display: none !important;
}
.mt-sai {
margin-top: var(--sait);
}
.mr-sai {
margin-right: var(--sair);
}
.mb-sai {
margin-bottom: var(--saib);
}
.ml-sai {
margin-left: var(--sail);
}
.mx-sai {
@apply ml-sai mr-sai;
}
.my-sai {
@apply mt-sai mb-sai;
}
.m-sai {
@apply my-sai mx-sai;
}
.top-sai {
top: var(--sait);
}
.right-sai {
right: var(--sair);
}
.bottom-sai {
bottom: var(--saib);
}
.left-sai {
left: var(--sail);
}
/* safe area insets */
}
/* utilities */
@@ -165,110 +272,18 @@
@apply bg-base-300 text-base-content transition-colors;
}
.card2 {
@apply rounded-box p-4 text-base-content sm:p-6;
}
.card2.card2-sm {
@apply p-2 text-base-content sm:p-4;
}
.column {
@apply flex flex-col;
}
.center {
@apply flex items-center justify-center;
}
.row-2 {
@apply flex items-center gap-2;
}
.row-3 {
@apply flex items-center gap-3;
}
.row-4 {
@apply flex items-center gap-4;
}
.col-2 {
@apply flex flex-col gap-2;
}
.col-3 {
@apply flex flex-col gap-3;
}
.col-4 {
@apply flex flex-col gap-4;
}
.col-8 {
@apply flex flex-col gap-8;
}
.badge {
@apply justify-start overflow-hidden text-ellipsis whitespace-nowrap;
}
.ellipsize {
@apply overflow-hidden text-ellipsis;
@apply text-base-content p-2 sm:p-4;
}
[data-tip]::before {
@apply ellipsize;
}
.content-padding-x {
@apply px-4 sm:px-8 md:px-12;
}
.content-padding-t {
@apply pt-4 sm:pt-8 md:pt-12;
}
.content-padding-b {
@apply pb-4 sm:pb-8 md:pb-12;
}
.content-padding-y {
@apply content-padding-t content-padding-b;
}
.content-sizing {
@apply m-auto w-full max-w-3xl;
}
.content {
@apply content-sizing content-padding-x content-padding-y;
}
.heading {
@apply text-center text-2xl;
}
.subheading {
@apply text-center text-xl;
}
.superheading {
@apply text-center text-4xl;
}
.link {
@apply cursor-pointer text-primary underline;
@apply overflow-hidden text-ellipsis;
}
.input input::placeholder {
opacity: 0.5;
}
.shadow-top-xl {
@apply shadow-[0_20px_25px_-5px_rgb(0,0,0,0.1)_0_8px_10px_-6px_rgb(0,0,0,0.1)];
}
/* tiptap */
.input-editor,
@@ -278,21 +293,21 @@
}
.tiptap {
--tiptap-object-bg: var(--neutral);
--tiptap-object-fg: var(--neutral-content);
--tiptap-active-bg: var(--primary);
--tiptap-active-fg: var(--primary-content);
--tiptap-object-bg: var(--color-neutral);
--tiptap-object-fg: var(--color-neutral-content);
--tiptap-active-bg: var(--color-primary);
--tiptap-active-fg: var(--color-primary-content);
}
.tiptap-suggestions {
--tiptap-object-bg: var(--base-100);
--tiptap-object-fg: var(--base-content);
--tiptap-active-bg: var(--base-300);
--tiptap-active-fg: var(--base-content);
--tiptap-object-bg: var(--color-base-100);
--tiptap-object-fg: var(--color-base-content);
--tiptap-active-bg: var(--color-base-300);
--tiptap-active-fg: var(--color-base-content);
}
.tiptap-suggestions__item {
@apply border-l-2 border-solid border-base-100;
@apply border-base-100 border-l-2 border-solid;
}
.tiptap-suggestions__selected {
@@ -312,13 +327,13 @@
}
.note-editor .tiptap {
--tiptap-object-bg: var(--base-200);
@apply input input-bordered h-auto min-h-32 rounded-box p-[.65rem] pb-6;
--tiptap-object-bg: var(--color-base-200);
@apply input rounded-box block h-auto min-h-32 w-full p-[.65rem] pb-6;
}
.input-editor .tiptap {
--tiptap-object-bg: var(--base-200);
@apply input input-bordered h-auto p-[.65rem];
--tiptap-object-bg: var(--color-base-200);
@apply input block h-auto p-[.65rem];
}
/* link-content, based on tiptap */
@@ -330,8 +345,8 @@
white-space: nowrap;
border-radius: 3px;
padding: 0 0.25rem;
background-color: var(--base-100);
color: var(--base-content);
background-color: var(--color-base-100);
color: var(--color-base-content);
}
/* content rendered by welshman/content */
@@ -347,23 +362,31 @@
/* date input */
.picker {
--date-picker-foreground: var(--base-content);
--date-picker-background: var(--base-300);
--date-picker-highlight-border: var(--primary);
--date-picker-selected-color: var(--primary-content);
--date-picker-selected-background: var(--primary);
--date-picker-foreground: var(--color-base-content);
--date-picker-background: var(--color-base-300);
--date-picker-highlight-border: var(--color-primary);
--date-picker-selected-color: var(--color-primary-content);
--date-picker-selected-background: var(--color-primary);
}
.date-time-field {
@apply input input-bordered rounded-lg px-0;
@apply input rounded-lg px-0;
}
.date-time-field input {
@apply !h-full !w-full !rounded-lg !border-none !bg-inherit !px-4 !text-inherit;
@apply h-full! w-full! rounded-lg! border-none! bg-inherit! px-4! text-inherit!;
}
/* tippy popover */
.tippy-target {
@apply z-tooltip pointer-events-none fixed inset-0;
}
.tippy-target > * {
pointer-events: auto;
}
.tippy-box {
@apply rounded-box shadow-xl;
}
@@ -371,15 +394,15 @@
/* emoji picker */
emoji-picker {
--background: var(--base-100);
--border-color: var(--base-100);
--background: var(--color-base-100);
--border-color: var(--color-base-100);
--border-radius: var(--rounded-box);
--button-active-background: var(--base-content);
--button-hover-background: var(--base-content);
--indicator-color: var(--base-content);
--input-border-color: var(--base-100);
--input-font-color: var(--base-content);
--outline-color: var(--base-100);
--button-active-background: var(--color-base-content);
--button-hover-background: var(--color-base-content);
--indicator-color: var(--color-base-content);
--input-border-color: var(--color-base-100);
--input-font-color: var(--color-base-content);
--outline-color: var(--color-base-100);
}
/* progress */
@@ -390,52 +413,38 @@ progress[value]::-webkit-progress-value {
/* content width for fixed elements */
.cw {
@apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))];
.left-content {
@apply md:left-[calc(18.5rem+var(--sail))];
}
.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)];
}
.ct {
@apply top-[calc(var(--sait)+5rem)] md:top-[calc(var(--sait)+3rem)];
.left-content-full {
@apply md:left-[calc(3.5rem+var(--sail))];
}
/* Keyboard open state adjustments */
body.keyboard-open .cb {
@apply bottom-sai;
body.keyboard-open {
--saib: 0px;
}
body.keyboard-open .hide-on-keyboard {
display: none;
}
body.keyboard-open .chat__compose {
margin-bottom: 0;
}
/* chat view */
.chat__compose {
@apply cb cw fixed z-compose;
@apply z-compose relative mb-14 shrink-0 md:mb-0;
}
.chat__compose-zone {
@apply cb cw fixed z-compose;
}
.chat__compose-zone .chat__compose-inner {
.chat__compose .chat__compose-inner {
@apply min-w-0;
}
.chat__scroll-down {
@apply pb-sai fixed bottom-28 right-4 z-feature md:bottom-16;
}
/* content visibility */
.cv {
content-visibility: auto;
@apply pb-sai z-feature fixed right-4 bottom-28 md:bottom-16;
}
+7 -4
View File
@@ -2,15 +2,18 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{NAME}</title>
<link rel="canonical" href="{URL}" />
<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}" />
<meta name="og:type" content="website" />
<meta name="og:title" content="{NAME}" />
<meta name="og:description" content="{DESCRIPTION}" />
<meta property="og:url" content="{URL}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="{NAME}" />
<meta property="og:description" content="{DESCRIPTION}" />
<meta property="og:image" content="" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:site" content="{URL}" />
<meta name="twitter:title" content="{NAME}" />
+57
View File
@@ -0,0 +1,57 @@
import {Room as LiveKitRoom} from "livekit-client"
import {derived, writable} from "svelte/store"
import {type Room} from "@app/core/state"
export type VoiceSession = {
url: string
h: string
room: LiveKitRoom
muted: boolean
cameraOn: boolean
screenShareOn: boolean
}
export type Pubkey = string
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
export enum VoiceState {
Joining = "joining",
Connected = "connected",
Disconnected = "disconnected",
}
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
export const currentVoiceRoom = writable<Room | undefined>(undefined)
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
const pk = pubkeyFromLiveKitIdentity(identity)
return pk ? {pubkey: pk, identity} : {identity}
}
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
export const speakingParticipants = writable<VoiceParticipant[]>([])
export const isParticipantSpeaking = derived(
speakingParticipants,
$participants => (p: VoiceParticipant) =>
$participants.some(sp => participantKey(sp) === participantKey(p)),
)
export const isLocalSpeaking = derived(
[currentVoiceSession, speakingParticipants],
([$session, $speaking]) => {
if (!$session?.room) return false
const local = participantFromLiveKitIdentity($session.room.localParticipant.identity)
return $speaking.some(sp => participantKey(sp) === participantKey(local))
},
)
+99
View File
@@ -0,0 +1,99 @@
import {Track} from "livekit-client"
import {MediaQuery} from "svelte/reactivity"
import {derived, get, writable} from "svelte/store"
import {currentVoiceSession, VoiceState, type VoiceSession, voiceState} from "@app/call/stores"
import {pushToast} from "@app/util/toast"
export enum VideoCallLayout {
Chat = "chat",
Video = "video",
Split = "split",
}
export const isDesktopLayout = new MediaQuery("min-width: 768px", false)
export enum ViewportSize {
Desktop = "desktop",
Mobile = "mobile",
}
export const videoCallViewportSync = {
previousLayout: undefined as ViewportSize | undefined,
}
export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
export const resetVideoCallLayout = () => {
videoCallViewportSync.previousLayout = undefined
videoCallLayout.set(VideoCallLayout.Chat)
}
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
export const toggleVideoPrimaryTile = (key: string) => {
videoPrimaryTileKey.update(k => (k === key ? undefined : key))
}
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
const countLiveVisualFeeds = (session: VoiceSession): number => {
const room = session.room
let n = 0
const lp = room.localParticipant
if (session.cameraOn) {
const pub = lp.getTrackPublication(Track.Source.Camera)
if (pub?.track) n += 1
}
if (session.screenShareOn) {
const pub = lp.getTrackPublication(Track.Source.ScreenShare)
if (pub?.track) n += 1
}
for (const rp of room.remoteParticipants.values()) {
for (const source of VISUAL_SOURCES) {
const pub = rp.getTrackPublication(source)
if (pub?.isSubscribed && pub.track) n += 1
}
}
return n
}
export const triggerVideoFeedCount = () => {
currentVoiceSession.update(s => (s ? {...s} : s))
}
export const videoTileCount = derived([currentVoiceSession, voiceState], ([$session, $state]) => {
if ($state !== VoiceState.Connected || !$session) return 0
return countLiveVisualFeeds($session)
})
export const toggleCamera = async () => {
const session = get(currentVoiceSession)
if (!session) return
const cameraOn = !session.cameraOn
try {
await session.room.localParticipant.setCameraEnabled(cameraOn)
currentVoiceSession.set({...session, cameraOn})
} catch {
pushToast({
theme: "error",
message: cameraOn ? "Could not access camera" : "Could not turn off camera",
})
}
}
export const toggleScreenShare = async () => {
const session = get(currentVoiceSession)
if (!session) return
const screenShareOn = !session.screenShareOn
try {
await session.room.localParticipant.setScreenShareEnabled(screenShareOn)
currentVoiceSession.set({...session, screenShareOn})
} catch {
pushToast({
theme: "error",
message: screenShareOn ? "Could not start screen sharing" : "Could not stop screen sharing",
})
}
}
+96 -44
View File
@@ -4,51 +4,77 @@
*/
import {
DisconnectReason,
LocalParticipant,
LocalTrackPublication,
Room as LiveKitRoom,
RoomEvent,
Track,
supportsAudioOutputSelection,
type AudioCaptureOptions,
type LocalParticipant,
} from "livekit-client"
import {derived, get, writable} from "svelte/store"
import {derived, get} from "svelte/store"
import {map, removeUndefined, uniqBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
import {signer} from "@welshman/app"
import {getLivekitEndpoint} from "$lib/livekit"
import {AbortError, whenAborted, whenTimeout} from "$lib/util"
import {deriveLatestEventForUrl, deriveRoom, makeRoomId, type Room} from "@app/core/state"
import {
currentVoiceRoom,
currentVoiceSession,
participantFromLiveKitIdentity,
participantKey,
participantPubkeyMap,
pubkeyFromLiveKitIdentity,
speakingParticipants,
VoiceState,
type VoiceParticipant,
voiceState,
} from "@app/call/stores"
import {resetVideoCallLayout, triggerVideoFeedCount, videoPrimaryTileKey} from "@app/call/video"
import {deriveLatestEventForUrl, deriveRoom, makeRoomId} from "@app/core/state"
import {pushToast} from "@app/util/toast"
export const LIVEKIT_PARTICIPANTS = 39004
export {checkRelayHasLivekit} from "$lib/livekit"
export type VoiceSession = {
url: string
h: string
room: LiveKitRoom
muted: boolean
export {supportsAudioOutputSelection}
const LIVEKIT_DEFAULT_DEVICE_ID = "default"
export enum DeviceKind {
AudioInput = "audioinput",
AudioOutput = "audiooutput",
VideoInput = "videoinput",
}
export type Pubkey = string
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
export enum VoiceState {
Joining = "joining",
Connected = "connected",
Disconnected = "disconnected",
export const switchVoiceActiveDevice = async (
kind: DeviceKind,
targetDeviceId: string,
): Promise<void> => {
const session = get(currentVoiceSession)
if (!session) return
const id = targetDeviceId === "" ? LIVEKIT_DEFAULT_DEVICE_ID : targetDeviceId
try {
await session.room.switchActiveDevice(kind, id)
} catch {
let label: string
switch (kind) {
case DeviceKind.AudioInput:
label = "microphone"
break
case DeviceKind.AudioOutput:
label = "speaker"
break
case DeviceKind.VideoInput:
label = "camera"
break
}
pushToast({theme: "error", message: `Error changing ${label}`})
}
}
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
export const currentVoiceRoom = writable<Room | undefined>(undefined)
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
const addParticipant = (identity: string) => {
participantPubkeyMap.update(m => {
const next = new Map(m)
@@ -65,24 +91,6 @@ const deleteParticipant = (identity: string) => {
})
}
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
const pk = pubkeyFromLiveKitIdentity(identity)
return pk ? {pubkey: pk, identity} : {identity}
}
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
export const speakingParticipants = writable<VoiceParticipant[]>([])
export const isParticipantSpeaking = derived(
speakingParticipants,
$participants => (p: VoiceParticipant) =>
$participants.some(sp => participantKey(sp) === participantKey(p)),
)
const fetchLivekitToken = async (
url: string,
groupId: string,
@@ -164,7 +172,9 @@ const setUpMicrophone = async (
}
const onRoomDisconnected = (reason?: DisconnectReason) => {
videoPrimaryTileKey.set(undefined)
currentVoiceSession.set(undefined)
resetVideoCallLayout()
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
voiceState.set(VoiceState.Disconnected)
const message =
@@ -183,11 +193,16 @@ const onTrackSubscribed = (track: Track) => {
element.style.display = "none"
document.body.appendChild(element)
element.play().catch(() => {})
} else if (track.kind === Track.Kind.Video) {
triggerVideoFeedCount()
}
}
const onTrackUnsubscribed = (track: Track) => {
track.detach().forEach(el => el.remove())
if (track.kind === Track.Kind.Video) {
triggerVideoFeedCount()
}
}
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
@@ -208,6 +223,17 @@ const onParticipantDisconnected = (participant: {identity: string}) => {
deleteParticipant(participant.identity)
}
const onLocalTrackUnpublished = (
publication: LocalTrackPublication,
participant: LocalParticipant,
) => {
if (publication.source !== Track.Source.ScreenShare) return
const session = get(currentVoiceSession)
if (!session || participant.identity !== session.room.localParticipant.identity) return
if (!session.screenShareOn) return
currentVoiceSession.set({...session, screenShareOn: false})
}
let joinAbortController: AbortController | undefined
export const cancelJoinVoiceRoom = () => {
@@ -245,12 +271,13 @@ export const joinVoiceRoom = async (
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
liveKitRoom.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
try {
await Promise.race([
liveKitRoom.connect(server_url, participant_token, {maxRetries: 0}),
whenTimeout(5_000, {
whenTimeout(15_000, {
message: "Connection timed out. Please check your network and try again.",
}),
whenAborted(signal),
@@ -268,7 +295,14 @@ export const joinVoiceRoom = async (
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
currentVoiceSession.set({url, h, room: liveKitRoom, muted})
currentVoiceSession.set({
url,
h,
room: liveKitRoom,
muted,
cameraOn: false,
screenShareOn: false,
})
voiceState.set(VoiceState.Connected)
playJoinSound()
} catch (e) {
@@ -287,8 +321,26 @@ export const leaveVoiceRoom = async () => {
const audio = new Audio("/leave-voice-room.mp3")
audio.play().catch(() => {})
if (session.cameraOn) {
try {
await session.room.localParticipant.setCameraEnabled(false)
} catch {
pushToast({theme: "error", message: "Error turning off camera."})
}
}
if (session.screenShareOn) {
try {
await session.room.localParticipant.setScreenShareEnabled(false)
} catch {
pushToast({theme: "error", message: "Error turning off screen sharing."})
}
}
voiceState.set(VoiceState.Disconnected)
videoPrimaryTileKey.set(undefined)
currentVoiceSession.set(undefined)
resetVideoCallLayout()
session.room.disconnect()
speakingParticipants.set([])
participantPubkeyMap.set(new Map())
@@ -38,7 +38,7 @@
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex flex-grow flex-wrap justify-end gap-2">
<div class="flex grow flex-wrap justify-end gap-2">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<RoomName {h} {url} />
@@ -7,12 +7,13 @@
type Props = {
url: string
h?: string
shareToChat?: boolean
}
const {url, h}: Props = $props()
const {url, h, shareToChat = false}: Props = $props()
</script>
<CalendarEventForm {url} {h}>
<CalendarEventForm {url} {h} {shareToChat}>
{#snippet header()}
<ModalHeader>
<ModalTitle>Create an Event</ModalTitle>
+80 -36
View File
@@ -3,7 +3,7 @@
import {writable} from "svelte/store"
import {randomId, HOUR} from "@welshman/lib"
import {makeEvent, EVENT_TIME} from "@welshman/util"
import {publishThunk} from "@welshman/app"
import {publishThunk, waitForThunkError} from "@welshman/app"
import {preventDefault} from "@lib/html"
import {daysBetween} from "@lib/util"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
@@ -20,24 +20,34 @@
import EditorContent from "@app/editor/EditorContent.svelte"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {pushToast} from "@app/util/toast"
import {canEnforceNip70} from "@app/core/commands"
import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
type Values = {
d: string
title: string
content: string | object
location: string
start?: number
end?: number
}
type Props = {
url: string
h?: string
shareToChat?: boolean
header: Snippet
initialValues?: {
d: string
title: string
content: string
location: string
start: number
end: number
}
initialValues?: Values
}
const {url, h, header, initialValues}: Props = $props()
let {url, h, shareToChat = false, header, initialValues}: Props = $props()
const draftKey = new DraftKey<Values>(`calendar:${url}:${h ?? ""}`)
if (!initialValues) {
initialValues = draftKey.get()
}
const shouldProtect = canEnforceNip70(url)
@@ -48,7 +58,7 @@
const selectFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const submit = async () => {
if ($uploading) return
if ($uploading || loading) return
if (!title) {
return pushToast({
@@ -74,38 +84,68 @@
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = [
["d", initialValues?.d || randomId()],
["d", d],
["title", title],
["location", location || ""],
["location", location],
["start", start.toString()],
["end", end.toString()],
...daysBetween(start, end).map(D => ["D", D.toString()]),
...ed.storage.nostr.getEditorTags(),
]
if (await shouldProtect) {
tags.push(PROTECTED)
loading = true
try {
const protect = await shouldProtect
if (protect) {
tags.push(PROTECTED)
}
if (h) {
tags.push(["h", h])
}
const event = makeEvent(EVENT_TIME, {content, tags})
const calendarThunk = publishThunk({event, relays: [url]})
const error = await waitForThunkError(calendarThunk)
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
history.back()
if (shareToChat) {
publishRoomQuote({url, h, parent: calendarThunk.event, protect})
}
pushToast({message: "Your event has been saved!"})
} finally {
loading = false
}
if (h) {
tags.push(["h", h])
}
const event = makeEvent(EVENT_TIME, {content, tags})
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 loading = $state(false)
let title = $state(initialValues?.title || "")
let location = $state(initialValues?.location || "")
const d = $state(initialValues?.d ?? randomId())
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)
let endDirty = $state(Boolean(initialValues?.end))
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({url, submit, uploading, onChange, content})
$effect(() => {
draftKey.set({d, title, location, start, end, content})
})
$effect(() => {
if (!endDirty && start) {
@@ -136,10 +176,14 @@
{#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">
<div class="input-editor grow overflow-hidden">
<EditorContent {editor} />
</div>
<Button data-tip="Add an image" class="center btn tooltip" onclick={selectFiles}>
<Button
data-tip="Add an image"
class="center btn tooltip"
onclick={selectFiles}
disabled={loading}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
@@ -178,12 +222,12 @@
</Field>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Button class="btn btn-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={$uploading}>
<Spinner loading={$uploading}>Save Event</Spinner>
<Button type="submit" class="btn btn-primary" disabled={$uploading || loading}>
<Spinner loading={$uploading || loading}>Save Event</Spinner>
</Button>
</ModalFooter>
</Modal>
@@ -19,15 +19,17 @@
const end = $derived(parseInt(meta.end))
</script>
<div class="flex flex-grow flex-wrap justify-between gap-2">
<p class="text-xl">{meta.title || meta.name}</p>
<div class="flex flex-col justify-between gap-1">
<p class="text-lg">{meta.title || meta.name}</p>
{#if !isNaN(start) && !isNaN(end)}
{@const startDateDisplay = formatTimestampAsDate(start)}
{@const endDateDisplay = formatTimestampAsDate(end)}
{@const isSingleDay = startDateDisplay === endDateDisplay}
<div class="flex items-center gap-2 text-sm">
<Icon icon={ClockCircle} size={4} />
<span class="hidden sm:block">{formatTimestampAsDate(start)}</span>
<div class="flex flex-wrap gap-2 text-xs">
<div class="flex items-center gap-2">
<Icon icon={ClockCircle} size={4} />
{formatTimestampAsDate(start)}
</div>
{formatTimestampAsTime(start)}{isSingleDay
? formatTimestampAsTime(end)
: formatTimestamp(end)}
+1 -1
View File
@@ -23,7 +23,7 @@
{#if meta.location}
<span class="flex items-start gap-1">
<Icon icon={MapPoint} class="mt-[2px]" size={4} />
<span class="break-words">{meta.location}</span>
<span class="wrap-break-word">{meta.location}</span>
</span>
{/if}
</div>
+12 -23
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {onMount} from "svelte"
import {goto} from "$app/navigation"
import {
ago,
int,
@@ -52,8 +53,9 @@
import ChatComposeEdit from "@app/components/ChatComposeEdit.svelte"
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import {userSettingsValues, deriveChat} from "@app/core/state"
import {userSettingsValues, deriveChat, makeChatId} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {DraftKey} from "@app/util/drafts"
import {makeDelete, prependParent} from "@app/core/commands"
import {pushToast} from "@app/util/toast"
@@ -64,7 +66,9 @@
const {pubkeys, info}: Props = $props()
const chat = deriveChat(pubkeys)
const chatId = makeChatId(pubkeys)
const chat = deriveChat(chatId)
const draftKey = new DraftKey<{content?: string | object}>(`dm:${chatId}`)
const others = remove($pubkey!, pubkeys)
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
@@ -73,7 +77,7 @@
? pushModal(ProfileDetail, {pubkey: others[0]})
: pushModal(ChatMembers, {pubkeys: others})
const back = () => history.back()
const back = () => goto("/chat")
const replyTo = (event: TrustedEvent) => {
parent = event
@@ -195,8 +199,6 @@
let compose: ChatCompose | undefined = $state()
let parent: TrustedEvent | undefined = $state()
let eventToEdit: TrustedEvent | undefined = $state()
let chatCompose: HTMLElement | undefined = $state()
let dynamicPadding: HTMLElement | undefined = $state()
const elements = $derived.by(() => {
const elements = []
@@ -232,20 +234,6 @@
for (const pubkey of others) {
loadMessagingRelayList(pubkey)
}
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(() => {
@@ -292,8 +280,7 @@
</div>
</PageBar>
<PageContent class="flex flex-col-reverse gap-2 pt-4">
<div bind:this={dynamicPadding}></div>
<PageContent class="flex flex-col-reverse gap-2 py-4">
{#if missingRelayLists.length > 0}
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
@@ -334,9 +321,10 @@
</Spinner>
{@render info?.()}
</p>
<div class="h-screen"></div>
</PageContent>
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
<div class="chat__compose bg-base-200">
<div>
{#if parent}
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
@@ -351,7 +339,8 @@
{onSubmit}
{onEscape}
{onEditPrevious}
content={eventToEdit?.content}
initialValues={eventToEdit}
draftKey={eventToEdit ? undefined : draftKey}
disabled={Boolean(missingRelayLists.length)} />
{/key}
</div>
+33 -5
View File
@@ -10,23 +10,40 @@
import Button from "@lib/components/Button.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
import {type DraftKey} from "@app/util/drafts"
type Values = {
content?: string | object
}
type Props = {
content?: string
disabled?: boolean
draftKey?: DraftKey<Values>
onEscape?: () => void
onEditPrevious?: () => void
onSubmit: (event: EventContent) => void
initialValues?: Values
}
const {content, disabled = false, onEscape, onEditPrevious, onSubmit}: Props = $props()
let {
initialValues,
disabled = false,
draftKey,
onEscape,
onEditPrevious,
onSubmit,
}: Props = $props()
if (!initialValues) {
initialValues = draftKey?.get()
}
const autofocus = !isMobile && !disabled
const uploading = writable(false)
const editorClass = $derived(
cx("chat-editor flex-grow overflow-hidden", {
cx("chat-editor grow overflow-hidden", {
"pointer-events-none opacity-50": disabled,
}),
)
@@ -59,18 +76,29 @@
onSubmit({content, tags})
draftKey?.clear()
ed.chain().clearContent().run()
}
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({
content,
autofocus,
submit,
uploading,
onChange,
aggressive: true,
encryptFiles: true,
})
$effect(() => {
draftKey?.set({content})
})
onMount(async () => {
const ed = await editor
ed.view.dom.addEventListener("keydown", handleKeyDown)
@@ -95,7 +123,7 @@
{/if}
</Button>
<div class={editorClass} aria-disabled={disabled}>
<EditorContent {editor} />
<EditorContent {autofocus} {editor} />
</div>
<Button
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
+1 -1
View File
@@ -35,7 +35,7 @@
<Button class="flex flex-col justify-start gap-1 w-full" onclick={openChat}>
<div
class="cursor-pointer border-t border-solid border-base-100 px-6 py-2 transition-colors hover:bg-base-100 {props.class}"
class="cursor-pointer border-t border-solid border-base-100 px-3 py-2 transition-colors hover:bg-base-100 {props.class}"
class:bg-base-100={active}>
<div class="flex flex-col justify-start gap-1">
<div class="flex items-center justify-between gap-2">
-9
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import {assoc} from "@welshman/lib"
import ChatSquare from "@assets/icons/chat-square.svg?dataurl"
import Check from "@assets/icons/check.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
import BellOff from "@assets/icons/bell-off.svg?dataurl"
@@ -8,13 +7,9 @@
import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ChatStart from "@app/components/ChatStart.svelte"
import {setChecked} from "@app/util/notifications"
import {pushModal} from "@app/util/modal"
import {notificationSettings} from "@app/core/state"
const startChat = () => pushModal(ChatStart, {}, {replaceState: true})
const markAsRead = () => {
setChecked("/chat/*")
history.back()
@@ -28,10 +23,6 @@
<Modal>
<ModalBody>
<div class="flex flex-col gap-2">
<Button class="btn btn-primary" onclick={startChat}>
<Icon size={5} icon={ChatSquare} />
Start chat
</Button>
<Button class="btn btn-neutral" onclick={markAsRead}>
<Icon size={5} icon={Check} />
Mark all read
+1 -1
View File
@@ -42,7 +42,7 @@
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex flex-grow flex-wrap justify-end gap-2">
<div class="flex grow flex-wrap justify-end gap-2">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<RoomName {h} {url} />
+3 -2
View File
@@ -7,12 +7,13 @@
type Props = {
url: string
h?: string
shareToChat?: boolean
}
const {url, h}: Props = $props()
const {url, h, shareToChat = false}: Props = $props()
</script>
<ClassifiedForm {url} {h}>
<ClassifiedForm {url} {h} {shareToChat}>
{#snippet header()}
<ModalHeader>
<ModalTitle>Create a Classified Listing</ModalTitle>
+58 -26
View File
@@ -2,7 +2,7 @@
import type {Snippet} from "svelte"
import {removeUndefined, randomId, uniq} from "@welshman/lib"
import {makeEvent, CLASSIFIED} from "@welshman/util"
import {publishThunk} from "@welshman/app"
import {publishThunk, waitForThunkError} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html"
import {normalizeTopic} from "@lib/util"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
@@ -20,25 +20,35 @@
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {canEnforceNip70, uploadFile} from "@app/core/commands"
import {DraftKey} from "@app/util/drafts"
import {canEnforceNip70, publishRoomQuote, uploadFile} from "@app/core/commands"
type Values = {
d: string
title: string
content: string | object
price: number
currency: string
images: (string | File)[]
status: string
topics: string[]
}
type Props = {
url: string
h?: string
shareToChat?: boolean
header: Snippet
initialValues?: {
d?: string
title?: string
content?: string
price?: number
currency?: string
images?: string[]
status?: string
topics?: string[]
}
initialValues?: Values
}
const {url, h, header, initialValues}: Props = $props()
let {url, h, shareToChat = false, header, initialValues}: Props = $props()
const draftKey = new DraftKey<Values>(`classified:${url}:${h ?? ""}`)
if (!initialValues) {
initialValues = draftKey.get()
}
const shouldProtect = canEnforceNip70(url)
@@ -66,7 +76,7 @@
}
const tags = [
["d", initialValues?.d || randomId()],
["d", d],
["title", title],
["summary", content],
["price", String(price), currency],
@@ -78,7 +88,9 @@
tags.push(["t", topic])
}
if (await shouldProtect) {
const protect = await shouldProtect
if (protect) {
tags.push(PROTECTED)
}
@@ -105,27 +117,47 @@
}
}
publishThunk({
const classifiedThunk = publishThunk({
relays: [url],
event: makeEvent(CLASSIFIED, {content, tags}),
})
const error = await waitForThunkError(classifiedThunk)
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
history.back()
if (shareToChat) {
publishRoomQuote({url, h, parent: classifiedThunk.event, protect})
}
} finally {
loading = false
}
}
const content = initialValues?.content || ""
const editor = makeEditor({url, submit, content})
let loading = $state(false)
let title = $state(initialValues?.title || "")
let status = $state(initialValues?.status || "active")
let price = $state(Number(initialValues?.price || 0))
let currency = $state(initialValues?.currency || "SAT")
let images = $state<(string | File)[]>(initialValues?.images || [])
let topics = $state(uniq(removeUndefined((initialValues?.topics || []).map(normalizeTopic))))
const d = $state(initialValues?.d ?? randomId())
let title = $state(initialValues?.title ?? "")
let status = $state(initialValues?.status ?? "active")
let price = $state(initialValues?.price ?? 0)
let currency = $state(initialValues?.currency ?? "SAT")
let images = $state(initialValues?.images ?? [])
let topics = $state(uniq(removeUndefined(initialValues?.topics?.map(normalizeTopic) ?? [])))
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({url, submit, onChange, content})
$effect(() => {
draftKey.set({d, title, status, price, currency, images, topics, content})
})
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
@@ -153,7 +185,7 @@
<p>Description*</p>
{/snippet}
{#snippet input()}
<div class="note-editor flex-grow overflow-hidden">
<div class="note-editor grow overflow-hidden">
<EditorContent {editor} />
</div>
{/snippet}
+1 -1
View File
@@ -28,7 +28,7 @@
</script>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2">
<div class="flex grow flex-wrap justify-end gap-2">
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
+14 -4
View File
@@ -4,6 +4,7 @@
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
import Revote from "@assets/icons/revote.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import {pushModal} from "@app/util/modal"
@@ -11,6 +12,7 @@
import ThreadCreate from "@app/components/ThreadCreate.svelte"
import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte"
import GoalCreate from "@app/components/GoalCreate.svelte"
import PollCreate from "@app/components/PollCreate.svelte"
type Props = {
url: string
@@ -20,13 +22,15 @@
const {url, h, onClick}: Props = $props()
const createGoal = () => pushModal(GoalCreate, {url, h})
const createGoal = () => pushModal(GoalCreate, {url, h, shareToChat: true})
const createCalendarEvent = () => pushModal(CalendarEventCreate, {url, h})
const createCalendarEvent = () => pushModal(CalendarEventCreate, {url, h, shareToChat: true})
const createThread = () => pushModal(ThreadCreate, {url, h})
const createThread = () => pushModal(ThreadCreate, {url, h, shareToChat: true})
const createClassified = () => pushModal(ClassifiedCreate, {url, h})
const createClassified = () => pushModal(ClassifiedCreate, {url, h, shareToChat: true})
const createPoll = () => pushModal(PollCreate, {url, h, shareToChat: true})
let ul: Element
@@ -60,4 +64,10 @@
Create Thread
</Button>
</li>
<li>
<Button onclick={createPoll}>
<Icon size={4} icon={Revote} />
Ask a Question
</Button>
</li>
</ul>
+5 -1
View File
@@ -6,6 +6,7 @@
truncate,
renderAsHtml,
isText,
isEmail,
isEmoji,
isTopic,
isCode,
@@ -26,6 +27,7 @@
import Button from "@lib/components/Button.svelte"
import ContentToken from "@app/components/ContentToken.svelte"
import ContentEmoji from "@app/components/ContentEmoji.svelte"
import ContentEmail from "@app/components/ContentEmail.svelte"
import ContentCode from "@app/components/ContentCode.svelte"
import ContentLinkInline from "@app/components/ContentLinkInline.svelte"
import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte"
@@ -150,7 +152,7 @@
</div>
{:else}
<div
class="overflow-hidden text-ellipsis break-words"
class="overflow-hidden text-ellipsis wrap-break-word"
style={expandBlock ? "mask-image: linear-gradient(0deg, transparent 0px, black 100px)" : ""}>
{#each shortContent as parsed, i}
{#if isNewline(parsed) && !isBlock(i - 1)}
@@ -159,6 +161,8 @@
<ContentTopic value={parsed.value} />
{:else if isEmoji(parsed)}
<ContentEmoji value={parsed.value} />
{:else if isEmail(parsed)}
<ContentEmail value={parsed.value} />
{:else if isCode(parsed)}
<ContentCode
value={parsed.value}
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts">
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
export let value: string
</script>
<Link external href="mailto:{value}">
<Icon icon={LinkRound} size={3} />
{value}
</Link>
+69 -41
View File
@@ -1,27 +1,44 @@
<script lang="ts">
import {call, ellipsize, displayUrl, postJson} from "@welshman/lib"
import {isRelayUrl, getTagValue} from "@welshman/util"
import {Capacitor} from "@capacitor/core"
import {preventDefault, stopPropagation} from "@lib/html"
import Link from "@lib/components/Link.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import ContentLinkUrl from "@app/components/ContentLinkUrl.svelte"
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
import {pushModal} from "@app/util/modal"
import {dufflepud, PLATFORM_URL, IMAGE_CONTENT_TYPES, VIDEO_CONTENT_TYPES} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
import {
dufflepud,
IMAGE_CONTENT_TYPES,
PLATFORM_URL,
VIDEO_CONTENT_TYPES,
THUMBNAIL_URL,
isRoomId,
} from "@app/core/state"
const {value, event} = $props()
let hideImage = $state(false)
const url = value.url.toString()
const fileType = getTagValue("file-type", event.tags) || ""
const isRoomOrRelay = isRoomId(url) || isRelayUrl(url)
const [href, external] = call(() => {
if (isRelayUrl(url)) return [makeSpacePath(url), false]
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
return [url, true]
})
const fileType = getTagValue("file-type", event.tags) || ""
const getVideoPoster = (videoUrl: string): string | undefined => {
if (Capacitor.getPlatform() === "android" && THUMBNAIL_URL) {
return `${THUMBNAIL_URL}/thumbnail?url=${encodeURIComponent(videoUrl)}`
}
return undefined
}
const loadPreview = async () => {
const json = await postJson(dufflepud("link/preview"), {url})
@@ -39,41 +56,52 @@
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
</script>
<Link {external} {href} class="my-2 block">
<div class="overflow-hidden rounded-box">
{#if url.match(/\.(mov|webm|mp4)$/) || VIDEO_CONTENT_TYPES.includes(fileType)}
<video controls src={url} class="max-h-96 rounded-box object-contain object-center">
<track kind="captions" />
</video>
{:else if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)}
<button type="button" onclick={stopPropagation(preventDefault(expand))}>
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-box" />
</button>
{:else}
{#await loadPreview()}
<div class="center my-12 w-full">
<span class="loading loading-spinner"></span>
</div>
{:then preview}
<div class="bg-alt flex max-w-xl flex-col leading-normal">
{#if preview.image && !hideImage}
<img
alt=""
onerror={onError}
src={preview.image}
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">
Unable to load a preview for {url}
</p>
{/await}
{/if}
{#if isRoomOrRelay}
<div>
<ContentLinkUrl {url} class="link-content whitespace-nowrap" />
</div>
</Link>
{:else}
<Link {external} {href} class="my-2 block">
<div class="overflow-hidden rounded-box">
{#if url.match(/\.(mov|webm|mp4)$/) || VIDEO_CONTENT_TYPES.includes(fileType)}
<video
controls
src={url}
poster={getVideoPoster(url)}
preload="metadata"
class="max-h-96 rounded-box object-contain object-center">
<track kind="captions" />
</video>
{:else if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)}
<button type="button" onclick={stopPropagation(preventDefault(expand))}>
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-box" />
</button>
{:else}
{#await loadPreview()}
<div class="center my-12 w-full">
<span class="loading loading-spinner"></span>
</div>
{:then preview}
<div class="bg-alt flex max-w-xl flex-col leading-normal">
{#if preview.image && !hideImage}
<img
alt=""
onerror={onError}
src={preview.image}
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">
Unable to load a preview for {url}
</p>
{/await}
{/if}
</div>
</Link>
{/if}
+5 -15
View File
@@ -1,25 +1,18 @@
<script lang="ts">
import {call, displayUrl} from "@welshman/lib"
import {isRelayUrl, getTagValue} from "@welshman/util"
import {displayUrl} from "@welshman/lib"
import {getTagValue} from "@welshman/util"
import {preventDefault, stopPropagation} from "@lib/html"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import ContentLinkUrl from "@app/components/ContentLinkUrl.svelte"
import {pushModal} from "@app/util/modal"
import {PLATFORM_URL, IMAGE_CONTENT_TYPES} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
import {IMAGE_CONTENT_TYPES} from "@app/core/state"
const {value, event} = $props()
const url = value.url.toString()
const fileType = getTagValue("file-type", event.tags) || ""
const [href, external] = call(() => {
if (isRelayUrl(url)) return [makeSpacePath(url), false]
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
return [url, true]
})
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
</script>
@@ -34,8 +27,5 @@
{displayUrl(url)}
</a>
{:else}
<Link {external} {href} class="link-content whitespace-nowrap">
<Icon icon={LinkRound} size={3} class="inline-block" />
{displayUrl(url)}
</Link>
<ContentLinkUrl {url} class="link-content whitespace-nowrap" />
{/if}
+59
View File
@@ -0,0 +1,59 @@
<script lang="ts">
import {call, displayUrl} from "@welshman/lib"
import {displayRelayUrl, isRelayUrl, normalizeRelayUrl} from "@welshman/util"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import {PLATFORM_URL, displayRoom, isRoomId, splitRoomId} from "@app/core/state"
import {makeRoomPath, makeSpacePath} from "@app/util/routes"
const {
url,
class: className = "",
}: {
url: string
class?: string
} = $props()
const roomReference = call(() => {
if (!isRoomId(url)) {
return undefined
}
const [roomUrl, h] = splitRoomId(url)
if (!roomUrl || !h || !isRelayUrl(roomUrl)) {
return undefined
}
return {url: normalizeRelayUrl(roomUrl), h}
})
const relayReference = call(() => {
if (roomReference || !isRelayUrl(url)) {
return undefined
}
return normalizeRelayUrl(url)
})
const [href, external] = call(() => {
if (roomReference) return [makeRoomPath(roomReference.url, roomReference.h), false]
if (relayReference) return [makeSpacePath(relayReference), false]
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
return [url, true]
})
</script>
<Link {external} {href} class={className}>
{#if roomReference}
~<span class="text-primary">{displayRelayUrl(roomReference.url)}</span> /
{displayRoom(roomReference.url, roomReference.h)}
{:else if relayReference}
<span class="text-primary">{displayRelayUrl(relayReference)}</span>
{:else}
<Icon icon={LinkRound} size={3} class="inline-block" />
{displayUrl(url)}
{/if}
</Link>
+5 -1
View File
@@ -7,6 +7,7 @@
renderAsHtml,
isText,
isEmoji,
isEmail,
isTopic,
isCode,
isCashu,
@@ -24,6 +25,7 @@
import Button from "@lib/components/Button.svelte"
import ContentToken from "@app/components/ContentToken.svelte"
import ContentEmoji from "@app/components/ContentEmoji.svelte"
import ContentEmail from "@app/components/ContentEmail.svelte"
import ContentCode from "@app/components/ContentCode.svelte"
import ContentLinkInline from "@app/components/ContentLinkInline.svelte"
import ContentNewline from "@app/components/ContentNewline.svelte"
@@ -101,7 +103,7 @@
</p>
</div>
{:else}
<div class="overflow-hidden text-ellipsis break-words">
<div class="overflow-hidden text-ellipsis wrap-break-word">
{#each shortContent as parsed, i}
{#if isNewline(parsed)}
<ContentNewline value={parsed.value} />
@@ -109,6 +111,8 @@
<ContentTopic value={parsed.value} />
{:else if isEmoji(parsed)}
<ContentEmoji value={parsed.value} />
{:else if isEmail(parsed)}
<ContentEmail value={parsed.value} />
{:else if isCode(parsed)}
<ContentCode
value={parsed.value}
+2 -2
View File
@@ -45,11 +45,11 @@
{#if $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%);">
style="background-color: color-mix(in srgb, var(--color-primary) 10%, var(--color-base-300) 90%);">
<NoteContentMinimal trimParent {url} event={$quote} />
</div>
{:else}
<NoteCard event={$quote} {url} class="bg-alt rounded-box p-4">
<NoteCard noShadow event={$quote} {url} class="bg-alt rounded-box p-4">
<NoteContentMinimal {url} event={$quote} />
</NoteCard>
{/if}
+3 -2
View File
@@ -42,7 +42,7 @@
let popover: Instance | undefined = $state()
</script>
<Button class="join rounded-full">
<div class="join items-center rounded-full">
{#if ENABLE_ZAPS && !hideZap}
<ZapButton {url} {event} class="btn join-item btn-neutral btn-xs">
<Icon icon={Bolt} size={4} />
@@ -52,6 +52,7 @@
<Icon icon={SmileCircle} size={4} />
</EmojiButton>
<Tippy
class="flex"
bind:popover
component={EventMenu}
props={{url, noun, event, customActions, onClick: hidePopover}}
@@ -60,4 +61,4 @@
<Icon icon={MenuDots} size={4} />
</Button>
</Tippy>
</Button>
</div>
+2 -2
View File
@@ -101,7 +101,7 @@
{/if}
<div class="relative">
<pre class="card2 card2-sm bg-alt overflow-auto text-xs"><code>{json}</code></pre>
<p class="absolute right-2 top-2 flex flex-grow items-center justify-between">
<p class="absolute right-2 top-2 flex grow items-center justify-between">
<Button onclick={copyJson} class="btn btn-neutral btn-sm flex items-center">
<Icon icon={Copy} /> Copy
</Button>
@@ -109,6 +109,6 @@
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-primary flex-grow" onclick={() => history.back()}>Got it</Button>
<Button class="btn btn-primary grow" onclick={() => history.back()}>Got it</Button>
</ModalFooter>
</Modal>
+28 -8
View File
@@ -10,13 +10,19 @@
import {publishComment, canEnforceNip70} from "@app/core/commands"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {pushToast} from "@app/util/toast"
type Values = {
content?: string | object
}
const {url, event, onClose, onSubmit} = $props()
const draftKey = new DraftKey<Values>(`reply:${event.id}`)
const initialValues = draftKey.get()
const shouldProtect = canEnforceNip70(url)
const uploading = writable(false)
const autofocus = !isMobile
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
@@ -38,13 +44,23 @@
})
}
draftKey.clear()
onSubmit(publishComment({event, content, tags, relays: [url]}))
}
const editor = makeEditor({url, submit, uploading, autofocus: !isMobile})
let form: HTMLElement
let spacer: HTMLElement
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({url, submit, uploading, content, onChange})
$effect(() => {
draftKey.set({content})
})
onMount(() => {
setTimeout(() => {
@@ -52,7 +68,7 @@
})
const observer = new ResizeObserver(() => {
spacer!.style.minHeight = `${form!.offsetHeight}px`
spacer!.style.minHeight = `${form!.offsetHeight + 60}px`
})
observer.observe(form!)
@@ -64,11 +80,15 @@
</script>
<div bind:this={spacer}></div>
<form in:fly bind:this={form} onsubmit={preventDefault(submit)} class="cb cw fixed z-feature pt-3">
<form
in:fly
bind:this={form}
onsubmit={preventDefault(submit)}
class="left-content bottom-sai right-sai fixed z-feature mb-14 md:mb-0 w-full md:w-auto pr-2">
<div class="card2 mx-2 my-2 bg-alt shadow-md">
<div class="relative">
<div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} />
<div class="note-editor grow overflow-hidden">
<EditorContent {autofocus} {editor} />
</div>
<Button
data-tip="Add an image"
+1 -1
View File
@@ -30,7 +30,7 @@
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex flex-grow flex-wrap justify-end gap-2">
<div class="flex grow flex-wrap justify-end gap-2">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<RoomName {h} {url} />
+90 -34
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import {writable} from "svelte/store"
import {makeEvent, ZAP_GOAL} from "@welshman/util"
import {publishThunk} from "@welshman/app"
import {publishThunk, waitForThunkError} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import Bolt from "@assets/icons/bolt.svg?dataurl"
@@ -10,6 +10,7 @@
import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
@@ -20,14 +21,29 @@
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {canEnforceNip70} from "@app/core/commands"
import {DraftKey} from "@app/util/drafts"
import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
type Values = {
title: string
content: string | object
amount: number
}
type Props = {
url: string
h?: string
initialValues?: Values
shareToChat?: boolean
}
const {url, h}: Props = $props()
let {url, h, initialValues, shareToChat = false}: Props = $props()
const draftKey = new DraftKey<Values>(`goal:${url}:${h ?? ""}`)
if (!initialValues) {
initialValues = draftKey.get()
}
const shouldProtect = canEnforceNip70(url)
@@ -38,9 +54,9 @@
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => {
if ($uploading) return
if ($uploading || loading) return
if (!content) {
if (!title) {
return pushToast({
theme: "error",
message: "Please provide a title for your funding goal.",
@@ -48,9 +64,9 @@
}
const ed = await editor
const summary = ed.getText({blockSeparator: "\n"}).trim()
const content = ed.getText({blockSeparator: "\n"}).trim()
if (!summary.trim()) {
if (!content.trim()) {
return pushToast({
theme: "error",
message: "Please provide details about your funding goal.",
@@ -59,31 +75,68 @@
const tags = [
...ed.storage.nostr.getEditorTags(),
["summary", summary],
["summary", content],
["amount", String(amount)],
["relays", url],
]
if (await shouldProtect) {
tags.push(PROTECTED)
loading = true
try {
const protect = await shouldProtect
if (protect) {
tags.push(PROTECTED)
}
if (h) {
tags.push(["h", h])
}
const goalThunk = publishThunk({
relays: [url],
event: makeEvent(ZAP_GOAL, {content: title, tags}),
})
const error = await waitForThunkError(goalThunk)
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
history.back()
if (shareToChat) {
publishRoomQuote({url, h, parent: goalThunk.event, protect})
}
} finally {
loading = false
}
if (h) {
tags.push(["h", h])
}
publishThunk({
relays: [url],
event: makeEvent(ZAP_GOAL, {content, tags}),
})
history.back()
}
const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"})
let loading = $state(false)
let content = $state("")
let amount = $state(1000)
let title = $state(initialValues?.title ?? "")
let amount = $state(initialValues?.amount ?? 1000)
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({
url,
submit,
uploading,
onChange,
placeholder: "What's on your mind?",
content,
})
$effect(() => {
draftKey.update({title, content, amount})
})
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
@@ -102,7 +155,7 @@
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus={!isMobile}
bind:value={content}
bind:value={title}
class="grow"
type="text"
placeholder="What do funds go towards?" />
@@ -115,7 +168,7 @@
<p>Details*</p>
{/snippet}
{#snippet input()}
<div class="note-editor flex-grow overflow-hidden">
<div class="note-editor grow overflow-hidden">
<EditorContent {editor} />
</div>
{/snippet}
@@ -123,7 +176,8 @@
<Button
data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2"
onclick={selectFiles}>
onclick={selectFiles}
disabled={loading}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
@@ -137,17 +191,17 @@
Goal Amount (sats)*
{/snippet}
{#snippet input()}
<div class="flex flex-grow justify-end">
<label class="input input-bordered flex items-center gap-2">
<div class="flex grow justify-end">
<label class="input input-bordered flex w-auto items-center gap-2">
<Icon icon={Bolt} />
<input bind:value={amount} type="number" class="w-28" />
<p class="opacity-50">sats</p>
<input bind:value={amount} type="number" class="w-28 grow" />
<p class="shrink-0 opacity-50">sats</p>
</label>
</div>
{/snippet}
</FieldInline>
<input
class="range range-primary -mt-2"
class="range range-primary -mt-2 w-full"
type="range"
min="1000"
max="100000"
@@ -157,10 +211,12 @@
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Button class="btn btn-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary">Create Goal</Button>
<Button type="submit" class="btn btn-primary" disabled={$uploading || loading}>
<Spinner {loading}>Create Goal</Spinner>
</Button>
</ModalFooter>
</Modal>
+1 -1
View File
@@ -23,7 +23,7 @@
<ModalTitle>Unable to Zap</ModalTitle>
</ModalHeader>
<p>
Zapping <ProfileLink {pubkey} class="!text-primary" /> isn't possible right now because
Zapping <ProfileLink {pubkey} class="text-primary!" /> isn't possible right now because
{#if $zapper}
their zap receiver isn't correctly set up.
{:else}
@@ -97,10 +97,10 @@
tabindex="-1"
onmousedown={stopPropagation(onClear)}
ontouchstart={stopPropagation(onClear)}>
<Icon icon={CloseCircle} class="scale-150 !bg-base-300" />
<Icon icon={CloseCircle} class="scale-150 bg-base-300!" />
</span>
{:else}
<Icon icon={AddCircle} class="scale-150 !bg-base-300" />
<Icon icon={AddCircle} class="scale-150 bg-base-300!" />
{/if}
</div>
{#if !url}
+7 -1
View File
@@ -15,6 +15,7 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProgressBar from "@app/components/ProgressBar.svelte"
import {pushToast} from "@app/util/toast"
import {PLATFORM_NAME} from "@app/core/state"
@@ -22,9 +23,11 @@
secret: string
next: () => unknown
submitText?: string
step?: number
totalSteps?: number
}
const {secret, next, submitText = "Continue"}: Props = $props()
const {secret, next, submitText = "Continue", step, totalSteps}: Props = $props()
const back = () => history.back()
@@ -150,6 +153,9 @@
</Button>
</div>
</ModalBody>
{#if step && totalSteps}
<ProgressBar current={step} total={totalSteps} />
{/if}
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
+1 -1
View File
@@ -3,7 +3,7 @@
import {Capacitor} from "@capacitor/core"
import {getNip07, getNip55, Nip55Signer} from "@welshman/signer"
import {addSession, type Session, makeNip07Session, makeNip55Session} from "@welshman/app"
import Widget from "@assets/icons/widget-2.svg?dataurl"
import Widget from "@assets/icons/widget-4.svg?dataurl"
import Letter from "@assets/icons/letter.svg?dataurl"
import Cpu from "@assets/icons/cpu-bolt.svg?dataurl"
import Compass from "@assets/icons/compass-big.svg?dataurl"
+11
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import {Capacitor} from "@capacitor/core"
import {onMount, onDestroy} from "svelte"
import type {Nip46ResponseWithResult} from "@welshman/signer"
import {Nip46Broker} from "@welshman/signer"
@@ -77,6 +78,7 @@
controller.stop()
loginWithNip46(pubkey, clientSecret, signerPubkey, relays)
setChecked("*")
} else {
return pushToast({
theme: "error",
@@ -102,10 +104,16 @@
mode = "connect"
}
const openSigner = () => {
controller.launchSigner()
}
const selectBunker = () => {
mode = "bunker"
}
const isIos = Capacitor.getPlatform() === "ios"
let mode: string = $state("bunker")
$effect(() => {
@@ -137,6 +145,9 @@
<BunkerUrl {controller} />
<Button class="btn {$bunker ? 'btn-neutral' : 'btn-primary'}" onclick={selectConnect}
>Log in with a QR code instead</Button>
{#if isIos}
<Button class="btn btn-neutral" onclick={openSigner}>Open in Signer</Button>
{/if}
{/if}
</ModalBody>
<ModalFooter>
+11 -3
View File
@@ -19,6 +19,7 @@
import LogInOTP from "@app/components/LogInOTP.svelte"
import LogInSelect from "@app/components/LogInSelect.svelte"
import {deleteDeactivatedPomadeSessions, loginWithPomade} from "@app/util/pomade"
import {getPomadeLoginFailureMessage, POMADE_NETWORK_ERROR_MESSAGE} from "@app/util/pomadeErrors"
import {pushModal, clearModals} from "@app/util/modal"
import {setChecked} from "@app/util/notifications"
import {pushToast} from "@app/util/toast"
@@ -44,7 +45,7 @@
return pushToast({
theme: "error",
message: "Sorry, we were unable to log you in.",
message: getPomadeLoginFailureMessage(messages),
})
}
@@ -64,10 +65,17 @@
pushToast({
theme: "error",
message: "Sorry, we were unable to log you in.",
message: getPomadeLoginFailureMessage(res.messages),
})
}
}
} catch (error) {
console.error("Login error:", error)
pushToast({
theme: "error",
message: POMADE_NETWORK_ERROR_MESSAGE,
})
} finally {
loading = false
}
@@ -90,7 +98,7 @@
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Letter} />
<input bind:value={email} />
<input type="email" bind:value={email} />
</label>
{/snippet}
</FieldInline>
+12 -2
View File
@@ -15,6 +15,7 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import LogInOTPConfirm from "@app/components/LogInOTPConfirm.svelte"
import {POMADE_NETWORK_ERROR_MESSAGE} from "@app/util/pomadeErrors"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
@@ -35,11 +36,20 @@
if (ok) {
pushModal(LogInOTPConfirm, {email, peersByPrefix})
} else {
console.error("Pomade challenge request failed during OTP login")
pushToast({
theme: "error",
message: "Sorry, we were unable to request a login code.",
message: POMADE_NETWORK_ERROR_MESSAGE,
})
}
} catch (error) {
console.error(error)
pushToast({
theme: "error",
message: POMADE_NETWORK_ERROR_MESSAGE,
})
} finally {
loading = false
}
@@ -61,7 +71,7 @@
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Letter} />
<input bind:value={email} />
<input type="email" bind:value={email} />
</label>
{/snippet}
</FieldInline>
+12 -4
View File
@@ -15,10 +15,11 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import StringMultiInput from "@lib/components/StringMultiInput.svelte"
import LogInSelect from "@app/components/LogInSelect.svelte"
import {pushToast} from "@app/util/toast"
import {setChecked} from "@app/util/notifications"
import {pushModal, clearModals} from "@app/util/modal"
import {setChecked} from "@app/util/notifications"
import {deleteDeactivatedPomadeSessions, loginWithPomade} from "@app/util/pomade"
import {getPomadeLoginFailureMessage, POMADE_NETWORK_ERROR_MESSAGE} from "@app/util/pomadeErrors"
import {pushToast} from "@app/util/toast"
type Props = {
email: string
@@ -44,7 +45,7 @@
return pushToast({
theme: "error",
message: "Sorry, we were unable to log you in.",
message: getPomadeLoginFailureMessage(messages),
})
}
@@ -64,10 +65,17 @@
pushToast({
theme: "error",
message: "Sorry, we were unable to log you in.",
message: getPomadeLoginFailureMessage(res.messages),
})
}
}
} catch (error) {
console.error("Login error:", error)
pushToast({
theme: "error",
message: POMADE_NETWORK_ERROR_MESSAGE,
})
} finally {
loading = false
}
+9 -1
View File
@@ -14,6 +14,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Profile from "@app/components/Profile.svelte"
import {deleteDeactivatedPomadeSessions, loginWithPomade} from "@app/util/pomade"
import {getPomadeLoginFailureMessage, POMADE_NETWORK_ERROR_MESSAGE} from "@app/util/pomadeErrors"
import {setChecked} from "@app/util/notifications"
import {clearModals} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
@@ -46,9 +47,16 @@
pushToast({
theme: "error",
message: "Sorry, we were unable to log you in.",
message: getPomadeLoginFailureMessage(res.messages),
})
}
} catch (error) {
console.error("Login error:", error)
pushToast({
theme: "error",
message: POMADE_NETWORK_ERROR_MESSAGE,
})
} finally {
loading = false
}
-32
View File
@@ -1,32 +0,0 @@
<script lang="ts">
import Link from "@lib/components/Link.svelte"
import CardButton from "@lib/components/CardButton.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import RelayName from "@app/components/RelayName.svelte"
import RelayDescription from "@app/components/RelayDescription.svelte"
import {makeSpacePath} from "@app/util/routes"
import {notifications} from "@app/util/notifications"
const {url} = $props()
const path = makeSpacePath(url)
</script>
<Link replaceState href={path}>
<CardButton class="btn-neutral shadow-md bg-alt rounded-box border-none">
{#snippet icon()}
<RelayIcon {url} size={12} class="rounded-full" />
{/snippet}
{#snippet title()}
<div class="flex gap-1">
<RelayName {url} />
{#if $notifications.has(path)}
<div class="relative top-1 h-2 w-2 rounded-full bg-primary"></div>
{/if}
</div>
{/snippet}
{#snippet info()}
<div><RelayDescription {url} /></div>
{/snippet}
</CardButton>
</Link>
+3 -1
View File
@@ -16,6 +16,7 @@
children,
minimal = false,
hideProfile = false,
noShadow = false,
url,
...restProps
}: {
@@ -23,6 +24,7 @@
children: Snippet
minimal?: boolean
hideProfile?: boolean
noShadow?: boolean
url?: string
class?: string
} = $props()
@@ -34,7 +36,7 @@
let muted = $state($isEventMuted(event))
</script>
<div class="flex flex-col gap-2 shadow-md {restProps.class}">
<div class="flex flex-col gap-2 {restProps.class}" class:shadow-md={!noShadow}>
{#if muted}
<div class="flex items-center justify-between">
<div class="row-2 relative">
+4 -1
View File
@@ -1,10 +1,11 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, POLL} from "@welshman/util"
import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte"
import NoteContentThread from "@app/components/NoteContentThread.svelte"
import NoteContentClassified from "@app/components/NoteContentClassified.svelte"
import NoteContentGoal from "@app/components/NoteContentGoal.svelte"
import NoteContentPoll from "@app/components/NoteContentPoll.svelte"
import Content from "@app/components/Content.svelte"
const props: ComponentProps<typeof Content> = $props()
@@ -19,6 +20,8 @@
<NoteContentClassified {...props} />
{:else if props.event.kind === ZAP_GOAL}
<NoteContentGoal {...props} />
{:else if props.event.kind === POLL}
<NoteContentPoll {...props} />
{:else}
<Content {...props} />
{/if}
@@ -9,10 +9,10 @@
<div class="flex items-start gap-4">
<CalendarEventDate event={props.event} />
<div class="flex flex-grow flex-col">
<div class="flex grow flex-col">
<CalendarEventHeader event={props.event} />
<div class="flex py-2 opacity-50">
<div class="h-px flex-grow bg-base-content opacity-25"></div>
<div class="h-px grow bg-base-content opacity-25"></div>
</div>
<Content {...props} />
</div>
+4 -1
View File
@@ -1,10 +1,11 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, POLL} from "@welshman/util"
import NoteContentMinimalEventTime from "@app/components/NoteContentMinimalEventTime.svelte"
import NoteContentMinimalThread from "@app/components/NoteContentMinimalThread.svelte"
import NoteContentMinimalClassified from "@app/components/NoteContentMinimalClassified.svelte"
import NoteContentMinimalGoal from "@app/components/NoteContentMinimalGoal.svelte"
import NoteContentMinimalPoll from "@app/components/NoteContentMinimalPoll.svelte"
import ContentMinimal from "@app/components/ContentMinimal.svelte"
const props: ComponentProps<typeof ContentMinimal> = $props()
@@ -19,6 +20,8 @@
<NoteContentMinimalClassified {...props} />
{:else if props.event.kind === ZAP_GOAL}
<NoteContentMinimalGoal {...props} />
{:else if props.event.kind === POLL}
<NoteContentMinimalPoll {...props} />
{:else}
<ContentMinimal {...props} />
{/if}
@@ -17,7 +17,7 @@
</script>
<div class="flex flex-col">
<div class="flex flex-grow flex-wrap justify-between gap-2">
<div class="flex grow flex-wrap justify-between gap-2">
<p class="text-sm">{meta.title || meta.name}</p>
{#if !isNaN(start) && !isNaN(end)}
{@const startDateDisplay = formatTimestampAsDate(start)}
@@ -0,0 +1,19 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {derived} from "svelte/store"
import {POLL_RESPONSE} from "@welshman/util"
import ContentMinimal from "@app/components/ContentMinimal.svelte"
import {deriveEvents} from "@app/core/state"
import {getPollResults} from "@app/util/polls"
const props: ComponentProps<typeof ContentMinimal> = $props()
const responses = deriveEvents([{kinds: [POLL_RESPONSE], "#e": [props.event.id]}])
const results = derived(responses, $responses => getPollResults(props.event, $responses))
</script>
<div class="flex flex-col gap-0">
<ContentMinimal {...props} />
<span class="text-xs opacity-50">{$results.voters} voter{$results.voters === 1 ? "" : "s"}</span>
</div>
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {onMount} from "svelte"
import {request} from "@welshman/net"
import {POLL_RESPONSE} from "@welshman/util"
import PollVotes from "@app/components/PollVotes.svelte"
import Content from "@app/components/Content.svelte"
const props: ComponentProps<typeof Content> = $props()
onMount(() => {
if (!props.url) {
return
}
request({
relays: [props.url],
filters: [{kinds: [POLL_RESPONSE], "#e": [props.event.id]}],
})
})
</script>
<div class="flex flex-col gap-3">
<Content event={props.event} showEntire url={props.url} />
{#if props.url}
<PollVotes url={props.url} event={props.event} />
{/if}
</div>
+280
View File
@@ -0,0 +1,280 @@
<script lang="ts">
import {insertAt, now, randomId, removeAt, removeUndefined} from "@welshman/lib"
import {makeEvent, POLL} from "@welshman/util"
import {publishThunk, waitForThunkError} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import HamburgerMenu from "@assets/icons/hamburger-menu.svg?dataurl"
import PlusCircle from "@assets/icons/add-circle.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
import {DraftKey} from "@app/util/drafts"
import type {PollType} from "@app/util/polls"
type Option = {
id: string
value: string
}
type Values = {
title: string
pollType: PollType
endsAt?: number
options: Option[]
}
type Props = {
url: string
h?: string
shareToChat?: boolean
}
const {url, h, shareToChat = false}: Props = $props()
const draftKey = new DraftKey<Values>(`poll:${url}:${h ?? ""}`)
const initialValues = draftKey.get()
const shouldProtect = canEnforceNip70(url)
const back = () => history.back()
const addOption = () => {
options = [...options, {id: randomId(), value: ""}]
}
const removeOption = (id: string) => {
options = options.filter(option => option.id !== id)
}
const updateOption = (id: string, value: string) => {
options = options.map(option => (option.id === id ? {...option, value} : option))
}
const reorderOptions = (targetId: string) => {
if (!draggedOptionId) {
return
}
const sourceIndex = options.findIndex(option => option.id === draggedOptionId)
const targetIndex = options.findIndex(option => option.id === targetId)
if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) {
return
}
options = insertAt(targetIndex, options[sourceIndex], removeAt(sourceIndex, options))
}
const onDragStart = (e: DragEvent, id: string) => {
draggedOptionId = id
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move"
e.dataTransfer.setData("text/plain", id)
}
}
const onDragOver = (e: DragEvent, targetId: string) => {
e.preventDefault()
reorderOptions(targetId)
}
const onDrop = (e: DragEvent, targetId: string) => {
e.preventDefault()
reorderOptions(targetId)
draggedOptionId = undefined
}
const onDragEnd = () => {
draggedOptionId = undefined
}
const submit = async () => {
if (loading) return
if (!title.trim()) {
return pushToast({theme: "error", message: "Please provide a title for your poll."})
}
const nonEmptyOptions = removeUndefined(options.map(option => option.value.trim() || undefined))
if (nonEmptyOptions.length < 2) {
return pushToast({theme: "error", message: "Please provide at least two options."})
}
if (endsAt && endsAt <= now()) {
return pushToast({theme: "error", message: "End time must be in the future."})
}
const tags: string[][] = [
...nonEmptyOptions.map(option => ["option", randomId(), option]),
["polltype", pollType],
["relay", url],
]
if (endsAt) {
tags.push(["endsAt", String(endsAt)])
}
if (h) {
tags.push(["h", h])
}
loading = true
try {
const protect = await shouldProtect
if (protect) {
tags.push(PROTECTED)
}
const pollThunk = publishThunk({
relays: [url],
event: makeEvent(POLL, {content: title.trim(), tags}),
})
const error = await waitForThunkError(pollThunk)
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
history.back()
if (shareToChat) {
publishRoomQuote({url, h, parent: pollThunk.event, protect})
}
} finally {
loading = false
}
}
let loading = $state(false)
let draggedOptionId = $state<string | undefined>()
let title = $state(initialValues?.title ?? "")
let pollType = $state<PollType>(initialValues?.pollType ?? "singlechoice")
let endsAt = $state<number | undefined>(initialValues?.endsAt)
let options = $state<Option[]>(
initialValues?.options ?? [
{id: randomId(), value: "Yes"},
{id: randomId(), value: "No"},
],
)
$effect(() => {
draftKey.set({title, pollType, endsAt, options})
})
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
<ModalBody>
<ModalHeader>
<ModalTitle>Create a Poll</ModalTitle>
<ModalSubtitle>Ask a question and collect votes right in the feed.</ModalSubtitle>
</ModalHeader>
<div class="col-8 relative">
<Field>
{#snippet label()}
<p>Question*</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={title}
class="grow"
type="text"
placeholder="What would you like to ask?" />
</label>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Options*</p>
{/snippet}
{#snippet input()}
<div class="flex flex-col gap-2" role="list">
{#each options as option, index (option.id)}
<div
class="flex items-center gap-2"
draggable="true"
role="listitem"
ondragstart={e => onDragStart(e, option.id)}
ondragover={e => onDragOver(e, option.id)}
ondrop={e => onDrop(e, option.id)}
ondragend={onDragEnd}>
<div class="cursor-move opacity-70" aria-label="Drag handle">
<Icon icon={HamburgerMenu} size={4} />
</div>
<label class="input input-bordered flex w-full items-center gap-2">
<input
value={option.value}
class="grow"
type="text"
placeholder={`Option ${index + 1}`}
oninput={e => updateOption(option.id, e.currentTarget.value)} />
</label>
<Button class="btn btn-ghost btn-sm" onclick={() => removeOption(option.id)}>
<Icon icon={MinusCircle} size={4} />
</Button>
</div>
{/each}
<Button class="btn btn-outline btn-sm self-end" onclick={addOption}>
<Icon icon={PlusCircle} size={4} />
Add option
</Button>
</div>
{/snippet}
</Field>
<div class="flex flex-col gap-2">
<FieldInline>
{#snippet label()}
Poll type
{/snippet}
{#snippet input()}
<select class="select select-bordered w-full max-w-xs" bind:value={pollType}>
<option value="singlechoice">Single choice</option>
<option value="multiplechoice">Multiple choice</option>
</select>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
Ends at
{/snippet}
{#snippet input()}
<DateTimeInput bind:value={endsAt} />
{/snippet}
</FieldInline>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Create Poll</Spinner>
</Button>
</ModalFooter>
</Modal>
+34
View File
@@ -0,0 +1,34 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {getTagValue} from "@welshman/util"
import Link from "@lib/components/Link.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import CommentActions from "@app/components/CommentActions.svelte"
import RoomLink from "@app/components/RoomLink.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import {makePollPath} from "@app/util/routes"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const h = getTagValue("h", event.tags)
</script>
<Link
class="cv col-2 card2 bg-alt w-full cursor-pointer shadow-md"
href={makePollPath(url, event.id)}>
<NoteContent {event} {url} />
<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} />
{#if h}
in <RoomLink {url} {h} />
{/if}
</span>
<CommentActions segment="polls" showActivity {url} {event} />
</div>
</Link>
+70
View File
@@ -0,0 +1,70 @@
<script lang="ts">
import {tweened} from "svelte/motion"
import type {TrustedEvent} from "@welshman/util"
import {noop} from "@welshman/lib"
import {stopPropagation} from "@lib/html"
import {getPollType, isPollClosed} from "@app/util/polls"
type Props = {
event: TrustedEvent
option: {id: string; label: string}
results: {voters: number; options: {id: string; votes: number}[]}
selectedIds: string[]
setSingleChoice: (id: string) => void
toggleMultipleChoice: (id: string) => void
}
const {event, option, results, selectedIds, setSingleChoice, toggleMultipleChoice}: Props =
$props()
const pollType = getPollType(event)
const closed = isPollClosed(event)
const selected = $derived(
pollType === "singlechoice" ? selectedIds[0] === option.id : selectedIds.includes(option.id),
)
const onselect = () =>
pollType === "singlechoice" ? setSingleChoice(option.id) : toggleMultipleChoice(option.id)
const votes = $derived(results.options.find(r => r.id === option.id)?.votes || 0)
const maxVotes = $derived(Math.max(...results.options.map(r => r.votes), 1))
const tweenedVotes = tweened(votes, {duration: 300})
const tweenedMax = tweened(maxVotes, {duration: 300})
$effect(() => {
tweenedVotes.set(votes)
})
$effect(() => {
tweenedMax.set(maxVotes)
})
</script>
<div class="flex flex-col gap-2 card2 card2-sm bg-alt">
<div class="flex items-center justify-between gap-2">
<label class="flex min-w-0 grow items-center gap-2">
{#if !closed}
{#if pollType === "singlechoice"}
<input
name={event.id}
type="radio"
class="radio radio-primary radio-sm"
checked={selected}
onclick={stopPropagation(noop)}
onchange={onselect} />
{:else}
<input
type="checkbox"
class="checkbox checkbox-primary checkbox-sm"
checked={selected}
onclick={stopPropagation(noop)}
onchange={onselect} />
{/if}
{/if}
<span class="truncate">{option.label}</span>
</label>
<span class="whitespace-nowrap text-xs opacity-75">{votes} vote{votes === 1 ? "" : "s"}</span>
</div>
<progress class="progress progress-primary" value={$tweenedVotes} max={$tweenedMax}></progress>
</div>
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import {onDestroy} from "svelte"
import type {TrustedEvent} from "@welshman/util"
import {POLL_RESPONSE} from "@welshman/util"
import {pubkey, publishThunk, abortThunk} from "@welshman/app"
import {formatTimestampRelative} from "@welshman/lib"
import {deriveEvents} from "@app/core/state"
import {pushToast} from "@app/util/toast"
import {makePollResponse} from "@app/core/commands"
import PollOption from "@app/components/PollOption.svelte"
import {
getPollEndsAt,
getPollOptions,
getPollResponseSelections,
getPollResults,
getPollType,
isPollClosed,
} from "@app/util/polls"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const responses = deriveEvents([{kinds: [POLL_RESPONSE], "#e": [event.id]}])
const pollType = getPollType(event)
const options = getPollOptions(event)
const closed = isPollClosed(event)
const endsAt = getPollEndsAt(event)
const publishDelay = pollType === "multiplechoice" ? 10_000 : undefined
const getOwnResponse = (responses: TrustedEvent[]) => {
let latest: TrustedEvent | undefined
for (const response of responses) {
if (response.pubkey !== $pubkey) {
continue
}
if (!latest || response.created_at > latest.created_at) {
latest = response
}
}
return latest
}
const publishSelection = (selection: string[]) => {
if (activeThunk) {
abortThunk(activeThunk)
}
if (selection.length === 0) {
activeThunk = undefined
return
}
activeThunk = publishThunk({
relays: [url],
event: makePollResponse({event, selectedIds: selection}),
delay: publishDelay,
})
}
const publishCurrentSelection = () => {
const selection = pollType === "singlechoice" ? selectedIds.slice(0, 1) : selectedIds
if (selection.length === 0) {
return pushToast({theme: "error", message: "Please select at least one option."})
}
publishSelection(selection)
}
const results = $derived(getPollResults(event, $responses))
const ownResponse = $derived(getOwnResponse($responses))
const setSingleChoice = (id: string) => {
selectedIds = [id]
publishCurrentSelection()
}
const toggleMultipleChoice = (id: string) => {
selectedIds = selectedIds.includes(id)
? selectedIds.filter(selectedId => selectedId !== id)
: [...selectedIds, id]
publishCurrentSelection()
}
let selectedIds = $state<string[]>([])
let activeThunk: ReturnType<typeof publishThunk> | undefined
$effect(() => {
if (ownResponse) {
selectedIds = getPollResponseSelections(ownResponse, pollType)
}
})
onDestroy(() => {
if (activeThunk) {
abortThunk(activeThunk)
}
})
</script>
<div class="flex flex-col gap-2">
{#each options as option (option.id)}
<PollOption {event} {option} {results} {selectedIds} {setSingleChoice} {toggleMultipleChoice} />
{/each}
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-sm opacity-75">
{pollType === "multiplechoice" ? "Multiple choice" : "Single choice"}
{#if endsAt}
{#if closed}
• Ended {formatTimestampRelative(endsAt)}
{:else}
• Ends {formatTimestampRelative(endsAt)}
{/if}
{/if}
</div>
<div class="text-sm opacity-75">{results.voters} vote{results.voters === 1 ? "" : "s"}</div>
</div>
</div>
+12 -16
View File
@@ -3,7 +3,7 @@
import {userProfile} from "@welshman/app"
import Letter from "@assets/icons/letter.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import Planet from "@assets/icons/planet-3.svg?dataurl"
import Widget from "@assets/icons/widget-4.svg?dataurl"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Settings from "@assets/icons/settings.svg?dataurl"
import ImageIcon from "@lib/components/ImageIcon.svelte"
@@ -14,7 +14,7 @@
import {userSpaceUrls, PLATFORM_RELAYS} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {notifications} from "@app/util/notifications"
import {goToChat} from "@app/util/routes"
import {goToChat, makeSpacePath} from "@app/util/routes"
type Props = {
children?: Snippet
@@ -26,22 +26,20 @@
const showSettingsMenu = () => pushModal(MenuSettings)
const anySpaceNotifications = $derived($userSpaceUrls.some(p => $notifications.has(p)))
const anySpaceNotifications = $derived(
$userSpaceUrls.some(p => $notifications.has(makeSpacePath(p))),
)
</script>
<div
class="ml-sai mt-sai mb-sai relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block">
class="ml-sai mt-sai mb-sai relative z-popover isolate hidden w-14 shrink-0 bg-base-200 pt-2 md:block">
<div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}>
<PrimaryNavSpaces />
{#if PLATFORM_RELAYS.length > 0}
<Divider />
{/if}
<div>
<PrimaryNavItem
title="Settings"
href="/settings/profile"
prefix="/settings"
class="tooltip-right">
<div class="flex flex-col">
<PrimaryNavItem title="Settings" href="/settings/profile" prefix="/settings">
{#if $userProfile?.picture}
<ImageIcon alt="Settings" src={$userProfile?.picture} class="rounded-full" size={10} />
{:else}
@@ -51,11 +49,10 @@
<PrimaryNavItem
title="Messages"
onclick={chatHandler}
class="tooltip-right"
notification={$notifications.has("/chat")}>
<ImageIcon alt="Messages" src={Letter} size={8} />
</PrimaryNavItem>
<PrimaryNavItem title="Search" href="/people" class="tooltip-right">
<PrimaryNavItem title="Search" href="/people">
<ImageIcon alt="Search" src={Magnifier} size={8} />
</PrimaryNavItem>
</div>
@@ -65,11 +62,10 @@
{@render children?.()}
<!-- a little extra something for ios -->
<div
class="bottom-nav hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-[var(--saib)] bg-base-100 md:hidden">
<div class="hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-(--saib) bg-base-100 md:hidden">
</div>
<div
class="bottom-nav hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
class="hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
<div class="content-padding-x content-sizing flex justify-between px-2">
<div class="flex gap-2 sm:gap-6">
<PrimaryNavItem title="Search" href="/people">
@@ -84,7 +80,7 @@
</PrimaryNavItem>
{#if PLATFORM_RELAYS.length !== 1}
<PrimaryNavItem title="Spaces" href="/spaces" notification={anySpaceNotifications}>
<ImageIcon alt="Spaces" src={Planet} size={8} />
<ImageIcon alt="Spaces" src={Widget} size={8} />
</PrimaryNavItem>
{/if}
</div>
@@ -1,5 +1,5 @@
<script lang="ts">
import {displayRelayUrl} from "@welshman/util"
import {deriveRelayDisplay} from "@welshman/app"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import {makeSpacePath, goToSpace} from "@app/util/routes"
@@ -12,12 +12,17 @@
const {url}: Props = $props()
const onClick = () => goToSpace(url)
const path = makeSpacePath(url)
const display = $derived(deriveRelayDisplay(url))
</script>
<PrimaryNavItem
href={path}
onclick={onClick}
title={displayRelayUrl(url)}
title={$display}
class="tooltip-right"
notification={$notifications.has(makeSpacePath(url))}>
notification={$notifications.has(path)}>
<RelayIcon {url} size={10} class="rounded-full" />
</PrimaryNavItem>
+4 -8
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import {splitAt} from "@welshman/lib"
import Widget from "@assets/icons/widget.svg?dataurl"
import Compass from "@assets/icons/compass.svg?dataurl"
import Widget from "@assets/icons/widget-4.svg?dataurl"
import ImageIcon from "@lib/components/ImageIcon.svelte"
import Divider from "@lib/components/Divider.svelte"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
@@ -13,7 +12,7 @@
const itemHeight = 56
const navPadding = 8 * itemHeight
const itemLimit = $derived((windowHeight - navPadding) / itemHeight)
const itemLimit = $derived(Math.max(0, (windowHeight - navPadding) / itemHeight))
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, $userSpaceUrls))
const otherSpaceNotifications = $derived(secondarySpaceUrls.some(p => $notifications.has(p)))
</script>
@@ -24,7 +23,7 @@
{#each PLATFORM_RELAYS as url (url)}
<PrimaryNavItemSpace {url} />
{:else}
<PrimaryNavItem title="Home" href="/home" class="tooltip-right">
<PrimaryNavItem title="Home" href="/home">
<ImageIcon alt="Home" src={PLATFORM_LOGO} class="rounded-full" size={10} />
</PrimaryNavItem>
<Divider />
@@ -34,12 +33,9 @@
<PrimaryNavItem
href="/spaces"
title="All Spaces"
class="tooltip-right"
prefix="no-highlight"
notification={otherSpaceNotifications}>
<ImageIcon alt="All Spaces" src={Widget} size={8} />
</PrimaryNavItem>
<PrimaryNavItem title="Add a Space" href="/discover" class="tooltip-right">
<ImageIcon alt="Add a Space" src={Compass} size={8} />
</PrimaryNavItem>
{/each}
</div>
+3 -3
View File
@@ -5,11 +5,11 @@
import type {Filter} from "@welshman/util"
import {deriveEventsDesc, deriveEventsById} from "@welshman/store"
import {formatTimestampRelative} from "@welshman/lib"
import {NOTE, ROOMS, COMMENT} from "@welshman/util"
import {NOTE, ROOMS, COMMENT, MESSAGE} from "@welshman/util"
import {repository, loadRelayList} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import ProfileSpaces from "@app/components/ProfileSpaces.svelte"
import {deriveGroupList, getSpaceUrlsFromGroupList, MESSAGE_KINDS} from "@app/core/state"
import {deriveGroupList, getSpaceUrlsFromGroupList} from "@app/core/state"
import {goToEvent} from "@app/util/routes"
import {pushModal} from "@app/util/modal"
@@ -36,7 +36,7 @@
load({
filters: [
{authors: [pubkey], kinds: [ROOMS]},
{authors: [pubkey], limit: 1, kinds: [NOTE, COMMENT, ...MESSAGE_KINDS]},
{authors: [pubkey], limit: 1, kinds: [NOTE, COMMENT, MESSAGE]},
],
relays: Router.get().FromPubkeys([pubkey]).getUrls(),
})
+4 -12
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import type {Profile} from "@welshman/util"
import {getTag, makeProfile} from "@welshman/util"
import {makeProfile} from "@welshman/util"
import {pubkey, profilesByPubkey, waitForThunkError} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import {errorMessage} from "@lib/util"
@@ -10,26 +10,18 @@
import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
import {clearModals} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {updateProfile} from "@app/core/commands"
const profile = $profilesByPubkey.get($pubkey!) || makeProfile()
const shouldBroadcast = !getTag(PROTECTED, profile.event?.tags || [])
const initialValues = {profile, shouldBroadcast}
const initialValues = {profile}
const back = () => history.back()
const onsubmit = async ({
profile,
shouldBroadcast,
}: {
profile: Profile
shouldBroadcast: boolean
}) => {
const onsubmit = async ({profile}: {profile: Profile}) => {
loading = true
try {
const error = await waitForThunkError(updateProfile({profile, shouldBroadcast}))
const error = await waitForThunkError(updateProfile({profile}))
if (error) {
pushToast({
+6 -23
View File
@@ -6,7 +6,6 @@
import MapPoint from "@assets/icons/map-point.svg?dataurl"
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 Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
@@ -17,7 +16,6 @@
type Values = {
profile: Profile
shouldBroadcast: boolean
}
type Props = {
@@ -25,9 +23,10 @@
onsubmit: (values: Values) => void
isSignup?: boolean
footer: Snippet
progressBar?: Snippet
}
const {initialValues, isSignup, onsubmit, footer}: Props = $props()
const {initialValues, isSignup, onsubmit, footer, progressBar}: Props = $props()
const values = $state(initialValues)
@@ -77,7 +76,7 @@
{/snippet}
{#snippet input()}
<textarea
class="textarea textarea-bordered leading-4"
class="textarea textarea-bordered leading-4 w-full"
rows="5"
bind:value={values.profile.about}></textarea>
{/snippet}
@@ -104,26 +103,10 @@
{/snippet}
</Field>
{/if}
{#if !isSignup}
<FieldInline>
{#snippet label()}
<p>Broadcast Profile</p>
{/snippet}
{#snippet input()}
<input
type="checkbox"
class="toggle toggle-primary"
bind:checked={values.shouldBroadcast} />
{/snippet}
{#snippet info()}
<p>
If enabled, changes will be published to the broader nostr network in addition to spaces
you are a member of.
</p>
{/snippet}
</FieldInline>
{/if}
</ModalBody>
{#if progressBar}
{@render progressBar()}
{/if}
<ModalFooter>
{@render footer()}
</ModalFooter>
+2 -2
View File
@@ -25,10 +25,10 @@
<div class="flex flex-col gap-2">
{#each spaceUrls as url (url)}
<div class="card2 bg-alt flex flex-row items-center gap-2">
<div class="flex-shrink-0">
<div class="shrink-0">
<RelayIcon {url} size={12} />
</div>
<div class="flex flex-grow flex-col">
<div class="flex grow flex-col">
<RelayName {url} />
<div class="text-sm opacity-75">
{url}
+9
View File
@@ -0,0 +1,9 @@
<script lang="ts">
const {current, total}: {current: number; total: number} = $props()
</script>
<div class="flex w-full">
{#each Array(total) as _, i}
<div class="h-1 flex-1 transition-colors {i < current ? 'bg-primary' : 'bg-base-300'}"></div>
{/each}
</div>
+21 -5
View File
@@ -23,7 +23,7 @@
import Icon from "@lib/components/Icon.svelte"
import Reaction from "@app/components/Reaction.svelte"
import ReportDetails from "@app/components/ReportDetails.svelte"
import {REACTION_KINDS} from "@app/core/state"
import {REACTION_KINDS, deriveUserIsSpaceAdmin} from "@app/core/state"
import {pushModal} from "@app/util/modal"
interface Props {
@@ -33,6 +33,7 @@
url?: string
reactionClass?: string
noTooltip?: boolean
innerEvent?: TrustedEvent
children?: Snippet
}
@@ -43,23 +44,36 @@
url = "",
reactionClass = "",
noTooltip = false,
innerEvent = undefined,
children,
}: Props = $props()
const eventIds = innerEvent ? [event.id, innerEvent.id] : [event.id]
const reports = deriveArray(
deriveEventsById({repository, filters: [{kinds: [REPORT], "#e": [event.id]}]}),
)
const reactions = deriveArray(
deriveEventsById({repository, filters: [{kinds: [REACTION], "#e": [event.id]}]}),
deriveEventsById({repository, filters: [{kinds: [REACTION], "#e": eventIds}]}),
)
const zaps = deriveArray(
deriveItemsByKey<Zap>({
repository,
getKey: zap => zap.response.id,
filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}],
eventToItem: (response: TrustedEvent) => getValidZap(response, event),
filters: [{kinds: [ZAP_RESPONSE], "#e": eventIds}],
eventToItem: (response: TrustedEvent) => {
const zap = getValidZap(response, event)
if (zap) {
return zap
}
if (innerEvent) {
return getValidZap(response, innerEvent)
}
},
}),
)
@@ -78,6 +92,8 @@
}
}
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const onReportClick = () => pushModal(ReportDetails, {url, event})
const reportReasons = $derived(uniq(map(e => getTag("e", e.tags)?.[2], $reports.values())))
@@ -118,7 +134,7 @@
{#if $reactions.length > 0 || $zaps.length || $reports.length > 0 || children}
<div class="flex min-w-0 flex-wrap gap-2">
{#if url && $reports.length > 0}
{#if url && $reports.length > 0 && $userIsAdmin}
<button
type="button"
data-tip={`This content has been reported as "${displayList(reportReasons)}".`}
+1 -1
View File
@@ -121,6 +121,6 @@
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-primary flex-grow" onclick={back}>Done</Button>
<Button class="btn btn-primary grow" onclick={back}>Done</Button>
</ModalFooter>
</Modal>
+2 -2
View File
@@ -26,8 +26,8 @@
type="button"
class="btn font-normal flex h-[unset] w-full flex-nowrap py-4 text-left items-start justify-between"
{onclick}>
<div class="flex flex-grow flex-row items-start gap-4">
<div class="flex h-7 w-7 flex-shrink-0 items-center justify-center">
<div class="flex grow flex-row items-start gap-4">
<div class="flex h-7 w-7 shrink-0 items-center justify-center">
<Icon {icon} />
</div>
<div class="flex flex-col gap-1">
+10 -11
View File
@@ -9,21 +9,22 @@
type Props = {
url: string
hideFavorites?: boolean
}
const {url}: Props = $props()
const {url, hideFavorites}: Props = $props()
const rooms = deriveUserRooms(url)
const favorited = deriveGroupListPubkeys(url)
</script>
<div class="col-4 text-left">
<div class="col-2">
<div class="relative flex gap-4">
<div class="relative flex gap-2 sm:gap-4">
<div class="relative">
<div class="avatar relative">
<div
class="center !flex h-12 w-12 min-w-12 rounded-full border-2 border-solid border-base-300 bg-base-300">
<RelayIcon {url} />
class="center flex! h-12 w-12 min-w-12 rounded-full border-2 border-solid border-base-300 bg-base-300">
<RelayIcon {url} size={10} />
</div>
</div>
{#if $rooms.includes(url)}
@@ -34,16 +35,14 @@
</div>
{/if}
</div>
<div>
<h2 class="ellipsize whitespace-nowrap text-xl">
<RelayName {url} />
</h2>
<p class="text-sm opacity-75">{url}</p>
<div class="min-w-0">
<RelayName {url} class="ellipsize whitespace-nowrap text-lg sm:text-xl" />
<p class="text-xs sm:text-sm opacity-75">{url}</p>
</div>
</div>
<RelayDescription {url} />
<RelayDescription {url} class="text-sm sm:text-md" />
</div>
{#if $favorited.size > 0}
{#if !hideFavorites && $favorited.size > 0}
<div class="row-2 card2 card2-sm bg-alt">
Favorited By:
<ProfileCircles pubkeys={Array.from($favorited)} />
+34 -8
View File
@@ -12,18 +12,29 @@
import ComposeMenu from "@app/components/ComposeMenu.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {onDestroy, onMount} from "svelte"
type Values = {
content?: string | object
}
type Props = {
url?: string
h?: string
content?: string
onEscape?: () => void
onEditPrevious?: () => void
onSubmit: (event: EventContent) => void
initialValues?: Values
}
const {url, h, content, onEscape, onEditPrevious, onSubmit}: Props = $props()
let {url, h, initialValues, onEscape, onEditPrevious, onSubmit}: Props = $props()
const draftKey = url || h ? new DraftKey<Values>(`room:${url ?? ""}:${h ?? ""}`) : undefined
if (!initialValues) {
initialValues = draftKey?.get()
}
const autofocus = !isMobile
@@ -57,16 +68,31 @@
const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = ed.storage.nostr.getEditorTags()
if (!content) return
onSubmit({content, tags})
draftKey?.clear()
ed.chain().clearContent().run()
}
const editor = makeEditor({url, content, autofocus, submit, uploading, aggressive: true})
let popover: Instance | undefined = $state()
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({
url,
content,
submit,
uploading,
onChange,
aggressive: true,
})
$effect(() => {
draftKey?.set({content})
})
onMount(async () => {
const ed = await editor
@@ -104,8 +130,8 @@
</Button>
</Tippy>
</div>
<div class="chat-editor flex-grow overflow-hidden">
<EditorContent {editor} />
<div class="chat-editor grow overflow-hidden">
<EditorContent {autofocus} {editor} />
</div>
<Button
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
+31 -26
View File
@@ -22,6 +22,7 @@
import Button from "@lib/components/Button.svelte"
import Popover from "@lib/components/Popover.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import Tooltip from "@lib/components/Tooltip.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
@@ -206,43 +207,43 @@
<strong class="text-lg">Room Permissions</strong>
<div class="flex gap-2 flex-wrap">
{#if $room?.isRestricted}
<Button
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
data-tip="Only members can send messages.">
<Icon size={4} icon={Microphone} /> Restricted
</Button>
<Tooltip content="Only members can send messages.">
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
<Icon size={4} icon={Microphone} /> Restricted
</Button>
</Tooltip>
{/if}
{#if $room?.isPrivate}
<Button
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
data-tip="Only members can view messages.">
<Icon size={4} icon={Lock} /> Private
</Button>
<Tooltip content="Only members can view messages.">
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
<Icon size={4} icon={Lock} /> Private
</Button>
</Tooltip>
{/if}
{#if $room?.isHidden}
<Button
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
data-tip="This room is not visible to non-members.">
<Icon size={4} icon={EyeClosed} /> Hidden
</Button>
<Tooltip content="This room is not visible to non-members.">
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
<Icon size={4} icon={EyeClosed} /> Hidden
</Button>
</Tooltip>
{/if}
{#if $room?.isClosed}
<Button
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
data-tip="Requests to join this room will be ignored.">
<Icon size={4} icon={MinusCircle} /> Closed
</Button>
<Tooltip content="Requests to join this room will be ignored.">
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
<Icon size={4} icon={MinusCircle} /> Closed
</Button>
</Tooltip>
{/if}
{#if !$room?.isRestricted && !$room?.isPrivate && !$room?.isHidden && !$room?.isClosed}
<Button
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
data-tip="This room has no additional access controls.">
<Icon size={4} icon={Eye} /> Public
</Button>
<Tooltip content="This room has no additional access controls.">
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
<Icon size={4} icon={Eye} /> Public
</Button>
</Tooltip>
{/if}
</div>
</div>
{#if $members.length > 0}
{#if $members !== undefined && $members.length > 0}
<div class="card2 card2-sm bg-alt flex items-center justify-between gap-4">
<div class="flex items-center gap-4">
<span>Members:</span>
@@ -250,6 +251,10 @@
</div>
<Button class="btn btn-neutral btn-sm" onclick={showMembers}>View All</Button>
</div>
{:else if $members === undefined}
<div class="card2 card2-sm bg-base-200 flex items-center gap-4">
<span class="text-error">Member list not available from this relay</span>
</div>
{/if}
<div class="card2 card2-sm bg-alt col-4">
<strong class="text-lg">Room Settings</strong>
+1 -1
View File
@@ -131,7 +131,7 @@
<p>Icon</p>
{/snippet}
{#snippet input()}
<div class="flex flex-grow items-center justify-between gap-4">
<div class="flex grow items-center justify-between gap-4">
{#if imagePreview}
<div class="flex items-center gap-2">
<span class="text-sm opacity-75">Selected:</span>
+37 -17
View File
@@ -1,8 +1,16 @@
<script lang="ts">
import cx from "classnames"
import {hash, now, displayList, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib"
import {readable} from "svelte/store"
import {
hash,
gte,
now,
displayList,
formatTimestampAsTime,
formatTimestampAsDate,
} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {MESSAGE, COMMENT} from "@welshman/util"
import {MESSAGE, COMMENT, getTag} from "@welshman/util"
import {
thunks,
pubkey,
@@ -27,7 +35,7 @@
import RoomItemMenuButton from "@app/components/RoomItemMenuButton.svelte"
import RoomItemMenuMobile from "@app/components/RoomItemMenuMobile.svelte"
import RoomItemContent from "@app/components/RoomItemContent.svelte"
import {colors, ENABLE_ZAPS, deriveEventsForUrl} from "@app/core/state"
import {colors, ENABLE_ZAPS, deriveEventsForUrl, deriveEvent} from "@app/core/state"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {getRoomItemPath} from "@app/util/routes"
import {pushModal} from "@app/util/modal"
@@ -37,7 +45,7 @@
event: TrustedEvent
replyTo?: (event: TrustedEvent) => void
showPubkey?: boolean
inert?: boolean
addSpaceBelow?: boolean
canEdit: (event: TrustedEvent) => boolean
onEdit: (event: TrustedEvent) => void
}
@@ -47,7 +55,7 @@
event,
replyTo = undefined,
showPubkey = false,
inert = false,
addSpaceBelow = false,
canEdit,
onEdit,
}: Props = $props()
@@ -58,7 +66,15 @@
const profileDisplay = deriveProfileDisplay(event.pubkey, [url])
const thunk = mergeThunks($thunks.filter(t => t.event.id === event.id))
const [_, colorValue] = colors[hash(event.pubkey) % colors.length]
const comments = deriveEventsForUrl(url, [{kinds: [COMMENT], "#e": [event.id]}])
const qTag = getTag("q", event.tags)
const isQuoteOnly = Boolean(
gte(qTag?.length, 2) && event.content.trim().match(/^nostr:n(event|addr)1\w+\s*$/),
)
const innerComments = isQuoteOnly
? deriveEventsForUrl(url, [{kinds: [COMMENT], "#e": [qTag![1]]}])
: readable([])
const innerEvent = isQuoteOnly ? deriveEvent(qTag![1], [url]) : readable(undefined)
const reply = () => replyTo!(event)
const edit = canEdit(event) ? () => onEdit(event) : undefined
@@ -76,20 +92,23 @@
<TapTarget
data-event={event.id}
onTap={inert ? null : onTap}
class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left hover:bg-base-100/50">
{onTap}
class={cx(
"group relative flex w-full cursor-default flex-col px-2 py-0.5 text-left hover:bg-base-100/50",
{"mt-1.5": showPubkey, "mb-1.5": addSpaceBelow},
)}>
<div class="flex w-full gap-3 overflow-auto">
{#if showPubkey}
<Button onclick={openProfile} class="flex items-start">
<Button onclick={openProfile} class="flex items-start pt-1.5 justify-center w-8 shrink-0">
<ProfileCircle
pubkey={event.pubkey}
class="border border-solid border-base-content"
size={8} />
</Button>
{:else}
<div class="w-8 min-w-8 max-w-8"></div>
<div class="w-8 shrink-0"></div>
{/if}
<div class="min-w-0 flex-grow pr-1">
<div class="min-w-0 grow pr-1">
{#if showPubkey}
<div class="flex items-center gap-2">
<Button onclick={openProfile} class="text-sm font-bold" style="color: {colorValue}">
@@ -106,7 +125,7 @@
</div>
{/if}
<div class:mt-2={showPubkey && event.kind !== MESSAGE}>
<RoomItemContent {url} {event} />
<RoomItemContent {url} event={$innerEvent ?? event} />
{#if thunk}
<ThunkFailure showToastOnRetry {thunk} class="mt-2 text-sm" />
{/if}
@@ -119,9 +138,10 @@
{event}
{deleteReaction}
{createReaction}
reactionClass="tooltip-right" />
{#if path && $comments.length > 0}
{@const pubkeys = $comments.map(e => e.pubkey)}
reactionClass="tooltip-right"
innerEvent={$innerEvent} />
{#if path && $innerComments.length > 0}
{@const pubkeys = $innerComments.map(e => e.pubkey)}
{@const isOwn = $pubkey && pubkeys.includes($pubkey)}
{@const info = displayList(pubkeys.map(pubkey => displayProfileByPubkey(pubkey)))}
{@const tooltip = `${info} commented`}
@@ -133,14 +153,14 @@
"btn-primary": isOwn,
})}>
<Icon icon={ReplyAlt} />
<span>{$comments.length} comment{$comments.length === 1 ? "" : "s"}</span>
<span>{$innerComments.length} comment{$innerComments.length === 1 ? "" : "s"}</span>
</Link>
</div>
{/if}
</div>
{#if !isMobile}
<button
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
class="join absolute right-2 top-0.5 border border-solid border-neutral text-xs opacity-0 transition-all pr-2"
class:group-hover:opacity-100={!isMobile}>
{#if ENABLE_ZAPS}
<RoomItemZapButton {url} {event} />
+4 -3
View File
@@ -8,16 +8,17 @@
import {getRoomItemPath} from "@app/util/routes"
const props: ComponentProps<typeof NoteContent> = $props()
const path = getRoomItemPath(props.url!, props.event)
const minLength = 5000
const maxLength = 5500
</script>
<div class={cx("text-sm", {"card2 card2-sm bg-alt": props.event.kind !== MESSAGE})}>
{#if path && !isMobile}
<Link href={path}>
<NoteContent {...props} />
<NoteContent {...props} {minLength} {maxLength} />
</Link>
{:else}
<NoteContent {...props} />
<NoteContent {...props} {minLength} {maxLength} />
{/if}
</div>
@@ -1,18 +0,0 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {getPubkeyTagValues} from "@welshman/util"
import ProfileLink from "@app/components/ProfileLink.svelte"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
</script>
{#each getPubkeyTagValues(event.tags) as pubkey}
<div class="py-1 text-center text-xs opacity-75">
<ProfileLink unstyled class="text-primary" {url} {pubkey} /> left the room
</div>
{/each}
+36 -26
View File
@@ -73,34 +73,44 @@
</ModalSubtitle>
</ModalHeader>
<div class="flex flex-col gap-2">
{#each $members as pubkey (pubkey)}
<div class="card2 bg-alt relative">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<Profile {pubkey} {url} />
</div>
<div class="relative">
<Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}>
<Icon icon={MenuDots} />
</Button>
{#if menuPubkey === pubkey}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
<li>
<Button class="text-error" onclick={() => removeMember(pubkey)}>
<Icon icon={MinusCircle} />
Remove Member
</Button>
</li>
</ul>
</Popover>
{/if}
{#if $members === undefined}
<div class="card2 bg-base-200 p-4">
<span class="text-error">Member list not available from this relay</span>
</div>
{:else if $members.length === 0}
<div class="card2 bg-base-200 p-4">
<span class="text-base-content/70">No members yet</span>
</div>
{:else}
{#each $members as pubkey (pubkey)}
<div class="card2 bg-alt relative">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<Profile {pubkey} {url} />
</div>
<div class="relative">
<Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}>
<Icon icon={MenuDots} />
</Button>
{#if menuPubkey === pubkey}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
<li>
<Button class="text-error" onclick={() => removeMember(pubkey)}>
<Icon icon={MinusCircle} />
Remove Member
</Button>
</li>
</ul>
</Popover>
{/if}
</div>
</div>
</div>
</div>
{/each}
{/each}
{/if}
</div>
</ModalBody>
<ModalFooter>
+5
View File
@@ -56,6 +56,11 @@
}
const onSubmit = async () => {
if (!$spaceMembers) {
addMembers()
return
}
const pubkeysSnapshot = $state.snapshot(pubkeys)
const nonSpaceMembers = pubkeysSnapshot.filter(pubkey => !$spaceMembers.includes(pubkey))
+2 -2
View File
@@ -11,8 +11,8 @@
const {url, h, ...props}: Props = $props()
</script>
<div class="flex flex-grow items-center justify-between gap-4 {props.class}">
<div class="flex items-center gap-3">
<div class="flex grow items-center justify-between gap-4 {props.class}">
<div class="flex items-center gap-2">
<RoomImage {url} {h} />
<div class="min-w-0 overflow-hidden text-ellipsis">
<RoomName {url} {h} />
+8 -6
View File
@@ -62,9 +62,10 @@
const flows = {
email: {
start: () => pushModal(SignUpEmail, {next: flows.email.profile}),
profile: () => pushModal(SignUpProfile, {next: flows.email.complete}),
complete: () => pushModal(SignUpComplete, {next: flows.email.finalize}),
start: () => pushModal(SignUpEmail, {next: flows.email.profile, step: 1, totalSteps: 3}),
profile: () => pushModal(SignUpProfile, {next: flows.email.complete, step: 2, totalSteps: 3}),
complete: () =>
pushModal(SignUpComplete, {next: flows.email.finalize, step: 3, totalSteps: 3}),
finalize: () => {
const email = getKey<string>("signup.email")!
const clientOptions = getKey<ClientOptions>("signup.clientOptions")!
@@ -74,9 +75,10 @@
},
},
nostr: {
start: () => pushModal(SignUpProfile, {next: flows.nostr.key}),
key: () => pushModal(SignUpKey, {next: flows.nostr.complete}),
complete: () => pushModal(SignUpComplete, {next: flows.nostr.finalize}),
start: () => pushModal(SignUpProfile, {next: flows.nostr.key, step: 1, totalSteps: 3}),
key: () => pushModal(SignUpKey, {next: flows.nostr.complete, step: 2, totalSteps: 3}),
complete: () =>
pushModal(SignUpComplete, {next: flows.nostr.finalize, step: 3, totalSteps: 3}),
finalize: () => {
const secret = getKey<string>("signup.secret")!
+7 -1
View File
@@ -9,12 +9,15 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProgressBar from "@app/components/ProgressBar.svelte"
type Props = {
next: () => void
step?: number
totalSteps?: number
}
const {next}: Props = $props()
const {next, step, totalSteps}: Props = $props()
const back = () => history.back()
</script>
@@ -33,6 +36,9 @@
on groups you've already joined. Click below to get started!
</p>
</ModalBody>
{#if step && totalSteps}
<ProgressBar current={step} total={totalSteps} />
{/if}
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
+12 -3
View File
@@ -18,14 +18,17 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import SignUpEmailConfirm from "@app/components/SignUpEmailConfirm.svelte"
import ProgressBar from "@app/components/ProgressBar.svelte"
import {pushToast, popToast} from "@app/util/toast"
import {pushModal} from "@app/util/modal"
type Props = {
next: () => void
step?: number
totalSteps?: number
}
const {next}: Props = $props()
const {next, step, totalSteps}: Props = $props()
const back = () => history.back()
@@ -81,7 +84,7 @@
setKey("signup.clientOptions", clientOptions)
popToast(toastId)
pushModal(SignUpEmailConfirm, {next})
pushModal(SignUpEmailConfirm, {next, step, totalSteps})
} catch (e) {
console.error(e)
@@ -120,7 +123,7 @@
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Letter} />
<input bind:value={email} />
<input type="email" bind:value={email} />
</label>
{/snippet}
</FieldInline>
@@ -134,8 +137,14 @@
<input type="password" bind:value={password} />
</label>
{/snippet}
{#snippet info()}
Must be at least 12 characters long.
{/snippet}
</FieldInline>
</ModalBody>
{#if step && totalSteps}
<ProgressBar current={step} total={totalSteps} />
{/if}
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />

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