Compare commits

...

118 Commits

Author SHA1 Message Date
Jon Staab 3b2870a318 Bump version 2026-02-10 17:14:02 -08:00
Jon Staab 1076e8531c Slightly tweak notifications function 2026-02-10 17:04:21 -08:00
Jon Staab 72f2effda4 Clean up modals 2026-02-10 11:39:29 -08:00
Jon Staab 7566f56858 Fix tippy placement for icon picker 2026-02-09 17:40:10 -08:00
Jon Staab c1f9c9e25e Bump welshman 2026-02-09 17:29:30 -08:00
Jon Staab 380a52efb3 Use space url as relay hint 2026-02-09 17:24:55 -08:00
Jon Staab 028c3ba92b Fix badge showing on current page 2026-02-09 17:11:03 -08:00
Jon Staab f80aba33f1 Optimistically load messaging relays 2026-02-09 16:57:44 -08:00
Jon Staab bb15c9e2d0 Fix last activity reactivity 2026-02-09 13:21:38 -08:00
Jon Staab 518c80bb1d Fix indexeddb failure 2026-02-09 11:59:58 -08:00
Jon Staab 0067d049e6 Tweak signer error 2026-02-09 11:44:50 -08:00
Jon Staab bf60dd24aa Handle sai in modals on small screens 2026-02-09 11:44:50 -08:00
Jon Staab 38d9cc4892 Add modal body to some menus 2026-02-09 11:44:50 -08:00
hodlbod c4f2f55617 Simplify notification badges, improve performance (#57)
Co-authored-by: Jon Staab <shtaab@gmail.com>
2026-02-09 11:44:32 -08:00
Jon Staab 8f73fb85e9 Show space url in page top bar 2026-02-09 08:42:30 -08:00
Jon Staab 3bd126c11d Fix a modal 2026-02-06 16:27:57 -08:00
Jon Staab 7e7aba06a6 Fix safe area insets 2026-02-06 14:57:36 -08:00
Jon Staab 2bf00f7ddc Tweak room/space icon buttons 2026-02-06 14:57:35 -08:00
Jon Staab 24b88e4ac0 Fix calendar detail 2026-02-06 14:57:35 -08:00
Jon Staab 3df3130395 remove pomade from links 2026-02-06 14:57:35 -08:00
Jon Staab c0c388d1b9 Tweak syncing so it works better for picky relays 2026-02-06 14:57:35 -08:00
Jon Staab 9f27cc61da Ignore another invite code error 2026-02-06 14:57:35 -08:00
Jon Staab 8fa1987ec0 Slight tweaks to wallet receive 2026-02-05 13:23:56 -08:00
Jon Staab 39eae42b05 Make space/room images a little bigger 2026-02-05 13:20:18 -08:00
Tyson Lupul 4dfbb437f9 Wallet receive flow (#15) (#52)
* Pin sharp via pnpm override, add wallet receive

* Revert toast success styling

* Route receive through wallet connect

* Simplify receive invoice validation

* Polish receive modal layout

* Clarify NWC client config

* Adjust wallet action layout on mobile
2026-02-05 20:51:59 +00:00
Jon Staab f132d22308 Revert safari fix (merged into nostr-editor) 2026-02-04 13:18:18 -08:00
Jon Staab b7dd2ff8b4 Bump welshman 2026-02-04 13:16:00 -08:00
Jon Staab b6b78591bc Update push impl 2026-02-04 10:37:50 -08:00
Jon Staab ec54a0dbce Use item components on recent page 2026-02-04 09:07:26 -08:00
Jon Staab 8793912b65 Prompt to add space members when adding room members 2026-02-03 17:38:22 -08:00
Jon Staab 70c430ddc2 Add classified status 2026-02-03 17:09:30 -08:00
Jon Staab 815dbba497 Use address for page param for replaceable events 2026-02-03 16:35:32 -08:00
Jon Staab dc5bac67aa Add image uploads to classifieds 2026-02-03 14:18:58 -08:00
Jon Staab 5427fd7860 Add a currency input 2026-02-03 13:25:24 -08:00
Jon Staab 119c09d730 Add classified listings 2026-02-03 12:43:36 -08:00
Jon Staab 1da6833c71 Rework recent activity page 2026-02-03 09:51:33 -08:00
Jon Staab 4b8cf53731 Rework recent activity page 2026-02-02 15:36:50 -08:00
Jon Staab d646ddd91d Fix edit 2026-02-02 14:33:45 -08:00
Jon Staab 764719afde Fix some notification related bugs 2026-02-02 14:29:12 -08:00
Jon Staab 75ec7688b1 Fix image uploads on ios 2026-02-02 14:06:36 -08:00
Jon Staab 7fc508603f Bring back service worker 2026-02-02 10:35:05 -08:00
Jon Staab fb2d78fd57 Rework modal header structure 2026-02-02 10:09:14 -08:00
Jon Staab 4480132c74 Add sticky submit buttons to settings pages 2026-02-02 09:51:36 -08:00
Jon Staab 38c0a9d403 Re work modal scrolling 2026-01-30 15:36:20 -08:00
Jon Staab 4169db33e6 Rework alert settings and UI 2026-01-30 09:13:50 -08:00
Jon Staab ee48072137 Add AGENTS.md 2026-01-29 15:09:26 -08:00
Jon Staab a3c1a5c731 Prevent icon picker from going off screen 2026-01-29 13:52:36 -08:00
Jon Staab e74f922e8d Fix tippy falling off the page 2026-01-29 13:52:36 -08:00
Jon Staab 16cd90f7b7 Refine discover page a bit to avoid slowness 2026-01-29 13:52:36 -08:00
Jon Staab e2ba10d224 Fix has alerts store 2026-01-29 13:52:36 -08:00
Jon Staab 459e9359db Disable macos build 2026-01-29 13:52:36 -08:00
Jon Staab d2a044f958 Small ui fixes 2026-01-29 13:52:36 -08:00
Jon Staab 2fbcd644d0 Tag event author when tagging parent event 2026-01-29 13:52:36 -08:00
Jon Staab cf8e736f46 Handle encrypted notifications 2026-01-29 13:52:04 -08:00
Jon Staab d4378731ae Fix a few bugs with push notifications 2026-01-28 14:08:15 -08:00
Jon Staab 000344a942 Fixing bugs with push notifications 2026-01-28 13:36:19 -08:00
Jon Staab bf6abd301c refactor notification syncing 2026-01-27 17:15:22 -08:00
Jon Staab 143a1dd39b Update notification subscriptions reactively 2026-01-27 13:38:24 -08:00
Jon Staab 9b3a8258ce Disable alerts on logout 2026-01-26 10:08:43 -08:00
Jon Staab 646b8f8736 Rework subscription storage 2026-01-23 16:51:02 -08:00
Jon Staab 2528e4acad Clean up and fix push notifications implementation 2026-01-23 15:35:54 -08:00
Jon Staab 286d939097 Moar upgrades 2026-01-23 10:53:50 -08:00
Jon Staab ca3d661830 Generally just refactor alerts, upgrade some deps 2026-01-23 10:05:47 -08:00
Jon Staab 63fee653e8 Add muted rooms, rework alert settings 2026-01-22 12:49:09 -08:00
Jon Staab 9da2473976 Add apns/fcm push notifications with new architecture 2026-01-21 16:20:48 -08:00
Jon Staab 6d1eeacc49 Add new alerts 2026-01-20 13:42:58 -08:00
Jon Staab f85748fef9 Remove old alerts 2026-01-19 16:33:49 -08:00
Jon Staab 9f34b33b7e Remove sourcemaps command 2026-01-19 10:40:56 -08:00
Jon Staab 1510f39a8a Bump ios version 2026-01-19 10:07:44 -08:00
Jon Staab bbbe011482 Publish default relay selections on signup 2026-01-16 16:11:45 -08:00
Jon Staab 82ab7a043f Remove glitchtip integration 2026-01-16 15:19:52 -08:00
Jon Staab 798253a50e Bump welshman 2026-01-16 15:07:55 -08:00
Jon Staab 52432ca068 Add sign in with private key 2026-01-16 14:25:44 -08:00
Jon Staab b3f1d8464b Add authentication policy setting 2026-01-16 13:49:35 -08:00
Jon Staab 87bb62b359 Add support for blocked relays 2026-01-16 13:10:48 -08:00
Jon Staab 3f914d02cc Fix signer disconnection flash, nav icon sizes 2026-01-16 11:33:03 -08:00
Jon Staab d1db77d0f5 Bump version 2026-01-16 11:01:24 -08:00
Jon Staab 6aa297c1a4 Rework onboarding flow, add recovery 2026-01-16 11:01:07 -08:00
Jon Staab f3647e9bc1 Use simple OTPs 2026-01-16 11:01:07 -08:00
Jon Staab 5b43c62f2d Remove pomade signers 2026-01-16 11:01:07 -08:00
Jon Staab 23ffb15a8d Fix incorrect secret being downloaded 2026-01-16 11:01:07 -08:00
Jon Staab adb2ce4846 Split key recovery components, bump deps 2026-01-16 11:01:06 -08:00
Jon Staab cdee6ca743 Add pomade key recovery 2026-01-16 11:00:46 -08:00
Jon Staab fe30aa4af2 Fix ContentLinkInline 2026-01-16 11:00:45 -08:00
Jon Staab 9943728eab Add pomade session list 2026-01-16 11:00:00 -08:00
Jon Staab 8ae7cf05cc Fix profile publishing on email sign up 2026-01-16 11:00:00 -08:00
Jon Staab a7c944e8ef Tweak breakpoint for field inline 2026-01-16 11:00:00 -08:00
Jon Staab 102339d7e8 Add link_peers script 2026-01-16 10:59:59 -08:00
Jon Staab 9a0ad0c663 Improve space join flow 2026-01-16 10:59:54 -08:00
Jon Staab f86afc08fa Normalize relay URLs 2026-01-16 10:59:46 -08:00
Jon Staab cd1b328b1b Add pomade signing 2026-01-16 10:59:45 -08:00
Jon Staab 48f2bb1c75 Bump gradle 2026-01-16 10:59:30 -08:00
Jon Staab d416fe913e Fix memory leak, notification badge not showing 2026-01-16 10:59:29 -08:00
Jon Staab 7f8744725c Improve signer status 2026-01-16 10:59:25 -08:00
Jon Staab e5d1b82a9d Fix chat list responsiveness 2026-01-16 10:59:22 -08:00
Jon Staab 619cf2e134 Update default relays 2026-01-16 10:59:16 -08:00
Jon Staab 28b522f015 Report pending signer to user 2026-01-16 10:59:10 -08:00
Jon Staab 39233f261e Force reload relay more simply 2026-01-16 10:59:05 -08:00
Jon Staab 00f0127caf Tweak room edit form 2026-01-16 10:59:03 -08:00
Jon Staab f69b575381 Fix some duplicates in eaches 2026-01-16 10:58:57 -08:00
Jon Staab 986973a605 Accept hex pubkeys/npubs/nprofiles in ProfileMultiSelect 2026-01-16 10:58:55 -08:00
Jon Staab 0d6b4591f1 Hide tooltips on mobile, sort comments ascending, make video embeds rounded 2026-01-16 10:58:40 -08:00
Jon Staab 2c62749d9b Attempt to fix new messages button 2026-01-16 10:56:18 -08:00
Jon Staab 4be4288ef0 Fix phantom notifications on mobile 2025-12-11 10:27:10 -08:00
Jon Staab c7eec167cf Fix scroll down z index 2025-12-08 09:27:38 -08:00
Jon Staab 7bae956ffa Release 1.6.2 2025-12-08 09:22:49 -08:00
Jon Staab a2f59a5b1b Fix some modal bugs 2025-12-08 09:19:41 -08:00
Jon Staab df56af9b0e Bump version 2025-12-05 09:51:15 -08:00
Jon Staab 83f7f9584f Fix duplicate rooms 2025-12-04 17:06:50 -08:00
Jon Staab a2d440e54f Fix dialog z index 2025-12-04 16:01:39 -08:00
Jon Staab 4132e8449b Fix recent missing events in feeds 2025-12-04 15:56:05 -08:00
Jon Staab ee444416e4 Fall back to file name as hash for images 2025-12-04 14:37:59 -08:00
Jon Staab 10c12c3c48 Improve time based chat partitioning 2025-12-04 14:29:12 -08:00
Jon Staab db3775ae99 Fix timezone parsing in AlertAdd 2025-12-04 11:20:54 -08:00
Jon Staab 393acce884 Fix removing non-normalized urls 2025-12-02 17:27:14 -08:00
Jon Staab 68fe663730 Fix chat content bottom offset when keyboard is open 2025-12-02 17:20:10 -08:00
Jon Staab f65a4b0db0 Handle relay urls in content and link within the app 2025-12-02 17:09:56 -08:00
Jon Staab cdfb502e6e Fix skinny profile circles 2025-12-02 13:49:04 -08:00
259 changed files with 9499 additions and 7535 deletions
+1
View File
@@ -3,6 +3,7 @@
--ignore-dir=build
--ignore-dir=ios/DerivedData
--ignore-dir=ios/App/App/public
--ignore-dir=ios/App/Pods
--ignore-file=match:.svg
--ignore-file=match:package-lock.json
+8 -5
View File
@@ -1,6 +1,6 @@
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_POMADE_SIGNERS=
VITE_PLATFORM_URL=https://app.flotilla.social
VITE_PLATFORM_TERMS=https://flotilla.social/terms
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
@@ -10,10 +10,13 @@ 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://offchain.pub/
VITE_NOTIFIER_PUBKEY=27b7c2ed89ef78322114225ea3ebf5f72c7767c2528d4d0c1854d039c00085df
VITE_NOTIFIER_RELAY=wss://anchor.coracle.social/
VITE_PUSH_SERVER=https://nps.flotilla.social/
VITE_PUSH_BRIDGE=wss://npb.coracle.social/
VITE_BLOCKED_RELAYS=brb.io,relay.nostr.band,nostr.mutinywallet.com,feeds.nostr.band,nostr.zbd.gg,wot.utxo.one,blastr.f7z.xyz,relay.current.fyi
VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com
VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
VITE_GLITCHTIP_API_KEY=
GLITCHTIP_AUTH_TOKEN=
+1 -1
View File
@@ -5,6 +5,6 @@
"svelteSortOrder": "options-styles-scripts-markup",
"arrowParens": "avoid",
"bracketSpacing": false,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"plugins": ["prettier-plugin-tailwindcss", "prettier-plugin-svelte"],
"overrides": [{"files": "*.svelte", "options": {"parser": "svelte"}}]
}
+257
View File
@@ -0,0 +1,257 @@
## Project Overview
Flotilla is a Nostr "relays as groups" community chat client. It implements NIP-29 (relay-based groups) to create Discord-like spaces (servers) and rooms (channels).
**Tech Stack:**
- SvelteKit 5.48+ with TypeScript 5.9+
- Capacitor for cross-platform (Web/PWA, Android, iOS)
- TailwindCSS + DaisyUI for styling
- Welshman library suite for Nostr protocol
- IndexedDB for local storage
- Vite for building
**Key Concepts:**
- **Spaces** - Relays used as community groups (like Discord servers)
- **Rooms** - NIP-29 groups within spaces (like Discord channels), identified by `h`
- **Chats** - Direct message conversations (NIP-04/NIP-44 encrypted)
## Architecture & Dependency Graph
The project follows a **strict acyclic dependency hierarchy**:
```
routes/ (top layer - can depend on anything)
app/components/ (can depend on app/* and lib/*)
app/core/ & app/util/ (can only depend on lib/*)
lib/ (can only depend on external libraries)
external libraries (bottom layer)
```
**Import Ordering Convention (CRITICAL):**
Always sort imports by dependency level:
1. Third-party libraries first
2. Then `lib/` imports
3. Then `app/` imports
Example:
```typescript
import {derived} from "svelte/store"
import {throttle} from "throttle-debounce"
import {Dialog} from "$lib/components"
import {repository} from "$app/core/state"
```
## File Structure
```
src/
├── lib/ # Generic reusable code
│ ├── components/ # 38 UI components (Button, Dialog, etc.)
│ ├── html.ts # DOM utilities
│ ├── indexeddb.ts # IndexedDB helpers
│ └── util.ts # Generic utilities
├── app/
│ ├── core/
│ │ ├── state.ts # State management, stores, constants (687 lines)
│ │ ├── commands.ts # Publishing events and other write operations (440+ lines)
│ │ ├── requests.ts # Loading data from network (191 lines)
│ │ ├── sync.ts # Data synchronization (296 lines)
│ │ └── storage.ts # IndexedDB setup
│ │
│ ├── util/
│ │ ├── notifications.ts # Push notifications (731 lines)
│ │ ├── policies.ts # Relay policies
│ │ ├── routes.ts # Routing helpers
│ │ ├── modal.ts # Modal management
│ │ ├── toast.ts # Toast notifications
│ │ ├── theme.ts # Theme switching
│ │ └── keyboard.ts # Keyboard handling
│ │
│ ├── editor/ # Rich text editor config
│ │ ├── index.ts # TipTap setup with Nostr integration
│ │ ├── EditorContent.svelte
│ │ └── MentionNodeView.ts
│ │
│ └── components/ # 188 app-specific components
│ ├── Space*.svelte # Space/relay management
│ ├── Room*.svelte # Room/channel management
│ ├── Chat*.svelte # Direct messaging
│ ├── Profile*.svelte # User profiles
│ ├── Thread*.svelte # Threaded posts
│ └── ...
├── routes/ # SvelteKit file-based routing
│ ├── +layout.svelte # Root layout (sync logic here)
│ ├── spaces/ # Space management
│ │ └── [relay]/ # Specific space
│ │ ├── chat/ # Space chat
│ │ ├── threads/ # Thread posts
│ │ ├── calendar/ # Events
│ │ └── [h]/ # Specific room (h = room id)
│ ├── chat/ # Direct messages
│ ├── settings/ # User settings
│ └── [bech32]/ # Bech32 entity viewer
├── assets/icons/ # ~1,277 SVG icons
├── app.html # HTML template
├── app.css # Global styles
└── types.d.ts # Type definitions
```
## State Management
**Core Principles:**
- Use Svelte 4 **stores** for all state (NOT runes outside UI components)
- Most global state flows through Welshman's `repository` (unidirectional)
- Query state using `deriveEventsMapped` or `deriveProfile` etc
- Update state by publishing events via `publishThunk`
**Thunks:**
- Reduce UI latency by handling signatures and sending in background
- Return status that should be displayed to user
- Allow cancellation and error handling
- Immediately publish to local repository for optimistic updates
## Nostr Integration
**Welshman Library Suite:**
- `@welshman/app` - High-level state (pubkey, signer, repository, tracker)
- `@welshman/net` - Network layer (Pool, Socket, load, pull, request)
- `@welshman/store` - Svelte integration (deriveEventsMapped, etc.)
- `@welshman/util` - Event utilities (kinds, tags, validation)
- `@welshman/signer` - Signing abstraction (NIP-01, NIP-07, NIP-46)
- `@welshman/router` - Relay routing (inbox/outbox model)
- `@welshman/editor` - Rich text editor with Nostr
- `@welshman/content` - Content parsing
- `@welshman/feeds` - Feed management
**Key NIPs Implemented:**
- NIP-01: Basic protocol
- NIP-44/59/17: Encrypted DMs
- NIP-07: Browser extension signing
- NIP-19: Bech32 encoding
- NIP-29: Relay-based Groups
- NIP-42: Relay authentication
- NIP-43: Relay membership
- NIP-46: Nostr Connect (remote signing)
- NIP-57: Lightning Zaps
## Development Conventions
**Component Parameterization:**
- Only pass entity identifiers (`url` for spaces, `h` for rooms)
- Derive all other data inside the component from identifiers
- Example: Don't pass `members` prop, derive it from `h` inside component
**Code Style:**
- **No `null`** - only use `undefined`
- Svelte 5 runes (`$state`, `$derived`, `$effect`) only in UI components
- TailwindCSS and DaisyUI styling
- Only add comments for really weird stuff
- Do not call functions in components unless a parameter is reactive. Instead, use a svelte store or rune to make it reactive.
- Do not use `any`. If there are type errors related to `unknown`, they are likely because the upstream definition of the data is incorrect.
- When dynamically building classes, use `cx` from `classnames` rather than embedded ternaries or svelte 4's old `class:` syntax.
- When creating forms, use `FieldInline` or `Field` instead of custom elements/tailwindcss
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates
## Common Tasks
### Adding a New Component
1. Determine if it's generic (`lib/components/`) or app-specific (`app/components/`)
2. Follow naming convention: `PascalCase.svelte`
3. Import in dependency order (3rd party → lib → app)
4. Use stores for state, runes only for UI reactivity
### Creating a New Route
1. Add to `src/routes/` following SvelteKit conventions
2. Use `+page.svelte` for page component
3. Use `+layout.svelte` for shared layouts
4. Top-level sync logic goes in root `+layout.svelte`
### Loading Data from Network
1. Use utilities from `app/core/requests.ts`
2. Or create derived stores in `app/core/state.ts`
3. Use `load`, `pull`, or `request` from `@welshman/net`
### Publishing Events
1. Create `make*` function to build event template
2. Create `publish*` function using `publishThunk`
3. Display thunk status to user (for cancel/error handling)
4. These go in in `app/core/commands.ts`
### Managing Modals/Toasts
- Import from `app/util/modal.ts` or `app/util/toast.ts`
- Pass component objects with parameters
- Use `$state.snapshot` if calling component might unmount
## Development Workflow
Agents should not run the dev server or build the app. Instead, use the following commands:
```bash
pnpm run format # Format changed files
pnpm run lint # Check formatting and linting
pnpm run check # Type check
```
**Welshman Development:**
- Clone welshman to parent directory
- Use `./link_deps` script to link local welshman packages
- Avoid committing `pnpm.overrides` changes
**Git Workflow:**
- `master` branch auto-deploys to production
- Work on feature branches based on `dev` branch
- Pre-commit hooks run lint/typecheck automatically
## Environment Variables
See `.env.template` for all options.
## Important Files to Reference
- **src/app/core/state.ts** - All stores and constants
- **src/app/core/sync.ts** - Data synchronization
- **src/app/core/requests.ts** - Utilities for requesting data
- **src/app/core/commands.ts** - Publishing patterns
- **src/app/util/notifications.ts** - Notification badges and push notifications
- **src/routes/+layout.svelte** - Top-level sync logic
## Mobile Development
**Capacitor Integration:**
- Android: Full support, APK builds via `pnpm run release:android`
- iOS: Full support (zaps disabled due to App Store policy)
- PWA: Progressive Web App with service worker
**Native Features:**
- Push notifications (FCM/APNs)
- Deep linking (nostr: and https: URLs)
- Native signing plugin
- Keyboard management
- Safe area handling
- Badge management
+59
View File
@@ -1,5 +1,64 @@
# Changelog
# 1.6.4
* Clean up modal design
* Fix overflowing popovers
* Use space urls for relay hints
* Re-work notification badges
* Add push notification support via NIP 9a
* Optimistically load messaging relays to avoid unnecessary warning
* Recover from indexeddb not being available
* Fix safe area inset support
* Show space URL in top bar on mobile
* Fix calendar detail page
* Improve relay synchronization, especially for pyramid and relay29
* Improve invite code error handling
* Add wallet receive flow
* Fix safari image uploads
* Re-work recent activity page
* Add classified listing content type
* Use address for page param for replaceable events
* Refine discover page to avoid slowness
* Upgrade som dependencies
* Tag event author when tagging parent event
* Disable macos build
* Add room muting
# 1.6.3
* Fix scroll down button z index
* Hide tooltips on mobile
* Sort comments ascending
* Make video embeds rounded
* Fix ProfileMultiSelect styling
* Accept hex pubkeys/npubs/nprofiles in ProfileMultiSelect
* Tweak room edit form design
* Report pending signer to user
* Update default relays
* Fix chat list responsiveness
* Fix memory leak, notification badge not showing
* Improve space join flow
* Fix opening images in fullscreen dialog
* Add support for blocked relays
* Add authentication policy setting
* Add login with key if no signer is detected
* Publish default relay selections on signup
# 1.6.2
* Fix modal scrolling and style
# 1.6.1
* Fix skinny profile images
* Custom handler for relay urls
* Improve time based chat partitioning
* Improve authenticated image access interop
* Fix image detail dialog
* Fix zapper loading
* Fix recent events missing in feeds
# 1.6.0
* Switch back to indexeddb to fix memory and performance
-34
View File
@@ -1,34 +0,0 @@
# Flotilla - AI Assistant Context
## Project Overview
Flotilla is a Discord-like Nostr client based on the concept of "relays as groups". It's built with SvelteKit, TypeScript, and Capacitor for cross-platform support (web, Android, iOS).
On boot, please run `tree -I assets src` to get an idea of the project structure.
## Key Dependencies
`@welshman/*` libraries contain the majority of nostr-related functionality.
`@app/core/*` contains additional app-specific data stores and commands.
When creating an import statement, first identify what functionality you need. Search the codebase for components with similar functionality, and imitate their imports.
## Dependency Graph (Acyclic)
The project follows a strict dependency hierarchy:
1. **External libraries** (bottom layer)
2. **`lib/`** - Only depends on external libraries
3. **`app/core/`** and **`app/util/`** - Can depend on `lib` only
4. **`app/components/`** - Can depend on anything in `app` or `lib`
5. **`routes/`** - Can depend on anything (top layer)
**Import Ordering Convention:** Always sort imports by dependency level:
1. Third-party libraries first
2. Then `lib` imports
3. Then `app` imports
## Development Conventions
When creating components related to a given space or room, parameterize them only with the entity's identifier (i.e., `url` and `h`). Only pass additional props if they can't be derived from the identifiers. For example, a room's `members` should be derived inside the child component, not passed in by the parent.
Do not use null, only undefined.
-96
View File
@@ -1,96 +0,0 @@
# 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 called "rooms". Conventionally, "h" is a group id, while a "room" 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.
-2
View File
@@ -15,8 +15,6 @@ You can also optionally create an `.env` file and populate it with the following
- `VITE_PLATFORM_RELAYS` - A list of comma-separated relay urls that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the first platform relay the home page.
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
- `VITE_PLATFORM_DESCRIPTION` - A description of the app
- `VITE_GLITCHTIP_API_KEY` - A Sentry DSN for use with glitchtip (error reporting)
- `GLITCHTIP_AUTH_TOKEN` - A glitchtip auth token for error reporting
If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer.
+5 -5
View File
@@ -1,19 +1,19 @@
apply plugin: 'com.android.application'
android {
namespace "social.flotilla"
compileSdk rootProject.ext.compileSdkVersion
namespace = "social.flotilla"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "social.flotilla"
minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion
versionCode 35
versionName "1.6.0"
versionCode 40
versionName "1.6.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
+1 -1
View File
@@ -9,7 +9,7 @@
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 705 B

After

Width:  |  Height:  |  Size: 711 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

+2 -2
View File
@@ -7,8 +7,8 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.8.0'
classpath 'com.google.gms:google-services:4.4.2'
classpath 'com.android.tools.build:gradle:8.13.2'
classpath 'com.google.gms:google-services:4.4.4'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
+10 -10
View File
@@ -1,30 +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.4.3_@capacitor+core@7.4.3/node_modules/@capacitor/android/capacitor')
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@8.0.1_@capacitor+core@8.0.1/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.4.3/node_modules/@capacitor-community/safe-area/android')
project(':capacitor-community-safe-area').projectDir = new File('../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor-community/safe-area/android')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@7.1.0_@capacitor+core@7.4.3/node_modules/@capacitor/app/android')
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app/android')
include ':capacitor-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')
project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem/android')
include ':capacitor-keyboard'
project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@7.0.3_@capacitor+core@7.4.3/node_modules/@capacitor/keyboard/android')
project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@8.0.0_@capacitor+core@8.0.1/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')
project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@capacitor+preferences@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/preferences/android')
include ':capacitor-push-notifications'
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')
project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications/android')
include ':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')
project(':capawesome-capacitor-android-dark-mode-support').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-android-dark-mode-support@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-android-dark-mode-support/android')
include ':capawesome-capacitor-badge'
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')
project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/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.4.3/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@8.0.1/node_modules/nostr-signer-capacitor-plugin/android')
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+4 -5
View File
@@ -86,8 +86,7 @@ done
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@@ -115,7 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@@ -206,7 +205,7 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
@@ -214,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
+2 -2
View File
@@ -70,11 +70,11 @@ goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
+14 -14
View File
@@ -1,18 +1,18 @@
ext {
minSdkVersion = 23
compileSdkVersion = 35
targetSdkVersion = 35
androidxActivityVersion = '1.9.2'
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
//https://github.com/ionic-team/capacitor/issues/7866
// androidxAppCompatVersion = '1.7.0'
androidxAppCompatVersion = '1.6.1'
androidxCoordinatorLayoutVersion = '1.2.0'
androidxCoreVersion = '1.15.0'
androidxFragmentVersion = '1.8.4'
coreSplashScreenVersion = '1.0.1'
androidxWebkitVersion = '1.12.1'
// androidxAppCompatVersion = '1.7.1'
androidxAppCompatVersion = '1.7.1'
androidxCoordinatorLayoutVersion = '1.3.0'
androidxCoreVersion = '1.17.0'
androidxFragmentVersion = '1.8.9'
coreSplashScreenVersion = '1.2.0'
androidxWebkitVersion = '1.14.0'
junitVersion = '4.13.2'
androidxJunitVersion = '1.2.1'
androidxEspressoCoreVersion = '3.6.1'
cordovaAndroidVersion = '10.1.1'
androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '14.0.1'
}
+20 -17
View File
@@ -1,18 +1,21 @@
import type { CapacitorConfig } from '@capacitor/cli';
import type {CapacitorConfig} from "@capacitor/cli"
const config: CapacitorConfig = {
appId: 'social.flotilla',
appName: 'Flotilla',
webDir: 'build'
server: {
androidScheme: "https"
},
appId: "social.flotilla",
appName: "Flotilla",
webDir: "build",
android: {
adjustMarginsForEdgeToEdge: false,
adjustMarginsForEdgeToEdge: true,
},
plugins: {
CapacitorHttp: {
enabled: true,
},
SystemBars: {
insetsHandling: "enable",
},
SplashScreen: {
androidSplashResourceName: "splash"
androidSplashResourceName: "splash",
},
Keyboard: {
style: "DARK",
@@ -20,14 +23,14 @@ const config: CapacitorConfig = {
},
Badge: {
persist: true,
autoClear: true
autoClear: true,
},
},
// Use this for live reload https://capacitorjs.com/docs/guides/live-reload
// server: {
// url: "http://192.168.1.115:1847",
// cleartext: true
// },
};
server: {
// Use this for live reload https://capacitorjs.com/docs/guides/live-reload
// url: "http://192.168.1.17:1847",
// cleartext: true,
},
}
export default config;
export default config
+10 -8
View File
@@ -291,7 +291,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -342,7 +342,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
@@ -358,18 +358,19 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26;
CURRENT_PROJECT_VERSION = 30;
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;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.6.0;
MARKETING_VERSION = 1.6.4;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -384,17 +385,18 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26;
CURRENT_PROJECT_VERSION = 30;
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;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.6.0;
MARKETING_VERSION = 1.6.4;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 158 KiB

