Compare commits

...

118 Commits

Author SHA1 Message Date
Jon Staab 2e8678e4c6 Bump welshman 2025-10-27 15:08:34 -07:00
Jon Staab 97569016fc Bump version 2025-10-27 14:19:06 -07:00
Jon Staab fe72798592 Send leave request 2025-10-27 14:13:17 -07:00
Jon Staab 4583c4e028 fix zapper loading 2025-10-27 13:36:29 -07:00
Jon Staab 0b98197a86 Add room deletion 2025-10-24 13:36:59 -07:00
Jon Staab 0e94a9c33f Use imperative svelte api for modals 2025-10-24 10:27:15 -07:00
Jon Staab 3dff1fcb4d Switch to new relays store 2025-10-24 09:38:57 -07:00
Jon Staab e163286dd4 Re-render suggestions on search update; prioritize space members in search 2025-10-24 09:09:59 -07:00
Jon Staab a99e12f12e Bump welshman 2025-10-24 06:47:20 -07:00
Matthew Remmel c3dd997e57 Add icon picker to room create component 2025-10-24 06:38:03 -07:00
Matthew Remmel a730384baf Add relay members list and room join/leave events 2025-10-24 05:03:22 -07:00
Jon Staab 43cf91e877 Remove connection toast now that we have a cta surfaced 2025-10-22 08:35:42 -07:00
Jon Staab 75bee027e1 Remove shards entirely, fix setup in layout 2025-10-21 10:29:29 -07:00
Jon Staab 5cbf69a8bd Push shards into storage lib 2025-10-21 09:26:06 -07:00
Jon Staab ecbb3086d8 Handle hot module unloading in layout 2025-10-21 08:27:30 -07:00
Jon Staab 7476767aa7 Add space status indicator #245 2025-10-20 17:05:22 -07:00
Jon Staab e5b8987a9d Move nav item 2025-10-20 16:06:00 -07:00
Jon Staab 6ca74c21bf Update to new version of welshman, including new thunks and wrap manager 2025-10-20 15:42:41 -07:00
Jon Staab e0099141aa Refactor synchronization logic 2025-10-17 12:23:03 -05:00
Jon Staab d0491ed202 Re-work space navigation #223 2025-10-17 12:23:03 -05:00
Jon Staab cbc2137ced Show all messages in non-nip29 chat 2025-10-17 12:23:03 -05:00
Jon Staab f9ac13ba11 Re-work space navigation #223 2025-10-17 12:21:22 -05:00
Jon Staab b3533c285f Show all messages in non-nip29 chat 2025-10-17 09:13:54 -07:00
Matthew Remmel a636ae6592 Simplify room create permission derive 2025-10-17 09:13:54 -07:00
Matthew Remmel 69e3ee0aff Move create room permission check to menu space 2025-10-17 09:13:54 -07:00
Matthew Remmel a39a87ba6d Disable create room button if no permission 2025-10-17 09:13:54 -07:00
Matthew Remmel 5b22d6ac01 Allow editing previous messages in channel chat 2025-10-17 11:13:09 -05:00
Jon Staab 7334cd26f8 Bump version 2025-10-13 15:17:46 -07:00
Jon Staab 44555215cf Track shards separately, upgrade deps 2025-10-13 13:41:27 -07:00
Jon Staab 0cc25913c0 Optimize event storage 2025-10-13 12:46:56 -07:00
Jon Staab 004b30b737 Update caniuse 2025-10-13 11:48:22 -07:00
Jon Staab 632f330b4c Re-work storage to optimize file access 2025-10-06 17:01:25 -07:00
Jon Staab 666433912f Only show send toast in chat if send_delay is set 2025-10-06 11:27:07 -07:00
Jon Staab db98ce8db7 Bump welshman 2025-10-06 11:26:27 -07:00
Jon Staab 71dcfae5ff Add heading, update changelog, bump version 2025-10-02 12:43:19 -07:00
Jon Staab 04155f5b23 Bump welshman 2025-10-01 17:03:25 -07:00
Jon Staab b4058389ec Avoid decrypt errors 2025-10-01 10:06:21 -07:00
Jon Staab 483fa81b74 Fix some storage bugs 2025-09-30 17:04:52 -07:00
Jon Staab a8d1c4bbbc Refactor storage 2025-09-30 16:28:12 -07:00
Matthew Remmel 0a8c2faa74 Add filestorage adapters 2025-09-30 10:52:24 -07:00
Jon Staab dd3231e70f Bring back blossom server auth, fix duplicate direct messages 2025-09-29 14:25:07 -07:00
Jon Staab 7ff9c00032 Force url extension for encrypted uploads 2025-09-29 14:25:07 -07:00
Matthew Remmel 9ed483abf7 Ignore exception from if failing to set badge 2025-09-29 10:32:13 -07:00
Matthew Remmel b9aeaf29a4 Disable notification sound when tab is focused 2025-09-29 10:32:13 -07:00
Matthew Remmel 65e3f81f36 Remove comments and test lines 2025-09-29 10:32:13 -07:00
Matthew Remmel c6641dba31 Move notification sound and badge settings to settings store 2025-09-29 10:32:13 -07:00
Matthew Remmel e48d1e0e59 Fix async bug and add sound component for notification sound 2025-09-29 10:32:13 -07:00
Matthew Remmel d1e5aee84e Add naive badge count implementation 2025-09-29 10:32:13 -07:00
Matthew Remmel 5cb22d0bed Add checkboxes for badge/sound settings 2025-09-29 10:32:13 -07:00
Matthew Remmel d1c6f53d7c Add royalty-free sound effect for new notification 2025-09-29 10:32:13 -07:00
Matthew Remmel 6e238f98c0 Auto-add receiving address on wallet setup 2025-09-29 10:25:02 -07:00
Jon Staab 290274d6c8 Tweak light theme, remove conditional button classes 2025-09-25 10:52:32 -07:00
Jon Staab e1de0239c9 Remove css that was breaking tooltips 2025-09-25 10:43:55 -07:00
Jon Staab bec77d59e8 Add theme toggle on mobile, change button color for quick links 2025-09-25 10:37:53 -07:00
Jon Staab 84f8794d7c Make link previews less aggressive 2025-09-25 10:12:58 -07:00
Jon Staab 4cddf41bf3 Set initial delay to 0 2025-09-24 09:50:07 -07:00
Jon Staab 125a7e238e Add qr scanner to discover page 2025-09-22 15:56:48 -07:00
Jon Staab 468200b717 Link directly to discover page 2025-09-22 15:48:13 -07:00
Jon Staab bdfcb99781 Show more information about signer type 2025-09-22 15:09:41 -07:00
Jon Staab 38da650861 Add qr code to invite screen 2025-09-22 14:57:43 -07:00
Jon Staab dd006badfc Bring back blossom feature detection 2025-09-22 14:05:57 -07:00
Jon Staab 87e4e3fe5b Catch all upload errors 2025-09-22 11:43:44 -07:00
Jon Staab af3e38254f Fix focus on input list 2025-09-22 11:06:16 -07:00
Jon Staab 70843f54d3 Increase contrast on mention badges in editor 2025-09-22 10:40:54 -07:00
Jon Staab bda75b29b4 Handle bunker login errors better 2025-09-22 10:35:31 -07:00
Jon Staab 750830d593 Bump version again 2025-09-18 14:39:15 -07:00
Jon Staab 3c0f1a1d2f Restore icons 2025-09-18 14:13:08 -07:00
Jon Staab 4253b0ed29 Remove all icons 2025-09-18 14:12:08 -07:00
Jon Staab 3c9b3f23df Upload profile pictures instead of doing base64 2025-09-18 13:43:43 -07:00
Jon Staab e0d83608be Bump version, changelog 2025-09-18 13:09:03 -07:00
Jon Staab a0301d599b Bump welshman, rename stripExifData 2025-09-18 12:17:31 -07:00
Jon Staab 7dcaa0e8d7 Change login icon 2025-09-16 11:06:36 -07:00
Matthew Remmel 129f49bcc7 Compress profile pictures on upload 2025-09-15 10:55:01 -07:00
Jon Staab fc3b68c390 Fix default avatar icon 2025-09-11 16:44:02 -07:00
Jon Staab 52c7df8a15 Default to light mode 2025-09-11 14:47:06 -07:00
Jon Staab ce1c4dd488 Fix some icons 2025-09-11 12:43:18 -07:00
Jon Staab fc6a1a3819 Move alerts to their own page, add direct message alerts 2025-09-11 12:28:54 -07:00
Jon Staab 69bd6d0e70 Use new icons 2025-09-11 08:59:47 -07:00
Jon Staab 6d383d54e8 Fix app image clipping 2025-09-09 10:53:16 -07:00
Jon Staab 998c48b1d3 Wait for thunk errors 2025-09-08 08:34:52 -07:00
Jon Staab 7217d122b5 Re-work invite links 2025-09-08 08:34:52 -07:00
Jon Staab 1c37c5bb3d Make white labeled nav look less bad 2025-09-05 16:21:17 -07:00
Jon Staab e8f785b558 Bump welshman 2025-09-05 11:35:34 -07:00
Matthew Remmel c94d314f6d Use capacitor preferences package instead of localStorage 2025-09-05 11:34:52 -07:00
Jon Staab 2672a8f922 Include instructions in key file 2025-09-05 10:49:20 -07:00
hodlbod 8a8d80d692 Merge pull request #197 from coracle-social/auth-errors
Auth errors
2025-09-04 11:25:30 -07:00
Jon Staab 95698813c6 Monitor relay connections for restricted responses and show error to user 2025-09-04 11:25:12 -07:00
hodlbod 4001e877b4 Merge pull request #193 from coracle-social/trusted-relays
Buffer unsigned events until approved
2025-09-04 11:21:54 -07:00
Jon Staab 99defc6d79 Allow users to opt-in to spaces that strip signatures 2025-09-03 16:36:30 -07:00
Jon Staab a94883089e Rename username to nickname 2025-09-03 16:34:00 -07:00
hodlbod 5ea4aeb75c Merge pull request #186 from coracle-social/flotilla-180-rooms-disappear
Save rooms to local storage
2025-08-27 06:25:08 -07:00
Matthew Remmel 456d111925 Save rooms to local storage 2025-08-27 09:07:32 -04:00
Jon Staab 837ae4b38e Update changelog, bump version 2025-08-26 11:17:38 -07:00
Jon Staab ffbcbf86c3 Bump welshman 2025-08-26 11:08:59 -07:00
Matthew Remmel bcda637192 Merge pull request #182 from coracle-social/flotilla-148-deep-linking
Add mobile deep linking support
2025-08-26 13:37:40 -04:00
Matthew Remmel 72c7dd6126 Add missing data config to android manifest 2025-08-26 13:31:01 -04:00
Matthew Remmel a2a4b3599f Remove temporary code and comments 2025-08-26 13:17:25 -04:00
Matthew Remmel 4955a4f16c Add real values in app association files 2025-08-26 13:16:32 -04:00
Matthew Remmel bb1ff4fb11 Add temporary web event listener for deep link navigation testing in web
browser
2025-08-26 12:43:44 -04:00
Matthew Remmel b81f7c9ed3 Add basic deep link route handling 2025-08-26 12:14:11 -04:00
Matthew Remmel 689cfb6d45 Add placholder changes for deep linking 2025-08-26 10:37:45 -04:00
Jon Staab 9da3141650 Add indicator for who sent the most recent message in a converssation 2025-08-25 16:24:24 -07:00
Jon Staab e4fe18df2f Fix encrypted uploads, show error 2025-08-21 16:06:14 -07:00
Jon Staab ba80ebac63 Add contributing file, rename some files 2025-08-21 15:01:31 -07:00
Jon Staab d4943daa82 Add chat prompt to dashboard 2025-08-19 14:05:02 -07:00
Jon Staab cde03ec0fe Avoid reflow by showing chat thunk status in a toast 2025-08-19 14:03:04 -07:00
Jon Staab 4f6c08f8a2 Build better onboarding 2025-08-18 15:02:17 -07:00
Jon Staab 38e0fc53ad Update wallet to use welshman's session wallet 2025-08-18 13:26:28 -07:00
Jon Staab 2a30ca5306 Bump welshman, drop support for safe area insets 2025-08-06 15:09:22 -07:00
Jon Staab 4a4ea13bef Show relays a note was seen on 2025-08-04 12:32:44 -07:00
Jon Staab 239bd3f31a Remove invite code input from alert add screen 2025-08-01 12:57:29 -07:00
Jon Staab 831ec05012 Filter out non-global chat from global chat 2025-07-31 13:25:40 -07:00
Jon Staab 0cc0598287 Allow tapping on tippy triggers on mobile 2025-07-31 10:25:55 -07:00
Jon Staab 0a5bc618c2 Fix formatting 2025-07-30 16:37:05 -07:00
Jon Staab 069904f07a Only protect events if the relay will authenticate with the user 2025-07-30 16:31:40 -07:00
Jon Staab 03b42c8276 Monitor signer status 2025-07-30 15:54:10 -07:00
Jon Staab 8697cc23be Add signer status, re-work bunker login 2025-07-29 10:53:48 -07:00
Jon Staab 69e1f97e72 Display create at on event info 2025-07-22 10:10:20 -07:00
1593 changed files with 19036 additions and 7443 deletions
+3 -2
View File
@@ -1,16 +1,17 @@
VITE_DEFAULT_PUBKEYS=06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71,266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed,6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e,76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa,7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3
VITE_DEFAULT_BLOSSOM_SERVERS=https://blossom.primal.net/
VITE_BURROW_URL=
VITE_PLATFORM_URL=https://flotilla.social
VITE_PLATFORM_TERMS=https://flotilla.social/terms
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
VITE_PLATFORM_NAME=Flotilla
VITE_PLATFORM_LOGO=static/flotilla.png
VITE_PLATFORM_LOGO=static/logo.png
VITE_PLATFORM_RELAYS=
VITE_PLATFORM_ACCENT="#7161FF"
VITE_PLATFORM_SECONDARY="#EB5E28"
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities."
VITE_INDEXER_RELAYS=wss://purplepag.es/,wss://relay.damus.io/,wss://relay.nostr.band/,wss://indexer.coracle.social/
VITE_SIGNER_RELAYS=wss://relay.nsec.app/,wss://bucket.coracle.social/
VITE_SIGNER_RELAYS=wss://relay.nsec.app/,wss://offchain.pub/
VITE_NOTIFIER_PUBKEY=27b7c2ed89ef78322114225ea3ebf5f72c7767c2528d4d0c1854d039c00085df
VITE_NOTIFIER_RELAY=wss://anchor.coracle.social/
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
+64
View File
@@ -1,5 +1,69 @@
# Changelog
# 1.4.0
* Allow "editing" chat messages
* Check for room create permission
* Re-work space navigation
* Show all messages in non-nip29 chat
* Improve synchronization logic
* Add connection status to space menu
* Add icon picker to room create component
* Improve mention suggestions
* Improve storage adapter and relay list performance
* Fix modals
* Add room deletion
* Fix zapper loading
* Add support for relay/group member lists and join/leave events
# 1.3.1
* Fix memory leak in storage adapter
* Show fewer annoying toast messages
# 1.3.0
* Add optional badge and sound for notifications
* Improve link rendering
* Remove imgproxy
* Bring back blossom feature detection for spaces
* Improve light theme
* Add more info to signer status
* Simplify navigation for adding a space
* Add ability to scan QR code for invite links
* Streamline wallet setup and move receive address setting
* Remove indexeddb on mobile, use capacitor file storage API
* Fix duplicate DMs showing up
# 1.2.5
* Fix icons in build
# 1.2.4
* Add direct message alerts
* Add alert settings page
* Add instructions to key download
* Add option that allows relays to strip signatures
* Detect relays that mostly refuse to serve requests
* Compress and upload profile images
* Use system theme by default
* Switch icon set, refactor how they're included
* Use capacitor's preferences for storage instead of localStorage
# 1.2.3
* Add `created_at` to event info dialog
* Add signer status to profile page
* Re-work bunker login flow
* Add in-app onboarding flow
* Only protect events if relay authenticates
* Filter out non-global chats from global chat
* Improve publish status indicator
* Fix encrypted upload content type
* Add relays to event details dialog
* Add universal link handler for apps
# 1.2.2
* Fix phantom chat notifications
-26
View File
@@ -1,26 +0,0 @@
## Project Overview
Flotilla is a Discord-like Nostr client that operates on the concept of "relays as groups/spaces." Built with SvelteKit 2.5 and Svelte 5, it provides messaging, threads, calendar events, and social features across Nostr relays.
## Important Patterns
### Finding Code
- Prefer navigating from one file to the next following imports when possible
- If search is necessary, use `ack`, not `grep` or `rg`.
### Nostr Event Handling
- Prefer seconds to milliseconds when handling nostr events.
### Styling Conventions
- When styling html, prefer flex/gap classes over margin or space-y classes.
### Room/space memberships
Memberships are surfaced as "bookmarks" to the user.
```typescript
import {membershipsByPubkey, getMembershipUrls} from '@app/state'
const spaces = getMembershipUrls($membershipsByPubkey.get(pubkey))
const rooms = getMembershipRooms($membershipsByPubkey.get(pubkey))
```
+96
View File
@@ -0,0 +1,96 @@
# Contributing guidelines
## Project Overview
Flotilla is a svelte/typescript/capacitor project. It's intended to be an alternative to Discord for Nostr users. A high-quality UX is a priority, with an emphasis on well-tested, intuitive designs, and robust implementations.
## Getting Started
Run `pnpm run dev` to get a dev server, and `pnpm run check:watch` to watch for typescript errors. When you're ready to commit, a pre-commit hook will run to lint and typecheck your work. To run the project on Android or iOS, use Android Studio or Xcode.
The `master` branch is automatically deployed to production, so always work on feature branches based on the `dev` branch. This project frequently uses unreleased versions of [welshman](https://welshman.coracle.social), using `pnpm` link to hotlink a local copy of the code. To set that up, clone welshman to the parent directory of your `coracle` client, then add `link:../welshman/packages/packagename` to the `pnpm.overrides` section of your `package.json`. Below is a nodejs script that will do that automatically for you:
```javascript
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
packageJson.pnpm.overrides = Object.keys(packageJson.dependencies)
.filter(pkg => pkg.startsWith('@welshman/'))
.reduce((acc, pkg) => {
const packageName = pkg.split('/')[1]
acc[pkg] = `link:../welshman/packages/${packageName}`
return acc
}, {})
fs.writeFileSync('./package.json', JSON.stringify(packageJson, null, 2) + '\n')
console.log('Added welshman package overrides.')
```
Be sure to avoid committing overrides to either `package.json` or `pnpm-lock.yaml`. These overrides can generally be added, installed, and removed, and will persist until another `pnpm install` command gets run.
## File Structure
The main parts of the application are as follows:
- `static` - static assets like fonts, images, etc.
- `src/assets` - svgs for use as icons.
- `src/lib` - general purpose components and utilities.
- `src/app/core/state` - environment variables, constants, custom stores, and some utilities derived from them.
- `src/app/core/requests` - utilities related to loading data from the nostr network.
- `src/app/core/commands` - utilities related to publishing nostr events and uploading media to blossom servers.
- `src/app/utils` - other application logic, including stuff related to modals, routing, etc.
- `src/app/editor` - configuration for `@welshman/editor` for use in various app views.
- `src/app/components` - reusable components that depend on other `app` stuff.
- `src/routes` - file-based routing interpreted by sveltekit.
Application organization is based on an acyclic dependency graph:
- `routes` can depend on anything
- `app/components` can depend on anything in `app` or `lib`
- `app/utils` and `app/core` can only depend on `lib`
- `lib` (and everything else) can depend only on external libraries
The main stylistic/organizational rule when working in this project is that imports should be sorted based on the dependency graph. Third-party libraries should come first, then `lib`, then `app`.
## System Architecture
Flotilla's architecture generally mirrors the file structure. State is stored using Svelte `store`s provided either by `@welshman/app` or by `app/core/state`, allowing for idiomatic svelte 4 usage (svelte 5 runes are [ghey](https://habla.news/u/hodlbod@coracle.social/1739830562159) and not allowed outside of UI components).
State is then synchronized to local storage or indexeddb using storage helpers provided by welshman in `routes/+layout.svelte`. Other top level synchronization logic generally belongs there.
`app/core/state` contains all environment variables, constants, custom stores, and utilities derived from them. Most stores are `derived` from our event `repository` using `deriveEventsMapped`, which efficiently queries the repository and maps events to custom data structures. Some of these data structures are provided by `welshman`, and some are defined in `app/core/state`. In either case, they can always be mapped back to an event, which is important for updating replaceables without dropping unknown data.
Here are a few important domain objects:
- Spaces are relays used as community groups. Their `url`s are core to a lot of data and components, and are frequently passed around from place to place.
- Chats are direct message conversations. There is currently some ambiguity in routing, since relays that don't support NIP 29 also have a "chat" tab, which uses vanilla NIP-C7.
- NIP 29 groups are variously called "rooms" and "channels". Conventionally, a "room" is a group id, while a "channel" as an object representing the group's metadata.
- "Alerts" are records of requests the user has made to be notified, following [this NIP](https://github.com/nostr-protocol/nips/pull/1796)
`app/core/requests` contains utilities related to loading data from the nostr network. This might include feed manager utilities, loaders, or listeners.
`app/core/commands` contains utilities related to publishing nostr events and uploading media to blossom servers. This also includes utilities related to sending lighting payments, authenticating with relays, or probing relay policy. Event creation should generally be split into `make` functions which build the event, and `publish` functions which publish the event using `publishThunk`.
Any of these utilities can be included either in `app/components` or `routes`. Crucial to keep in mind is that nearly all global state runs through welshman's `repository` in a unidirectional way. To update state, run `publishThunk`, which immediately publishes the event to the local repository. State can be read from the repository using `deriveEventsMapped` or other utilities provided by welshman like `deriveProfile`.
Thunks are designed to reduce UI latency, handling signatures and delayed sending the background. In most cases, thunk status should be displayed to the user so that they can cancel sending or address errors.
Toast, modals, and sidebar dialogs are controlled in `app/util/modal` and `app/util/toast`. In both cases, component objects can be passed along with parameters, but care has to be taken that the calling component either doesn't unmount before the modal (as when one modal replaces another), or that `$state.snapshot` is appropriately called on any state runes. These components frequently run into weird svelte compiler bugs too, in which case you may have to do some silly things to cope.
## Issues and Pull Requests
All work by contributors should be done against an issue. If there is no issue for the work you're doing, please open one or ask the project owner to open one. All PRs should be opened against the `dev` branch (unless for hotfixes).
## Communication
Discussion about development is done [on Flotilla](https://app.flotilla.social/spaces/internal.coracle.social). The group is currently closed, so please let me know if you'd like access.
## Project License
This project is licensed under the MIT license. By contributing, you agree to waive all intellectual property rights to your contributions to this project.
+1 -1
View File
@@ -22,7 +22,7 @@ If you're deploying a custom version of flotilla, be sure to remove the `plausib
## Development
Run `pnpm run dev` to get a dev server, and `pnpm run check:watch` to watch for typescript errors. When you're ready to commit, a pre-commit hook will run to lint and typecheck your work.
See [./CONTRIBUTING.md](CONTRIBUTING.md).
## Deployment
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "social.flotilla"
minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion
versionCode 23
versionName "1.2.2"
versionCode 29
versionName "1.4.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+3
View File
@@ -11,8 +11,11 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-community-safe-area')
implementation project(':capacitor-app')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-keyboard')
implementation project(':capacitor-preferences')
implementation project(':capacitor-push-notifications')
implementation project(':capawesome-capacitor-android-dark-mode-support')
implementation project(':capawesome-capacitor-badge')
implementation project(':nostr-signer-capacitor-plugin')
+7
View File
@@ -20,6 +20,13 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="app.flotilla.social" />
</intent-filter>
</activity>
<provider
+2 -1
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
@@ -11,6 +11,7 @@
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
<item name="android:windowOptOutEdgeToEdgeEnforcement" tools:ignore="NewApi">true</item>
</style>
+16 -7
View File
@@ -1,21 +1,30 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/android/capacitor')
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@7.4.3_@capacitor+core@7.4.3/node_modules/@capacitor/android/capacitor')
include ':capacitor-community-safe-area'
project(':capacitor-community-safe-area').projectDir = new File('../node_modules/.pnpm/@capacitor-community+safe-area@7.0.0-alpha.1_@capacitor+core@7.2.0/node_modules/@capacitor-community/safe-area/android')
project(':capacitor-community-safe-area').projectDir = new File('../node_modules/.pnpm/@capacitor-community+safe-area@7.0.0-alpha.1_@capacitor+core@7.4.3/node_modules/@capacitor-community/safe-area/android')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app/android')
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@7.1.0_@capacitor+core@7.4.3/node_modules/@capacitor/app/android')
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@7.1.4_@capacitor+core@7.4.3/node_modules/@capacitor/filesystem/android')
include ':capacitor-keyboard'
project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard/android')
project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@7.0.3_@capacitor+core@7.4.3/node_modules/@capacitor/keyboard/android')
include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@capacitor+preferences@7.0.2_@capacitor+core@7.4.3/node_modules/@capacitor/preferences/android')
include ':capacitor-push-notifications'
project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/push-notifications/android')
project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@7.0.3_@capacitor+core@7.4.3/node_modules/@capacitor/push-notifications/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@7.0.0_@capacitor+core@7.4.3/node_modules/@capawesome/capacitor-android-dark-mode-support/android')
include ':capawesome-capacitor-badge'
project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@7.0.1_@capacitor+core@7.2.0/node_modules/@capawesome/capacitor-badge/android')
project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@7.0.1_@capacitor+core@7.4.3/node_modules/@capawesome/capacitor-badge/android')
include ':nostr-signer-capacitor-plugin'
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin/android')
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.4.3/node_modules/nostr-signer-capacitor-plugin/android')
+3
View File
@@ -7,6 +7,9 @@ const config: CapacitorConfig = {
server: {
androidScheme: "https"
},
android: {
adjustMarginsForEdgeToEdge: false,
},
plugins: {
SplashScreen: {
androidSplashResourceName: "splash"
+8 -4
View File
@@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
3478F0332E846FEB002431E0 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3478F0322E846FEB002431E0 /* PrivacyInfo.xcprivacy */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
@@ -21,6 +22,7 @@
051414282E0CC28400BE0BC8 /* Flotilla Chat.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Flotilla Chat.entitlements"; sourceTree = "<group>"; };
1F53EE54954731A2328CBC4B /* Pods-Flotilla Chat.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Flotilla Chat.release.xcconfig"; path = "Pods/Target Support Files/Pods-Flotilla Chat/Pods-Flotilla Chat.release.xcconfig"; sourceTree = "<group>"; };
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
3478F0322E846FEB002431E0 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
504EC3041FED79650016851F /* Flotilla Chat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Flotilla Chat.app"; sourceTree = BUILT_PRODUCTS_DIR; };
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@@ -58,6 +60,7 @@
504EC2FB1FED79650016851F = {
isa = PBXGroup;
children = (
3478F0322E846FEB002431E0 /* PrivacyInfo.xcprivacy */,
051414282E0CC28400BE0BC8 /* Flotilla Chat.entitlements */,
504EC3061FED79650016851F /* App */,
504EC3051FED79650016851F /* Products */,
@@ -162,6 +165,7 @@
buildActionMask = 2147483647;
files = (
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */,
3478F0332E846FEB002431E0 /* PrivacyInfo.xcprivacy in Resources */,
50B271D11FEDC1A000F3C39B /* public in Resources */,
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
@@ -354,14 +358,14 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 16;
CURRENT_PROJECT_VERSION = 20;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.2.2;
MARKETING_VERSION = 1.4.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -380,14 +384,14 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 16;
CURRENT_PROJECT_VERSION = 20;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.2.2;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
+4
View File
@@ -4,5 +4,9 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:app.flotilla.social</string>
</array>
</dict>
</plist>
+11 -9
View File
@@ -1,4 +1,4 @@
require_relative '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios/scripts/pods_helpers'
require_relative '../../node_modules/.pnpm/@capacitor+ios@7.4.3_@capacitor+core@7.4.3/node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '14.0'
use_frameworks!
@@ -9,14 +9,16 @@ use_frameworks!
install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios'
pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@7.0.0-alpha.1_@capacitor+core@7.2.0/node_modules/@capacitor-community/safe-area'
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app'
pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard'
pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/push-notifications'
pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@7.0.1_@capacitor+core@7.2.0/node_modules/@capawesome/capacitor-badge'
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin'
pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@7.4.3_@capacitor+core@7.4.3/node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@7.4.3_@capacitor+core@7.4.3/node_modules/@capacitor/ios'
pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@7.0.0-alpha.1_@capacitor+core@7.4.3/node_modules/@capacitor-community/safe-area'
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@7.1.0_@capacitor+core@7.4.3/node_modules/@capacitor/app'
pod 'CapacitorFilesystem', :path => '../../node_modules/.pnpm/@capacitor+filesystem@7.1.4_@capacitor+core@7.4.3/node_modules/@capacitor/filesystem'
pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@7.0.3_@capacitor+core@7.4.3/node_modules/@capacitor/keyboard'
pod 'CapacitorPreferences', :path => '../../node_modules/.pnpm/@capacitor+preferences@7.0.2_@capacitor+core@7.4.3/node_modules/@capacitor/preferences'
pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@7.0.3_@capacitor+core@7.4.3/node_modules/@capacitor/push-notifications'
pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@7.0.1_@capacitor+core@7.4.3/node_modules/@capawesome/capacitor-badge'
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.4.3/node_modules/nostr-signer-capacitor-plugin'
end
target 'Flotilla Chat' do
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
</array>
</dict>
</plist>
+55 -52
View File
@@ -1,6 +1,6 @@
{
"name": "flotilla",
"version": "1.2.2",
"version": "1.4.0",
"private": true,
"scripts": {
"dev": "vite dev",
@@ -10,74 +10,77 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check src && eslint src",
"format": "prettier --write src",
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte)$' | xargs -r prettier --write",
"format:all": "prettier --write src",
"prepare": "husky"
},
"devDependencies": {
"@capacitor/assets": "^3.0.5",
"@eslint/js": "^9.26.0",
"@sentry/cli": "^2.40.0",
"@sveltejs/kit": "^2.5.27",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/eslint": "^9.6.0",
"autoprefixer": "^10.4.19",
"@eslint/js": "^9.37.0",
"@sentry/cli": "^2.56.1",
"@sveltejs/kit": "^2.46.5",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@types/eslint": "^9.6.1",
"autoprefixer": "^10.4.21",
"classnames": "^2.5.1",
"eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.45.1",
"globals": "^15.0.0",
"postcss": "^8.4.40",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.2.6",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^3.4.7",
"typescript": "^5.5.0",
"typescript-eslint": "^8.0.0",
"vite": "^5.4.4"
"eslint": "^9.37.0",
"eslint-config-prettier": "^9.1.2",
"eslint-plugin-svelte": "^2.46.1",
"globals": "^15.15.0",
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.39.12",
"svelte-check": "^4.3.3",
"tailwindcss": "^3.4.18",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.1",
"vite": "^5.4.20"
},
"type": "module",
"dependencies": {
"@capacitor-community/safe-area": "7.0.0-alpha.1",
"@capacitor/android": "^7.0.0",
"@capacitor/app": "^7.0.0",
"@capacitor/cli": "^7.0.0",
"@capacitor/core": "^7.0.1",
"@capacitor/ios": "^7.0.0",
"@capacitor/keyboard": "^7.0.0",
"@capacitor/push-notifications": "^7.0.1",
"@capacitor/android": "^7.4.3",
"@capacitor/app": "^7.1.0",
"@capacitor/cli": "^7.4.3",
"@capacitor/core": "^7.4.3",
"@capacitor/filesystem": "^7.1.4",
"@capacitor/ios": "^7.4.3",
"@capacitor/keyboard": "^7.0.3",
"@capacitor/preferences": "^7.0.2",
"@capacitor/push-notifications": "^7.0.3",
"@capawesome/capacitor-android-dark-mode-support": "^7.0.0",
"@capawesome/capacitor-badge": "^7.0.1",
"@getalby/sdk": "^5.1.0",
"@getalby/sdk": "^5.1.2",
"@poppanator/sveltekit-svg": "^4.2.1",
"@sentry/browser": "^8.35.0",
"@sveltejs/adapter-static": "^3.0.4",
"@tiptap/core": "^2.12.0",
"@sentry/browser": "^8.55.0",
"@sveltejs/adapter-static": "^3.0.10",
"@tiptap/core": "^2.26.3",
"@types/qrcode": "^1.5.5",
"@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.6",
"@welshman/app": "^0.4.0",
"@welshman/content": "^0.4.0",
"@welshman/editor": "^0.4.0",
"@welshman/feeds": "^0.4.0",
"@welshman/lib": "^0.4.0",
"@welshman/net": "^0.4.0",
"@welshman/relay": "^0.4.0",
"@welshman/router": "^0.4.0",
"@welshman/signer": "^0.4.0",
"@welshman/store": "^0.4.0",
"@welshman/util": "^0.4.0",
"@vite-pwa/sveltekit": "^0.6.8",
"@welshman/app": "^0.6.2",
"@welshman/content": "^0.6.2",
"@welshman/editor": "^0.6.2",
"@welshman/feeds": "^0.6.2",
"@welshman/lib": "^0.6.2",
"@welshman/net": "^0.6.2",
"@welshman/router": "^0.6.2",
"@welshman/signer": "^0.6.2",
"@welshman/store": "^0.6.2",
"@welshman/util": "^0.6.2",
"compressorjs": "^1.2.1",
"daisyui": "^4.12.10",
"date-picker-svelte": "^2.13.0",
"dotenv": "^16.4.5",
"emoji-picker-element": "^1.22.8",
"fuse.js": "^7.0.0",
"husky": "^9.1.6",
"idb": "^8.0.0",
"daisyui": "^4.12.24",
"date-picker-svelte": "^2.16.0",
"dotenv": "^16.6.1",
"emoji-picker-element": "^1.27.0",
"fuse.js": "^7.1.0",
"husky": "^9.1.7",
"idb": "^8.0.3",
"nostr-signer-capacitor-plugin": "^0.0.4",
"nostr-tools": "^2.14.2",
"prettier-plugin-tailwindcss": "^0.6.5",
"prettier-plugin-tailwindcss": "^0.6.14",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"throttle-debounce": "^5.0.2",
+2023 -1841
View File
File diff suppressed because it is too large Load Diff
+10 -14
View File
@@ -46,10 +46,10 @@
:root {
font-family: Lato;
--sait: env(safe-area-inset-top);
--saib: env(safe-area-inset-bottom);
--sail: env(safe-area-inset-left);
--sair: env(safe-area-inset-right);
--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));
}
[data-theme] {
@@ -62,6 +62,8 @@
--primary-content: oklch(var(--pc));
--secondary: oklch(var(--s));
--secondary-content: oklch(var(--sc));
--neutral: oklch(var(--n));
--neutral-content: oklch(var(--nc));
}
/* safe area insets */
@@ -160,11 +162,11 @@
}
.card2 {
@apply rounded-box p-6 text-base-content;
@apply rounded-box p-4 text-base-content sm:p-6;
}
.card2.card2-sm {
@apply p-4 text-base-content;
@apply p-2 text-base-content sm:p-4;
}
.column {
@@ -215,12 +217,6 @@
@apply ellipsize;
}
@media (max-width: 639px) {
[data-tip]::before {
display: none;
}
}
.content-padding-x {
@apply px-4 sm:px-8 md:px-12;
}
@@ -278,8 +274,8 @@
}
.tiptap {
--tiptap-object-bg: var(--base-100);
--tiptap-object-fg: var(--base-content);
--tiptap-object-bg: var(--neutral);
--tiptap-object-fg: var(--neutral-content);
--tiptap-active-bg: var(--primary);
--tiptap-active-fg: var(--primary-content);
}
-464
View File
@@ -1,464 +0,0 @@
import {nwc} from "@getalby/sdk"
import * as nip19 from "nostr-tools/nip19"
import {get} from "svelte/store"
import {randomId, flatten, poll, uniq, equals, TIMEZONE, LOCALE} from "@welshman/lib"
import type {Feed} from "@welshman/feeds"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {
DELETE,
REPORT,
PROFILE,
INBOX_RELAYS,
RELAYS,
FOLLOWS,
REACTION,
AUTH_JOIN,
ROOMS,
COMMENT,
ALERT_EMAIL,
ALERT_WEB,
ALERT_IOS,
ALERT_ANDROID,
isSignedEvent,
makeEvent,
displayProfile,
normalizeRelayUrl,
makeList,
addToListPublicly,
removeFromListByPredicate,
getTag,
getListTags,
getRelayTags,
getRelayTagValues,
toNostrURI,
getRelaysFromList,
RelayMode,
} from "@welshman/util"
import {Pool, AuthStatus, SocketStatus} from "@welshman/net"
import {Router} from "@welshman/router"
import {
pubkey,
signer,
repository,
publishThunk,
profilesByPubkey,
relaySelectionsByPubkey,
tagEvent,
tagEventForReaction,
userRelaySelections,
userInboxRelaySelections,
nip44EncryptToSelf,
loadRelay,
clearStorage,
dropSession,
tagEventForComment,
tagEventForQuote,
getThunkError,
} from "@welshman/app"
import {
wallet,
getWebLn,
PROTECTED,
userMembership,
INDEXER_RELAYS,
NOTIFIER_PUBKEY,
NOTIFIER_RELAY,
userRoomsByUrl,
} from "@app/state"
// Utils
export const getPubkeyHints = (pubkey: string) => {
const selections = relaySelectionsByPubkey.get().get(pubkey)
const relays = selections ? getRelaysFromList(selections, RelayMode.Write) : []
const hints = relays.length ? relays : INDEXER_RELAYS
return hints
}
export const getPubkeyPetname = (pubkey: string) => {
const profile = profilesByPubkey.get().get(pubkey)
const display = displayProfile(profile)
return display
}
export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => {
if (parent) {
const nevent = nip19.neventEncode({
id: parent.id,
kind: parent.kind,
author: parent.pubkey,
relays: Router.get().Event(parent).limit(3).getUrls(),
})
tags = [...tags, tagEventForQuote(parent)]
content = toNostrURI(nevent) + "\n\n" + content
}
return {content, tags}
}
// Log out
export const logout = async () => {
const $pubkey = pubkey.get()
if ($pubkey) {
dropSession($pubkey)
}
await clearStorage()
localStorage.clear()
}
// Synchronization
export const broadcastUserData = async (relays: string[]) => {
const authors = [pubkey.get()!]
const kinds = [RELAYS, INBOX_RELAYS, FOLLOWS, PROFILE]
const events = repository.query([{kinds, authors}])
for (const event of events) {
if (isSignedEvent(event)) {
await publishThunk({event, relays}).result
}
}
}
// List updates
export const addSpaceMembership = async (url: string) => {
const list = get(userMembership) || makeList({kind: ROOMS})
const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf)
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays})
}
export const removeSpaceMembership = async (url: string) => {
const list = get(userMembership) || makeList({kind: ROOMS})
const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays})
}
export const addRoomMembership = async (url: string, room: string) => {
const list = get(userMembership) || makeList({kind: ROOMS})
const newTags = [
["r", url],
["group", room, url],
]
const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf)
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays})
}
export const removeRoomMembership = async (url: string, room: string) => {
const list = get(userMembership) || makeList({kind: ROOMS})
const pred = (t: string[]) => equals(["group", room, url], t.slice(0, 3))
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays})
}
export const setRelayPolicy = (url: string, read: boolean, write: boolean) => {
const list = get(userRelaySelections) || makeList({kind: RELAYS})
const tags = getRelayTags(getListTags(list)).filter(t => normalizeRelayUrl(t[1]) !== url)
if (read && write) {
tags.push(["r", url])
} else if (read) {
tags.push(["r", url, "read"])
} else if (write) {
tags.push(["r", url, "write"])
}
return publishThunk({
event: makeEvent(list.kind, {tags}),
relays: [
url,
...INDEXER_RELAYS,
...Router.get().FromUser().getUrls(),
...userRoomsByUrl.get().keys(),
],
})
}
export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
const list = get(userInboxRelaySelections) || makeList({kind: INBOX_RELAYS})
// Only update inbox policies if they already exist or we're adding them
if (enabled || getRelaysFromList(list).includes(url)) {
const tags = getRelayTags(getListTags(list)).filter(t => normalizeRelayUrl(t[1]) !== url)
if (enabled) {
tags.push(["relay", url])
}
return publishThunk({
event: makeEvent(list.kind, {tags}),
relays: [
...INDEXER_RELAYS,
...Router.get().FromUser().getUrls(),
...userRoomsByUrl.get().keys(),
],
})
}
}
// Relay access
export const attemptAuth = (url: string) =>
Pool.get()
.get(url)
.auth.attemptAuth(e => signer.get()?.sign(e))
export const checkRelayAccess = async (url: string, claim = "") => {
const socket = Pool.get().get(url)
await attemptAuth(url)
const thunk = publishThunk({
event: makeEvent(AUTH_JOIN, {tags: [["claim", claim]]}),
relays: [url],
})
const error = await getThunkError(thunk)
if (error) {
const message =
socket.auth.details?.replace(/^\w+: /, "") ||
error?.replace(/^\w+: /, "") ||
"join request rejected"
// If it's a strict NIP 29 relay don't worry about requesting access
// TODO: remove this if relay29 ever gets less strict
if (message === "missing group (`h`) tag") return
// Ignore messages about the relay ignoring ours
if (error?.startsWith("mute: ")) return
// Ignore rejected empty claims
if (!claim && error?.includes("invite code")) return
return message
}
}
export const checkRelayProfile = async (url: string) => {
const relay = await loadRelay(url)
if (!relay?.profile) {
return "Sorry, we weren't able to find that relay."
}
}
export const checkRelayConnection = async (url: string) => {
const socket = Pool.get().get(url)
socket.attemptToOpen()
await poll({
signal: AbortSignal.timeout(3000),
condition: () => socket.status === SocketStatus.Open,
})
if (socket.status !== SocketStatus.Open) {
return `Failed to connect`
}
}
export const checkRelayAuth = async (url: string, timeout = 3000) => {
const socket = Pool.get().get(url)
const okStatuses = [AuthStatus.None, AuthStatus.Ok]
await attemptAuth(url)
// Only raise an error if it's not a timeout.
// If it is, odds are the problem is with our signer, not the relay
if (!okStatuses.includes(socket.auth.status) && socket.auth.details) {
return `Failed to authenticate (${socket.auth.details})`
}
}
export const attemptRelayAccess = async (url: string, claim = "") => {
const checks = [
() => checkRelayConnection(url),
() => checkRelayAccess(url, claim),
() => checkRelayAuth(url),
]
for (const check of checks) {
const error = await check()
if (error) {
return error
}
}
}
// Actions
export const makeDelete = ({event, tags = []}: {event: TrustedEvent; tags?: string[][]}) => {
const thisTags = [["k", String(event.kind)], ...tagEvent(event), ...tags]
const groupTag = getTag("h", event.tags)
if (groupTag) {
thisTags.push(PROTECTED, groupTag)
}
return makeEvent(DELETE, {tags: thisTags})
}
export const publishDelete = ({
relays,
event,
tags = [],
}: {
relays: string[]
event: TrustedEvent
tags?: string[][]
}) => publishThunk({event: makeDelete({event, tags}), relays})
export type ReportParams = {
event: TrustedEvent
content: string
reason: string
}
export const makeReport = ({event, reason, content}: ReportParams) => {
const tags = [
["p", event.pubkey],
["e", event.id, reason],
]
return makeEvent(REPORT, {content, tags})
}
export const publishReport = ({
relays,
event,
reason,
content,
}: ReportParams & {relays: string[]}) =>
publishThunk({event: makeReport({event, reason, content}), relays})
export type ReactionParams = {
event: TrustedEvent
content: string
tags?: string[][]
}
export const makeReaction = ({content, event, tags: paramTags = []}: ReactionParams) => {
const tags = [...paramTags, ...tagEventForReaction(event)]
const groupTag = getTag("h", event.tags)
if (groupTag) {
tags.push(PROTECTED)
tags.push(groupTag)
}
return makeEvent(REACTION, {content, tags})
}
export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) =>
publishThunk({event: makeReaction(params), relays})
export type CommentParams = {
event: TrustedEvent
content: string
tags?: string[][]
}
export const makeComment = ({event, content, tags = []}: CommentParams) =>
makeEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event)]})
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
publishThunk({event: makeComment(params), relays})
export type AlertParams = {
feed: Feed
description: string
claims: Record<string, string>
email?: {
cron: string
email: string
handler: string[]
}
web?: {
endpoint: string
p256dh: string
auth: string
}
ios?: {
device_token: string
bundle_identifier: string
}
android?: {
device_token: string
}
}
export const makeAlert = async (params: AlertParams) => {
const tags = [
["feed", JSON.stringify(params.feed)],
["locale", LOCALE],
["timezone", TIMEZONE],
["description", params.description],
]
for (const [relay, claim] of Object.entries(params.claims)) {
tags.push(["claim", relay, claim])
}
let kind: number
if (params.email) {
kind = ALERT_EMAIL
tags.push(...Object.entries(params.email).map(flatten))
} else if (params.web) {
kind = ALERT_WEB
tags.push(...Object.entries(params.web).map(flatten))
} else if (params.ios) {
kind = ALERT_IOS
tags.push(...Object.entries(params.ios).map(flatten))
} else if (params.android) {
kind = ALERT_ANDROID
tags.push(...Object.entries(params.android).map(flatten))
} else {
throw new Error("Alert has invalid params")
}
return makeEvent(kind, {
content: await signer.get().nip44.encrypt(NOTIFIER_PUBKEY, JSON.stringify(tags)),
tags: [
["d", randomId()],
["p", NOTIFIER_PUBKEY],
],
})
}
export const publishAlert = async (params: AlertParams) =>
publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]})
export const payInvoice = async (invoice: string) => {
const $wallet = get(wallet)
if (!$wallet) {
throw new Error("No wallet is connected")
}
if ($wallet.type === "nwc") {
return new nwc.NWCClient($wallet.info).payInvoice({invoice})
} else if ($wallet.type === "webln") {
return getWebLn()
.enable()
.then(() => getWebLn().sendPayment(invoice))
}
}
+26 -108
View File
@@ -1,38 +1,23 @@
<script lang="ts">
import {onMount} from "svelte"
import {preventDefault} from "@lib/html"
import {decrypt} from "@welshman/signer"
import {randomInt, parseJson, fromPairs, displayList, TIMEZONE, identity} from "@welshman/lib"
import {
displayRelayUrl,
getTagValue,
getAddress,
THREAD,
MESSAGE,
EVENT_TIME,
COMMENT,
} from "@welshman/util"
import {randomInt, displayList, TIMEZONE, identity} from "@welshman/lib"
import {displayRelayUrl, getTagValue, THREAD, MESSAGE, EVENT_TIME, COMMENT} from "@welshman/util"
import type {Filter} from "@welshman/util"
import {makeIntersectionFeed, makeRelayFeed, feedFromFilters} from "@welshman/feeds"
import {pubkey, signer, getThunkError} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {
alerts,
getMembershipUrls,
userMembership,
NOTIFIER_PUBKEY,
NOTIFIER_RELAY,
} from "@app/state"
import {loadAlertStatuses, requestRelayClaim} from "@app/requests"
import {publishAlert, attemptAuth} from "@app/commands"
import type {AlertParams} from "@app/commands"
import {platform, platformName, canSendPushNotifications, getPushInfo} from "@app/push"
import {pushToast} from "@app/toast"
import {alerts, userSpaceUrls} from "@app/core/state"
import {requestRelayClaim} from "@app/core/requests"
import {createAlert} from "@app/core/commands"
import {canSendPushNotifications} from "@app/util/push"
import {pushToast} from "@app/util/toast"
type Props = {
url?: string
@@ -60,7 +45,6 @@
let loading = $state(false)
let cron = $state(WEEKLY)
let claim = $state("")
let email = $state($alerts.map(a => getTagValue("email", a.tags)).filter(identity)[0] || "")
const back = () => history.back()
@@ -110,66 +94,21 @@
loading = true
try {
const claims = claim ? {[url]: claim} : {}
const feed = makeIntersectionFeed(feedFromFilters(filters), makeRelayFeed(url))
const description = `for ${displayList(display)} on ${displayRelayUrl(url)}`
const params: AlertParams = {feed, claims, description}
const claim = url ? await requestRelayClaim(url) : undefined
if (channel === "email") {
const cadence = cron?.endsWith("1") ? "Weekly" : "Daily"
params.description = `${cadence} alert ${description}, sent via email.`
params.email = {
cron,
email,
handler: [
"31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1737058597050",
"wss://relay.nostr.band/",
"web",
],
}
} else {
try {
// @ts-ignore
params[platform] = await getPushInfo()
params.description = `${platformName} push notification ${description}.`
} catch (e: any) {
return pushToast({
theme: "error",
message: String(e),
})
}
}
// If we don't do this we'll get an event rejection
await attemptAuth(NOTIFIER_RELAY)
const thunk = await publishAlert(params)
const error = await getThunkError(thunk)
const {error} = await createAlert({
feed: makeIntersectionFeed(feedFromFilters(filters), makeRelayFeed(url)),
claims: claim ? {[url]: claim} : {},
description: `for ${displayList(display)} on ${displayRelayUrl(url)}`,
email: channel === "email" ? {cron, email} : undefined,
})
if (error) {
return pushToast({
theme: "error",
message: `Failed to send your alert to the notification server (${error}).`,
})
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Your alert has been successfully created!"})
back()
}
// Fetch our new status to make sure it's active
const address = getAddress(thunk.event)
const statusEvents = await loadAlertStatuses($pubkey!)
const statusEvent = statusEvents.find(event => getTagValue("d", event.tags) === address)
const statusTags = statusEvent
? parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, statusEvent.content))
: []
const {status = "error", message = "Your alert was not activated"}: Record<string, string> =
fromPairs(statusTags)
if (status === "error") {
return pushToast({theme: "error", message})
}
pushToast({message: "Your alert has been successfully created!"})
back()
} finally {
loading = false
}
@@ -179,14 +118,6 @@
if (!canSendPushNotifications()) {
channel = "email"
}
if (url) {
requestRelayClaim(url).then(code => {
if (code) {
claim = code
}
})
}
})
</script>
@@ -195,6 +126,9 @@
{#snippet title()}
Add an Alert
{/snippet}
{#snippet info()}
Enable notifications to keep up to date on activity you care about.
{/snippet}
</ModalHeader>
{#if canSendPushNotifications()}
<FieldInline>
@@ -240,7 +174,7 @@
{#snippet input()}
<select bind:value={url} class="select select-bordered">
<option value="" disabled selected>Choose a space URL</option>
{#each getMembershipUrls($userMembership) as url (url)}
{#each $userSpaceUrls as url (url)}
<option value={url}>{displayRelayUrl(url)}</option>
{/each}
</select>
@@ -268,30 +202,14 @@
</div>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Invite Code</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<input bind:value={claim} />
</label>
{/snippet}
{#snippet info()}
<p>
To get notifications from private spaces, please provide an invite code which grants access
to the space.
</p>
{/snippet}
</FieldInline>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Confirm</Spinner>
<Icon icon="alt-arrow-right" />
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
+4 -5
View File
@@ -1,9 +1,8 @@
<script lang="ts">
import Confirm from "@lib/components/Confirm.svelte"
import type {Alert} from "@app/state"
import {NOTIFIER_RELAY, NOTIFIER_PUBKEY} from "@app/state"
import {publishDelete} from "@app/commands"
import {pushToast} from "@app/toast"
import type {Alert} from "@app/core/state"
import {deleteAlert} from "@app/core/commands"
import {pushToast} from "@app/util/toast"
type Props = {
alert: Alert
@@ -12,7 +11,7 @@
const {alert}: Props = $props()
const confirm = () => {
publishDelete({event: alert.event, relays: [NOTIFIER_RELAY], tags: [["p", NOTIFIER_PUBKEY]]})
deleteAlert(alert)
pushToast({message: "Your alert has been deleted!"})
history.back()
}
+7 -34
View File
@@ -1,13 +1,14 @@
<script lang="ts">
import {parseJson} from "@welshman/lib"
import {displayFeeds} from "@welshman/feeds"
import {getAddress, getTagValue, getTagValues} from "@welshman/util"
import {getTagValue, getTagValues} from "@welshman/util"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import AlertDelete from "@app/components/AlertDelete.svelte"
import type {Alert} from "@app/state"
import {deriveAlertStatus} from "@app/state"
import {pushModal} from "@app/modal"
import AlertStatus from "@app/components/AlertStatus.svelte"
import type {Alert} from "@app/core/state"
import {pushModal} from "@app/util/modal"
type Props = {
alert: Alert
@@ -15,7 +16,6 @@
const {alert}: Props = $props()
const status = deriveAlertStatus(getAddress(alert.event))
const cron = $derived(getTagValue("cron", alert.tags))
const channel = $derived(getTagValue("channel", alert.tags))
const feeds = $derived(getTagValues("feed", alert.tags))
@@ -34,36 +34,9 @@
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4">
<Button class="py-1" onclick={startDelete}>
<Icon icon="trash-bin-2" />
<Icon icon={TrashBin2} />
</Button>
<div class="flex-inline gap-1">{description}</div>
</div>
{#if $status}
{@const statusText = getTagValue("status", $status.tags) || "error"}
{#if statusText === "ok"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content px-3 py-1 text-sm"
data-tip={getTagValue("message", $status.tags)}>
Active
</span>
{:else if statusText === "pending"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content border-yellow-500 px-3 py-1 text-sm text-yellow-500"
data-tip={getTagValue("message", $status.tags)}>
Pending
</span>
{:else}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip={getTagValue("message", $status.tags)}>
{statusText.replace("-", " ").replace(/^(.)/, x => x.toUpperCase())}
</span>
{/if}
{:else}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip="The notification server did not respond to your request.">
Inactive
</span>
{/if}
<AlertStatus {alert} />
</div>
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import {getAddress, getTagValue} from "@welshman/util"
import type {Alert} from "@app/core/state"
import {deriveAlertStatus} from "@app/core/state"
type Props = {
alert: Alert
}
const {alert}: Props = $props()
const status = deriveAlertStatus(getAddress(alert.event))
</script>
{#if $status}
{@const statusText = getTagValue("status", $status.tags) || "error"}
{#if statusText === "ok"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content px-3 py-1 text-sm"
data-tip={getTagValue("message", $status.tags)}>
Active
</span>
{:else if statusText === "pending"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content border-yellow-500 px-3 py-1 text-sm text-yellow-500"
data-tip={getTagValue("message", $status.tags)}>
Pending
</span>
{:else}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip={getTagValue("message", $status.tags)}>
{statusText.replace("-", " ").replace(/^(.)/, x => x.toUpperCase())}
</span>
{/if}
{:else}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip="The notification server did not respond to your request.">
Inactive
</span>
{/if}
+133 -21
View File
@@ -1,11 +1,26 @@
<script lang="ts">
import {getTagValue} from "@welshman/util"
import {sleep} from "@welshman/lib"
import {getTagValue, getAddress} from "@welshman/util"
import {isRelayFeed, findFeed} from "@welshman/feeds"
import Inbox from "@assets/icons/inbox.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import AlertAdd from "@app/components/AlertAdd.svelte"
import AlertItem from "@app/components/AlertItem.svelte"
import {pushModal} from "@app/modal"
import {alerts} from "@app/state"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {
alerts,
dmAlert,
deriveAlertStatus,
userInboxRelays,
getAlertFeed,
userSettingsValues,
} from "@app/core/state"
import {deleteAlert, createDmAlert} from "@app/core/commands"
import {clearBadges} from "../util/notifications"
type Props = {
url?: string
@@ -15,29 +30,126 @@
const {url = "", channel = "push", hideSpaceField = false}: Props = $props()
const startAlert = () => pushModal(AlertAdd, {url, channel, hideSpaceField})
const dmStatus = $derived($dmAlert ? deriveAlertStatus(getAddress($dmAlert.event)) : undefined)
const filteredAlerts = $derived(
url ? $alerts.filter(a => getTagValue("feed", a.tags)?.includes(url)) : $alerts,
$alerts.filter(alert => {
const feed = getAlertFeed(alert)
// Skip non-feeds and DM alerts
if (!feed || alert === $dmAlert) return false
// If we have a space url, only match feeds for this space
if (url) return findFeed(feed, f => isRelayFeed(f) && f.includes(url))
return true
}),
)
const startAlert = () => pushModal(AlertAdd, {url, channel, hideSpaceField})
const uncheckDmAlert = async (message: string) => {
await sleep(100)
directMessagesNotificationToggle.checked = false
pushToast({theme: "error", message})
}
const onDirectMessagesNotificationToggle = async () => {
if ($dmAlert) {
deleteAlert($dmAlert)
} else {
if ($userInboxRelays.length === 0) {
return uncheckDmAlert("Please set up your messaging relays before enabling alerts.")
}
const {error} = await createDmAlert()
if (error) {
return uncheckDmAlert(error)
}
pushToast({message: "Your alert has been successfully created!"})
}
}
const onShowBadgeOnUnreadToggle = async () => {
$userSettingsValues.show_notifications_badge = !$userSettingsValues.show_notifications_badge
if (!$userSettingsValues.show_notifications_badge) {
await clearBadges()
}
}
const onDirectMessagesNotificationSoundToggle = async () => {
$userSettingsValues.play_notification_sound = !$userSettingsValues.play_notification_sound
}
let directMessagesNotificationToggle: HTMLInputElement
</script>
<div class="card2 bg-alt flex flex-col gap-6 shadow-xl">
<div class="flex items-center justify-between">
<strong class="flex items-center gap-3">
<Icon icon="inbox" />
Alerts
</strong>
<Button class="btn btn-primary btn-sm" onclick={startAlert}>
<Icon icon="add-circle" />
Add Alert
</Button>
<div class="col-4">
<div class="card2 bg-alt flex flex-col gap-6 shadow-xl">
<div class="flex items-center justify-between">
<strong class="flex items-center gap-3">
<Icon icon={Inbox} />
Alerts
</strong>
<Button class="btn btn-primary btn-sm" onclick={startAlert}>
<Icon icon={AddCircle} />
Add Alert
</Button>
</div>
<div class="col-4">
{#each filteredAlerts as alert (alert.event.id)}
<AlertItem {alert} />
{:else}
<p class="text-center opacity-75 py-12">Nothing here yet!</p>
{/each}
</div>
</div>
<div class="col-4">
{#each filteredAlerts as alert (alert.event.id)}
<AlertItem {alert} />
{:else}
<p class="text-center opacity-75 py-12">Nothing here yet!</p>
{/each}
<div class="card2 bg-alt flex flex-col gap-4 shadow-xl">
<div class="flex items-center justify-between">
<strong class="flex items-center gap-3">
<Icon icon={Bell} />
Notifications
</strong>
</div>
<div class="flex justify-between">
<p>Notify me about new direct messages</p>
<input
type="checkbox"
class="toggle toggle-primary"
bind:this={directMessagesNotificationToggle}
checked={Boolean($dmAlert)}
oninput={onDirectMessagesNotificationToggle} />
</div>
<div class="flex justify-between">
<p>Show badge for unread direct messages</p>
<input
type="checkbox"
class="toggle toggle-primary"
checked={Boolean($userSettingsValues.show_notifications_badge)}
oninput={onShowBadgeOnUnreadToggle} />
</div>
<div class="flex justify-between">
<p>Play sound for new direct messages</p>
<input
type="checkbox"
class="toggle toggle-primary"
checked={Boolean($userSettingsValues.play_notification_sound)}
oninput={onDirectMessagesNotificationSoundToggle} />
</div>
{#if $dmStatus}
{@const status = getTagValue("status", $dmStatus.tags) || "error"}
{#if status !== "ok"}
<div class="alert alert-error border border-solid border-error bg-transparent text-error">
<p>
{getTagValue("message", $dmStatus.tags) ||
"The notification server did not respond to your request."}
</p>
</div>
{/if}
{/if}
</div>
</div>
+2 -2
View File
@@ -7,8 +7,8 @@
import PrimaryNav from "@app/components/PrimaryNav.svelte"
import EmailConfirm from "@app/components/EmailConfirm.svelte"
import PasswordReset from "@app/components/PasswordReset.svelte"
import {BURROW_URL} from "@app/state"
import {modals, pushModal} from "@app/modal"
import {BURROW_URL} from "@app/core/state"
import {modals, pushModal} from "@app/util/modal"
interface Props {
children: Snippet
+15 -69
View File
@@ -1,79 +1,25 @@
<script module lang="ts">
import type {Nip46ResponseWithResult} from "@welshman/signer"
import {Nip46Broker, makeSecret} from "@welshman/signer"
import {NIP46_PERMS, PLATFORM_URL, PLATFORM_NAME, PLATFORM_LOGO, SIGNER_RELAYS} from "@app/state"
export class BunkerConnectController {
url = $state("")
bunker = $state("")
loading = $state(false)
clientSecret = makeSecret()
abortController = new AbortController()
broker = new Nip46Broker({clientSecret: this.clientSecret, relays: SIGNER_RELAYS})
onNostrConnect: (response: Nip46ResponseWithResult) => void
constructor({onNostrConnect}: {onNostrConnect: (response: Nip46ResponseWithResult) => void}) {
this.onNostrConnect = onNostrConnect
}
async start() {
this.url = await this.broker.makeNostrconnectUrl({
perms: NIP46_PERMS,
url: PLATFORM_URL,
name: PLATFORM_NAME,
image: PLATFORM_LOGO,
})
let response
try {
response = await this.broker.waitForNostrconnect(this.url, this.abortController.signal)
} catch (errorResponse: any) {
if (errorResponse?.error) {
pushToast({
theme: "error",
message: `Received error from signer: ${errorResponse.error}`,
})
} else if (errorResponse) {
console.error(errorResponse)
}
}
if (response) {
this.loading = true
this.onNostrConnect(response)
}
}
stop() {
this.broker.cleanup()
this.abortController.abort()
}
}
</script>
<script lang="ts">
import {onMount, onDestroy} from "svelte"
import {slideAndFade} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte"
import QRCode from "@app/components/QRCode.svelte"
import {pushToast} from "@app/toast"
import type {Nip46Controller} from "@app/util/nip46"
type Props = {
controller: BunkerConnectController
controller: Nip46Controller
}
const {controller}: Props = $props()
onMount(() => {
controller.start()
})
onDestroy(() => {
controller.stop()
})
const {url, loading} = controller
</script>
{#if controller.url}
<div class="flex justify-center" out:slideAndFade>
<QRCode code={controller.url} />
</div>
{#if $url}
{#if $loading}
<div class="flex justify-center">
<Spinner loading>Establishing connection...</Spinner>
</div>
{:else}
<div class="flex flex-col items-center gap-2">
<QRCode code={$url} />
<p class="text-sm opacity-75">Scan with your signer to log in, or click to copy.</p>
</div>
{/if}
{/if}
+29 -7
View File
@@ -1,16 +1,32 @@
<script lang="ts">
import {pushModal} from "@app/modal"
import InfoBunker from "@app/components/InfoBunker.svelte"
import {debounce} from "throttle-debounce"
import Scanner from "@lib/components/Scanner.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import CpuBolt from "@assets/icons/cpu-bolt.svg?dataurl"
import QrCode from "@assets/icons/qr-code.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import InfoBunker from "@app/components/InfoBunker.svelte"
import type {Nip46Controller} from "@app/util/nip46"
import {pushModal} from "@app/util/modal"
type Props = {
bunker: string
loading: boolean
controller: Nip46Controller
}
let {loading, bunker = $bindable("")}: Props = $props()
const {controller}: Props = $props()
const {loading, bunker} = controller
const toggleScanner = () => {
showScanner = !showScanner
}
const onScan = debounce(1000, async (data: string) => {
showScanner = false
$bunker = data
})
let showScanner = $state(false)
</script>
<Field>
@@ -19,8 +35,11 @@
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="cpu" />
<input disabled={loading} bind:value={bunker} class="grow" placeholder="bunker://" />
<Icon icon={CpuBolt} />
<input disabled={$loading} bind:value={$bunker} class="grow" placeholder="bunker://" />
<Button onclick={toggleScanner}>
<Icon icon={QrCode} />
</Button>
</label>
{/snippet}
{#snippet info()}
@@ -30,3 +49,6 @@
</p>
{/snippet}
</Field>
{#if showScanner}
<Scanner onscan={onScan} />
{/if}
+41 -32
View File
@@ -1,55 +1,64 @@
<script lang="ts">
import type {TrustedEvent, EventContent} from "@welshman/util"
import {getTagValue} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Link from "@lib/components/Link.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
import {publishDelete, publishReaction} from "@app/commands"
import {makeCalendarPath} from "@app/routes"
import {pushModal} from "@app/modal"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {makeCalendarPath, makeSpacePath} from "@app/util/routes"
import {pushModal} from "@app/util/modal"
import Pen2 from "@assets/icons/pen-2.svg?dataurl"
const {
url,
event,
showActivity = false,
}: {
type Props = {
url: string
event: TrustedEvent
showRoom?: boolean
showActivity?: boolean
} = $props()
}
const {url, event, showRoom, showActivity}: Props = $props()
const room = getTagValue("h", event.tags)
const path = makeCalendarPath(url, event.id)
const shouldProtect = canEnforceNip70(url)
const editEvent = () => pushModal(CalendarEventEdit, {url, event})
const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
const deleteReaction = async (event: TrustedEvent) =>
publishDelete({relays: [url], event, protect: await shouldProtect})
const createReaction = (template: EventContent) =>
publishReaction({...template, event, relays: [url]})
const createReaction = async (template: EventContent) =>
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2">
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Event">
{#snippet customActions()}
{#if event.pubkey === $pubkey}
<li>
<Button onclick={editEvent}>
<Icon size={4} icon="pen" />
Edit Event
</Button>
</li>
{/if}
{/snippet}
</EventActions>
</div>
<div class="flex flex-grow flex-wrap justify-end gap-2">
{#if room && showRoom}
<Link href={makeSpacePath(url, room)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<ChannelName {room} {url} />
</Link>
{/if}
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Event">
{#snippet customActions()}
{#if event.pubkey === $pubkey}
<li>
<Button onclick={editEvent}>
<Icon size={4} icon={Pen2} />
Edit Event
</Button>
</li>
{/if}
{/snippet}
</EventActions>
</div>
@@ -4,12 +4,13 @@
type Props = {
url: string
room?: string
}
const {url}: Props = $props()
const {url, room}: Props = $props()
</script>
<CalendarEventForm {url}>
<CalendarEventForm {url} {room}>
{#snippet header()}
<ModalHeader>
{#snippet title()}
+11 -8
View File
@@ -8,13 +8,16 @@
const {event}: Props = $props()
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
const startDate = $derived(secondsToDate(parseInt(meta.start)))
const start = $derived(parseInt(meta.start))
</script>
<div
class="hidden h-32 w-32 min-w-32 flex-col items-center justify-center gap-1 rounded-box bg-base-300 p-2 sm:flex">
<strong>{Intl.DateTimeFormat(LOCALE, {month: "short"}).format(startDate)}</strong>
<span class="text-4xl">{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(startDate)}</span>
<span class="text-xs opacity-75"
>{Intl.DateTimeFormat(LOCALE, {weekday: "long"}).format(startDate)}</span>
</div>
{#if !isNaN(start)}
{@const startDate = secondsToDate(start)}
<div
class="hidden h-32 w-32 min-w-32 flex-col items-center justify-center gap-1 rounded-box bg-base-300 p-2 sm:flex">
<strong>{Intl.DateTimeFormat(LOCALE, {month: "short"}).format(startDate)}</strong>
<span class="text-4xl">{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(startDate)}</span>
<span class="text-xs opacity-75"
>{Intl.DateTimeFormat(LOCALE, {weekday: "long"}).format(startDate)}</span>
</div>
{/if}
+33 -19
View File
@@ -6,6 +6,9 @@
import {publishThunk} from "@welshman/app"
import {preventDefault} from "@lib/html"
import {daysBetween} from "@lib/util"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
import MapPoint from "@assets/icons/map-point.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
@@ -13,12 +16,14 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {PROTECTED} from "@app/state"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {pushToast} from "@app/toast"
import {pushToast} from "@app/util/toast"
import {canEnforceNip70} from "@app/core/commands"
type Props = {
url: string
room?: string
header: Snippet
initialValues?: {
d: string
@@ -30,7 +35,9 @@
}
}
const {url, header, initialValues}: Props = $props()
const {url, room, header, initialValues}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const uploading = writable(false)
@@ -63,19 +70,26 @@
}
const ed = await editor
const event = makeEvent(EVENT_TIME, {
content: ed.getText({blockSeparator: "\n"}).trim(),
tags: [
["d", initialValues?.d || randomId()],
["title", title],
["location", location || ""],
["start", start.toString()],
["end", end.toString()],
...daysBetween(start, end).map(D => ["D", D.toString()]),
...ed.storage.nostr.getEditorTags(),
PROTECTED,
],
})
const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = [
["d", initialValues?.d || randomId()],
["title", title],
["location", location || ""],
["start", start.toString()],
["end", end.toString()],
...daysBetween(start, end).map(D => ["D", D.toString()]),
...ed.storage.nostr.getEditorTags(),
]
if (await shouldProtect) {
tags.push(PROTECTED)
}
if (room) {
tags.push(["h", room])
}
const event = makeEvent(EVENT_TIME, {content, tags})
pushToast({message: "Your event has been saved!"})
publishThunk({event, relays: [url]})
@@ -125,7 +139,7 @@
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="gallery-send" />
<Icon icon={GallerySend} />
{/if}
</Button>
</div>
@@ -153,14 +167,14 @@
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="map-point" />
<Icon icon={MapPoint} />
<input bind:value={location} class="grow" type="text" />
</label>
{/snippet}
</Field>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={$uploading}>
+13 -10
View File
@@ -6,6 +6,7 @@
formatTimestampAsTime,
} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
type Props = {
@@ -16,18 +17,20 @@
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
const start = $derived(parseInt(meta.start))
const end = $derived(parseInt(meta.end))
const startDateDisplay = $derived(formatTimestampAsDate(start))
const endDateDisplay = $derived(formatTimestampAsDate(end))
const isSingleDay = $derived(startDateDisplay === endDateDisplay)
</script>
<div class="flex flex-grow flex-wrap justify-between gap-2">
<p class="text-xl">{meta.title || meta.name}</p>
<div class="flex items-center gap-2 text-sm">
<Icon icon="clock-circle" size={4} />
<span class="sm:hidden">{formatTimestampAsDate(start)}</span>
{formatTimestampAsTime(start)}{isSingleDay
? formatTimestampAsTime(end)
: formatTimestamp(end)}
</div>
{#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>
{formatTimestampAsTime(start)}{isSingleDay
? formatTimestampAsTime(end)
: formatTimestamp(end)}
</div>
{/if}
</div>
+8 -1
View File
@@ -1,10 +1,12 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {getTagValue} from "@welshman/util"
import Link from "@lib/components/Link.svelte"
import CalendarEventActions from "@app/components/CalendarEventActions.svelte"
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import {makeCalendarPath} from "@app/routes"
import ChannelLink from "@app/components/ChannelLink.svelte"
import {makeCalendarPath} from "@app/util/routes"
type Props = {
url: string
@@ -12,6 +14,8 @@
}
const {url, event}: Props = $props()
const room = getTagValue("h", event.tags)
</script>
<Link class="col-3 card2 bg-alt w-full cursor-pointer" href={makeCalendarPath(url, event.id)}>
@@ -19,6 +23,9 @@
<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 room}
in <ChannelLink {url} {room} />
{/if}
</span>
<CalendarEventActions showActivity {url} {event} />
</div>
+4 -2
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import {fromPairs} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
import MapPoint from "@assets/icons/map-point.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
@@ -15,12 +17,12 @@
<div class="flex min-w-0 flex-col gap-1 text-sm opacity-75">
<span class="flex items-center gap-1">
<Icon icon="user-circle" size={4} />
<Icon icon={UserCircle} size={4} />
Posted by <ProfileLink pubkey={event.pubkey} {url} />
</span>
{#if meta.location}
<span class="flex items-start gap-1">
<Icon icon="map-point" class="mt-[2px]" size={4} />
<Icon icon={MapPoint} class="mt-[2px]" size={4} />
<span class="break-words">{meta.location}</span>
</span>
{/if}
+64 -14
View File
@@ -1,18 +1,28 @@
<script lang="ts">
import type {Instance} from "tippy.js"
import {writable} from "svelte/store"
import type {EventContent} from "@welshman/util"
import {isMobile, preventDefault} from "@lib/html"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
import WidgetAdd from "@assets/icons/widget-add.svg?dataurl"
import Plane from "@assets/icons/plane-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import ComposeMenu from "@app/components/ComposeMenu.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
import {onDestroy, onMount} from "svelte"
type Props = {
url?: string
room?: string
content?: string
onEditPrevious?: () => void
onSubmit: (event: EventContent) => void
}
const {onSubmit, url}: Props = $props()
const {url, room, content, onEditPrevious, onSubmit}: Props = $props()
const autofocus = !isMobile
@@ -20,8 +30,21 @@
export const focus = () => editor.then(ed => ed.chain().focus().run())
export const canEnterEditPrevious = () =>
editor.then(ed => ed.getText({blockSeparator: "\n"}) === "")
const handleKeyDown = async (event: KeyboardEvent) => {
if (event.key === "ArrowUp" && (await canEnterEditPrevious())) {
onEditPrevious?.()
}
}
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const showPopover = () => popover?.show()
const hidePopover = () => popover?.hide()
const submit = async () => {
if ($uploading) return
@@ -36,21 +59,48 @@
ed.chain().clearContent().run()
}
const editor = makeEditor({url, autofocus, submit, uploading, aggressive: true})
const editor = makeEditor({url, content, autofocus, submit, uploading, aggressive: true})
let popover: Instance | undefined = $state()
onMount(async () => {
const ed = await editor
ed.view.dom.addEventListener("keydown", handleKeyDown)
})
onDestroy(async () => {
const ed = await editor
ed?.view?.dom.removeEventListener("keydown", handleKeyDown)
})
</script>
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
<Button
data-tip="Add an image"
class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200"
disabled={$uploading}
onclick={uploadFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="gallery-send" />
{/if}
</Button>
<div class="join">
<Button
data-tip="Add an image"
class="center join-item tooltip tooltip-right h-10 w-10 min-w-10 rounded-full border border-solid border-base-200 bg-base-300"
disabled={$uploading}
onclick={uploadFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon={GallerySend} />
{/if}
</Button>
<Tippy
bind:popover
component={ComposeMenu}
props={{url, room, onClick: hidePopover}}
params={{trigger: "manual", interactive: true}}>
<Button
data-tip="More options"
class="center join-item tooltip tooltip-right h-10 w-10 min-w-10 rounded-full border border-solid border-base-200 bg-base-300"
disabled={$uploading}
onclick={showPopover}>
<Icon icon={WidgetAdd} />
</Button>
</Tippy>
</div>
<div class="chat-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
@@ -59,6 +109,6 @@
class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full"
disabled={$uploading}
onclick={submit}>
<Icon icon="plain" />
<Icon icon={Plane} />
</Button>
</form>
@@ -0,0 +1,21 @@
<script lang="ts">
import {slide} from "@lib/transition"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
const {
clear,
}: {
clear: () => void
} = $props()
</script>
<div
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8 text-xs"
transition:slide>
<p class="text-primary">Editing message</p>
<Button class="absolute right-2 top-2 cursor-pointer" onclick={clear}>
<Icon icon={CloseCircle} />
</Button>
</div>
+6 -10
View File
@@ -2,9 +2,10 @@
import type {TrustedEvent} from "@welshman/util"
import {displayProfileByPubkey} from "@welshman/app"
import {slide} from "@lib/transition"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
const {
verb,
@@ -18,18 +19,13 @@
</script>
<div
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8 text-xs"
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8"
transition:slide>
<p class="text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p>
<p class="text-xs text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p>
{#key event.id}
<NoteContent
{event}
hideMediaAtDepth={0}
minLength={100}
maxLength={300}
expandMode="disabled" />
<NoteContentMinimal trimParent {event} />
{/key}
<Button class="absolute right-2 top-2 cursor-pointer" onclick={clear}>
<Icon icon="close-circle" />
<Icon icon={CloseCircle} />
</Button>
</div>
+161
View File
@@ -0,0 +1,161 @@
<script lang="ts">
import cx from "classnames"
import {hash, now, displayList, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {MESSAGE, COMMENT} from "@welshman/util"
import {
thunks,
pubkey,
mergeThunks,
deriveProfile,
deriveProfileDisplay,
displayProfileByPubkey,
} from "@welshman/app"
import {isMobile} from "@lib/html"
import Pen from "@assets/icons/pen.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import ReplyAlt from "@assets/icons/reply.svg?dataurl"
import TapTarget from "@lib/components/TapTarget.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import ThunkFailure from "@app/components/ThunkFailure.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ChannelItemZapButton from "@app/components/ChannelItemZapButton.svelte"
import ChannelItemEmojiButton from "@app/components/ChannelItemEmojiButton.svelte"
import ChannelItemMenuButton from "@app/components/ChannelItemMenuButton.svelte"
import ChannelItemMenuMobile from "@app/components/ChannelItemMenuMobile.svelte"
import ChannelItemContent from "@app/components/ChannelItemContent.svelte"
import {colors, ENABLE_ZAPS, deriveEventsForUrl} from "@app/core/state"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {getChannelItemPath} from "@app/util/routes"
import {pushModal} from "@app/util/modal"
interface Props {
url: string
event: TrustedEvent
replyTo?: (event: TrustedEvent) => void
showPubkey?: boolean
inert?: boolean
canEdit: (event: TrustedEvent) => boolean
onEdit: (event: TrustedEvent) => void
}
const {
url,
event,
replyTo = undefined,
showPubkey = false,
inert = false,
canEdit,
onEdit,
}: Props = $props()
const path = getChannelItemPath(url, event)
const shouldProtect = canEnforceNip70(url)
const today = formatTimestampAsDate(now())
const profile = deriveProfile(event.pubkey, [url])
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 reply = () => replyTo!(event)
const edit = canEdit(event) ? () => onEdit(event) : undefined
const onTap = () => pushModal(ChannelItemMenuMobile, {url, event, reply, edit})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
const deleteReaction = async (event: TrustedEvent) =>
publishDelete({relays: [url], event, protect: await shouldProtect})
const createReaction = async (template: EventContent) =>
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<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">
<div class="flex w-full gap-3 overflow-auto">
{#if showPubkey}
<Button onclick={openProfile} class="flex items-start">
<Avatar src={$profile?.picture} class="border border-solid border-base-content" size={8} />
</Button>
{:else}
<div class="w-8 min-w-8 max-w-8"></div>
{/if}
<div class="min-w-0 flex-grow pr-1">
{#if showPubkey}
<div class="flex items-center gap-2">
<Button onclick={openProfile} class="text-sm font-bold" style="color: {colorValue}">
{$profileDisplay}
</Button>
<span class="text-xs opacity-50">
{#if formatTimestampAsDate(event.created_at) === today}
Today
{:else}
{formatTimestampAsDate(event.created_at)}
{/if}
at {formatTimestampAsTime(event.created_at)}
</span>
</div>
{/if}
<div class:mt-2={showPubkey && event.kind !== MESSAGE}>
<ChannelItemContent {url} {event} />
{#if thunk}
<ThunkFailure showToastOnRetry {thunk} class="mt-2 text-sm" />
{/if}
</div>
</div>
</div>
<div class="row-2 ml-10 mt-1 pl-1">
<ReactionSummary
{url}
{event}
{deleteReaction}
{createReaction}
reactionClass="tooltip-right" />
{#if path && $comments.length > 0}
{@const pubkeys = $comments.map(e => e.pubkey)}
{@const isOwn = $pubkey && pubkeys.includes($pubkey)}
{@const info = displayList(pubkeys.map(pubkey => displayProfileByPubkey(pubkey)))}
{@const tooltip = `${info} commented`}
<div data-tip={tooltip} class="tooltip tooltip-right flex">
<Link
href={path}
class={cx("btn btn-xs gap-1 rounded-full", {
"btn-neutral": !isOwn,
"btn-primary": isOwn,
})}>
<Icon icon={ReplyAlt} />
<span>{$comments.length} comment{$comments.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:group-hover:opacity-100={!isMobile}>
{#if ENABLE_ZAPS}
<ChannelItemZapButton {url} {event} />
{/if}
<ChannelItemEmojiButton {url} {event} />
{#if replyTo}
<Button class="btn join-item btn-xs" onclick={reply}>
<Icon icon={Reply} size={4} />
</Button>
{/if}
{#if edit}
<Button class="btn join-item btn-xs" onclick={edit}>
<Icon icon={Pen} size={4} />
</Button>
{/if}
<ChannelItemMenuButton {url} {event} />
</button>
{/if}
</TapTarget>
@@ -0,0 +1,18 @@
<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} /> joined the room
</div>
{/each}
@@ -0,0 +1,23 @@
<script lang="ts">
import cx from "classnames"
import type {ComponentProps} from "svelte"
import {MESSAGE} from "@welshman/util"
import {isMobile} from "@lib/html"
import Link from "@lib/components/Link.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import {getChannelItemPath} from "@app/util/routes"
const props: ComponentProps<typeof NoteContent> = $props()
const path = getChannelItemPath(props.url!, props.event)
</script>
<div class={cx("text-sm", {"card2 card2-sm bg-alt": props.event.kind !== MESSAGE})}>
{#if path && !isMobile}
<Link href={path}>
<NoteContent {...props} />
</Link>
{:else}
<NoteContent {...props} />
{/if}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared"
import EmojiButton from "@lib/components/EmojiButton.svelte"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import {publishReaction, canEnforceNip70} from "@app/core/commands"
const {url, event} = $props()
const shouldProtect = canEnforceNip70(url)
const onEmoji = async (emoji: NativeEmoji) =>
publishReaction({
event,
relays: [url],
content: emoji.unicode,
protect: await shouldProtect,
})
</script>
<EmojiButton {onEmoji} class="btn join-item btn-xs">
<Icon icon={SmileCircle} size={4} />
</EmojiButton>
@@ -1,13 +1,23 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import Danger from "@assets/icons/danger.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import EventReport from "@app/components/EventReport.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {pushModal} from "@app/modal"
import {pushModal} from "@app/util/modal"
const {url, event, onClick} = $props()
type Props = {
url: string
event: TrustedEvent
onClick: () => void
}
const {url, event, onClick}: Props = $props()
const report = () => {
onClick()
@@ -28,21 +38,21 @@
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-xl">
<li>
<Button onclick={showInfo}>
<Icon size={4} icon="code-2" />
Message Details
<Icon size={4} icon={Code2} />
Show JSON
</Button>
</li>
{#if event.pubkey === $pubkey}
<li>
<Button onclick={showDelete} class="text-error">
<Icon size={4} icon="trash-bin-2" />
<Icon size={4} icon={TrashBin2} />
Delete Message
</Button>
</li>
{:else}
<li>
<Button class="text-error" onclick={report}>
<Icon size={4} icon="danger" />
<Icon size={4} icon={Danger} />
Report Content
</Button>
</li>
@@ -1,10 +1,11 @@
<script lang="ts">
import {type Instance} from "tippy.js"
import {between} from "@welshman/lib"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import ChannelMessageMenu from "@app/components/ChannelMessageMenu.svelte"
import ChannelItemMenu from "@app/components/ChannelItemMenu.svelte"
const {url, event} = $props()
@@ -29,11 +30,11 @@
<div class="flex">
<Button class="btn join-item btn-xs" onclick={open}>
<Icon icon="menu-dots" size={4} />
<Icon icon={MenuDots} size={4} />
</Button>
<Tippy
bind:popover
component={ChannelMessageMenu}
component={ChannelItemMenu}
props={{url, event, onClick}}
params={{trigger: "manual", interactive: true}} />
</div>
@@ -0,0 +1,88 @@
<script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Bolt from "@assets/icons/bolt.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {ENABLE_ZAPS} from "@app/core/state"
import {publishReaction, canEnforceNip70} from "@app/core/commands"
import {getChannelItemPath} from "@app/util/routes"
import {pushModal} from "@app/util/modal"
type Props = {
url: string
event: TrustedEvent
reply: () => void
}
const {url, event, reply}: Props = $props()
const path = getChannelItemPath(url, event)
const shouldProtect = canEnforceNip70(url)
const onEmoji = (async (event: TrustedEvent, url: string, emoji: NativeEmoji) => {
history.back()
publishReaction({
event,
relays: [url],
content: emoji.unicode,
protect: await shouldProtect,
})
}).bind(undefined, event, url)
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
const sendReply = () => {
history.back()
reply()
}
const showInfo = () => pushModal(EventInfo, {url, event}, {replaceState: true})
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
</script>
<div class="flex flex-col gap-2">
{#if event.pubkey === $pubkey}
<Button class="btn btn-neutral text-error" onclick={showDelete}>
<Icon size={4} icon={TrashBin2} />
Delete
</Button>
{/if}
<Button class="btn btn-neutral" onclick={showInfo}>
<Icon size={4} icon={Code2} />
Show JSON
</Button>
{#if path}
<Link class="btn btn-neutral" href={path}>
<Icon size={4} icon={MenuDots} />
View Details
</Link>
{/if}
<Button class="btn btn-outline btn-neutral w-full" onclick={sendReply}>
<Icon size={4} icon={Reply} />
Reply
</Button>
<Button class="btn btn-secondary w-full" onclick={showEmojiPicker}>
<Icon size={4} icon={SmileCircle} />
React
</Button>
{#if ENABLE_ZAPS}
<ZapButton replaceState {url} {event} class="btn btn-primary w-full">
<Icon size={4} icon={Bolt} />
Zap
</ZapButton>
{/if}
</div>
@@ -0,0 +1,18 @@
<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}
@@ -1,4 +1,5 @@
<script lang="ts">
import Bolt from "@assets/icons/bolt.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
@@ -6,5 +7,5 @@
</script>
<ZapButton {url} {event} class="btn join-item btn-xs">
<Icon icon="bolt" size={4} />
<Icon icon={Bolt} size={4} />
</ZapButton>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import cx from "classnames"
import Link from "@lib/components/Link.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import {makeSpacePath} from "@app/util/routes"
type Props = {
room: string
url: string
class?: string
unstyled?: boolean
}
const {room, url, unstyled, ...props}: Props = $props()
const path = makeSpacePath(url, room)
</script>
<Link href={path} class={cx(props.class, {"link-content bg-alt": !unstyled})}>
#<ChannelName {room} {url} />
</Link>
-110
View File
@@ -1,110 +0,0 @@
<script lang="ts">
import {hash, now, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {thunks, deriveProfile, deriveProfileDisplay} from "@welshman/app"
import {isMobile} from "@lib/html"
import TapTarget from "@lib/components/TapTarget.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Content from "@app/components/Content.svelte"
import ThunkStatus from "@app/components/ThunkStatus.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ChannelMessageZapButton from "@app/components/ChannelMessageZapButton.svelte"
import ChannelMessageEmojiButton from "@app/components/ChannelMessageEmojiButton.svelte"
import ChannelMessageMenuButton from "@app/components/ChannelMessageMenuButton.svelte"
import ChannelMessageMenuMobile from "@app/components/ChannelMessageMenuMobile.svelte"
import {colors, ENABLE_ZAPS} from "@app/state"
import {publishDelete, publishReaction} from "@app/commands"
import {pushModal} from "@app/modal"
interface Props {
url: string
event: TrustedEvent
replyTo?: (event: TrustedEvent) => void
showPubkey?: boolean
inert?: boolean
}
const {url, event, replyTo = undefined, showPubkey = false, inert = false}: Props = $props()
const thunk = $thunks[event.id]
const today = formatTimestampAsDate(now())
const profile = deriveProfile(event.pubkey, [url])
const profileDisplay = deriveProfileDisplay(event.pubkey, [url])
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
const reply = () => replyTo!(event)
const onTap = () => pushModal(ChannelMessageMenuMobile, {url, event, reply})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
const createReaction = (template: EventContent) =>
publishReaction({...template, event, relays: [url]})
</script>
<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">
<div class="flex w-full gap-3 overflow-auto">
{#if showPubkey}
<Button onclick={openProfile} class="flex items-start">
<Avatar src={$profile?.picture} class="border border-solid border-base-content" size={8} />
</Button>
{:else}
<div class="w-8 min-w-8 max-w-8"></div>
{/if}
<div class="min-w-0 flex-grow pr-1">
{#if showPubkey}
<div class="flex items-center gap-2">
<Button onclick={openProfile} class="text-sm font-bold" style="color: {colorValue}">
{$profileDisplay}
</Button>
<span class="text-xs opacity-50">
{#if formatTimestampAsDate(event.created_at) === today}
Today
{:else}
{formatTimestampAsDate(event.created_at)}
{/if}
at {formatTimestampAsTime(event.created_at)}
</span>
</div>
{/if}
<div class="text-sm">
<Content minimalQuote {event} {url} />
{#if thunk}
<ThunkStatus {thunk} class="mt-2" />
{/if}
</div>
</div>
</div>
<div class="row-2 ml-10 mt-1">
<ReactionSummary
{url}
{event}
{deleteReaction}
{createReaction}
reactionClass="tooltip-right" />
</div>
{#if !isMobile}
<button
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}>
{#if ENABLE_ZAPS}
<ChannelMessageZapButton {url} {event} />
{/if}
<ChannelMessageEmojiButton {url} {event} />
{#if replyTo}
<Button class="btn join-item btn-xs" onclick={reply}>
<Icon icon="reply" size={4} />
</Button>
{/if}
<ChannelMessageMenuButton {url} {event} />
</button>
{/if}
</TapTarget>
@@ -1,15 +0,0 @@
<script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared"
import EmojiButton from "@lib/components/EmojiButton.svelte"
import Icon from "@lib/components/Icon.svelte"
import {publishReaction} from "@app/commands"
const {url, event} = $props()
const onEmoji = (emoji: NativeEmoji) =>
publishReaction({event, relays: [url], content: emoji.unicode})
</script>
<EmojiButton {onEmoji} class="btn join-item btn-xs">
<Icon icon="smile-circle" size={4} />
</EmojiButton>
@@ -1,65 +0,0 @@
<script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {ENABLE_ZAPS} from "@app/state"
import {publishReaction} from "@app/commands"
import {pushModal} from "@app/modal"
type Props = {
url: string
event: TrustedEvent
reply: () => void
}
const {url, event, reply}: Props = $props()
const onEmoji = ((event: TrustedEvent, url: string, emoji: NativeEmoji) => {
history.back()
publishReaction({event, relays: [url], content: emoji.unicode})
}).bind(undefined, event, url)
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
const sendReply = () => {
history.back()
reply()
}
const showInfo = () => pushModal(EventInfo, {url, event}, {replaceState: true})
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
</script>
<div class="col-2">
<Button class="btn btn-primary w-full" onclick={showEmojiPicker}>
<Icon size={4} icon="smile-circle" />
Send Reaction
</Button>
{#if ENABLE_ZAPS}
<ZapButton replaceState {url} {event} class="btn btn-secondary w-full">
<Icon size={4} icon="bolt" />
Send Zap
</ZapButton>
{/if}
<Button class="btn btn-neutral w-full" onclick={sendReply}>
<Icon size={4} icon="reply" />
Send Reply
</Button>
<Button class="btn btn-neutral" onclick={showInfo}>
<Icon size={4} icon="code-2" />
Message Details
</Button>
{#if event.pubkey === $pubkey}
<Button class="btn btn-neutral text-error" onclick={showDelete}>
<Icon size={4} icon="trash-bin-2" />
Delete Message
</Button>
{/if}
</div>
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import {channelsById, makeChannelId} from "@app/state"
import {channelsById, makeChannelId} from "@app/core/state"
const {url, room} = $props()
</script>
+29 -20
View File
@@ -11,6 +11,7 @@
MINUTE,
sortBy,
remove,
enumerate,
formatTimestampAsDate,
} from "@welshman/lib"
import type {TrustedEvent, EventTemplate, EventContent} from "@welshman/util"
@@ -21,15 +22,16 @@
getTags,
DIRECT_MESSAGE,
DIRECT_MESSAGE_FILE,
INBOX_RELAYS,
} from "@welshman/util"
import {
pubkey,
tagPubkey,
sendWrapped,
loadUsingOutbox,
mergeThunks,
loadInboxRelaySelections,
inboxRelaySelectionsByPubkey,
} from "@welshman/app"
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Spinner from "@lib/components/Spinner.svelte"
@@ -45,15 +47,17 @@
import ChatMessage from "@app/components/ChatMessage.svelte"
import ChatCompose from "@app/components/ChatCompose.svelte"
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import {
INDEXER_RELAYS,
userSettingValues,
userSettingsValues,
deriveChat,
splitChatId,
PLATFORM_NAME,
} from "@app/state"
import {pushModal} from "@app/modal"
import {prependParent} from "@app/commands"
} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {prependParent} from "@app/core/commands"
import {pushToast} from "@app/util/toast"
type Props = {
id: string
@@ -122,11 +126,21 @@
// Split the message into multiple pieces so that we can use kind 15 to send images per nip 17
// Sleep 1 second between each one to make sure timestamps are distinct
for (let i = 0; i < templates.length; i++) {
const template = templates[i]
const thunks = Array.from(enumerate(templates)).map(([i, event]) =>
sendWrapped({
event,
recipients: pubkeys,
delay: $userSettingsValues.send_delay + ms(i),
}),
)
await sendWrapped({pubkeys, template, delay: $userSettingValues.send_delay + ms(i)})
}
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk: mergeThunks(thunks)},
},
})
clearParent()
}
@@ -156,7 +170,7 @@
id,
type: "note",
value: event,
showPubkey: created_at - previousCreatedAt > int(15, MINUTE) || previousPubkey !== pubkey,
showPubkey: created_at - previousCreatedAt > int(2, MINUTE) || previousPubkey !== pubkey,
})
previousDate = date
@@ -168,13 +182,8 @@
})
onMount(() => {
// Don't use loadInboxRelaySelection because we want to force reload
for (const pubkey of others) {
loadUsingOutbox({
pubkey,
kind: INBOX_RELAYS,
relays: INDEXER_RELAYS,
})
loadInboxRelaySelections(pubkey, INDEXER_RELAYS, true)
}
const observer = new ResizeObserver(() => {
@@ -241,7 +250,7 @@
<div
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer"
data-tip="{count} {label} not configured.">
<Icon icon="danger" />
<Icon icon={Danger} />
{count}
</div>
{/if}
@@ -255,7 +264,7 @@
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error">
<Icon icon="danger" />
<Icon icon={Danger} />
Your inbox is not configured.
</p>
<p>
@@ -268,7 +277,7 @@
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error">
<Icon icon="danger" />
<Icon icon={Danger} />
{missingInboxes.length}
{missingInboxes.length > 1 ? "inboxes are" : "inbox is"} not configured.
</p>
+6 -5
View File
@@ -2,17 +2,18 @@
import {writable} from "svelte/store"
import type {EventContent} from "@welshman/util"
import {isMobile, preventDefault} from "@lib/html"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
import Plane from "@assets/icons/plane-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
type Props = {
url?: string
onSubmit: (event: EventContent) => void
}
const {onSubmit, url}: Props = $props()
const {onSubmit}: Props = $props()
const autofocus = !isMobile
@@ -37,11 +38,11 @@
}
const editor = makeEditor({
url,
autofocus,
submit,
uploading,
aggressive: true,
encryptFiles: true,
})
</script>
@@ -54,7 +55,7 @@
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="gallery-send" />
<Icon icon={GallerySend} />
{/if}
</Button>
<div class="chat-editor flex-grow overflow-hidden">
@@ -65,6 +66,6 @@
class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full"
disabled={$uploading}
onclick={submit}>
<Icon icon="plain" />
<Icon icon={Plane} />
</Button>
</form>
+6 -10
View File
@@ -2,9 +2,10 @@
import type {TrustedEvent} from "@welshman/util"
import {displayProfileByPubkey} from "@welshman/app"
import {slide} from "@lib/transition"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
const {
verb,
@@ -18,18 +19,13 @@
</script>
<div
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8 text-xs"
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8"
transition:slide>
<p class="text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p>
<p class="text-xs text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p>
{#key event.id}
<NoteContent
{event}
hideMediaAtDepth={0}
minLength={100}
maxLength={300}
expandMode="disabled" />
<NoteContentMinimal trimParent {event} />
{/key}
<Button class="absolute right-2 top-2 cursor-pointer" onclick={clear}>
<Icon icon="close-circle" />
<Icon icon={CloseCircle} />
</Button>
</div>
+10 -18
View File
@@ -1,15 +1,16 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {WRAP} from "@welshman/util"
import {repository} from "@welshman/app"
import {preventDefault} from "@lib/html"
import {shouldUnwrap} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {canDecrypt, PLATFORM_NAME, ensureUnwrapped} from "@app/state"
import {clearModals} from "@app/modal"
import {PLATFORM_NAME} from "@app/core/state"
import {clearModals} from "@app/util/modal"
const {next} = $props()
@@ -17,22 +18,13 @@
let loading = $state(false)
const enableChat = async () => {
canDecrypt.set(true)
for (const event of repository.query([{kinds: [WRAP]}])) {
ensureUnwrapped(event)
}
clearModals()
goto(nextUrl)
}
const submit = async () => {
loading = true
try {
await enableChat()
shouldUnwrap.set(true)
clearModals()
goto(nextUrl)
} finally {
loading = false
}
@@ -60,12 +52,12 @@
</p>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Enable Messages</Spinner>
<Icon icon="alt-arrow-right" />
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
+7 -2
View File
@@ -9,8 +9,8 @@
import ProfileName from "@app/components/ProfileName.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {makeChatPath} from "@app/routes"
import {notifications} from "@app/notifications"
import {makeChatPath} from "@app/util/routes"
import {notifications} from "@app/util/notifications"
interface Props {
id: string
@@ -59,6 +59,11 @@
{/if}
</div>
<p class="overflow-hidden text-ellipsis whitespace-nowrap text-sm">
<span class="opacity-50">
{#if props.messages[0].pubkey === $pubkey}
You:
{/if}
</span>
{props.messages[0].content}
</p>
</div>
+62 -4
View File
@@ -1,9 +1,18 @@
<script lang="ts">
import {waitForThunkCompletion} from "@welshman/app"
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"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ChatStart from "@app/components/ChatStart.svelte"
import {setChecked} from "@app/notifications"
import {pushModal} from "@app/modal"
import {setChecked} from "@app/util/notifications"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {dmAlert, userInboxRelays} from "@app/core/state"
import {deleteAlert, createDmAlert} from "@app/core/commands"
const startChat = () => pushModal(ChatStart, {}, {replaceState: true})
@@ -11,15 +20,64 @@
setChecked("/chat/*")
history.back()
}
const enableAlerts = async () => {
if ($userInboxRelays.length === 0) {
return pushToast({
theme: "error",
message: "Please set up your messaging relays before enabling alerts.",
})
}
enablingAlert = true
try {
const {error} = await createDmAlert()
if (error) {
return pushToast({theme: "error", message: error})
}
} finally {
enablingAlert = false
}
}
const disableAlerts = async () => {
disablingAlert = true
try {
await waitForThunkCompletion(deleteAlert($dmAlert!))
} finally {
disablingAlert = false
}
}
let enablingAlert = $state(false)
let disablingAlert = $state(false)
</script>
<div class="col-2">
<Button class="btn btn-primary" onclick={startChat}>
<Icon size={4} icon="add-circle" />
<Icon size={5} icon={ChatSquare} />
Start chat
</Button>
<Button class="btn btn-neutral" onclick={markAsRead}>
<Icon size={4} icon="check-circle" />
<Icon size={5} icon={Check} />
Mark all read
</Button>
{#if (!enablingAlert && $dmAlert) || disablingAlert}
<Button class="btn btn-neutral" onclick={disableAlerts} disabled={disablingAlert}>
{#if !disablingAlert}
<Icon size={4} icon={BellOff} />
{/if}
<Spinner loading={disablingAlert}>Disable alerts</Spinner>
</Button>
{:else}
<Button class="btn btn-neutral" onclick={enableAlerts} disabled={enablingAlert}>
{#if !enablingAlert}
<Icon size={4} icon={Bell} />
{/if}
<Spinner loading={enablingAlert}>Enable alerts</Spinner>
</Button>
{/if}
</div>
+19 -11
View File
@@ -2,8 +2,16 @@
import {type Instance} from "tippy.js"
import {hash, formatTimestampAsTime} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {thunks, pubkey, deriveProfile, deriveProfileDisplay, sendWrapped} from "@welshman/app"
import {
thunks,
mergeThunks,
pubkey,
deriveProfile,
deriveProfileDisplay,
sendWrapped,
} from "@welshman/app"
import {isMobile} from "@lib/html"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Tippy from "@lib/components/Tippy.svelte"
@@ -11,13 +19,13 @@
import Avatar from "@lib/components/Avatar.svelte"
import Content from "@app/components/Content.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkStatus from "@app/components/ThunkStatus.svelte"
import ThunkFailure from "@app/components/ThunkFailure.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte"
import {colors} from "@app/state"
import {makeDelete, makeReaction} from "@app/commands"
import {pushModal} from "@app/modal"
import {colors} from "@app/core/state"
import {makeDelete, makeReaction} from "@app/core/commands"
import {pushModal} from "@app/util/modal"
interface Props {
event: TrustedEvent
@@ -28,19 +36,19 @@
const {event, replyTo, pubkeys, showPubkey = false}: Props = $props()
const thunk = $thunks[event.id]
const isOwn = event.pubkey === $pubkey
const profile = deriveProfile(event.pubkey)
const profileDisplay = deriveProfileDisplay(event.pubkey)
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
const thunk = mergeThunks($thunks.filter(t => t.event.id === event.id))
const [_, colorValue] = colors[hash(event.pubkey) % colors.length]
const reply = () => replyTo(event)
const deleteReaction = (event: TrustedEvent) =>
sendWrapped({template: makeDelete({event}), pubkeys})
sendWrapped({event: makeDelete({event, protect: false}), recipients: pubkeys})
const createReaction = (template: EventContent) =>
sendWrapped({template: makeReaction({event, ...template}), pubkeys})
sendWrapped({event: makeReaction({event, protect: false, ...template}), recipients: pubkeys})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
@@ -59,7 +67,7 @@
</script>
{#if thunk}
<ThunkStatus {thunk} class="mt-1" />
<ThunkFailure showToastOnRetry {thunk} class="mt-1" />
{/if}
<div
data-event={event.id}
@@ -87,7 +95,7 @@
class="opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}
onclick={togglePopover}>
<Icon icon="menu-dots" size={4} />
<Icon icon={MenuDots} size={4} />
</button>
</Tippy>
{/if}
@@ -2,9 +2,10 @@
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import {sendWrapped} from "@welshman/app"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte"
import {makeReaction} from "@app/commands"
import {makeReaction} from "@app/core/commands"
interface Props {
event: TrustedEvent
@@ -14,9 +15,12 @@
const {event, pubkeys}: Props = $props()
const onEmoji = (emoji: NativeEmoji) =>
sendWrapped({template: makeReaction({event, content: emoji.unicode}), pubkeys})
sendWrapped({
event: makeReaction({event, content: emoji.unicode, protect: false}),
recipients: pubkeys,
})
</script>
<EmojiButton {onEmoji} class="btn join-item btn-xs">
<Icon icon="smile-circle" size={4} />
<Icon icon={SmileCircle} size={4} />
</EmojiButton>
+5 -3
View File
@@ -3,7 +3,9 @@
import Button from "@lib/components/Button.svelte"
import ChatMessageEmojiButton from "@app/components/ChatMessageEmojiButton.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import {pushModal} from "@app/modal"
import {pushModal} from "@app/util/modal"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
const {event, pubkeys, popover, replyTo} = $props()
@@ -19,10 +21,10 @@
<ChatMessageEmojiButton {event} {pubkeys} />
{#if replyTo}
<Button class="btn join-item btn-xs" onclick={reply}>
<Icon size={4} icon="reply" />
<Icon size={4} icon={Reply} />
</Button>
{/if}
<Button class="btn join-item btn-xs" onclick={showInfo}>
<Icon size={4} icon="code-2" />
<Icon size={4} icon={Code2} />
</Button>
</div>
@@ -6,9 +6,13 @@
import Button from "@lib/components/Button.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import {makeReaction} from "@app/commands"
import {pushModal} from "@app/modal"
import {clip} from "@app/toast"
import {makeReaction} from "@app/core/commands"
import {pushModal} from "@app/util/modal"
import {clip} from "@app/util/toast"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Copy from "@assets/icons/copy.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
type Props = {
pubkeys: string[]
@@ -20,7 +24,10 @@
const onEmoji = ((event: TrustedEvent, pubkeys: string[], emoji: NativeEmoji) => {
history.back()
sendWrapped({template: makeReaction({event, content: emoji.unicode}), pubkeys})
sendWrapped({
event: makeReaction({event, content: emoji.unicode, protect: false}),
recipients: pubkeys,
})
}).bind(undefined, event, pubkeys)
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
@@ -40,19 +47,19 @@
<div class="col-2">
<Button class="btn btn-primary w-full" onclick={showEmojiPicker}>
<Icon size={4} icon="smile-circle" />
<Icon size={4} icon={SmileCircle} />
Send Reaction
</Button>
<Button class="btn btn-neutral w-full" onclick={sendReply}>
<Icon size={4} icon="reply" />
<Icon size={4} icon={Reply} />
Send Reply
</Button>
<Button class="btn btn-neutral w-full" onclick={copyText}>
<Icon size={4} icon="copy" />
<Icon size={4} icon={Copy} />
Copy Text
</Button>
<Button class="btn btn-neutral" onclick={showInfo}>
<Icon size={4} icon="code-2" />
<Icon size={4} icon={Code2} />
Message Details
</Button>
</div>
+5 -3
View File
@@ -9,11 +9,13 @@
import {preventDefault} from "@lib/html"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
import {makeChatPath} from "@app/routes"
import {makeChatPath} from "@app/util/routes"
const back = () => history.back()
@@ -67,12 +69,12 @@
</Field>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={pubkeys.length === 0}>
Create Chat
<Icon icon="alt-arrow-right" />
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
+8 -5
View File
@@ -4,8 +4,8 @@
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import {publishDelete, publishReaction} from "@app/commands"
import {makeThreadPath} from "@app/routes"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {makeThreadPath} from "@app/util/routes"
interface Props {
url: any
@@ -15,12 +15,15 @@
const {url, event, showActivity = false}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const path = makeThreadPath(url, event.id)
const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
const deleteReaction = async (event: TrustedEvent) =>
publishDelete({relays: [url], event, protect: await shouldProtect})
const createReaction = (template: EventContent) =>
publishReaction({...template, event, relays: [url]})
const createReaction = async (template: EventContent) =>
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex flex-wrap items-center justify-between gap-2">
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
import {onMount} from "svelte"
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import {pushModal} from "@app/util/modal"
import CalendarEventCreate from "@app/components/CalendarEventCreate.svelte"
import ThreadCreate from "@app/components/ThreadCreate.svelte"
import GoalCreate from "@app/components/GoalCreate.svelte"
type Props = {
url: string
onClick: () => void
room?: string
}
const {url, room, onClick}: Props = $props()
const createGoal = () => pushModal(GoalCreate, {url, room})
const createCalendarEvent = () => pushModal(CalendarEventCreate, {url, room})
const createThread = () => pushModal(ThreadCreate, {url, room})
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-xl" bind:this={ul}>
<li>
<Button onclick={createGoal}>
<Icon size={4} icon={StarFallMinimalistic} />
Create Funding Goal
</Button>
</li>
<li>
<Button onclick={createCalendarEvent}>
<Icon size={4} icon={CalendarMinimalistic} />
Create Calendar Event
</Button>
</li>
<li>
<Button onclick={createThread}>
<Icon size={4} icon={NotesMinimalistic} />
Create Thread
</Button>
</li>
</ul>
+44 -30
View File
@@ -18,8 +18,10 @@
isAddress,
isNewline,
} from "@welshman/content"
import type {Parsed} from "@welshman/content"
import {preventDefault, stopPropagation} from "@lib/html"
import Link from "@lib/components/Link.svelte"
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ContentToken from "@app/components/ContentToken.svelte"
@@ -31,17 +33,15 @@
import ContentQuote from "@app/components/ContentQuote.svelte"
import ContentTopic from "@app/components/ContentTopic.svelte"
import ContentMention from "@app/components/ContentMention.svelte"
import {entityLink, userSettingValues} from "@app/state"
import {entityLink, userSettingsValues} from "@app/core/state"
interface Props {
event: any
minLength?: number
maxLength?: number
showEntire?: boolean
hideMediaAtDepth?: number
expandMode?: string
minimalQuote?: boolean
depth?: number
trimParent?: boolean
url?: string
}
@@ -50,10 +50,8 @@
minLength = 500,
maxLength = 700,
showEntire = $bindable(false),
hideMediaAtDepth = 1,
expandMode = "block",
minimalQuote = false,
depth = 0,
trimParent = false,
url,
}: Props = $props()
@@ -66,13 +64,13 @@
const isBlock = (i: number) => {
const parsed = fullContent[i]
if (!parsed || hideMediaAtDepth <= depth) return false
if (!parsed) return false
if (isLink(parsed) && $userSettingValues.show_media && isStartOrEnd(i)) {
if (isLink(parsed) && $userSettingsValues.show_media && isStartAndEnd(i)) {
return true
}
if ((isEvent(parsed) || isAddress(parsed)) && isStartOrEnd(i)) {
if (isQuote(parsed) && isStartAndEnd(i)) {
return true
}
@@ -94,25 +92,47 @@
const isStartAndEnd = (i: number) => isStart(i) && isEnd(i)
const isStartOrEnd = (i: number) => isStart(i) || isEnd(i)
const isQuote = (p: Parsed) => isEvent(p) || isAddress(p)
const ignoreWarning = () => {
warning = null
}
let warning = $state(
$userSettingValues.hide_sensitive && event.tags.find(nthEq(0, "content-warning"))?.[1],
$userSettingsValues.hide_sensitive && event.tags.find(nthEq(0, "content-warning"))?.[1],
)
const shortContent = $derived(
showEntire
? fullContent
: truncate(fullContent, {
minLength,
maxLength,
mediaLength: hideMediaAtDepth <= depth ? 20 : 200,
}),
)
const dropWhile = <T,>(f: (x: T) => boolean, xs: Iterable<T>) => {
const result: T[] = []
for (const x of xs) {
if (result.length === 0 && f(x)) {
continue
}
result.push(x)
}
return result
}
const shortContent = $derived.by(() => {
let result = fullContent
if (trimParent && result.length > 0 && isQuote(result[0])) {
result = dropWhile(p => isQuote(p) || isNewline(p), result)
}
if (!showEntire) {
result = truncate(result, {
minLength,
maxLength,
mediaLength: 200,
})
}
return result
})
const hasEllipsis = $derived(shortContent.some(isEllipsis))
const expandInline = $derived(hasEllipsis && expandMode === "inline")
@@ -122,7 +142,7 @@
<div class="relative">
{#if warning}
<div class="card2 card2-sm bg-alt row-2">
<Icon icon="danger" />
<Icon icon={Danger} />
<p>
This note has been flagged by the author as "{warning}".<br />
<Button class="link" onclick={ignoreWarning}>Show anyway</Button>
@@ -153,15 +173,9 @@
{/if}
{:else if isProfile(parsed)}
<ContentMention value={parsed.value} {url} />
{:else if isEvent(parsed) || isAddress(parsed)}
{:else if isQuote(parsed)}
{#if isBlock(i)}
<ContentQuote
{depth}
{url}
{hideMediaAtDepth}
value={parsed.value}
{event}
minimal={minimalQuote} />
<ContentQuote {url} value={parsed.value} {event} />
{:else}
<Link
external
+1 -5
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import type {ParsedEmojiValue} from "@welshman/content"
import {imgproxy} from "@app/state"
export let value: ParsedEmojiValue
@@ -8,10 +7,7 @@
</script>
{#if value.url}
<img
{alt}
src={imgproxy(value.url, {w: 24, h: 24})}
class="-mt-0.5 inline h-[1em] min-w-[1em] align-middle" />
<img {alt} src={value.url} class="-mt-0.5 inline h-[1em] min-w-[1em] align-middle" />
{:else}
{alt}
{/if}
+3 -3
View File
@@ -1,11 +1,11 @@
<script lang="ts">
import {ellipsize, displayUrl, postJson} from "@welshman/lib"
import {dufflepud, imgproxy} from "@app/state"
import {dufflepud} from "@app/core/state"
import {preventDefault, stopPropagation} from "@lib/html"
import Link from "@lib/components/Link.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
import {pushModal} from "@app/modal"
import {pushModal} from "@app/util/modal"
const {value, event} = $props()
@@ -51,7 +51,7 @@
<img
alt="Link preview"
onerror={onError}
src={imgproxy(preview.image)}
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">
@@ -1,9 +1,17 @@
<script lang="ts">
import {onMount, onDestroy} from "svelte"
import {displayUrl} from "@welshman/lib"
import {getTags, decryptFile, getTagValue, tagsFromIMeta} from "@welshman/util"
import {
getTags,
getBlob,
decryptFile,
getTagValue,
tagsFromIMeta,
makeBlossomAuthEvent,
} from "@welshman/util"
import {signer} from "@welshman/app"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import {imgproxy} from "@app/state"
const {value, event, ...props} = $props()
@@ -13,18 +21,34 @@
.map(tagsFromIMeta)
.find(meta => getTagValue("url", meta) === url) || event.tags
const hash = getTagValue("x", meta)
const key = getTagValue("decryption-key", meta)
const nonce = getTagValue("decryption-nonce", meta)
const algorithm = getTagValue("encryption-algorithm", meta)
const onError = () => {
hasError = true
const onError = async () => {
// If the image failed to load, try authenticating
if (hash && $signer) {
const server = new URL(url).origin
const template = makeBlossomAuthEvent({action: "get", server, hashes: [hash]})
const authEvent = await $signer.sign(template)
const res = await getBlob(server, hash, {authEvent})
if (res.status === 200) {
src = URL.createObjectURL(await res.blob())
} else {
hasError = true
}
} else {
hasError = true
}
}
let hasError = $state(false)
let src = $state(imgproxy(url))
let src = $state("")
onMount(async () => {
// If we have an encryption algorithm, fetch and decrypt
if (algorithm === "aes-gcm" && key && nonce) {
const response = await fetch(url)
@@ -32,8 +56,10 @@
const ciphertext = new Uint8Array(await response.arrayBuffer())
const decryptedData = await decryptFile({ciphertext, key, nonce, algorithm})
src = URL.createObjectURL(new Blob([decryptedData]))
src = URL.createObjectURL(new Blob([new Uint8Array(decryptedData)]))
}
} else {
src = url
}
})
@@ -44,9 +70,9 @@
{#if hasError}
<a href={url} class="link-content whitespace-nowrap">
<Icon icon="link-round" size={3} class="inline-block" />
<Icon icon={LinkRound} size={3} class="inline-block" />
{displayUrl(url)}
</a>
{:else}
{:else if src}
<img alt="" {src} onerror={onError} {...props} />
{/if}
+4 -3
View File
@@ -1,10 +1,11 @@
<script lang="ts">
import {displayUrl} from "@welshman/lib"
import {preventDefault} 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 {pushModal} from "@app/modal"
import {pushModal} from "@app/util/modal"
const {value} = $props()
@@ -16,12 +17,12 @@
{#if url.match(/\.(jpe?g|png|gif|webp)$/)}
<!-- Use a real link so people can copy the href -->
<a href={url} class="link-content whitespace-nowrap" onclick={preventDefault(expand)}>
<Icon icon="link-round" size={3} class="inline-block" />
<Icon icon={LinkRound} size={3} class="inline-block" />
{displayUrl(url)}
</a>
{:else}
<Link external href={url} class="link-content whitespace-nowrap">
<Icon icon="link-round" size={3} class="inline-block" />
<Icon icon={LinkRound} size={3} class="inline-block" />
{displayUrl(url)}
</Link>
{/if}
+1 -1
View File
@@ -4,7 +4,7 @@
import {deriveProfileDisplay} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal"
import {pushModal} from "@app/util/modal"
type Props = {
value: ProfilePointer
+135
View File
@@ -0,0 +1,135 @@
<script lang="ts">
import {fromNostrURI} from "@welshman/util"
import {nthEq} from "@welshman/lib"
import {
parse,
truncate,
renderAsHtml,
isText,
isEmoji,
isTopic,
isCode,
isCashu,
isInvoice,
isLink,
isProfile,
isEvent,
isAddress,
isNewline,
} from "@welshman/content"
import type {Parsed} from "@welshman/content"
import Link from "@lib/components/Link.svelte"
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ContentToken from "@app/components/ContentToken.svelte"
import ContentEmoji from "@app/components/ContentEmoji.svelte"
import ContentCode from "@app/components/ContentCode.svelte"
import ContentLinkInline from "@app/components/ContentLinkInline.svelte"
import ContentNewline from "@app/components/ContentNewline.svelte"
import ContentTopic from "@app/components/ContentTopic.svelte"
import ContentMention from "@app/components/ContentMention.svelte"
import {entityLink, userSettingsValues} from "@app/core/state"
interface Props {
event: any
trimParent?: boolean
url?: string
}
const {event, trimParent = false, url}: Props = $props()
const fullContent = parse(event)
const isBoundary = (i: number) => {
const parsed = fullContent[i]
if (!parsed || isNewline(parsed)) return true
if (isText(parsed)) return Boolean(parsed.value.match(/^\s+$/))
return false
}
const isStart = (i: number) => isBoundary(i - 1)
const isEnd = (i: number) => isBoundary(i + 1)
const isStartAndEnd = (i: number) => isStart(i) && isEnd(i)
const isQuote = (p: Parsed) => isEvent(p) || isAddress(p)
const ignoreWarning = () => {
warning = null
}
let warning = $state(
$userSettingsValues.hide_sensitive && event.tags.find(nthEq(0, "content-warning"))?.[1],
)
const dropWhile = <T,>(f: (x: T) => boolean, xs: Iterable<T>) => {
const result: T[] = []
for (const x of xs) {
if (result.length === 0 && f(x)) {
continue
}
result.push(x)
}
return result
}
const shortContent = $derived.by(() => {
let result = fullContent
if (trimParent && result.length > 0 && isQuote(result[0])) {
result = dropWhile(p => isQuote(p) || isNewline(p), result)
}
return truncate(result, {minLength: 200, maxLength: 300, mediaLength: 20})
})
</script>
<div class="relative">
{#if warning}
<div class="card2 card2-sm bg-alt row-2">
<Icon icon={Danger} />
<p>
This note has been flagged by the author as "{warning}".<br />
<Button class="link" onclick={ignoreWarning}>Show anyway</Button>
</p>
</div>
{:else}
<div class="overflow-hidden text-ellipsis break-words">
{#each shortContent as parsed, i}
{#if isNewline(parsed)}
<ContentNewline value={parsed.value} />
{:else if isTopic(parsed)}
<ContentTopic value={parsed.value} />
{:else if isEmoji(parsed)}
<ContentEmoji value={parsed.value} />
{:else if isCode(parsed)}
<ContentCode
value={parsed.value}
isBlock={isStartAndEnd(i) || parsed.value.includes("\n")} />
{:else if isCashu(parsed) || isInvoice(parsed)}
<ContentToken value={parsed.value} />
{:else if isLink(parsed)}
<ContentLinkInline value={parsed.value} />
{:else if isProfile(parsed)}
<ContentMention value={parsed.value} {url} />
{:else if isQuote(parsed)}
<Link
external
class="overflow-hidden text-ellipsis whitespace-nowrap underline"
href={entityLink(parsed.raw)}>
{fromNostrURI(parsed.raw).slice(0, 16) + "…"}
</Link>
{:else}
{@html renderAsHtml(parsed)}
{/if}
{/each}
</div>
{/if}
</div>
+8 -11
View File
@@ -6,20 +6,17 @@
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import {deriveEvent, entityLink} from "@app/state"
import {goToEvent} from "@app/routes"
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
import {deriveEvent, entityLink} from "@app/core/state"
import {goToEvent} from "@app/util/routes"
type Props = {
value: any
hideMediaAtDepth: number
event: TrustedEvent
depth: number
url?: string
minimal?: boolean
}
const {value, event, depth, hideMediaAtDepth, url, minimal}: Props = $props()
const {value, event, url}: Props = $props()
const {id, identifier, kind, pubkey, relays = []} = value
const idOrAddress = id || new Address(kind, pubkey, identifier).toString()
@@ -43,17 +40,17 @@
}
</script>
<Button class="my-2 block max-w-full text-left" {onclick}>
<Button class="my-2 block w-full max-w-full text-left" {onclick}>
{#if $quote}
{#if minimal && $quote.kind === MESSAGE}
{#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%);">
<NoteContent {hideMediaAtDepth} {url} event={$quote} depth={depth + 1} />
<NoteContentMinimal trimParent {url} event={$quote} />
</div>
{:else}
<NoteCard event={$quote} {url} class="bg-alt rounded-box p-4">
<NoteContent {hideMediaAtDepth} {url} event={$quote} depth={depth + 1} />
<NoteContentMinimal {url} event={$quote} />
</NoteCard>
{/if}
{:else}
+3 -2
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import Bolt from "@assets/icons/bolt.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import {clip} from "@app/toast"
import {clip} from "@app/util/toast"
const {value} = $props()
@@ -9,6 +10,6 @@
</script>
<Button onclick={copy} class="link-content">
<Icon icon="bolt" size={3} class="inline-block translate-y-px" />
<Icon icon={Bolt} size={3} class="inline-block translate-y-px" />
{value.slice(0, 16)}...
</Button>
+9 -8
View File
@@ -1,13 +1,14 @@
<script lang="ts">
import {formatTimestamp} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Content from "@app/components/Content.svelte"
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {goToEvent} from "@app/routes"
import {displayChannel} from "@app/state"
import {goToEvent} from "@app/util/routes"
import {displayChannel} from "@app/core/state"
type Props = {
url: string
@@ -28,19 +29,19 @@
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 text-sm opacity-70">
{#if room}
<span class="font-medium text-blue-400">
<span class="truncate font-medium text-blue-400">
#{displayChannel(url, room)}
</span>
<span class="opacity-50"></span>
{/if}
<span>{formatTimestamp(earliest.created_at)}</span>
<span class="text-nowrap">{formatTimestamp(earliest.created_at)}</span>
</div>
<Content minimalQuote minLength={100} maxLength={400} event={earliest} />
<NoteContentMinimal event={earliest} />
</div>
</div>
<div class="ml-13 flex items-center justify-between">
<div class="flex gap-1">
<Icon icon="alt-arrow-left" />
<Icon icon={AltArrowLeft} />
<span class="text-sm opacity-70">
{events.length}
{events.length === 1 ? "message" : "messages"}
@@ -66,7 +67,7 @@
{formatTimestamp(latest.created_at)}
</span>
</div>
<Content minimalQuote minLength={100} maxLength={400} event={latest} />
<NoteContentMinimal event={latest} />
</div>
</Button>
{/if}
+2 -2
View File
@@ -4,8 +4,8 @@
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import LogInPassword from "@app/components/LogInPassword.svelte"
import {pushModal} from "@app/modal"
import {BURROW_URL} from "@app/state"
import {pushModal} from "@app/util/modal"
import {BURROW_URL} from "@app/core/state"
const {email, confirm_token} = $props()
+17 -7
View File
@@ -3,14 +3,17 @@
import type {Instance} from "tippy.js"
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import Bolt from "@assets/icons/bolt.svg?dataurl"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import Button from "@lib/components/Button.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte"
import EventMenu from "@app/components/EventMenu.svelte"
import {ENABLE_ZAPS} from "@app/state"
import {publishReaction} from "@app/commands"
import {ENABLE_ZAPS} from "@app/core/state"
import {publishReaction, canEnforceNip70} from "@app/core/commands"
type Props = {
url: string
@@ -22,12 +25,19 @@
const {url, noun, event, hideZap, customActions}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const showPopover = () => popover?.show()
const hidePopover = () => popover?.hide()
const onEmoji = (emoji: NativeEmoji) =>
publishReaction({event, content: emoji.unicode, relays: [url]})
const onEmoji = async (emoji: NativeEmoji) =>
publishReaction({
event,
content: emoji.unicode,
relays: [url],
protect: await shouldProtect,
})
let popover: Instance | undefined = $state()
</script>
@@ -35,11 +45,11 @@
<Button class="join rounded-full">
{#if ENABLE_ZAPS && !hideZap}
<ZapButton {url} {event} class="btn join-item btn-neutral btn-xs">
<Icon icon="bolt" size={4} />
<Icon icon={Bolt} size={4} />
</ZapButton>
{/if}
<EmojiButton {onEmoji} class="btn join-item btn-neutral btn-xs">
<Icon icon="smile-circle" size={4} />
<Icon icon={SmileCircle} size={4} />
</EmojiButton>
<Tippy
bind:popover
@@ -47,7 +57,7 @@
props={{url, noun, event, customActions, onClick: hidePopover}}
params={{trigger: "manual", interactive: true}}>
<Button class="btn join-item btn-neutral btn-xs" onclick={showPopover}>
<Icon icon="menu-dots" size={4} />
<Icon icon={MenuDots} size={4} />
</Button>
</Tippy>
</Button>
+3 -2
View File
@@ -6,7 +6,8 @@
import {deriveEvents} from "@welshman/store"
import type {TrustedEvent} from "@welshman/util"
import {repository} from "@welshman/app"
import {notifications} from "@app/notifications"
import {notifications} from "@app/util/notifications"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
const {url, path, event}: {url: string; path: string; event: TrustedEvent} = $props()
@@ -21,7 +22,7 @@
</script>
<div class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full">
<Icon icon="reply" />
<Icon icon={Reply} />
<span>{$replies.length} {$replies.length === 1 ? "reply" : "replies"}</span>
</div>
<div class="btn btn-neutral btn-xs relative hidden rounded-full sm:flex">
+5 -3
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import Confirm from "@lib/components/Confirm.svelte"
import {publishDelete} from "@app/commands"
import {clearModals} from "@app/modal"
import {publishDelete, canEnforceNip70} from "@app/core/commands"
import {clearModals} from "@app/util/modal"
type Props = {
url: string
@@ -11,8 +11,10 @@
const {url, event}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const confirm = async () => {
await publishDelete({event, relays: [url]})
await publishDelete({event, relays: [url], protect: await shouldProtect})
clearModals()
}
+42 -6
View File
@@ -1,12 +1,18 @@
<script lang="ts">
import * as nip19 from "nostr-tools/nip19"
import {Router} from "@welshman/router"
import {LOCALE, secondsToDate} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {displayRelayUrl} from "@welshman/util"
import FileText from "@assets/icons/file-text.svg?dataurl"
import Copy from "@assets/icons/copy.svg?dataurl"
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import {clip} from "@app/toast"
import {trackerStore} from "@app/core/state"
import {clip} from "@app/util/toast"
type Props = {
url?: string
@@ -17,11 +23,17 @@
const relays = url ? [url] : Router.get().Event(event).getUrls()
const nevent1 = nip19.neventEncode({...event, relays})
const seenOn = $trackerStore.getRelays(event.id)
const npub1 = nip19.npubEncode(event.pubkey)
const json = JSON.stringify(event, null, 2)
const copyLink = () => clip(nevent1)
const copyPubkey = () => clip(npub1)
const copyJson = () => clip(json)
const formatter = new Intl.DateTimeFormat(LOCALE, {
dateStyle: "long",
timeStyle: "long",
})
</script>
<div class="column gap-4">
@@ -33,16 +45,24 @@
<div>The full details of this event are shown below.</div>
{/snippet}
</ModalHeader>
<FieldInline>
{#snippet label()}
<p>Created At</p>
{/snippet}
{#snippet input()}
<p>{formatter.format(secondsToDate(event.created_at))}</p>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Event Link</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="file" />
<Icon icon={FileText} />
<input type="text" class="ellipsize min-w-0 grow" value={nevent1} />
<Button onclick={copyLink} class="flex items-center">
<Icon icon="copy" />
<Icon icon={Copy} />
</Button>
</label>
{/snippet}
@@ -53,19 +73,35 @@
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="user-circle" />
<Icon icon={UserCircle} />
<input type="text" class="ellipsize min-w-0 grow" value={npub1} />
<Button onclick={copyPubkey} class="flex items-center">
<Icon icon="copy" />
<Icon icon={Copy} />
</Button>
</label>
{/snippet}
</FieldInline>
{#if !url && seenOn.size > 0}
<FieldInline>
{#snippet label()}
<p>Seen On</p>
{/snippet}
{#snippet input()}
<div class="flex flex-wrap gap-2">
{#each seenOn as url, i (url)}
<span class="bg-alt badge flex gap-1">
{displayRelayUrl(url)}
</span>
{/each}
</div>
{/snippet}
</FieldInline>
{/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">
<Button onclick={copyJson} class="btn btn-neutral btn-sm flex items-center">
<Icon icon="copy" /> Copy
<Icon icon={Copy} /> Copy
</Button>
</p>
</div>
+22 -7
View File
@@ -1,16 +1,24 @@
<script lang="ts">
import {onMount} from "svelte"
import type {Snippet} from "svelte"
import {goto} from "$app/navigation"
import type {TrustedEvent} from "@welshman/util"
import {COMMENT} from "@welshman/util"
import {pubkey} from "@welshman/app"
import {pubkey, relaysByUrl} from "@welshman/app"
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import Danger from "@assets/icons/danger.svg?dataurl"
import {setKey} from "@lib/implicit"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import EventReport from "@app/components/EventReport.svelte"
import EventShare from "@app/components/EventShare.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {pushModal} from "@app/modal"
import {hasNip29} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {makeSpaceChatPath} from "@app/util/routes"
type Props = {
url: string
@@ -28,7 +36,14 @@
const showInfo = () => pushModal(EventInfo, {url, event})
const share = () => pushModal(EventShare, {url, event})
const share = async () => {
if (hasNip29($relaysByUrl.get(url))) {
pushModal(EventShare, {url, event})
} else {
setKey("share", event)
goto(makeSpaceChatPath(url))
}
}
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
@@ -43,14 +58,14 @@
{#if isRoot}
<li>
<Button onclick={share}>
<Icon size={4} icon="share-circle" />
<Icon size={4} icon={ShareCircle} />
Share to Chat
</Button>
</li>
{/if}
<li>
<Button onclick={showInfo}>
<Icon size={4} icon="code-2" />
<Icon size={4} icon={Code2} />
{noun} Details
</Button>
</li>
@@ -58,14 +73,14 @@
{#if event.pubkey === $pubkey}
<li>
<Button onclick={showDelete} class="text-error">
<Icon size={4} icon="trash-bin-2" />
<Icon size={4} icon={TrashBin2} />
Delete {noun}
</Button>
</li>
{:else}
<li>
<Button class="text-error" onclick={report}>
<Icon size={4} icon="danger" />
<Icon size={4} icon={Danger} />
Report Content
</Button>
</li>
+12 -5
View File
@@ -3,17 +3,20 @@
import {writable} from "svelte/store"
import {isMobile, preventDefault} from "@lib/html"
import {fly} from "@lib/transition"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {publishComment} from "@app/commands"
import {PROTECTED} from "@app/state"
import {publishComment, canEnforceNip70} from "@app/core/commands"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {pushToast} from "@app/toast"
import {pushToast} from "@app/util/toast"
const {url, event, onClose, onSubmit} = $props()
const shouldProtect = canEnforceNip70(url)
const uploading = writable(false)
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
@@ -23,7 +26,11 @@
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = [...ed.storage.nostr.getEditorTags(), PROTECTED]
const tags = ed.storage.nostr.getEditorTags()
if (await shouldProtect) {
tags.push(PROTECTED)
}
if (!content) {
return pushToast({
@@ -75,7 +82,7 @@
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="paperclip" size={3} />
<Icon icon={Paperclip} size={3} />
{/if}
</Button>
</div>
+6 -4
View File
@@ -3,11 +3,13 @@
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {pushToast} from "@app/toast"
import {publishReport} from "@app/commands"
import {pushToast} from "@app/util/toast"
import {publishReport} from "@app/core/commands"
const {url, event} = $props()
@@ -78,12 +80,12 @@
</Field>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Send Report</Spinner>
<Icon icon="alt-arrow-right" />
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
+5 -3
View File
@@ -6,18 +6,20 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import Button from "@lib/components/Button.svelte"
import Profile from "@app/components/Profile.svelte"
import {publishDelete} from "@app/commands"
import {publishDelete, canEnforceNip70} from "@app/core/commands"
const {url, event} = $props()
const shouldProtect = canEnforceNip70(url)
const reports = deriveEvents(repository, {
filters: [{kinds: [REPORT], "#e": [event.id]}],
})
const back = () => history.back()
const deleteReport = (report: TrustedEvent) => {
publishDelete({event: report, relays: [url]})
const deleteReport = async (report: TrustedEvent) => {
publishDelete({event: report, relays: [url], protect: await shouldProtect})
if ($reports.length === 0) {
history.back()
+7 -5
View File
@@ -2,14 +2,16 @@
import {goto} from "$app/navigation"
import type {TrustedEvent} from "@welshman/util"
import {preventDefault} from "@lib/html"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import {setKey} from "@lib/implicit"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import {channelsByUrl} from "@app/state"
import {makeRoomPath} from "@app/routes"
import {setKey} from "@app/implicit"
import {channelsByUrl} from "@app/core/state"
import {makeRoomPath} from "@app/util/routes"
const {url, noun, event}: {url: string; noun: string; event: TrustedEvent} = $props()
@@ -50,12 +52,12 @@
</div>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={!selection}>
Share {noun}
<Icon icon="alt-arrow-right" />
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
+27 -17
View File
@@ -1,35 +1,45 @@
<script lang="ts">
import type {TrustedEvent, EventContent} from "@welshman/util"
import {getTagValue} from "@welshman/util"
import Link from "@lib/components/Link.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import {publishDelete, publishReaction} from "@app/commands"
import {makeGoalPath} from "@app/routes"
import ChannelName from "@app/components/ChannelName.svelte"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {makeGoalPath, makeSpacePath} from "@app/util/routes"
interface Props {
url: any
event: any
url: string
event: TrustedEvent
showRoom?: boolean
showActivity?: boolean
}
const {url, event, showActivity = false}: Props = $props()
const {url, event, showRoom, showActivity}: Props = $props()
const path = makeGoalPath(url, event.id)
const room = getTagValue("h", event.tags)
const shouldProtect = canEnforceNip70(url)
const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
const deleteReaction = async (event: TrustedEvent) =>
publishDelete({relays: [url], event, protect: await shouldProtect})
const createReaction = (template: EventContent) =>
publishReaction({...template, event, relays: [url]})
const createReaction = async (template: EventContent) =>
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2">
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} hideZap noun="Goal" />
</div>
<div class="flex flex-grow flex-wrap justify-end gap-2">
{#if room && showRoom}
<Link href={makeSpacePath(url, room)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<ChannelName {room} {url} />
</Link>
{/if}
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} hideZap noun="Goal" />
</div>
+25 -7
View File
@@ -3,6 +3,9 @@
import {makeEvent, ZAP_GOAL} from "@welshman/util"
import {publishThunk} 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"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
@@ -10,11 +13,19 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {pushToast} from "@app/toast"
import {PROTECTED} from "@app/state"
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {canEnforceNip70} from "@app/core/commands"
const {url} = $props()
type Props = {
url: string
room?: string
}
const {url, room}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const uploading = writable(false)
@@ -47,9 +58,16 @@
["summary", summary],
["amount", String(amount)],
["relays", url],
PROTECTED,
]
if (await shouldProtect) {
tags.push(PROTECTED)
}
if (room) {
tags.push(["h", room])
}
publishThunk({
relays: [url],
event: makeEvent(ZAP_GOAL, {content, tags}),
@@ -108,7 +126,7 @@
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="paperclip" size={3} />
<Icon icon={Paperclip} size={3} />
{/if}
</Button>
</div>
@@ -120,7 +138,7 @@
{#snippet input()}
<div class="flex flex-grow justify-end">
<label class="input input-bordered flex items-center gap-2">
<Icon icon="bolt" />
<Icon icon={Bolt} />
<input bind:value={amount} type="number" class="w-28" />
<p class="opacity-50">sats</p>
</label>
@@ -138,7 +156,7 @@
</div>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary">Create Goal</Button>
+6 -1
View File
@@ -6,7 +6,8 @@
import ProfileLink from "@app/components/ProfileLink.svelte"
import GoalActions from "@app/components/GoalActions.svelte"
import GoalSummary from "@app/components/GoalSummary.svelte"
import {makeGoalPath} from "@app/routes"
import ChannelLink from "@app/components/ChannelLink.svelte"
import {makeGoalPath} from "@app/util/routes"
type Props = {
url: string
@@ -16,6 +17,7 @@
const {url, event}: Props = $props()
const summary = getTagValue("summary", event.tags)
const room = getTagValue("h", event.tags)
</script>
<Link class="col-2 card2 bg-alt w-full cursor-pointer" href={makeGoalPath(url, event.id)}>
@@ -30,6 +32,9 @@
<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 room}
in <ChannelLink {url} {room} />
{/if}
</span>
<GoalActions showActivity {url} {event} />
</div>
+6 -4
View File
@@ -4,15 +4,17 @@
import {getTagValue, fromMsats, ZAP_RESPONSE} from "@welshman/util"
import {deriveEventsMapped} from "@welshman/store"
import {repository, getValidZap} from "@welshman/app"
import Bolt from "@assets/icons/bolt.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
type Props = {
url: string
url?: string
event: TrustedEvent
class?: string
}
const {url, event}: Props = $props()
const {url, event, ...props}: Props = $props()
const zaps = deriveEventsMapped<Zap>(repository, {
filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}],
@@ -26,7 +28,7 @@
const daysOld = Math.ceil((now() - event.created_at) / DAY)
</script>
<div class="card2 bg-alt flex flex-col gap-8">
<div class="flex flex-col gap-8 {props.class}">
<div class="flex gap-8">
<div>
<p class="text-xl text-primary">{zapAmount} sats</p>
@@ -43,7 +45,7 @@
</div>
<progress class="progress progress-primary" value={zapAmount} max={goalAmount}></progress>
<ZapButton {url} {event} class="btn btn-primary lg:m-auto lg:px-20">
<Icon icon="bolt" />
<Icon icon={Bolt} />
Contribute to this goal
</ZapButton>
</div>
+63
View File
@@ -0,0 +1,63 @@
<script lang="ts">
import {createSearch} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
const iconModules = import.meta.glob("@assets/icons/*.svg", {
query: "?dataurl",
eager: true,
})
const icons = Object.entries(iconModules)
.map(([path, module]) => {
const name = path.split("/").pop()?.replace(".svg", "") || ""
return {
name,
url: (module as any).default,
searchText: name.replace(/[-_]/g, " ").toLowerCase(),
}
})
.filter(icon => icon.name && !icon.name.startsWith("icon-") && icon.name !== "index")
.sort((a, b) => a.name.localeCompare(b.name))
const iconSearch = createSearch(icons, {
getValue: icon => icon.name,
fuseOptions: {
keys: ["name", "searchText"],
threshold: 0.4,
},
})
type Props = {
onSelect: (iconUrl: string) => void
}
const {onSelect}: Props = $props()
let searchTerm = $state("")
const filteredIcons = $derived(searchTerm ? iconSearch.searchOptions(searchTerm) : icons)
const handleSelect = (iconUrl: string) => {
onSelect(iconUrl)
}
</script>
<div class="w-96 rounded-box bg-base-100 p-4 shadow-lg">
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Magnifier} />
<input bind:value={searchTerm} class="grow" type="text" placeholder="Search icons..." />
</label>
<div class="mt-2 max-h-80 overflow-y-auto">
<div class="grid grid-cols-8 gap-2 p-2">
{#each filteredIcons as icon}
<button
class="flex aspect-square items-center justify-center rounded-box transition-colors hover:bg-primary hover:text-primary-content"
onclick={() => handleSelect(icon.url)}
title={icon.name}>
<Icon icon={icon.url} class="h-6 w-6" />
</button>
{/each}
</div>
</div>
</div>
+1 -1
View File
@@ -2,7 +2,7 @@
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import {PLATFORM_NAME} from "@app/state"
import {PLATFORM_NAME} from "@app/core/state"
</script>
<div class="column gap-4">
+1 -1
View File
@@ -2,7 +2,7 @@
import Button from "@lib/components/Button.svelte"
import Link from "@lib/components/Link.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import {PLATFORM_NAME} from "@app/state"
import {PLATFORM_NAME} from "@app/core/state"
</script>
<div class="column gap-4">
+8 -6
View File
@@ -1,13 +1,15 @@
<script lang="ts">
import {session} from "@welshman/app"
import Link from "@lib/components/Link.svelte"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileEject from "@app/components/ProfileEject.svelte"
import {PLATFORM_NAME} from "@app/state"
import {pushModal} from "@app/modal"
import {PLATFORM_NAME} from "@app/core/state"
import {pushModal} from "@app/util/modal"
const back = () => history.back()
@@ -27,8 +29,8 @@
</p>
<p>
On <Link external href="https://nostr.com/">Nostr</Link>, <strong>you</strong> control your own
identity and social data, through the magic of crytography. The basic idea is that you have a
<strong>public key</strong>, which acts as your user id, and a
identity and social data, through the magic of cryptography. The basic idea is that you have a
<strong>public key</strong>, which acts as your user ID, and a
<strong>private key</strong> which allows you to prove your identity.
</p>
{#if $session?.email}
@@ -39,11 +41,11 @@
<p>If you'd like to switch to self-custody, please click below to get started.</p>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button class="btn btn-primary" onclick={startEject}>
<Icon icon="check-circle" />
<Icon icon={CheckCircle} />
I want to hold my own keys
</Button>
</ModalFooter>
+1 -1
View File
@@ -2,7 +2,7 @@
import Button from "@lib/components/Button.svelte"
import Link from "@lib/components/Link.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import {PLATFORM_NAME} from "@app/state"
import {PLATFORM_NAME} from "@app/core/state"
</script>
<div class="column gap-4">
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts">
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
const back = () => history.back()
</script>
<div class="column gap-4">
<ModalHeader>
{#snippet title()}
<div>What are digital signatures?</div>
{/snippet}
</ModalHeader>
<p>
Most online services ask their users to trust them that they're being honest, and they usually
are. However, traditional social media platforms have the ability to <strong
>create forged content</strong> that can appear to be genuinely authored, but which are actually
counterfeit.
</p>
<p>
On <Link external href="https://nostr.com/">Nostr</Link>, all your content is authenticated
using <strong>digital signatures</strong>, which cryptographically tie a particular person to a
given post or message.
</p>
<p>
The result is that you don't normally have to trust service providers not to tamper with the
information flowing through the network — instead, your client software can prove that a given
piece of data is authentic.
</p>
<Button class="btn btn-primary" onclick={back}>Got it</Button>
</div>
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import {deriveZapperForPubkey} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
@@ -29,7 +30,7 @@
</p>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
<Icon icon={AltArrowLeft} />
Go back
</Button>
</ModalFooter>
@@ -1,7 +1,11 @@
<script lang="ts">
import {randomId} from "@welshman/lib"
import {preventDefault, stopPropagation} from "@lib/html"
import {randomId, call} from "@welshman/lib"
import {preventDefault, stopPropagation, compressFile} from "@lib/html"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import {uploadFile} from "@app/core/commands"
interface Props {
file?: File | undefined
@@ -24,14 +28,14 @@
active = false
}
const onDrop = (e: any) => {
const onDrop = async (e: any) => {
active = false
file = e.dataTransfer.files[0]
file = await compressFile(e.dataTransfer.files[0])
}
const onChange = (e: any) => {
file = e.target.files[0]
const onChange = async (e: any) => {
file = await compressFile(e.target.files[0])
}
const onClear = () => {
@@ -44,20 +48,29 @@
let initialUrl = $state(url)
$effect(() => {
if (file) {
const reader = new FileReader()
call(async () => {
if (file) {
const {result} = await uploadFile(file)
reader.addEventListener(
"load",
() => {
url = reader.result as string
},
false,
)
reader.readAsDataURL(file)
} else {
url = initialUrl
}
if (result?.url) {
url = result.url
} else {
const reader = new FileReader()
reader.addEventListener(
"load",
() => {
url = reader.result as string
},
false,
)
reader.readAsDataURL(file)
}
} else {
url = initialUrl
}
})
})
</script>
@@ -84,14 +97,14 @@
tabindex="-1"
onmousedown={stopPropagation(onClear)}
ontouchstart={stopPropagation(onClear)}>
<Icon icon="close-circle" class="scale-150 !bg-base-300" />
<Icon icon={CloseCircle} class="scale-150 !bg-base-300" />
</span>
{:else}
<Icon icon="add-circle" class="scale-150 !bg-base-300" />
<Icon icon={AddCircle} class="scale-150 !bg-base-300" />
{/if}
</div>
{#if !url}
<Icon icon="gallery-send" size={7} />
<Icon icon={GallerySend} size={7} />
{/if}
</label>
</form>
+8 -6
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import Login from "@assets/icons/login-3.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Link from "@lib/components/Link.svelte"
@@ -6,8 +8,8 @@
import CardButton from "@lib/components/CardButton.svelte"
import LogIn from "@app/components/LogIn.svelte"
import SignUp from "@app/components/SignUp.svelte"
import {PLATFORM_TERMS, PLATFORM_PRIVACY, PLATFORM_NAME} from "@app/state"
import {pushModal} from "@app/modal"
import {PLATFORM_TERMS, PLATFORM_PRIVACY, PLATFORM_NAME} from "@app/core/state"
import {pushModal} from "@app/util/modal"
const logIn = () => pushModal(LogIn)
@@ -21,9 +23,9 @@
<p class="text-center">The chat app built for self-hosted communities.</p>
</div>
<Button onclick={logIn}>
<CardButton class="!btn-primary">
<CardButton class="btn-primary">
{#snippet icon()}
<div><Icon icon="login-2" size={7} /></div>
<div><Icon icon={Login} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Log in</div>
@@ -33,10 +35,10 @@
{/snippet}
</CardButton>
</Button>
<Button onclick={signUp}>
<Button onclick={signUp} class="btn-neutral">
<CardButton>
{#snippet icon()}
<div><Icon icon="add-circle" size={7} /></div>
<div><Icon icon={AddCircle} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Create an account</div>
+14 -13
View File
@@ -3,6 +3,10 @@
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 Key from "@assets/icons/key-minimalistic.svg?dataurl"
import Cpu from "@assets/icons/cpu-bolt.svg?dataurl"
import Compass from "@assets/icons/compass-big.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
@@ -10,11 +14,10 @@
import InfoNostr from "@app/components/InfoNostr.svelte"
import LogInBunker from "@app/components/LogInBunker.svelte"
import LogInPassword from "@app/components/LogInPassword.svelte"
import {pushModal, clearModals} from "@app/modal"
import {PLATFORM_NAME, BURROW_URL} from "@app/state"
import {pushToast} from "@app/toast"
import {loadUserData} from "@app/requests"
import {setChecked} from "@app/notifications"
import {pushModal, clearModals} from "@app/util/modal"
import {PLATFORM_NAME, BURROW_URL} from "@app/core/state"
import {pushToast} from "@app/util/toast"
import {setChecked} from "@app/util/notifications"
let signers: any[] = $state([])
let loading: string | undefined = $state()
@@ -23,9 +26,7 @@
const signUp = () => pushModal(SignUp)
const onSuccess = async (session: Session, relays: string[] = []) => {
await loadUserData(session.pubkey, relays)
const onSuccess = async (session: Session) => {
addSession(session)
pushToast({message: "Successfully logged in!"})
setChecked("*")
@@ -96,7 +97,7 @@
{#if loading === "nip07"}
<span class="loading loading-spinner mr-3"></span>
{:else}
<Icon icon="widget" />
<Icon icon={Widget} />
{/if}
Log in with Extension
</Button>
@@ -116,7 +117,7 @@
{#if loading === "password"}
<span class="loading loading-spinner mr-3"></span>
{:else}
<Icon icon="key" />
<Icon icon={Key} />
{/if}
Log in with Password
</Button>
@@ -125,7 +126,7 @@
onclick={loginWithBunker}
{disabled}
class="btn {hasSigner || BURROW_URL ? 'btn-neutral' : 'btn-primary'}">
<Icon icon="cpu" />
<Icon icon={Cpu} />
Log in with Remote Signer
</Button>
{#if BURROW_URL && hasSigner}
@@ -133,7 +134,7 @@
{#if loading === "password"}
<span class="loading loading-spinner mr-3"></span>
{:else}
<Icon icon="key" />
<Icon icon={Key} />
{/if}
Log in with Password
</Button>
@@ -144,7 +145,7 @@
{disabled}
href="https://nostrapps.com#signers"
class="btn {hasSigner || BURROW_URL ? '' : 'btn-neutral'}">
<Icon icon="compass" />
<Icon icon={Compass} />
Browse Signer Apps
</Link>
{/if}
+69 -33
View File
@@ -1,51 +1,65 @@
<script lang="ts">
import {onMount, onDestroy} from "svelte"
import type {Nip46ResponseWithResult} from "@welshman/signer"
import {Nip46Broker, makeSecret} from "@welshman/signer"
import {loginWithNip01, loginWithNip46} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import BunkerConnect, {BunkerConnectController} from "@app/components/BunkerConnect.svelte"
import BunkerConnect from "@app/components/BunkerConnect.svelte"
import BunkerUrl from "@app/components/BunkerUrl.svelte"
import {loadUserData} from "@app/requests"
import {clearModals} from "@app/modal"
import {setChecked} from "@app/notifications"
import {pushToast} from "@app/toast"
import {SIGNER_RELAYS, NIP46_PERMS} from "@app/state"
import {Nip46Controller} from "@app/util/nip46"
import {clearModals} from "@app/util/modal"
import {setChecked} from "@app/util/notifications"
import {pushToast} from "@app/util/toast"
import {SIGNER_RELAYS, NIP46_PERMS} from "@app/core/state"
const back = () => history.back()
const back = () => {
if (mode === "connect") {
selectBunker()
} else {
history.back()
}
}
const controller = new BunkerConnectController({
const controller = new Nip46Controller({
onNostrConnect: async (response: Nip46ResponseWithResult) => {
const pubkey = await controller.broker.getPublicKey()
await loadUserData(pubkey)
loginWithNip46(pubkey, controller.clientSecret, response.event.pubkey, SIGNER_RELAYS)
setChecked("*")
clearModals()
},
})
const {loading, bunker} = controller
const onSubmit = async () => {
if (controller.loading) return
if ($loading) return
try {
const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(controller.bunker)
const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl($bunker)
console.log({signerPubkey, connectSecret, relays})
if (!signerPubkey || relays.length === 0) {
if (!signerPubkey) {
return pushToast({
theme: "error",
message: "Sorry, it looks like that's an invalid bunker link.",
})
}
controller.loading = true
if (relays.length === 0) {
return pushToast({
theme: "error",
message: "That bunker link does not include any relays.",
})
}
controller.loading.set(true)
const {clientSecret} = controller
const broker = new Nip46Broker({relays, clientSecret, signerPubkey})
@@ -57,8 +71,6 @@
broker.cleanup()
controller.stop()
await loadUserData(pubkey)
loginWithNip46(pubkey, clientSecret, signerPubkey, relays)
} else {
return pushToast({
@@ -74,42 +86,66 @@
message: "Something went wrong, please try again!",
})
} finally {
controller.loading = false
controller.loading.set(false)
}
clearModals()
}
const selectConnect = () => {
controller.loading.set(false)
mode = "connect"
}
const selectBunker = () => {
mode = "bunker"
}
let mode: string = $state("bunker")
$effect(() => {
// For testing and for play store reviewers
if (controller.bunker === "reviewkey") {
if ($bunker === "reviewkey") {
loginWithNip01(makeSecret())
}
})
onMount(() => {
controller.start()
})
onDestroy(() => {
controller.stop()
})
</script>
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
<ModalHeader>
{#snippet title()}
<div>Log In</div>
<div>Log In with a Signer</div>
{/snippet}
{#snippet info()}
<div>Connect your signer by scanning the QR code below or pasting a bunker link.</div>
<div>Using a remote signer app helps you keep your keys safe.</div>
{/snippet}
</ModalHeader>
<BunkerConnect {controller} />
<BunkerUrl loading={controller.loading} bind:bunker={controller.bunker} />
<div class:hidden={mode !== "bunker"}></div>
{#if mode === "connect"}
<BunkerConnect {controller} />
{:else}
<BunkerUrl {controller} />
<Button class="btn {$bunker ? 'btn-neutral' : 'btn-primary'}" onclick={selectConnect}
>Log in with a QR code instead</Button>
{/if}
<ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={controller.loading}>
<Icon icon="alt-arrow-left" />
<Button class="btn btn-link" onclick={back} disabled={$loading}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button
type="submit"
class="btn btn-primary"
disabled={controller.loading || !controller.bunker}>
<Spinner loading={controller.loading}>Next</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
{#if mode === "bunker"}
<Button type="submit" class="btn btn-primary" disabled={$loading || !$bunker}>
<Spinner loading={$loading}>Next</Spinner>
<Icon icon={AltArrowRight} />
</Button>
{/if}
</ModalFooter>
</form>
+18 -11
View File
@@ -8,15 +8,24 @@
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Key from "@assets/icons/key-minimalistic.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import PasswordResetRequest from "@app/components/PasswordResetRequest.svelte"
import {loadUserData} from "@app/requests"
import {clearModals, pushModal} from "@app/modal"
import {setChecked} from "@app/notifications"
import {pushToast} from "@app/toast"
import {NIP46_PERMS, BURROW_URL, PLATFORM_URL, PLATFORM_NAME, PLATFORM_LOGO} from "@app/state"
import {clearModals, pushModal} from "@app/util/modal"
import {setChecked} from "@app/util/notifications"
import {pushToast} from "@app/util/toast"
import {
NIP46_PERMS,
BURROW_URL,
PLATFORM_URL,
PLATFORM_NAME,
PLATFORM_LOGO,
} from "@app/core/state"
interface Props {
email?: string
@@ -86,8 +95,6 @@
const pubkey = await broker.getPublicKey()
const session = makeNip46Session(pubkey, clientSecret, response.event.pubkey, relays)
await loadUserData(pubkey)
addSession({...session, email})
broker.cleanup()
setChecked("*")
@@ -115,7 +122,7 @@
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="user-rounded" />
<Icon icon={UserRounded} />
<input bind:value={email} />
</label>
{/snippet}
@@ -126,7 +133,7 @@
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="key" />
<Icon icon={Key} />
<input bind:value={password} type="password" />
</label>
{/snippet}
@@ -138,12 +145,12 @@
</p>
<ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}>
<Icon icon="alt-arrow-left" />
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading || !email || !password}>
<Spinner {loading}>Next</Spinner>
<Icon icon="alt-arrow-right" />
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>

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