+12 -12
View File
@@ -1,6 +1,6 @@
require_relative '../../node_modules/.pnpm/@capacitor+ios@7.4.3_@capacitor+core@7.4.3/node_modules/@capacitor/ios/scripts/pods_helpers'
require_relative '../../node_modules/.pnpm/@capacitor+ios@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '14.0'
platform :ios, '15.0'
use_frameworks!
# workaround to avoid Xcode caching of Pods that requires
@@ -9,16 +9,16 @@ use_frameworks!
install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods
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'
pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/ios'
pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor-community/safe-area'
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app'
pod 'CapacitorFilesystem', :path => '../../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem'
pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/keyboard'
pod 'CapacitorPreferences', :path => '../../node_modules/.pnpm/@capacitor+preferences@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/preferences'
pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications'
pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge'
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@8.0.1/node_modules/nostr-signer-capacitor-plugin'
end
target 'Flotilla Chat' do
Executable
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
import { execSync } from 'child_process'
if (execSync('git status --porcelain', { encoding: 'utf8' }).trim()) {
console.error('Error: Git working tree is dirty. Please commit or stash your changes first.')
process.exit(1)
}
const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
pkg.pnpm.overrides = pkg.pnpm.overrides || {}
pkg.pnpm.overrides["@welshman/app"] = "link:../welshman/packages/app"
pkg.pnpm.overrides["@welshman/content"] = "link:../welshman/packages/content"
pkg.pnpm.overrides["@welshman/editor"] = "link:../welshman/packages/editor"
pkg.pnpm.overrides["@welshman/feeds"] = "link:../welshman/packages/feeds"
pkg.pnpm.overrides["@welshman/lib"] = "link:../welshman/packages/lib"
pkg.pnpm.overrides["@welshman/net"] = "link:../welshman/packages/net"
pkg.pnpm.overrides["@welshman/router"] = "link:../welshman/packages/router"
pkg.pnpm.overrides["@welshman/signer"] = "link:../welshman/packages/signer"
pkg.pnpm.overrides["@welshman/store"] = "link:../welshman/packages/store"
pkg.pnpm.overrides["@welshman/util"] = "link:../welshman/packages/util"
// pkg.pnpm.overrides["@pomade/core"] = "link:../pomade/packages/core"
fs.writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\n')
execSync('pnpm i', { stdio: 'inherit' })
execSync('git checkout -f pnpm-lock.yaml', { stdio: 'inherit' })
execSync('git checkout -f package.json', { stdio: 'inherit' })
+47 -46
View File
@@ -1,86 +1,85 @@
{
"name": "flotilla",
"version": "1.6.0",
"version": "1.6.4",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "./build.sh",
"sourcemaps": "./build.sh && ./sourcemaps.sh",
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check src && eslint src",
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte)$' | xargs -r prettier --write",
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
"format:all": "prettier --write src",
"prepare": "husky"
},
"devDependencies": {
"@capacitor/assets": "^3.0.5",
"@eslint/js": "^9.37.0",
"@sentry/cli": "^2.56.1",
"@sveltejs/kit": "^2.46.5",
"@eslint/js": "^9.39.2",
"@sveltejs/kit": "^2.50.1",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@types/eslint": "^9.6.1",
"autoprefixer": "^10.4.21",
"autoprefixer": "^10.4.23",
"classnames": "^2.5.1",
"eslint": "^9.37.0",
"eslint": "^9.39.2",
"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",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.48.0",
"svelte-check": "^4.3.5",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.1",
"vite": "^5.4.20"
"typescript-eslint": "^8.53.1",
"vite": "^5.4.21"
},
"type": "module",
"dependencies": {
"@capacitor-community/safe-area": "7.0.0-alpha.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/lightning-tools": "^6.0.0",
"@capacitor-community/safe-area": "^8.0.1",
"@capacitor/android": "^8.0.1",
"@capacitor/app": "^8.0.0",
"@capacitor/cli": "^8.0.1",
"@capacitor/core": "^8.0.1",
"@capacitor/filesystem": "^8.1.0",
"@capacitor/ios": "^8.0.1",
"@capacitor/keyboard": "^8.0.0",
"@capacitor/preferences": "^8.0.0",
"@capacitor/push-notifications": "^8.0.0",
"@capawesome/capacitor-android-dark-mode-support": "^8.0.0",
"@capawesome/capacitor-badge": "^8.0.0",
"@getalby/lightning-tools": "^6.1.0",
"@getalby/sdk": "^5.1.2",
"@noble/curves": "^1.9.7",
"@pomade/core": "^0.0.12",
"@poppanator/sveltekit-svg": "^4.2.1",
"@sentry/browser": "^8.55.0",
"@sveltejs/adapter-static": "^3.0.10",
"@tiptap/core": "^2.26.3",
"@types/qrcode": "^1.5.5",
"@tiptap/core": "^2.27.2",
"@types/qrcode": "^1.5.6",
"@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.8",
"@welshman/app": "^0.7.0",
"@welshman/content": "^0.7.0",
"@welshman/editor": "^0.7.0",
"@welshman/feeds": "^0.7.0",
"@welshman/lib": "^0.7.0",
"@welshman/net": "^0.7.0",
"@welshman/router": "^0.7.0",
"@welshman/signer": "^0.7.0",
"@welshman/store": "^0.7.0",
"@welshman/util": "^0.7.0",
"@welshman/app": "^0.8.4",
"@welshman/content": "^0.8.4",
"@welshman/editor": "^0.8.4",
"@welshman/feeds": "^0.8.4",
"@welshman/lib": "^0.8.4",
"@welshman/net": "^0.8.4",
"@welshman/router": "^0.8.4",
"@welshman/signer": "^0.8.4",
"@welshman/store": "^0.8.4",
"@welshman/util": "^0.8.4",
"compressorjs": "^1.2.1",
"daisyui": "^4.12.24",
"date-picker-svelte": "^2.16.0",
"date-picker-svelte": "^2.17.0",
"dotenv": "^16.6.1",
"emoji-picker-element": "^1.27.0",
"emoji-picker-element": "^1.28.1",
"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",
"nostr-tools": "^2.19.4",
"prettier-plugin-tailwindcss": "^0.6.14",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
@@ -89,11 +88,13 @@
},
"pnpm": {
"ignoredBuiltDependencies": [
"@sentry/cli",
"esbuild"
],
"onlyBuiltDependencies": [
"sharp"
]
],
"overrides": {
"sharp": "0.35.0-rc.0"
}
}
}
+1840 -2155
View File
File diff suppressed because it is too large Load Diff
-15
View File
@@ -1,15 +0,0 @@
#!/bin/bash
hash=$(find build -type f -print0 | sort -z | xargs -0 sha1sum | sha1sum | awk '{print $1}')
sentry-cli \
--url https://glitchtip.coracle.social \
--auth-token $GLITCHTIP_AUTH_TOKEN \
--api-key $VITE_GLITCHTIP_API_KEY \
sourcemaps \
--org coracle \
--project flotilla \
--release $hash \
upload \
--url-prefix /_app/immutable/ \
build/_app/immutable
+17 -7
View File
@@ -66,6 +66,10 @@
--neutral-content: oklch(var(--nc));
}
.mobile [data-tip]::before {
display: none !important;
}
/* safe area insets */
@layer components {
@@ -98,19 +102,19 @@
}
.mt-sai {
padding-top: var(--sait);
margin-top: var(--sait);
}
.mr-sai {
padding-right: var(--sair);
margin-right: var(--sair);
}
.mb-sai {
padding-bottom: var(--saib);
margin-bottom: var(--saib);
}
.ml-sai {
padding-left: var(--sail);
margin-left: var(--sail);
}
.mx-sai {
@@ -270,7 +274,7 @@
.input-editor,
.chat-editor,
.note-editor {
@apply -m-1 min-h-12 p-1;
@apply -m-1 min-h-12 p-1 text-sm;
}
.tiptap {
@@ -358,6 +362,12 @@
@apply !h-full !w-full !rounded-lg !border-none !bg-inherit !px-4 !text-inherit;
}
/* tippy popover */
.tippy-box {
@apply rounded-box shadow-xl;
}
/* emoji picker */
emoji-picker {
@@ -398,7 +408,7 @@ body.keyboard-open .cb {
@apply bottom-sai;
}
body.keyboard-open .bottom-nav {
body.keyboard-open .hide-on-keyboard {
display: none;
}
@@ -409,5 +419,5 @@ body.keyboard-open .bottom-nav {
}
.chat__scroll-down {
@apply fixed bottom-28 right-4 md:bottom-16;
@apply fixed bottom-28 right-4 z-feature md:bottom-16;
}
+1 -1
View File
@@ -26,7 +26,7 @@
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon-180x180.png" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<body data-sveltekit-preload-data="hover" data-sveltekit-preload-code="eager">
<div style="display: contents">%sveltekit.body%</div>
<script
defer
-217
View File
@@ -1,217 +0,0 @@
<script lang="ts">
import {onMount} from "svelte"
import {preventDefault} from "@lib/html"
import {randomInt, map, 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 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 {alertsById, 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
channel?: string
notifyChat?: boolean
notifyThreads?: boolean
notifyCalendar?: boolean
hideSpaceField?: boolean
}
let {
url = "",
channel = "email",
notifyChat = true,
notifyThreads = true,
notifyCalendar = true,
hideSpaceField = false,
}: Props = $props()
const timezoneOffset = parseInt(TIMEZONE.slice(3)) / 100
const minute = randomInt(0, 59)
const hour = (17 - timezoneOffset) % 24
const WEEKLY = `0 ${minute} ${hour} * * 1`
const DAILY = `0 ${minute} ${hour} * * *`
let loading = $state(false)
let cron = $state(WEEKLY)
let email = $state(
map(a => getTagValue("email", a.tags), $alertsById.values()).filter(identity)[0] || "",
)
const back = () => history.back()
const submit = async () => {
if (channel === "email" && !email.includes("@")) {
return pushToast({
theme: "error",
message: "Please provide an email address",
})
}
if (!url) {
return pushToast({
theme: "error",
message: "Please select a space",
})
}
if (!notifyThreads && !notifyCalendar && !notifyChat) {
return pushToast({
theme: "error",
message: "Please select something to be notified about",
})
}
const filters: Filter[] = []
const display: string[] = []
if (notifyThreads) {
display.push("threads")
filters.push({kinds: [THREAD]})
filters.push({kinds: [COMMENT], "#k": [String(THREAD)]})
}
if (notifyCalendar) {
display.push("calendar events")
filters.push({kinds: [EVENT_TIME]})
filters.push({kinds: [COMMENT], "#k": [String(EVENT_TIME)]})
}
if (notifyChat) {
display.push("chat")
filters.push({kinds: [MESSAGE]})
}
loading = true
try {
const claim = url ? await requestRelayClaim(url) : undefined
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) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Your alert has been successfully created!"})
back()
}
} finally {
loading = false
}
}
onMount(() => {
if (!canSendPushNotifications()) {
channel = "email"
}
})
</script>
<form class="column gap-4" onsubmit={preventDefault(submit)}>
<ModalHeader>
{#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>
{#snippet label()}
<p>Alert Type*</p>
{/snippet}
{#snippet input()}
<select bind:value={channel} class="select select-bordered">
<option value="email">Email Digest</option>
<option value="push">Push Notification</option>
</select>
{/snippet}
</FieldInline>
{/if}
{#if channel === "email"}
<FieldInline>
{#snippet label()}
<p>Email Address*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<input placeholder="email@example.com" bind:value={email} />
</label>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Frequency*</p>
{/snippet}
{#snippet input()}
<select bind:value={cron} class="select select-bordered">
<option value={WEEKLY}>Weekly</option>
<option value={DAILY}>Daily</option>
</select>
{/snippet}
</FieldInline>
{/if}
{#if !hideSpaceField}
<FieldInline>
{#snippet label()}
<p>Space*</p>
{/snippet}
{#snippet input()}
<select bind:value={url} class="select select-bordered">
<option value="" disabled selected>Choose a space URL</option>
{#each $userSpaceUrls as url (url)}
<option value={url}>{displayRelayUrl(url)}</option>
{/each}
</select>
{/snippet}
</FieldInline>
{/if}
<FieldInline>
{#snippet label()}
<p>Notifications*</p>
{/snippet}
{#snippet input()}
<div class="flex items-center justify-end gap-4">
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyThreads} />
Threads
</span>
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyCalendar} />
Calendar
</span>
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyChat} />
Chat
</span>
</div>
{/snippet}
</FieldInline>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Confirm</Spinner>
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
-20
View File
@@ -1,20 +0,0 @@
<script lang="ts">
import Confirm from "@lib/components/Confirm.svelte"
import type {Alert} from "@app/core/state"
import {deleteAlert} from "@app/core/commands"
import {pushToast} from "@app/util/toast"
type Props = {
alert: Alert
}
const {alert}: Props = $props()
const confirm = () => {
deleteAlert(alert)
pushToast({message: "Your alert has been deleted!"})
history.back()
}
</script>
<Confirm {confirm} message="You'll no longer receive messages for this alert." />
-42
View File
@@ -1,42 +0,0 @@
<script lang="ts">
import {parseJson} from "@welshman/lib"
import {displayFeeds} from "@welshman/feeds"
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 AlertStatus from "@app/components/AlertStatus.svelte"
import type {Alert} from "@app/core/state"
import {pushModal} from "@app/util/modal"
type Props = {
alert: Alert
}
const {alert}: Props = $props()
const cron = $derived(getTagValue("cron", alert.tags))
const channel = $derived(getTagValue("channel", alert.tags))
const feeds = $derived(getTagValues("feed", alert.tags))
const description = $derived(
getTagValue("description", alert.tags) ||
[
`${cron?.endsWith("1") ? "Weekly" : "Daily"} alert for events`,
displayFeeds(feeds.map(parseJson)),
`sent via ${channel}.`,
].join(" "),
)
const startDelete = () => pushModal(AlertDelete, {alert})
</script>
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4">
<Button class="py-1" onclick={startDelete}>
<Icon icon={TrashBin2} />
</Button>
<div class="flex-inline gap-1">{description}</div>
</div>
<AlertStatus {alert} />
</div>
-42
View File
@@ -1,42 +0,0 @@
<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}
-155
View File
@@ -1,155 +0,0 @@
<script lang="ts">
import {sleep, filter} from "@welshman/lib"
import {getTagValue, getAddress, RelayMode} from "@welshman/util"
import {isRelayFeed, findFeed} from "@welshman/feeds"
import {getPubkeyRelays, pubkey} from "@welshman/app"
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/util/modal"
import {pushToast} from "@app/util/toast"
import {
dmAlert,
alertsById,
deriveAlertStatus,
getAlertFeed,
userSettingsValues,
} from "@app/core/state"
import {deleteAlert, createDmAlert} from "@app/core/commands"
import {clearBadges} from "../util/notifications"
type Props = {
url?: string
channel?: string
hideSpaceField?: boolean
}
const {url = "", channel = "push", hideSpaceField = false}: Props = $props()
const dmStatus = $derived($dmAlert ? deriveAlertStatus(getAddress($dmAlert.event)) : undefined)
const filteredAlerts = $derived(
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
}, $alertsById.values()),
)
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 (getPubkeyRelays($pubkey!, RelayMode.Messaging).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="col-4">
<div class="card2 bg-alt flex flex-col gap-6 shadow-md">
<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="card2 bg-alt flex flex-col gap-4 shadow-md">
<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>
+3 -21
View File
@@ -2,35 +2,17 @@
import type {Snippet} from "svelte"
import {page} from "$app/stores"
import {pubkey} from "@welshman/app"
import Dialog from "@lib/components/Dialog.svelte"
import Landing from "@app/components/Landing.svelte"
import Toast from "@app/components/Toast.svelte"
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/core/state"
import {modals, pushModal} from "@app/util/modal"
import {modals} from "@app/util/modal"
interface Props {
children: Snippet
}
const {children}: Props = $props()
if (BURROW_URL && !$pubkey) {
if ($page.url.pathname === "/confirm-email") {
pushModal(EmailConfirm, {
email: $page.url.searchParams.get("email"),
confirm_token: $page.url.searchParams.get("confirm_token"),
})
}
if ($page.url.pathname === "/reset-password") {
pushModal(PasswordReset, {
email: $page.url.searchParams.get("email"),
reset_token: $page.url.searchParams.get("reset_token"),
})
}
}
</script>
<div class="flex h-screen overflow-hidden">
@@ -39,7 +21,7 @@
{@render children?.()}
</PrimaryNav>
{:else if !$modals[$page.url.hash.slice(1)]}
<Landing />
<Dialog children={{component: Landing, props: {}}} />
{/if}
</div>
<Toast />
@@ -1,7 +1,8 @@
<script lang="ts">
import type {TrustedEvent, EventContent} from "@welshman/util"
import {getTagValue} from "@welshman/util"
import {getTagValue, getAddress} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Pen2 from "@assets/icons/pen-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Link from "@lib/components/Link.svelte"
@@ -14,7 +15,6 @@
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"
type Props = {
url: string
@@ -26,7 +26,7 @@
const {url, event, showRoom, showActivity}: Props = $props()
const h = getTagValue("h", event.tags)
const path = makeCalendarPath(url, event.id)
const path = makeCalendarPath(url, getAddress(event))
const shouldProtect = canEnforceNip70(url)
const editEvent = () => pushModal(CalendarEventEdit, {url, event})
@@ -1,5 +1,7 @@
<script lang="ts">
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import CalendarEventForm from "@app/components/CalendarEventForm.svelte"
type Props = {
@@ -13,12 +15,8 @@
<CalendarEventForm {url} {h}>
{#snippet header()}
<ModalHeader>
{#snippet title()}
<div>Create an Event</div>
{/snippet}
{#snippet info()}
<div>Invite other group members to events online or in real life.</div>
{/snippet}
<ModalTitle>Create an Event</ModalTitle>
<ModalSubtitle>Invite other group members to events online or in real life.</ModalSubtitle>
</ModalHeader>
{/snippet}
</CalendarEventForm>
+4 -6
View File
@@ -2,6 +2,8 @@
import type {TrustedEvent} from "@welshman/util"
import {getTagValue} from "@welshman/util"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import CalendarEventForm from "@app/components/CalendarEventForm.svelte"
type Props = {
@@ -24,12 +26,8 @@
<CalendarEventForm {url} {initialValues}>
{#snippet header()}
<ModalHeader>
{#snippet title()}
<div>Edit this Event</div>
{/snippet}
{#snippet info()}
<div>Invite other group members to events online or in real life.</div>
{/snippet}
<ModalTitle>Edit this Event</ModalTitle>
<ModalSubtitle>Invite other group members to events online or in real life.</ModalSubtitle>
</ModalHeader>
{/snippet}
</CalendarEventForm>
+63 -58
View File
@@ -14,6 +14,8 @@
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {PROTECTED} from "@app/core/state"
@@ -114,64 +116,67 @@
})
</script>
<form novalidate class="column gap-4" onsubmit={preventDefault(submit)}>
{@render header()}
<Field>
{#snippet label()}
<p>Title*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<input bind:value={title} class="grow" type="text" />
</label>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Summary</p>
{/snippet}
{#snippet input()}
<div class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
<div class="input-editor flex-grow overflow-hidden">
<EditorContent {editor} />
<Modal tag="form" novalidate onsubmit={preventDefault(submit)}>
<ModalBody>
{@render header()}
<Field>
{#snippet label()}
<p>Title*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<input bind:value={title} class="grow" type="text" />
</label>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Summary</p>
{/snippet}
{#snippet input()}
<div
class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
<div class="input-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
<Button data-tip="Add an image" class="center btn tooltip" onclick={selectFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon={GallerySend} />
{/if}
</Button>
</div>
<Button data-tip="Add an image" class="center btn tooltip" onclick={selectFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon={GallerySend} />
{/if}
</Button>
</div>
{/snippet}
</Field>
<Field>
{#snippet label()}
Start*
{/snippet}
{#snippet input()}
<DateTimeInput bind:value={start} />
{/snippet}
</Field>
<Field>
{#snippet label()}
End*
{/snippet}
{#snippet input()}
<DateTimeInput bind:value={end} />
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Location (optional)</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={MapPoint} />
<input bind:value={location} class="grow" type="text" />
</label>
{/snippet}
</Field>
{/snippet}
</Field>
<Field>
{#snippet label()}
Start*
{/snippet}
{#snippet input()}
<DateTimeInput bind:value={start} />
{/snippet}
</Field>
<Field>
{#snippet label()}
End*
{/snippet}
{#snippet input()}
<DateTimeInput bind:value={end} />
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Location (optional)</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={MapPoint} />
<input bind:value={location} class="grow" type="text" />
</label>
{/snippet}
</Field>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
@@ -181,4 +186,4 @@
<Spinner loading={$uploading}>Save Event</Spinner>
</Button>
</ModalFooter>
</form>
</Modal>
+2 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {getTagValue} from "@welshman/util"
import {getTagValue, getAddress} from "@welshman/util"
import Link from "@lib/components/Link.svelte"
import CalendarEventActions from "@app/components/CalendarEventActions.svelte"
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
@@ -20,7 +20,7 @@
<Link
class="col-3 card2 bg-alt w-full cursor-pointer shadow-md"
href={makeCalendarPath(url, event.id)}>
href={makeCalendarPath(url, getAddress(event))}>
<CalendarEventHeader {event} />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75">
+10 -8
View File
@@ -48,7 +48,7 @@
import ChatCompose from "@app/components/ChatCompose.svelte"
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import {INDEXER_RELAYS, userSettingsValues, PLATFORM_NAME, deriveChat} from "@app/core/state"
import {userSettingsValues, PLATFORM_NAME, deriveChat} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {prependParent} from "@app/core/commands"
import {pushToast} from "@app/util/toast"
@@ -121,12 +121,14 @@
// 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
const thunks = Array.from(enumerate(templates)).map(([i, event]) =>
sendWrapped({
event,
recipients: pubkeys,
delay: $userSettingsValues.send_delay + ms(i),
}),
const thunks = await Promise.all(
Array.from(enumerate(templates)).map(([i, event]) =>
sendWrapped({
event,
recipients: pubkeys,
delay: $userSettingsValues.send_delay + ms(i),
}),
),
)
pushToast({
@@ -178,7 +180,7 @@
onMount(() => {
for (const pubkey of others) {
loadMessagingRelayList(pubkey, INDEXER_RELAYS, true)
loadMessagingRelayList(pubkey)
}
const observer = new ResizeObserver(() => {
+20 -18
View File
@@ -7,7 +7,11 @@
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {PLATFORM_NAME} from "@app/core/state"
import {clearModals} from "@app/util/modal"
@@ -33,23 +37,21 @@
const back = () => history.back()
</script>
<form class="column gap-4" onsubmit={preventDefault(submit)}>
<ModalHeader>
{#snippet title()}
<div>Enable Messages</div>
{/snippet}
{#snippet info()}
<div>Do you want to enable direct messages?</div>
{/snippet}
</ModalHeader>
<p>
By default, direct messages are disabled, since loading them requires
{PLATFORM_NAME} to download and decrypt a lot of data.
</p>
<p>
If you'd like to enable them, please make sure your signer is set up to to auto-approve requests
to decrypt data.
</p>
<Modal tag="form" onsubmit={preventDefault(submit)}>
<ModalBody>
<ModalHeader>
<ModalTitle>Enable Messages</ModalTitle>
<ModalSubtitle>Do you want to enable direct messages?</ModalSubtitle>
</ModalHeader>
<p>
By default, direct messages are disabled, since loading them requires
{PLATFORM_NAME} to download and decrypt a lot of data.
</p>
<p>
If you'd like to enable them, please make sure your signer is set up to to auto-approve
requests to decrypt data.
</p>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
@@ -60,4 +62,4 @@
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
</Modal>
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import {onMount} from "svelte"
import {page} from "$app/stores"
import {remove, formatTimestamp} from "@welshman/lib"
import {remove, uniq, formatTimestamp} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {pubkey, loadMessagingRelayList} from "@welshman/app"
import {fade} from "@lib/transition"
@@ -21,7 +21,7 @@
const {...props}: Props = $props()
const others = remove($pubkey!, props.pubkeys)
const others = uniq(remove($pubkey!, props.pubkeys))
const active = $derived($page.params.chat === props.id)
const path = makeChatPath(props.pubkeys)
+20 -12
View File
@@ -1,6 +1,10 @@
<script lang="ts">
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import Profile from "@app/components/Profile.svelte"
interface Props {
@@ -10,16 +14,20 @@
const {pubkeys}: Props = $props()
</script>
<div class="column gap-4">
<ModalHeader>
{#snippet title()}
<div>People in this conversation</div>
{/snippet}
</ModalHeader>
{#each pubkeys as pubkey (pubkey)}
<div class="card2 bg-alt">
<Profile {pubkey} />
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>People in this conversation</ModalTitle>
</ModalHeader>
<div class="flex flex-col gap-2">
{#each pubkeys as pubkey (pubkey)}
<div class="card2 bg-alt">
<Profile {pubkey} />
</div>
{/each}
</div>
{/each}
<Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button>
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button>
</ModalFooter>
</Modal>
+30 -62
View File
@@ -1,19 +1,17 @@
<script lang="ts">
import {RelayMode} from "@welshman/util"
import {waitForThunkCompletion, getPubkeyRelays, pubkey} from "@welshman/app"
import {assoc} from "@welshman/lib"
import ChatSquare from "@assets/icons/chat-square.svg?dataurl"
import Check from "@assets/icons/check.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
import BellOff from "@assets/icons/bell-off.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ChatStart from "@app/components/ChatStart.svelte"
import {setChecked} from "@app/util/notifications"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {dmAlert} from "@app/core/state"
import {deleteAlert, createDmAlert} from "@app/core/commands"
import {notificationSettings} from "@app/core/state"
const startChat = () => pushModal(ChatStart, {}, {replaceState: true})
@@ -22,63 +20,33 @@
history.back()
}
const enableAlerts = async () => {
if (getPubkeyRelays($pubkey!, RelayMode.Messaging).length === 0) {
return pushToast({
theme: "error",
message: "Please set up your messaging relays before enabling alerts.",
})
}
const enableAlerts = () => notificationSettings.update(assoc("messages", true))
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)
const disableAlerts = () => notificationSettings.update(assoc("messages", false))
</script>
<div class="col-2">
<Button class="btn btn-primary" onclick={startChat}>
<Icon size={5} icon={ChatSquare} />
Start chat
</Button>
<Button class="btn btn-neutral" onclick={markAsRead}>
<Icon size={5} icon={Check} />
Mark all read
</Button>
{#if (!enablingAlert && $dmAlert) || disablingAlert}
<Button class="btn btn-neutral" onclick={disableAlerts} disabled={disablingAlert}>
{#if !disablingAlert}
<Icon size={4} icon={BellOff} />
<Modal>
<ModalBody>
<div class="flex flex-col gap-2">
<Button class="btn btn-primary" onclick={startChat}>
<Icon size={5} icon={ChatSquare} />
Start chat
</Button>
<Button class="btn btn-neutral" onclick={markAsRead}>
<Icon size={5} icon={Check} />
Mark all read
</Button>
{#if $notificationSettings.messages}
<Button class="btn btn-neutral" onclick={disableAlerts}>
<Icon size={4} icon={BellOff} />
Disable alerts
</Button>
{:else}
<Button class="btn btn-neutral" onclick={enableAlerts}>
<Icon size={4} icon={Bell} />
Enable alerts
</Button>
{/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>
</div>
</ModalBody>
</Modal>
+28 -22
View File
@@ -2,17 +2,19 @@
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 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"
import Icon from "@lib/components/Icon.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
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/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[]
@@ -45,21 +47,25 @@
const showInfo = () => pushModal(EventInfo, {event}, {replaceState: true})
</script>
<div class="col-2">
<Button class="btn btn-primary w-full" onclick={showEmojiPicker}>
<Icon size={4} icon={SmileCircle} />
Send Reaction
</Button>
<Button class="btn btn-neutral w-full" onclick={sendReply}>
<Icon size={4} icon={Reply} />
Send Reply
</Button>
<Button class="btn btn-neutral w-full" onclick={copyText}>
<Icon size={4} icon={Copy} />
Copy Text
</Button>
<Button class="btn btn-neutral" onclick={showInfo}>
<Icon size={4} icon={Code2} />
Message Details
</Button>
</div>
<Modal>
<ModalBody>
<div class="flex flex-col gap-2">
<Button class="btn btn-neutral" onclick={showInfo}>
<Icon size={4} icon={Code2} />
Message Info
</Button>
<Button class="btn btn-neutral w-full" onclick={copyText}>
<Icon size={4} icon={Copy} />
Copy Text
</Button>
<Button class="btn btn-neutral w-full" onclick={sendReply}>
<Icon size={4} icon={Reply} />
Send Reply
</Button>
<Button class="btn btn-primary w-full" onclick={showEmojiPicker}>
<Icon size={4} icon={SmileCircle} />
Send Reaction
</Button>
</div>
</ModalBody>
</Modal>
+23 -17
View File
@@ -5,21 +5,25 @@
import {goto} from "$app/navigation"
import {tryCatch, uniq} from "@welshman/lib"
import {fromNostrURI} from "@welshman/util"
import {pubkey} from "@welshman/app"
import {loadMessagingRelayList} from "@welshman/app"
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 Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
import {makeChatPath} from "@app/util/routes"
const back = () => history.back()
const onSubmit = () => goto(makeChatPath([...pubkeys, $pubkey!]))
const onSubmit = () => goto(makeChatPath(pubkeys))
const addPubkey = (pubkey: string) => {
pubkeys = uniq([...pubkeys, pubkey])
@@ -30,6 +34,10 @@
let pubkeys: string[] = $state([])
$effect(() => {
pubkeys.forEach(pubkey => loadMessagingRelayList(pubkey))
})
onMount(() => {
return term.subscribe(t => {
if (t.match(/^[0-9a-f]{64}$/)) {
@@ -53,20 +61,18 @@
})
</script>
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
<ModalHeader>
{#snippet title()}
<div>Start a Chat</div>
{/snippet}
{#snippet info()}
<div>Create an encrypted chat room for private conversations.</div>
{/snippet}
</ModalHeader>
<Field>
{#snippet input()}
<ProfileMultiSelect autofocus bind:value={pubkeys} {term} />
{/snippet}
</Field>
<Modal tag="form" onsubmit={preventDefault(onSubmit)}>
<ModalBody>
<ModalHeader>
<ModalTitle>Start a Chat</ModalTitle>
<ModalSubtitle>Create an encrypted chat room for private conversations.</ModalSubtitle>
</ModalHeader>
<Field>
{#snippet input()}
<ProfileMultiSelect autofocus bind:value={pubkeys} {term} />
{/snippet}
</Field>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
@@ -77,4 +83,4 @@
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
</Modal>
@@ -0,0 +1,67 @@
<script lang="ts">
import type {TrustedEvent, EventContent} from "@welshman/util"
import {getTagValue, getAddress} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Pen2 from "@assets/icons/pen-2.svg?dataurl"
import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import RoomName from "@app/components/RoomName.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ClassifiedStatus from "@app/components/ClassifiedStatus.svelte"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import ClassifiedEdit from "@app/components/ClassifiedEdit.svelte"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {makeClassifiedPath, makeSpacePath} from "@app/util/routes"
import {pushModal} from "@app/util/modal"
interface Props {
url: string
event: TrustedEvent
showRoom?: boolean
showActivity?: boolean
}
const {url, event, showRoom, showActivity}: Props = $props()
const h = getTagValue("h", event.tags)
const path = makeClassifiedPath(url, getAddress(event))
const shouldProtect = canEnforceNip70(url)
const editClassified = () => pushModal(ClassifiedEdit, {url, event})
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>
<div class="flex flex-grow flex-wrap justify-end gap-2">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<RoomName {h} {url} />
</Link>
{/if}
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event}>
<ClassifiedStatus {event} />
</ThunkStatusOrDeleted>
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Listing">
{#snippet customActions()}
{#if event.pubkey === $pubkey}
<li>
<Button onclick={editClassified}>
<Icon size={4} icon={Pen2} />
Edit Listing
</Button>
</li>
{/if}
{/snippet}
</EventActions>
</div>
@@ -0,0 +1,22 @@
<script lang="ts">
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ClassifiedForm from "@app/components/ClassifiedForm.svelte"
type Props = {
url: string
h?: string
}
const {url, h}: Props = $props()
</script>
<ClassifiedForm {url} {h}>
{#snippet header()}
<ModalHeader>
<ModalTitle>Create a Classified Listing</ModalTitle>
<ModalSubtitle>Advertise a job, sale, or need.</ModalSubtitle>
</ModalHeader>
{/snippet}
</ClassifiedForm>
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
import {fromPairs} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {getTag, getTagValues} from "@welshman/util"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ClassifiedForm from "@app/components/ClassifiedForm.svelte"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const {content} = event
const {d, title, status} = fromPairs(event.tags)
const [_, price = 0, currency = "SAT"] = getTag("price", event.tags) || []
const images = getTagValues("image", event.tags)
const initialValues = {d, title, status, content, price: Number(price), currency, images}
</script>
<ClassifiedForm {url} {initialValues}>
{#snippet header()}
<ModalHeader>
<ModalTitle>Edit this Listing</ModalTitle>
<ModalSubtitle>Advertise a job, sale, or need.</ModalSubtitle>
</ModalHeader>
{/snippet}
</ClassifiedForm>
+198
View File
@@ -0,0 +1,198 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {randomId} from "@welshman/lib"
import {makeEvent, CLASSIFIED} from "@welshman/util"
import {publishThunk} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html"
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"
import Spinner from "@lib/components/Spinner.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ImagesInput from "@lib/components/ImagesInput.svelte"
import CurrencyInput from "@app/components/CurrencyInput.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {canEnforceNip70, uploadFile} from "@app/core/commands"
type Props = {
url: string
h?: string
header: Snippet
initialValues?: {
d?: string
title?: string
content?: string
price?: number
currency?: string
images?: string[]
status?: string
}
}
const {url, h, header, initialValues}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const back = () => history.back()
const submit = async () => {
loading = true
try {
if (!title) {
return pushToast({
theme: "error",
message: "Please provide a title for your listing.",
})
}
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
if (!content.trim()) {
return pushToast({
theme: "error",
message: "Please provide a description for your listing.",
})
}
const tags = [
["d", initialValues?.d || randomId()],
["title", title],
["summary", content],
["price", String(price), currency],
["status", status],
...ed.storage.nostr.getEditorTags(),
]
if (await shouldProtect) {
tags.push(PROTECTED)
}
if (h) {
tags.push(["h", h])
}
for (const image of images) {
if (typeof image === "string") {
tags.push(["image", image])
} else {
const {result, error} = await uploadFile(image, {url})
if (error) {
return pushToast({
theme: "error",
message: `Failed to upload file ${image.name}`,
})
}
if (result) {
tags.push(["image", result.url])
}
}
}
publishThunk({
relays: [url],
event: makeEvent(CLASSIFIED, {content, tags}),
})
history.back()
} finally {
loading = false
}
}
const content = initialValues?.content || ""
const editor = makeEditor({url, submit, content})
let loading = $state(false)
let title = $state(initialValues?.title || "")
let status = $state(initialValues?.status || "active")
let price = $state(Number(initialValues?.price || 0))
let currency = $state(initialValues?.currency || "SAT")
let images = $state<(string | File)[]>(initialValues?.images || [])
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
<ModalBody>
{@render header?.()}
<div class="col-8 relative">
<Field>
{#snippet label()}
<p>Title*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus={!isMobile}
bind:value={title}
class="grow"
type="text"
placeholder="What is this listing for?" />
</label>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Description*</p>
{/snippet}
{#snippet input()}
<div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Price*</p>
{/snippet}
{#snippet input()}
<div class="join grid grid-cols-2">
<label class="join-item input input-bordered flex w-full items-center gap-2">
<input bind:value={price} class="grow w-32" type="number" />
</label>
<CurrencyInput class="join-item" bind:value={currency} />
</div>
{/snippet}
</Field>
{#if initialValues}
<Field>
{#snippet label()}
<p>Status*</p>
{/snippet}
{#snippet input()}
<select class="select select-bordered w-full" bind:value={status}>
<option value="active">Active</option>
<option value="sold">Sold</option>
</select>
{/snippet}
</Field>
{/if}
<Field>
{#snippet label()}
<p>Images (optional)</p>
{/snippet}
{#snippet input()}
<ImagesInput bind:value={images} />
{/snippet}
</Field>
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Save Listing</Spinner>
</Button>
</ModalFooter>
</Modal>
+61
View File
@@ -0,0 +1,61 @@
<script lang="ts">
import {formatTimestamp} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {getTag, getAddress, getTagValue, getTagValues} from "@welshman/util"
import Link from "@lib/components/Link.svelte"
import CurrencySymbol from "@lib/components/CurrencySymbol.svelte"
import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte"
import Content from "@app/components/Content.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import ClassifiedActions from "@app/components/ClassifiedActions.svelte"
import RoomLink from "@app/components/RoomLink.svelte"
import {makeClassifiedPath} from "@app/util/routes"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const title = getTagValue("title", event.tags)
const h = getTagValue("h", event.tags)
const images = getTagValues("image", event.tags)
const [_, price = 0, currency = "SAT"] = getTag("price", event.tags) || []
</script>
<Link
class="col-2 card2 bg-alt w-full cursor-pointer shadow-xl"
href={makeClassifiedPath(url, getAddress(event))}>
{#if title}
<div class="flex w-full items-center justify-between gap-2">
<p class="text-xl">
{title}
<CurrencySymbol code={currency} />{price}
</p>
<p class="text-sm opacity-75">
{formatTimestamp(event.created_at)}
</p>
</div>
{:else}
<p class="mb-3 h-0 text-xs opacity-75">
{formatTimestamp(event.created_at)}
</p>
{/if}
<Content {event} {url} expandMode="inline" />
<div class="grid grid-cols-3 sm:grid-cols-5">
{#each images as image (image)}
<ContentLinkBlock {event} value={{url: image}} />
{/each}
</div>
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75">
Posted by
<ProfileLink pubkey={event.pubkey} {url} />
{#if h}
in <RoomLink {url} {h} />
{/if}
</span>
<ClassifiedActions showActivity {url} {event} />
</div>
</Link>
@@ -0,0 +1,20 @@
<script lang="ts">
import cx from "classnames"
import type {TrustedEvent} from "@welshman/util"
import {getTagValue} from "@welshman/util"
import {ucFirst} from "@lib/util"
type Props = {
event: TrustedEvent
}
const {event}: Props = $props()
const status = getTagValue("status", event.tags)
</script>
{#if status}
<div class={cx("btn btn-xs rounded-full", {"btn-primary": status !== "active"})}>
{ucFirst(status)}
</div>
{/if}
+6 -5
View File
@@ -5,19 +5,20 @@
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {makeThreadPath} from "@app/util/routes"
import {makeSpacePath} from "@app/util/routes"
interface Props {
url: any
event: any
url: string
event: TrustedEvent
segment: string
showActivity?: boolean
}
const {url, event, showActivity = false}: Props = $props()
const {url, event, segment, showActivity = false}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const path = makeThreadPath(url, event.id)
const path = makeSpacePath(url, segment, event.id)
const deleteReaction = async (event: TrustedEvent) =>
publishDelete({relays: [url], event, protect: await shouldProtect})
+12 -2
View File
@@ -3,11 +3,13 @@
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 CaseMinimalistic from "@assets/icons/case-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 ClassifiedCreate from "@app/components/ClassifiedCreate.svelte"
import GoalCreate from "@app/components/GoalCreate.svelte"
type Props = {
@@ -24,6 +26,8 @@
const createThread = () => pushModal(ThreadCreate, {url, h})
const createClassified = () => pushModal(ClassifiedCreate, {url, h})
let ul: Element
onMount(() => {
@@ -35,13 +39,19 @@
<li>
<Button onclick={createGoal}>
<Icon size={4} icon={StarFallMinimalistic} />
Create Funding Goal
Funding Goal
</Button>
</li>
<li>
<Button onclick={createCalendarEvent}>
<Icon size={4} icon={CalendarMinimalistic} />
Create Calendar Event
Calendar Event
</Button>
</li>
<li>
<Button onclick={createClassified}>
<Icon size={4} icon={CaseMinimalistic} />
Classified Listing
</Button>
</li>
<li>
+7 -2
View File
@@ -169,7 +169,7 @@
{#if isBlock(i)}
<ContentLinkBlock value={parsed.value} {event} />
{:else}
<ContentLinkInline value={parsed.value} />
<ContentLinkInline value={parsed.value} {event} />
{/if}
{:else if isProfile(parsed)}
<ContentMention value={parsed.value} {url} />
@@ -186,7 +186,12 @@
{/if}
{:else if isEllipsis(parsed) && expandInline}
{@html renderAsHtml(parsed)}
<button type="button" class="text-sm underline"> Read more </button>
<button
type="button"
class="text-sm underline"
onclick={stopPropagation(preventDefault(expand))}>
Read more
</button>
{:else}
{@html renderAsHtml(parsed)}
{/if}
+11 -6
View File
@@ -1,20 +1,25 @@
<script lang="ts">
import {ellipsize, displayUrl, postJson} from "@welshman/lib"
import {dufflepud} from "@app/core/state"
import {call, ellipsize, displayUrl, postJson} from "@welshman/lib"
import {isRelayUrl} from "@welshman/util"
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/util/modal"
import {PLATFORM_URL} from "@app/core/state"
import {dufflepud, PLATFORM_URL} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
const {value, event} = $props()
let hideImage = $state(false)
const url = value.url.toString()
const external = !url.startsWith(PLATFORM_URL)
const href = external ? url : url.replace(PLATFORM_URL, "")
const [href, external] = call(() => {
if (isRelayUrl(url)) return [makeSpacePath(url), false]
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
return [url, true]
})
const loadPreview = async () => {
const json = await postJson(dufflepud("link/preview"), {url})
@@ -36,7 +41,7 @@
<Link {external} {href} class="my-2 block">
<div class="overflow-hidden rounded-box">
{#if url.match(/\.(mov|webm|mp4)$/)}
<video controls src={url} class="max-h-96 object-contain object-center">
<video controls src={url} class="max-h-96 rounded-box object-contain object-center">
<track kind="captions" />
</video>
{:else if url.match(/\.(jpe?g|png|gif|webp)$/)}
@@ -21,7 +21,8 @@
.map(tagsFromIMeta)
.find(meta => getTagValue("url", meta) === url) || event.tags
const hash = getTagValue("x", meta)
// Fallback to filename if hash was omitted from the message for interoperability
const hash = getTagValue("x", meta) || url.split(/[\/\.]/).slice(-2)[0]
const key = getTagValue("decryption-key", meta)
const nonce = getTagValue("decryption-nonce", meta)
const algorithm = getTagValue("encryption-algorithm", meta)
+16 -7
View File
@@ -1,25 +1,34 @@
<script lang="ts">
import {displayUrl} from "@welshman/lib"
import {preventDefault} from "@lib/html"
import {call, displayUrl} from "@welshman/lib"
import {isRelayUrl} from "@welshman/util"
import {preventDefault, stopPropagation} from "@lib/html"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import {pushModal} from "@app/util/modal"
import {PLATFORM_URL} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
const {value} = $props()
const {value, event} = $props()
const url = value.url.toString()
const external = !url.startsWith(PLATFORM_URL)
const href = external ? url : url.replace(PLATFORM_URL, "")
const [href, external] = call(() => {
if (isRelayUrl(url)) return [makeSpacePath(url), false]
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
const expand = () => pushModal(ContentLinkDetail, {url}, {fullscreen: true})
return [url, true]
})
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
</script>
{#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)}>
<a
href={url}
class="link-content whitespace-nowrap"
onclick={stopPropagation(preventDefault(expand))}>
<Icon icon={LinkRound} size={3} class="inline-block" />
{displayUrl(url)}
</a>
+1 -1
View File
@@ -116,7 +116,7 @@
{:else if isCashu(parsed) || isInvoice(parsed)}
<ContentToken value={parsed.value} />
{:else if isLink(parsed)}
<ContentLinkInline value={parsed.value} />
<ContentLinkInline value={parsed.value} {event} />
{:else if isProfile(parsed)}
<ContentMention value={parsed.value} {url} />
{:else if isQuote(parsed)}
@@ -1,75 +0,0 @@
<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 NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {goToEvent} from "@app/util/routes"
import {displayRoom} from "@app/core/state"
type Props = {
url: string
h?: string
events: TrustedEvent[]
latest: TrustedEvent
earliest: TrustedEvent
participants: string[]
}
const {url, h, events, latest, earliest, participants}: Props = $props()
</script>
<Button class="card2 bg-alt shadow-lg" onclick={() => goToEvent(earliest)}>
<div class="flex flex-col gap-3">
<div class="flex items-start gap-3">
<ProfileCircle pubkey={earliest.pubkey} size={10} />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 text-sm opacity-70">
{#if h}
<span class="truncate font-medium text-blue-400">
#{displayRoom(url, h)}
</span>
<span class="opacity-50"></span>
{/if}
<span class="text-nowrap">{formatTimestamp(earliest.created_at)}</span>
</div>
<NoteContentMinimal event={earliest} />
</div>
</div>
<div class="ml-13 flex items-center justify-between">
<div class="flex gap-1">
<Icon icon={AltArrowLeft} />
<span class="text-sm opacity-70">
{events.length}
{events.length === 1 ? "message" : "messages"}
</span>
</div>
<div class="flex gap-2">
<ProfileCircles pubkeys={participants} size={6} />
<span class="text-sm opacity-70">
{participants.length}
{participants.length === 1 ? "participant" : "participants"}
</span>
</div>
</div>
{#if latest !== earliest}
<Button class="card2 bg-alt" onclick={() => goToEvent(latest)}>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-sm opacity-70">
<ProfileCircle pubkey={latest.pubkey} size={5} />
<span class="font-medium">Latest reply:</span>
</div>
<span class="text-xs opacity-50">
{formatTimestamp(latest.created_at)}
</span>
</div>
<NoteContentMinimal event={latest} />
</div>
</Button>
{/if}
</div>
</Button>
+101
View File
@@ -0,0 +1,101 @@
<script lang="ts">
import cx from "classnames"
import {writable} from "svelte/store"
import type {Writable} from "svelte/store"
import type {Instance} from "tippy.js"
import {preventDefault} from "@lib/html"
import {createSearch} from "@welshman/app"
import {currencyOptions, displayCurrency} from "@lib/currency"
import Suggestions from "@lib/components/Suggestions.svelte"
import CurrencySuggestion from "@app/components/CurrencySuggestion.svelte"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte"
interface Props {
value: string
autofocus?: boolean
term?: Writable<string>
class?: string
}
let {value = $bindable(), term = writable(""), autofocus = false, ...props}: Props = $props()
const options = createSearch(currencyOptions, {
getValue: item => item.code,
fuseOptions: {
keys: ["name", "code"],
threshold: 0.4,
},
})
const search = (term: string) =>
term ? options.searchValues(term) : ["BTC", "SAT", "USD", "GBP", "AUD", "CAD"]
const selectCurrency = (code: string) => {
value = code
term.set("")
popover?.hide()
}
const clearAndFocus = () => {
value = ""
term.set("")
setTimeout(() => wrapper?.querySelector("input")?.focus())
}
const onKeyDown = (e: Event) => {
if (instance.onKeyDown(e)) {
e.preventDefault()
}
}
const currency = $derived(currencyOptions.find(c => c.code === value))
let wrapper: Element | undefined = $state()
let popover: Instance | undefined = $state()
let instance: any = $state()
$effect(() => {
if ($term) {
popover?.show()
} else {
popover?.hide()
}
})
</script>
<button
class={cx(
props.class,
{"bg-base-200": currency},
"input input-bordered flex items-center gap-2 cursor-pointer",
)}
bind:this={wrapper}
onfocus={preventDefault(clearAndFocus)}
onclick={preventDefault(clearAndFocus)}>
<Icon icon={AltArrowDown} />
{#if currency}
<span class="text-sm ellipsize whitespace-nowrap">
{displayCurrency(currency)}
</span>
{:else}
<!-- svelte-ignore a11y_autofocus -->
<input {autofocus} class="grow" type="text" bind:value={$term} onkeydown={onKeyDown} />
{/if}
<Tippy
bind:popover
bind:instance
component={Suggestions}
props={{
term,
search,
select: selectCurrency,
component: CurrencySuggestion,
}}
params={{
trigger: "manual",
interactive: true,
getReferenceClientRect: () => wrapper!.getBoundingClientRect(),
}} />
</button>
@@ -0,0 +1,11 @@
<script lang="ts">
import {displayCurrencyByCode} from "@lib/currency"
type Props = {
value: string
}
const {value}: Props = $props()
</script>
{displayCurrencyByCode(value)}
-52
View File
@@ -1,52 +0,0 @@
<script lang="ts">
import {onMount} from "svelte"
import {postJson, sleep} from "@welshman/lib"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import LogInPassword from "@app/components/LogInPassword.svelte"
import {pushModal} from "@app/util/modal"
import {BURROW_URL} from "@app/core/state"
const {email, confirm_token} = $props()
const login = () => {
pushModal(LogInPassword, {email}, {path: "/"})
}
let error = $state("")
let loading = $state(true)
onMount(async () => {
const [res] = await Promise.all([
postJson(BURROW_URL + "/user/confirm-email", {email, confirm_token}),
sleep(2000),
])
error = res.error
loading = false
})
</script>
<div class="column gap-4">
<h1 class="heading">
{#if loading}
Just a second...
{:else if error}
Oops!
{:else}
Success!
{/if}
</h1>
<p class="m-auto max-w-sm text-center">
<Spinner {loading}>
{#if loading}
Hang tight, we're checking your confirmation link.
{:else if error}
Looks like something went wrong. {error}
{:else}
You're all set - click below to log in.
{/if}
</Spinner>
</p>
<Button class="btn btn-primary" onclick={login} disabled={loading}>Continue to Login</Button>
</div>
+4 -3
View File
@@ -1,17 +1,18 @@
<script lang="ts">
import {onMount} from "svelte"
import {max, formatTimestampRelative} from "@welshman/lib"
import {max, gt, formatTimestampRelative} from "@welshman/lib"
import {COMMENT} from "@welshman/util"
import {load} from "@welshman/net"
import {deriveArray, deriveEventsById} from "@welshman/store"
import type {TrustedEvent} from "@welshman/util"
import {repository} from "@welshman/app"
import {notifications} from "@app/util/notifications"
import {deriveChecked} 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()
const checked = deriveChecked(path)
const filters = [{kinds: [COMMENT], "#E": [event.id]}]
const replies = deriveArray(deriveEventsById({repository, filters}))
const lastActive = $derived(max([...$replies, event].map(e => e.created_at)))
@@ -26,7 +27,7 @@
<span>{$replies.length} {$replies.length === 1 ? "reply" : "replies"}</span>
</div>
<div class="btn btn-neutral btn-xs relative hidden rounded-full sm:flex">
{#if $notifications.has(path)}
{#if gt(lastActive, $checked)}
<div class="h-2 w-2 rounded-full bg-primary"></div>
{/if}
Active {formatTimestampRelative(lastActive)}
+70 -65
View File
@@ -11,7 +11,12 @@
import Icon from "@lib/components/Icon.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import {clip} from "@app/util/toast"
type Props = {
@@ -36,74 +41,74 @@
})
</script>
<div class="column gap-4">
<ModalHeader>
{#snippet title()}
<div>Event Details</div>
{/snippet}
{#snippet info()}
<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={FileText} />
<input type="text" class="ellipsize min-w-0 grow" value={nevent1} />
<Button onclick={copyLink} class="flex items-center">
<Icon icon={Copy} />
</Button>
</label>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Author Pubkey</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={UserCircle} />
<input type="text" class="ellipsize min-w-0 grow" value={npub1} />
<Button onclick={copyPubkey} class="flex items-center">
<Icon icon={Copy} />
</Button>
</label>
{/snippet}
</FieldInline>
{#if !url && seenOn.size > 0}
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Event Details</ModalTitle>
<ModalSubtitle>The full details of this event are shown below.</ModalSubtitle>
</ModalHeader>
<FieldInline>
{#snippet label()}
<p>Seen On</p>
<p>Created At</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>
<p>{formatter.format(secondsToDate(event.created_at))}</p>
{/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
</Button>
</p>
</div>
<Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button>
</div>
<FieldInline>
{#snippet label()}
<p>Event Link</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={FileText} />
<input type="text" class="ellipsize min-w-0 grow" value={nevent1} />
<Button onclick={copyLink} class="flex items-center">
<Icon icon={Copy} />
</Button>
</label>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Author Pubkey</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={UserCircle} />
<input type="text" class="ellipsize min-w-0 grow" value={npub1} />
<Button onclick={copyPubkey} class="flex items-center">
<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
</Button>
</p>
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-primary flex-grow" onclick={() => history.back()}>Got it</Button>
</ModalFooter>
</Modal>
+4 -9
View File
@@ -6,7 +6,6 @@
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, canEnforceNip70} from "@app/core/commands"
import {PROTECTED} from "@app/core/state"
@@ -65,12 +64,8 @@
</script>
<div bind:this={spacer}></div>
<form
in:fly
bind:this={form}
onsubmit={preventDefault(submit)}
class="cb cw fixed z-feature -mx-2 pt-3">
<div class="card2 mx-2 my-2 bg-neutral">
<form in:fly bind:this={form} onsubmit={preventDefault(submit)} class="cb cw fixed z-feature pt-3">
<div class="card2 mx-2 my-2 bg-alt shadow-md">
<div class="relative">
<div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} />
@@ -86,9 +81,9 @@
{/if}
</Button>
</div>
<ModalFooter>
<div class="flex justify-between pt-3">
<Button class="btn btn-link" onclick={onClose}>Cancel</Button>
<Button type="submit" class="btn btn-primary">Post Reply</Button>
</ModalFooter>
</div>
</div>
</form>
+24 -22
View File
@@ -8,7 +8,11 @@
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {roomsByUrl} from "@app/core/state"
import {makeRoomPath} from "@app/util/routes"
@@ -29,27 +33,25 @@
let selection = $state("")
</script>
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
<ModalHeader>
{#snippet title()}
<div>Share {noun}</div>
{/snippet}
{#snippet info()}
<div>Which room would you like to share this event to?</div>
{/snippet}
</ModalHeader>
<div class="grid grid-cols-3 gap-2">
{#each $roomsByUrl.get(url) || [] as room (room.h)}
<button
type="button"
class="btn"
class:btn-neutral={selection !== room.h}
class:btn-primary={selection === room.h}
onclick={() => toggleRoom(room.h)}>
#<RoomName {...room} />
</button>
{/each}
</div>
<Modal tag="form" onsubmit={preventDefault(onSubmit)}>
<ModalBody>
<ModalHeader>
<ModalTitle>Share {noun}</ModalTitle>
<ModalSubtitle>Which room would you like to share this event to?</ModalSubtitle>
</ModalHeader>
<div class="grid grid-cols-3 gap-2">
{#each $roomsByUrl.get(url) || [] as room (room.h)}
<button
type="button"
class="btn"
class:btn-neutral={selection !== room.h}
class:btn-primary={selection === room.h}
onclick={() => toggleRoom(room.h)}>
#<RoomName {...room} />
</button>
{/each}
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
@@ -60,4 +62,4 @@
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
</Modal>
+68 -66
View File
@@ -11,7 +11,11 @@
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
@@ -82,78 +86,76 @@
let amount = $state(1000)
</script>
<form class="column gap-4" onsubmit={preventDefault(submit)}>
<ModalHeader>
{#snippet title()}
<div>Create a Funding Goal</div>
{/snippet}
{#snippet info()}
<div>Request contributions for your fundraiser.</div>
{/snippet}
</ModalHeader>
<div class="col-8 relative">
<Field>
{#snippet label()}
<p>Title*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus={!isMobile}
bind:value={content}
class="grow"
type="text"
placeholder="What do funds go towards?" />
</label>
{/snippet}
</Field>
<div class="relative">
<Modal tag="form" onsubmit={preventDefault(submit)}>
<ModalBody>
<ModalHeader>
<ModalTitle>Create a Funding Goal</ModalTitle>
<ModalSubtitle>Request contributions for your fundraiser.</ModalSubtitle>
</ModalHeader>
<div class="col-8 relative">
<Field>
{#snippet label()}
<p>Details*</p>
<p>Title*</p>
{/snippet}
{#snippet input()}
<div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
<label class="input input-bordered flex w-full items-center gap-2">
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus={!isMobile}
bind:value={content}
class="grow"
type="text"
placeholder="What do funds go towards?" />
</label>
{/snippet}
</Field>
<Button
data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2"
onclick={selectFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon={Paperclip} size={3} />
{/if}
</Button>
<div class="relative">
<Field>
{#snippet label()}
<p>Details*</p>
{/snippet}
{#snippet input()}
<div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
{/snippet}
</Field>
<Button
data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2"
onclick={selectFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon={Paperclip} size={3} />
{/if}
</Button>
</div>
<div class="flex flex-col gap-1">
<FieldInline>
{#snippet label()}
Goal Amount (sats)*
{/snippet}
{#snippet input()}
<div class="flex flex-grow justify-end">
<label class="input input-bordered flex items-center gap-2">
<Icon icon={Bolt} />
<input bind:value={amount} type="number" class="w-28" />
<p class="opacity-50">sats</p>
</label>
</div>
{/snippet}
</FieldInline>
<input
class="range range-primary -mt-2"
type="range"
min="1000"
max="100000"
step="1000"
bind:value={amount} />
</div>
</div>
<div class="flex flex-col gap-1">
<FieldInline>
{#snippet label()}
Goal Amount (sats)*
{/snippet}
{#snippet input()}
<div class="flex flex-grow justify-end">
<label class="input input-bordered flex items-center gap-2">
<Icon icon={Bolt} />
<input bind:value={amount} type="number" class="w-28" />
<p class="opacity-50">sats</p>
</label>
</div>
{/snippet}
</FieldInline>
<input
class="range range-primary -mt-2"
type="range"
min="1000"
max="100000"
step="1000"
bind:value={amount} />
</div>
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
@@ -161,4 +163,4 @@
</Button>
<Button type="submit" class="btn btn-primary">Create Goal</Button>
</ModalFooter>
</form>
</Modal>
+15 -16
View File
@@ -43,21 +43,20 @@
}
</script>
<div class="w-96 rounded-box bg-base-100 p-4 shadow-2xl">
<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>
<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
type="button"
title={icon.name}
class="flex aspect-square items-center justify-center rounded-box transition-colors hover:bg-primary hover:text-primary-content"
onclick={() => handleSelect(icon.url)}>
<Icon icon={icon.url} class="h-6 w-6" />
</button>
{/each}
</div>
</div>

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