forked from coracle/flotilla
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc88030943 | |||
| 84acad4a20 | |||
| 64a62a72d1 | |||
| e0511edc4d | |||
| 981c8fd706 | |||
| 45ade602b5 | |||
| ef8a8682cd | |||
| 112ac4b6d5 | |||
| 3a26d2cb0b | |||
| a678bf42f1 | |||
| dc314a1d1b | |||
| 3af56f6bb1 | |||
| a996664e6c |
@@ -170,6 +170,15 @@ src/
|
|||||||
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates
|
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates
|
||||||
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
|
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
|
||||||
|
|
||||||
|
**Human-First Simplicity (Jon Staab Style):**
|
||||||
|
|
||||||
|
- Prefer direct, readable code over layered abstractions.
|
||||||
|
- Do not add indirection (extra helpers, wrappers, stores, or derived state) unless it removes real repeated complexity.
|
||||||
|
- Reuse existing Welshman and Flotilla primitives before introducing new utilities or dependencies.
|
||||||
|
- Favor linear control flow and explicit naming over clever patterns.
|
||||||
|
- Remove defensive checks that do not apply in this runtime model.
|
||||||
|
- When two approaches work, pick the one that feels more human and easier to maintain.
|
||||||
|
|
||||||
## Common Tasks
|
## Common Tasks
|
||||||
|
|
||||||
### Adding a New Component
|
### Adding a New Component
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env zsh
|
||||||
|
|
||||||
|
onchange src -ik -- npx svelte-kit sync &
|
||||||
|
|
||||||
|
onchange src -ik -- bash -c 'unbuffer npx svelte-check --tsconfig ./tsconfig.json | less -R' &
|
||||||
|
|
||||||
|
wait
|
||||||
+3
-2
@@ -7,7 +7,7 @@
|
|||||||
"build": "./build.sh",
|
"build": "./build.sh",
|
||||||
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
|
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "./check.sh",
|
||||||
"lint": "prettier --check src && eslint src",
|
"lint": "prettier --check src && eslint src",
|
||||||
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | 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",
|
"format:all": "prettier --write src",
|
||||||
@@ -49,8 +49,9 @@
|
|||||||
"@capacitor/push-notifications": "^8.0.0",
|
"@capacitor/push-notifications": "^8.0.0",
|
||||||
"@capawesome/capacitor-android-dark-mode-support": "^8.0.0",
|
"@capawesome/capacitor-android-dark-mode-support": "^8.0.0",
|
||||||
"@capawesome/capacitor-badge": "^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",
|
"@noble/curves": "^1.9.7",
|
||||||
"@noble/hashes": "^2.0.1",
|
|
||||||
"@pomade/core": "^0.0.12",
|
"@pomade/core": "^0.0.12",
|
||||||
"@poppanator/sveltekit-svg": "^4.2.1",
|
"@poppanator/sveltekit-svg": "^4.2.1",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
|
|||||||
Generated
+49
-3
@@ -47,12 +47,15 @@ importers:
|
|||||||
'@capawesome/capacitor-badge':
|
'@capawesome/capacitor-badge':
|
||||||
specifier: ^8.0.0
|
specifier: ^8.0.0
|
||||||
version: 8.0.0(@capacitor/core@8.0.1)
|
version: 8.0.0(@capacitor/core@8.0.1)
|
||||||
|
'@getalby/lightning-tools':
|
||||||
|
specifier: ^6.1.0
|
||||||
|
version: 6.1.0
|
||||||
|
'@getalby/sdk':
|
||||||
|
specifier: ^5.1.2
|
||||||
|
version: 5.1.2(typescript@5.9.3)
|
||||||
'@noble/curves':
|
'@noble/curves':
|
||||||
specifier: ^1.9.7
|
specifier: ^1.9.7
|
||||||
version: 1.9.7
|
version: 1.9.7
|
||||||
'@noble/hashes':
|
|
||||||
specifier: ^2.0.1
|
|
||||||
version: 2.0.1
|
|
||||||
'@pomade/core':
|
'@pomade/core':
|
||||||
specifier: ^0.0.12
|
specifier: ^0.0.12
|
||||||
version: 0.0.12(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.4)(@welshman/net@0.8.4(@welshman/lib@0.8.4)(@welshman/util@0.8.4(@noble/curves@1.9.7)(@welshman/lib@0.8.4)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.4(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.4)(@welshman/net@0.8.4(@welshman/lib@0.8.4)(@welshman/util@0.8.4(@noble/curves@1.9.7)(@welshman/lib@0.8.4)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.4(@noble/curves@1.9.7)(@welshman/lib@0.8.4)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3)))(@welshman/util@0.8.4(@noble/curves@1.9.7)(@welshman/lib@0.8.4)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3))
|
version: 0.0.12(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.4)(@welshman/net@0.8.4(@welshman/lib@0.8.4)(@welshman/util@0.8.4(@noble/curves@1.9.7)(@welshman/lib@0.8.4)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.4(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.4)(@welshman/net@0.8.4(@welshman/lib@0.8.4)(@welshman/util@0.8.4(@noble/curves@1.9.7)(@welshman/lib@0.8.4)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.4(@noble/curves@1.9.7)(@welshman/lib@0.8.4)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3)))(@welshman/util@0.8.4(@noble/curves@1.9.7)(@welshman/lib@0.8.4)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3))
|
||||||
@@ -996,6 +999,18 @@ packages:
|
|||||||
'@frostr/bifrost@1.0.7':
|
'@frostr/bifrost@1.0.7':
|
||||||
resolution: {integrity: sha512-9PO8s8ra7Cf94HqsF0sArRkLLFYqDyGfRKUOflTWMGgaDvSWIksNA8PckcXvy5/G6u4RtAkTAqki47+ga+7yow==}
|
resolution: {integrity: sha512-9PO8s8ra7Cf94HqsF0sArRkLLFYqDyGfRKUOflTWMGgaDvSWIksNA8PckcXvy5/G6u4RtAkTAqki47+ga+7yow==}
|
||||||
|
|
||||||
|
'@getalby/lightning-tools@5.2.1':
|
||||||
|
resolution: {integrity: sha512-dxOmJLJAh6qJ8rsbA5/Bwj7MSI9X3RkxxqmCedl5rfP+yKwNSdfu8i4EiCZN/tk2hNBJb8GHSCcPRNZfwfmEHg==}
|
||||||
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
'@getalby/lightning-tools@6.1.0':
|
||||||
|
resolution: {integrity: sha512-rGurar9X4Gm+9xwoNYS8s9YLK7ZYqvbqv4KbHLYV0LEeB0HxZHRgmxblGqg+fYfp6iiYHx+edIgUpt9rS3VwFw==}
|
||||||
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
'@getalby/sdk@5.1.2':
|
||||||
|
resolution: {integrity: sha512-yUF9LhuvdIFOwjV1aG0ryzfwDiGBFk/CRLkRvrrM9dsE38SUjKsf1FDga5jxsKMu80nWcPZR9TiGGASWedoYPA==}
|
||||||
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
'@humanfs/core@0.19.1':
|
'@humanfs/core@0.19.1':
|
||||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
@@ -3491,6 +3506,14 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@capacitor/core': ^7.0.0
|
'@capacitor/core': ^7.0.0
|
||||||
|
|
||||||
|
nostr-tools@2.15.0:
|
||||||
|
resolution: {integrity: sha512-Jj/+UFbu3JbTAWP4ipPFNuyD4W5eVRBNAP+kmnoRCYp3bLmTrlQ0Qhs5O1xSQJTFpjdZqoS0zZOUKdxUdjc+pw==}
|
||||||
|
peerDependencies:
|
||||||
|
typescript: '>=5.0.0'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
|
||||||
nostr-tools@2.20.0:
|
nostr-tools@2.20.0:
|
||||||
resolution: {integrity: sha512-Kq/2lMyeOdGvpDsYH2an8HP4H0aFCqwKythhTzxfgZTVv4L3NOgrJw2SxH8jkWlH8xPhWxGfN6lFtC+EAa2qYQ==}
|
resolution: {integrity: sha512-Kq/2lMyeOdGvpDsYH2an8HP4H0aFCqwKythhTzxfgZTVv4L3NOgrJw2SxH8jkWlH8xPhWxGfN6lFtC+EAa2qYQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -5770,6 +5793,17 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- typescript
|
- typescript
|
||||||
|
|
||||||
|
'@getalby/lightning-tools@5.2.1': {}
|
||||||
|
|
||||||
|
'@getalby/lightning-tools@6.1.0': {}
|
||||||
|
|
||||||
|
'@getalby/sdk@5.1.2(typescript@5.9.3)':
|
||||||
|
dependencies:
|
||||||
|
'@getalby/lightning-tools': 5.2.1
|
||||||
|
nostr-tools: 2.15.0(typescript@5.9.3)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- typescript
|
||||||
|
|
||||||
'@humanfs/core@0.19.1': {}
|
'@humanfs/core@0.19.1': {}
|
||||||
|
|
||||||
'@humanfs/node@0.16.7':
|
'@humanfs/node@0.16.7':
|
||||||
@@ -8413,6 +8447,18 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@capacitor/core': 8.0.1
|
'@capacitor/core': 8.0.1
|
||||||
|
|
||||||
|
nostr-tools@2.15.0(typescript@5.9.3):
|
||||||
|
dependencies:
|
||||||
|
'@noble/ciphers': 0.5.3
|
||||||
|
'@noble/curves': 1.2.0
|
||||||
|
'@noble/hashes': 1.3.1
|
||||||
|
'@scure/base': 1.1.1
|
||||||
|
'@scure/bip32': 1.3.1
|
||||||
|
'@scure/bip39': 1.2.1
|
||||||
|
nostr-wasm: 0.1.0
|
||||||
|
optionalDependencies:
|
||||||
|
typescript: 5.9.3
|
||||||
|
|
||||||
nostr-tools@2.20.0(typescript@5.9.3):
|
nostr-tools@2.20.0(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@noble/ciphers': 0.5.3
|
'@noble/ciphers': 0.5.3
|
||||||
|
|||||||
+2
-2
@@ -274,7 +274,7 @@
|
|||||||
.input-editor,
|
.input-editor,
|
||||||
.chat-editor,
|
.chat-editor,
|
||||||
.note-editor {
|
.note-editor {
|
||||||
@apply -m-1 min-h-12 p-1 text-sm;
|
@apply -m-1 p-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap {
|
.tiptap {
|
||||||
@@ -300,7 +300,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tiptap {
|
.tiptap {
|
||||||
@apply max-h-[350px] overflow-y-auto p-2 px-4;
|
@apply max-h-[350px] min-h-10 overflow-y-auto p-2 px-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap p.is-editor-empty:first-child::before {
|
.tiptap p.is-editor-empty:first-child::before {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount, onDestroy} from "svelte"
|
import {onMount, onDestroy} from "svelte"
|
||||||
import {displayUrl} from "@welshman/lib"
|
import {displayUrl, once} from "@welshman/lib"
|
||||||
import {
|
import {
|
||||||
getTags,
|
getTags,
|
||||||
getBlob,
|
getBlob,
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
const nonce = getTagValue("decryption-nonce", meta)
|
const nonce = getTagValue("decryption-nonce", meta)
|
||||||
const algorithm = getTagValue("encryption-algorithm", meta)
|
const algorithm = getTagValue("encryption-algorithm", meta)
|
||||||
|
|
||||||
const onError = async () => {
|
const onError = once(async () => {
|
||||||
// If the image failed to load, try authenticating
|
// If the image failed to load, try authenticating
|
||||||
if (hash && $signer) {
|
if (hash && $signer) {
|
||||||
const server = new URL(url).origin
|
const server = new URL(url).origin
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
} else {
|
} else {
|
||||||
hasError = true
|
hasError = true
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
let hasError = $state(false)
|
let hasError = $state(false)
|
||||||
let src = $state("")
|
let src = $state("")
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
<Icon icon={Reply} />
|
<Icon icon={Reply} />
|
||||||
<span>{$replies.length} {$replies.length === 1 ? "reply" : "replies"}</span>
|
<span>{$replies.length} {$replies.length === 1 ? "reply" : "replies"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn btn-neutral btn-xs relative hidden rounded-full sm:flex">
|
<div class="btn btn-neutral btn-xs relative rounded-full">
|
||||||
{#if gt(lastActive, $checked)}
|
{#if gt(lastActive, $checked)}
|
||||||
<div class="h-2 w-2 rounded-full bg-primary"></div>
|
<div class="h-2 w-2 rounded-full bg-primary"></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte"
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
urls: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const {urls}: Props = $props()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="column menu gap-2">
|
|
||||||
{#each urls as url (url)}
|
|
||||||
<MenuSpacesItem {url} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {Capacitor} from "@capacitor/core"
|
import {Capacitor} from "@capacitor/core"
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
|
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
|
||||||
import Server from "@assets/icons/server.svg?dataurl"
|
import Server from "@assets/icons/server.svg?dataurl"
|
||||||
import Moon from "@assets/icons/moon.svg?dataurl"
|
import Moon from "@assets/icons/moon.svg?dataurl"
|
||||||
@@ -19,8 +20,8 @@
|
|||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
import {theme} from "@app/util/theme"
|
import {theme} from "@app/util/theme"
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
const logout = () => pushModal(LogOut)
|
const logout = () => pushModal(LogOut)
|
||||||
|
|
||||||
const toggleTheme = () => theme.set($theme === "dark" ? "light" : "dark")
|
const toggleTheme = () => theme.set($theme === "dark" ? "light" : "dark")
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -123,6 +124,10 @@
|
|||||||
<Button onclick={logout} class="btn btn-neutral">
|
<Button onclick={logout} class="btn btn-neutral">
|
||||||
<Icon icon={Exit} /> Log Out
|
<Icon icon={Exit} /> Log Out
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button class="btn btn-link w-full md:hidden" onclick={back}>
|
||||||
|
<Icon icon={AltArrowLeft} />
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Link replaceState href={path}>
|
<Link replaceState href={path}>
|
||||||
<CardButton class="btn-neutral shadow-md bg-alt">
|
<CardButton class="btn-neutral shadow-md bg-alt rounded-box border-none">
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<RelayIcon {url} size={12} class="rounded-full" />
|
<RelayIcon {url} size={12} class="rounded-full" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -2,15 +2,14 @@
|
|||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
import type {Snippet} from "svelte"
|
import type {Snippet} from "svelte"
|
||||||
import {formatTimestamp} from "@welshman/lib"
|
import {formatTimestamp} from "@welshman/lib"
|
||||||
import {getListTags, getPubkeyTagValues} from "@welshman/util"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {userMuteList} from "@welshman/app"
|
|
||||||
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Profile from "@app/components/Profile.svelte"
|
import Profile from "@app/components/Profile.svelte"
|
||||||
import ProfileName from "@app/components/ProfileName.svelte"
|
import ProfileName from "@app/components/ProfileName.svelte"
|
||||||
import {goToEvent} from "@app/util/routes"
|
import {goToEvent} from "@app/util/routes"
|
||||||
|
import {isEventMuted} from "@app/core/state"
|
||||||
|
|
||||||
const {
|
const {
|
||||||
event,
|
event,
|
||||||
@@ -32,7 +31,7 @@
|
|||||||
muted = false
|
muted = false
|
||||||
}
|
}
|
||||||
|
|
||||||
let muted = $state(getPubkeyTagValues(getListTags($userMuteList)).includes(event.pubkey))
|
let muted = $state($isEventMuted(event))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 shadow-md {restProps.class}">
|
<div class="flex flex-col gap-2 shadow-md {restProps.class}">
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
import Divider from "@lib/components/Divider.svelte"
|
import Divider from "@lib/components/Divider.svelte"
|
||||||
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
||||||
import ChatEnable from "@app/components/ChatEnable.svelte"
|
import ChatEnable from "@app/components/ChatEnable.svelte"
|
||||||
import MenuOtherSpaces from "@app/components/MenuOtherSpaces.svelte"
|
|
||||||
import MenuSettings from "@app/components/MenuSettings.svelte"
|
import MenuSettings from "@app/components/MenuSettings.svelte"
|
||||||
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
|
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
|
||||||
import {userSpaceUrls, PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/core/state"
|
import {userSpaceUrls, PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/core/state"
|
||||||
@@ -28,8 +27,6 @@
|
|||||||
|
|
||||||
const {children}: Props = $props()
|
const {children}: Props = $props()
|
||||||
|
|
||||||
const showOtherSpacesMenu = () => pushModal(MenuOtherSpaces, {urls: secondarySpaceUrls})
|
|
||||||
|
|
||||||
const showSettingsMenu = () => pushModal(MenuSettings)
|
const showSettingsMenu = () => pushModal(MenuSettings)
|
||||||
|
|
||||||
const openChat = () => ($shouldUnwrap ? goto("/chat") : pushModal(ChatEnable, {next: "/chat"}))
|
const openChat = () => ($shouldUnwrap ? goto("/chat") : pushModal(ChatEnable, {next: "/chat"}))
|
||||||
@@ -60,15 +57,13 @@
|
|||||||
{#each primarySpaceUrls as url (url)}
|
{#each primarySpaceUrls as url (url)}
|
||||||
<PrimaryNavItemSpace {url} />
|
<PrimaryNavItemSpace {url} />
|
||||||
{/each}
|
{/each}
|
||||||
{#if secondarySpaceUrls.length > 0}
|
<PrimaryNavItem
|
||||||
<PrimaryNavItem
|
href="/spaces"
|
||||||
title="Other Spaces"
|
title="All Spaces"
|
||||||
class="tooltip-right"
|
class="tooltip-right"
|
||||||
onclick={showOtherSpacesMenu}
|
notification={otherSpaceNotifications}>
|
||||||
notification={otherSpaceNotifications}>
|
<ImageIcon alt="All Spaces" src={Widget} size={8} />
|
||||||
<ImageIcon alt="Other Spaces" src={Widget} size={8} />
|
</PrimaryNavItem>
|
||||||
</PrimaryNavItem>
|
|
||||||
{/if}
|
|
||||||
<PrimaryNavItem title="Add a Space" href="/discover" class="tooltip-right">
|
<PrimaryNavItem title="Add a Space" href="/discover" class="tooltip-right">
|
||||||
<ImageIcon alt="Add a Space" src={Compass} size={8} />
|
<ImageIcon alt="Add a Space" src={Compass} size={8} />
|
||||||
</PrimaryNavItem>
|
</PrimaryNavItem>
|
||||||
|
|||||||
@@ -152,18 +152,18 @@
|
|||||||
transition:fly
|
transition:fly
|
||||||
class="bg-alt menu absolute right-0 z-popover w-48 gap-1 rounded-box p-2 shadow-md">
|
class="bg-alt menu absolute right-0 z-popover w-48 gap-1 rounded-box p-2 shadow-md">
|
||||||
{#if $userIsAdmin}
|
{#if $userIsAdmin}
|
||||||
<li>
|
|
||||||
<Button class="text-error" onclick={startDelete}>
|
|
||||||
<Icon icon={TrashBin2} />
|
|
||||||
Delete Room
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<Button onclick={startEdit}>
|
<Button onclick={startEdit}>
|
||||||
<Icon icon={Pen} />
|
<Icon icon={Pen} />
|
||||||
Edit Room
|
Edit Room
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Button class="text-error" onclick={startDelete}>
|
||||||
|
<Icon icon={TrashBin2} />
|
||||||
|
Delete Room
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
{:else if $membershipStatus === MembershipStatus.Initial}
|
{:else if $membershipStatus === MembershipStatus.Initial}
|
||||||
<li>
|
<li>
|
||||||
<Button disabled={loading} onclick={join}>
|
<Button disabled={loading} onclick={join}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {debounce} from "throttle-debounce"
|
import {debounce} from "throttle-debounce"
|
||||||
import {nwc} from "@lib/lightning"
|
import {nwc} from "@getalby/sdk"
|
||||||
import {sleep, assoc} from "@welshman/lib"
|
import {sleep, assoc} from "@welshman/lib"
|
||||||
import type {NWCInfo} from "@welshman/util"
|
import type {NWCInfo} from "@welshman/util"
|
||||||
import {pubkey, userProfile, updateSession} from "@welshman/app"
|
import {pubkey, userProfile, updateSession} from "@welshman/app"
|
||||||
@@ -33,14 +33,8 @@
|
|||||||
loading = true
|
loading = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const webLn = getWebLn()
|
await Promise.all([sleep(800), getWebLn().enable()])
|
||||||
|
const info = await getWebLn().getInfo()
|
||||||
if (!webLn) {
|
|
||||||
throw new Error("WebLN not available")
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all([sleep(800), webLn.enable()])
|
|
||||||
const info = (await webLn.getInfo?.()) || {}
|
|
||||||
|
|
||||||
if (!info?.supports?.includes("lightning")) {
|
if (!info?.supports?.includes("lightning")) {
|
||||||
pushToast({
|
pushToast({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {Invoice} from "@lib/lightning/bolt11"
|
import {Invoice} from "@getalby/lightning-tools/bolt11"
|
||||||
import {debounce} from "throttle-debounce"
|
import {debounce} from "throttle-debounce"
|
||||||
import {session} from "@welshman/app"
|
import {session} from "@welshman/app"
|
||||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {Invoice} from "@lib/lightning/bolt11"
|
import {Invoice} from "@getalby/lightning-tools/bolt11"
|
||||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||||
import Refresh from "@assets/icons/refresh.svg?dataurl"
|
import Refresh from "@assets/icons/refresh.svg?dataurl"
|
||||||
|
|||||||
+86
-25
@@ -1,5 +1,4 @@
|
|||||||
import {wallet as lightningWallet} from "@lib/lightning"
|
import {nwc} from "@getalby/sdk"
|
||||||
import type {IWallet} from "@lib/lightning/wallet"
|
|
||||||
import * as nip19 from "nostr-tools/nip19"
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
import {get, derived} from "svelte/store"
|
import {get, derived} from "svelte/store"
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +37,7 @@ import {
|
|||||||
makeList,
|
makeList,
|
||||||
addToListPublicly,
|
addToListPublicly,
|
||||||
removeFromListByPredicate,
|
removeFromListByPredicate,
|
||||||
|
updateList,
|
||||||
getTag,
|
getTag,
|
||||||
getListTags,
|
getListTags,
|
||||||
getRelayTagValues,
|
getRelayTagValues,
|
||||||
@@ -149,6 +149,20 @@ export const removeSpaceMembership = async (url: string) => {
|
|||||||
return publishThunk({event, relays})
|
return publishThunk({event, relays})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const setSpaceMembershipOrder = async (urls: string[]) => {
|
||||||
|
const list = get(userGroupList) || makeList({kind: ROOMS})
|
||||||
|
const orderedUrls = uniq(urls.map(normalizeRelayUrl))
|
||||||
|
const relayTags = list.publicTags.filter(t => t[0] === "r")
|
||||||
|
const otherPublicTags = list.publicTags.filter(t => t[0] !== "r")
|
||||||
|
const relayTagByUrl = new Map(relayTags.map(t => [normalizeRelayUrl(t[1]), t]))
|
||||||
|
const orderedRelayTags = orderedUrls.map(url => relayTagByUrl.get(url) || ["r", url])
|
||||||
|
const publicTags = [...orderedRelayTags, ...otherPublicTags]
|
||||||
|
const event = await updateList(list, {publicTags}).reconcile(nip44EncryptToSelf)
|
||||||
|
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
||||||
|
|
||||||
|
return publishThunk({event, relays})
|
||||||
|
}
|
||||||
|
|
||||||
export const addRoomMembership = async (url: string, h: string) => {
|
export const addRoomMembership = async (url: string, h: string) => {
|
||||||
const list = get(userGroupList) || makeList({kind: ROOMS})
|
const list = get(userGroupList) || makeList({kind: ROOMS})
|
||||||
const newTags = [
|
const newTags = [
|
||||||
@@ -433,34 +447,43 @@ export const publishLeaveRequest = (params: LeaveRequestParams) =>
|
|||||||
|
|
||||||
// Lightning
|
// Lightning
|
||||||
|
|
||||||
export const getWebLn = () => lightningWallet.getWebLn()
|
export const getWebLn = () => (window as any).webln
|
||||||
|
|
||||||
const getWalletAdapter = (): IWallet => {
|
export const getNwcClient = () => {
|
||||||
|
const $session = session.get()
|
||||||
|
|
||||||
|
if (!$session?.wallet || $session.wallet.type !== "nwc") {
|
||||||
|
throw new Error("No NWC wallet is connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
const {info} = $session.wallet
|
||||||
|
|
||||||
|
if (info.nostrWalletConnectUrl) {
|
||||||
|
return new nwc.NWCClient({nostrWalletConnectUrl: info.nostrWalletConnectUrl})
|
||||||
|
}
|
||||||
|
|
||||||
|
return new nwc.NWCClient(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const payInvoice = async (invoice: string, msats?: number) => {
|
||||||
const $session = session.get()
|
const $session = session.get()
|
||||||
|
|
||||||
if (!$session?.wallet) {
|
if (!$session?.wallet) {
|
||||||
throw new Error("No wallet is connected")
|
throw new Error("No wallet is connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
return lightningWallet.createWalletAdapter($session.wallet)
|
if ($session.wallet.type === "nwc") {
|
||||||
}
|
const params: {invoice: string; amount?: number} = {invoice}
|
||||||
|
if (msats) params.amount = msats
|
||||||
const withConnectedWallet = async <T>(f: (wallet: IWallet) => Promise<T>) => {
|
return getNwcClient().payInvoice(params)
|
||||||
const wallet = getWalletAdapter()
|
} else if ($session.wallet.type === "webln") {
|
||||||
|
if (msats) throw new Error("Unable to pay zero invoices with webln")
|
||||||
try {
|
return getWebLn()
|
||||||
return await f(wallet)
|
.enable()
|
||||||
} finally {
|
.then(() => getWebLn().sendPayment(invoice))
|
||||||
wallet.close()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getWalletBalance = () => withConnectedWallet(wallet => wallet.getBalanceSats())
|
|
||||||
|
|
||||||
export const payInvoice = async (invoice: string, msats?: number) => {
|
|
||||||
return withConnectedWallet(wallet => wallet.payInvoice({invoice, msats}))
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CreateInvoiceParams = {
|
export type CreateInvoiceParams = {
|
||||||
sats: number
|
sats: number
|
||||||
description?: string
|
description?: string
|
||||||
@@ -470,18 +493,56 @@ export const createInvoice = async ({
|
|||||||
sats,
|
sats,
|
||||||
description = "Receive via lightning",
|
description = "Receive via lightning",
|
||||||
}: CreateInvoiceParams) => {
|
}: CreateInvoiceParams) => {
|
||||||
|
const $session = session.get()
|
||||||
|
|
||||||
|
if (!$session?.wallet) {
|
||||||
|
throw new Error("No wallet is connected")
|
||||||
|
}
|
||||||
|
|
||||||
const satAmount = Math.floor(sats)
|
const satAmount = Math.floor(sats)
|
||||||
|
|
||||||
if (!Number.isFinite(satAmount) || satAmount <= 0) {
|
if (!Number.isFinite(satAmount) || satAmount <= 0) {
|
||||||
throw new Error("Invalid satoshi amount")
|
throw new Error("Invalid satoshi amount")
|
||||||
}
|
}
|
||||||
|
|
||||||
return withConnectedWallet(wallet =>
|
if ($session.wallet.type === "nwc") {
|
||||||
wallet.createInvoice({
|
const createdInvoice = await getNwcClient().makeInvoice({
|
||||||
sats: satAmount,
|
amount: satAmount * 1000,
|
||||||
description,
|
description,
|
||||||
}),
|
})
|
||||||
)
|
|
||||||
|
if (!createdInvoice.invoice) {
|
||||||
|
throw new Error("NWC wallet failed to return an invoice")
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdInvoice.invoice
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($session.wallet.type === "webln") {
|
||||||
|
const webLn = getWebLn()
|
||||||
|
|
||||||
|
if (!webLn) {
|
||||||
|
throw new Error("WebLN not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
await webLn.enable()
|
||||||
|
|
||||||
|
const response = await webLn.makeInvoice({
|
||||||
|
amount: satAmount,
|
||||||
|
defaultMemo: description,
|
||||||
|
})
|
||||||
|
|
||||||
|
const paymentRequest =
|
||||||
|
typeof response === "string" ? response : response?.paymentRequest || response?.pr || ""
|
||||||
|
|
||||||
|
if (!paymentRequest) {
|
||||||
|
throw new Error("Invalid payment request returned from WebLN")
|
||||||
|
}
|
||||||
|
|
||||||
|
return paymentRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unsupported wallet type")
|
||||||
}
|
}
|
||||||
|
|
||||||
// File upload
|
// File upload
|
||||||
|
|||||||
+131
-1
@@ -27,6 +27,7 @@ import {
|
|||||||
randomId,
|
randomId,
|
||||||
tryCatch,
|
tryCatch,
|
||||||
fromPairs,
|
fromPairs,
|
||||||
|
groupBy,
|
||||||
remove,
|
remove,
|
||||||
} from "@welshman/lib"
|
} from "@welshman/lib"
|
||||||
import type {Override} from "@welshman/lib"
|
import type {Override} from "@welshman/lib"
|
||||||
@@ -48,6 +49,7 @@ import {
|
|||||||
makeDeriveEvent,
|
makeDeriveEvent,
|
||||||
makeLoadItem,
|
makeLoadItem,
|
||||||
makeDeriveItem,
|
makeDeriveItem,
|
||||||
|
deriveItems,
|
||||||
deriveItemsByKey,
|
deriveItemsByKey,
|
||||||
deriveDeduplicated,
|
deriveDeduplicated,
|
||||||
deriveEventsById,
|
deriveEventsById,
|
||||||
@@ -58,6 +60,8 @@ import {
|
|||||||
deriveEventsDesc,
|
deriveEventsDesc,
|
||||||
} from "@welshman/store"
|
} from "@welshman/store"
|
||||||
import {
|
import {
|
||||||
|
FEED,
|
||||||
|
FEEDS,
|
||||||
APP_DATA,
|
APP_DATA,
|
||||||
CLIENT_AUTH,
|
CLIENT_AUTH,
|
||||||
COMMENT,
|
COMMENT,
|
||||||
@@ -90,6 +94,8 @@ import {
|
|||||||
ZAP_GOAL,
|
ZAP_GOAL,
|
||||||
ZAP_REQUEST,
|
ZAP_REQUEST,
|
||||||
ZAP_RESPONSE,
|
ZAP_RESPONSE,
|
||||||
|
REPOST,
|
||||||
|
GENERIC_REPOST,
|
||||||
asDecryptedEvent,
|
asDecryptedEvent,
|
||||||
getGroupTags,
|
getGroupTags,
|
||||||
getListTags,
|
getListTags,
|
||||||
@@ -104,14 +110,29 @@ import {
|
|||||||
makeRoomMeta,
|
makeRoomMeta,
|
||||||
ManagementMethod,
|
ManagementMethod,
|
||||||
sortEventsDesc,
|
sortEventsDesc,
|
||||||
|
getAddress,
|
||||||
|
Address,
|
||||||
|
getIdFilters,
|
||||||
|
getEventTagValues,
|
||||||
|
getAddressTagValues,
|
||||||
|
getParentIds,
|
||||||
|
getParentAddrs,
|
||||||
|
} from "@welshman/util"
|
||||||
|
import type {
|
||||||
|
TrustedEvent,
|
||||||
|
RelayProfile,
|
||||||
|
PublishedList,
|
||||||
|
PublishedRoomMeta,
|
||||||
|
List,
|
||||||
|
Filter,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import type {TrustedEvent, RelayProfile, PublishedRoomMeta, List, Filter} from "@welshman/util"
|
|
||||||
import {routerContext, Router} from "@welshman/router"
|
import {routerContext, Router} from "@welshman/router"
|
||||||
import {
|
import {
|
||||||
pubkey,
|
pubkey,
|
||||||
repository,
|
repository,
|
||||||
tracker,
|
tracker,
|
||||||
createSearch,
|
createSearch,
|
||||||
|
userMuteList,
|
||||||
userFollowList,
|
userFollowList,
|
||||||
ensurePlaintext,
|
ensurePlaintext,
|
||||||
makeOutboxLoader,
|
makeOutboxLoader,
|
||||||
@@ -121,7 +142,9 @@ import {
|
|||||||
makeUserLoader,
|
makeUserLoader,
|
||||||
manageRelay,
|
manageRelay,
|
||||||
displayProfileByPubkey,
|
displayProfileByPubkey,
|
||||||
|
getProfile,
|
||||||
} from "@welshman/app"
|
} from "@welshman/app"
|
||||||
|
import {readFeed} from "@lib/feeds"
|
||||||
|
|
||||||
export const fromCsv = (s: string) => (s || "").split(",").filter(identity)
|
export const fromCsv = (s: string) => (s || "").split(",").filter(identity)
|
||||||
|
|
||||||
@@ -285,6 +308,8 @@ export const makeCommentFilter = (kinds: number[], extra: Filter = {}) => ({
|
|||||||
...extra,
|
...extra,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const REPOST_KINDS = [REPOST, GENERIC_REPOST]
|
||||||
|
|
||||||
export const REACTION_KINDS = [REPORT, DELETE, REACTION]
|
export const REACTION_KINDS = [REPORT, DELETE, REACTION]
|
||||||
|
|
||||||
if (ENABLE_ZAPS) {
|
if (ENABLE_ZAPS) {
|
||||||
@@ -915,6 +940,111 @@ export const deriveUserCanCreateRoom = (url: string) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Feeds
|
||||||
|
|
||||||
|
export const feedsByAddress = deriveItemsByKey({
|
||||||
|
repository,
|
||||||
|
getKey: feed => getAddress(feed.event),
|
||||||
|
filters: [{kinds: [FEED]}],
|
||||||
|
eventToItem: readFeed,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getFeedsByAddress = getter(feedsByAddress)
|
||||||
|
|
||||||
|
export const feeds = deriveItems(feedsByAddress)
|
||||||
|
|
||||||
|
export const getFeeds = getter(feeds)
|
||||||
|
|
||||||
|
export const getFeed = (address: string) => getFeedsByAddress().get(address)
|
||||||
|
|
||||||
|
export const fetchFeed = (address: string) => {
|
||||||
|
const {pubkey} = Address.from(address)
|
||||||
|
|
||||||
|
return load({
|
||||||
|
relays: Router.get().FromPubkey(pubkey).getUrls(),
|
||||||
|
filters: getIdFilters([address]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadFeed = makeLoadItem(fetchFeed, getFeed)
|
||||||
|
|
||||||
|
export const deriveFeed = makeDeriveItem(feedsByAddress, loadFeed)
|
||||||
|
|
||||||
|
// Feeds by pubkey
|
||||||
|
|
||||||
|
export const feedsByPubkey = derived(feeds, $feeds => groupBy(f => f.event.pubkey, $feeds))
|
||||||
|
|
||||||
|
export const getFeedsByPubkey = getter(feedsByPubkey)
|
||||||
|
|
||||||
|
export const getFeedsForPubkey = (pubkey: string) => getFeedsByPubkey().get(pubkey)
|
||||||
|
|
||||||
|
export const loadFeedsForPubkey = makeLoadItem(makeOutboxLoader(FEED), getFeedsForPubkey)
|
||||||
|
|
||||||
|
export const userFeeds = makeUserData(feedsByPubkey, loadFeedsForPubkey)
|
||||||
|
|
||||||
|
export const loadUserFeeds = makeUserLoader(loadFeedsForPubkey)
|
||||||
|
|
||||||
|
// Feed favorites
|
||||||
|
|
||||||
|
export const feedFavoritesByPubkey = deriveItemsByKey<PublishedList>({
|
||||||
|
repository,
|
||||||
|
getKey: list => list.event.pubkey,
|
||||||
|
filters: [{kinds: [FEEDS]}],
|
||||||
|
eventToItem: async event =>
|
||||||
|
readList(
|
||||||
|
asDecryptedEvent(event, {
|
||||||
|
content: await ensurePlaintext(event),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getFeedFavoritesByPubkey = getter(feedFavoritesByPubkey)
|
||||||
|
|
||||||
|
export const getFeedFavorites = (pubkey: string) => getFeedFavoritesByPubkey().get(pubkey)
|
||||||
|
|
||||||
|
export const loadFeedFavorites = makeLoadItem(makeOutboxLoader(FEEDS), getFeedFavorites)
|
||||||
|
|
||||||
|
export const userFeedFavorites = makeUserData(feedFavoritesByPubkey, loadFeedFavorites)
|
||||||
|
|
||||||
|
export const loadUserFeedFavorites = makeUserLoader(loadFeedFavorites)
|
||||||
|
|
||||||
|
// Mutes
|
||||||
|
|
||||||
|
export const isEventMuted = withGetter(
|
||||||
|
derived(userMuteList, $userMuteList => {
|
||||||
|
const pubkey = $userMuteList?.event.pubkey
|
||||||
|
const tags = getListTags($userMuteList)
|
||||||
|
const mutedEvents = new Set(getEventTagValues(tags))
|
||||||
|
const mutedPubkeys = new Set(getPubkeyTagValues(tags))
|
||||||
|
const mutedAddresses = new Set(getAddressTagValues(tags))
|
||||||
|
const mutedTopics = new Set(getTagValues("t", tags))
|
||||||
|
const mutedWords = getTagValues("word", tags)
|
||||||
|
const regex =
|
||||||
|
mutedWords.length > 0
|
||||||
|
? new RegExp(`\\b(${mutedWords.map(w => w.toLowerCase().trim()).join("|")})\\b`)
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (e: TrustedEvent) => {
|
||||||
|
if (!pubkey) return false
|
||||||
|
if (pubkey === e.pubkey) return false
|
||||||
|
if (mutedPubkeys.has(e.pubkey)) return true
|
||||||
|
if (mutedEvents.has(e.id)) return true
|
||||||
|
if (mutedAddresses.has(getAddress(e))) return true
|
||||||
|
if (getParentIds(e).some(id => mutedEvents.has(id))) return true
|
||||||
|
if (getParentAddrs(e).some(address => mutedAddresses.has(address))) return true
|
||||||
|
if (getTagValues("t", e.tags).some(t => mutedTopics.has(t))) return true
|
||||||
|
|
||||||
|
if (regex) {
|
||||||
|
if (e.content?.toLowerCase().match(regex)) return true
|
||||||
|
if (displayProfileByPubkey(e.pubkey).toLowerCase().match(regex)) return true
|
||||||
|
if (tryCatch(() => getProfile(e.pubkey)?.nip05?.match(regex))) return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
// Other utils
|
// Other utils
|
||||||
|
|
||||||
export const encodeRelay = (url: string) =>
|
export const encodeRelay = (url: string) =>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
getSpaceUrlsFromGroupList,
|
getSpaceUrlsFromGroupList,
|
||||||
getSpaceRoomsFromGroupList,
|
getSpaceRoomsFromGroupList,
|
||||||
makeCommentFilter,
|
makeCommentFilter,
|
||||||
|
loadFeedsForPubkey,
|
||||||
} from "@app/core/state"
|
} from "@app/core/state"
|
||||||
import {hasBlossomSupport} from "@app/core/commands"
|
import {hasBlossomSupport} from "@app/core/commands"
|
||||||
|
|
||||||
@@ -200,6 +201,7 @@ const syncUserData = () => {
|
|||||||
loadMuteList($userRelayList.event.pubkey)
|
loadMuteList($userRelayList.event.pubkey)
|
||||||
loadProfile($userRelayList.event.pubkey)
|
loadProfile($userRelayList.event.pubkey)
|
||||||
loadSettings($userRelayList.event.pubkey)
|
loadSettings($userRelayList.event.pubkey)
|
||||||
|
loadFeedsForPubkey($userRelayList.event.pubkey)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import {append, identity, uniq} from "@welshman/lib"
|
||||||
|
import {repository} from "@welshman/app"
|
||||||
|
import {displayPubkey, getTagValue} from "@welshman/util"
|
||||||
|
import {PLATFORM_NAME, decodeRelay, getRoom, makeRoomId, splitChatId} from "@app/core/state"
|
||||||
|
|
||||||
|
const FALLBACK_APP_NAME = "Flotilla"
|
||||||
|
|
||||||
|
const staticTitles = new Map<string, string>([
|
||||||
|
["/", "Redirecting"],
|
||||||
|
["/home", "Home"],
|
||||||
|
["/discover", "Discover Spaces"],
|
||||||
|
["/spaces", "Your Spaces"],
|
||||||
|
["/spaces/create", "Create a Space"],
|
||||||
|
["/spaces/[relay]", "Space"],
|
||||||
|
["/spaces/[relay]/chat", "Space Chat"],
|
||||||
|
["/spaces/[relay]/recent", "Recent Activity"],
|
||||||
|
["/spaces/[relay]/threads", "Threads"],
|
||||||
|
["/spaces/[relay]/classifieds", "Classifieds"],
|
||||||
|
["/spaces/[relay]/calendar", "Calendar"],
|
||||||
|
["/spaces/[relay]/goals", "Goals"],
|
||||||
|
["/chat", "Messages"],
|
||||||
|
["/join", "Join Space"],
|
||||||
|
["/people", "Find People"],
|
||||||
|
["/settings/about", "About"],
|
||||||
|
["/settings/profile", "Profile Settings"],
|
||||||
|
["/settings/content", "Content Settings"],
|
||||||
|
["/settings/privacy", "Privacy Settings"],
|
||||||
|
["/settings/relays", "Relay Settings"],
|
||||||
|
["/settings/alerts", "Alert Settings"],
|
||||||
|
["/settings/wallet", "Wallet Settings"],
|
||||||
|
["/[bech32]", "Opening Link"],
|
||||||
|
])
|
||||||
|
|
||||||
|
const eventRoutes = new Set([
|
||||||
|
"/spaces/[relay]/threads/[id]",
|
||||||
|
"/spaces/[relay]/goals/[id]",
|
||||||
|
"/spaces/[relay]/calendar/[address]",
|
||||||
|
"/spaces/[relay]/classifieds/[address]",
|
||||||
|
])
|
||||||
|
|
||||||
|
type RouteParams = Record<string, string | undefined>
|
||||||
|
|
||||||
|
type TitlePage = {
|
||||||
|
route: {id: string | null}
|
||||||
|
params: RouteParams
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageTitleContext = {
|
||||||
|
page: TitlePage
|
||||||
|
pubkey: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRoomTitle = (params: RouteParams) => {
|
||||||
|
const relay = params.relay
|
||||||
|
const h = params.h
|
||||||
|
|
||||||
|
if (!relay || !h) {
|
||||||
|
return "Room"
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = decodeRelay(relay)
|
||||||
|
|
||||||
|
return getRoom(makeRoomId(url, h))?.name || "Room"
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEventForTitle = (routeId: string, params: RouteParams) => {
|
||||||
|
if (!eventRoutes.has(routeId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventId = params.id || params.address
|
||||||
|
|
||||||
|
if (!eventId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return repository.getEvent(eventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getChatTitle = (chatId: string | undefined, pubkey: string | undefined) => {
|
||||||
|
if (!chatId) {
|
||||||
|
return "Chat"
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatPeers = pubkey ? uniq(append(pubkey, splitChatId(chatId))) : splitChatId(chatId)
|
||||||
|
const others = pubkey ? chatPeers.filter(pk => pk !== pubkey) : chatPeers
|
||||||
|
|
||||||
|
if (others.length === 1) {
|
||||||
|
return `Chat with ${displayPubkey(others[0])}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (others.length > 1) {
|
||||||
|
return `Group chat (${others.length})`
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Chat"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeTitle = (...parts: Array<string | undefined>) =>
|
||||||
|
parts
|
||||||
|
.map(part => part?.trim() || "")
|
||||||
|
.filter(identity)
|
||||||
|
.join(" · ") ||
|
||||||
|
PLATFORM_NAME ||
|
||||||
|
FALLBACK_APP_NAME
|
||||||
|
|
||||||
|
export const getPageTitle = ({page, pubkey}: PageTitleContext) => {
|
||||||
|
const routeId = page.route.id || ""
|
||||||
|
const staticTitle = staticTitles.get(routeId)
|
||||||
|
|
||||||
|
if (staticTitle) {
|
||||||
|
return makeTitle(staticTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routeId === "/chat/[chat]") {
|
||||||
|
return makeTitle(getChatTitle(page.params.chat, pubkey))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routeId === "/spaces/[relay]/[h]") {
|
||||||
|
return makeTitle(getRoomTitle(page.params))
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = getEventForTitle(routeId, page.params)
|
||||||
|
|
||||||
|
if (routeId === "/spaces/[relay]/threads/[id]") {
|
||||||
|
return makeTitle(getTagValue("title", event?.tags || []) || "Thread")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routeId === "/spaces/[relay]/calendar/[address]") {
|
||||||
|
return makeTitle(getTagValue("title", event?.tags || []) || "Event")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routeId === "/spaces/[relay]/classifieds/[address]") {
|
||||||
|
return makeTitle(getTagValue("title", event?.tags || []) || "Listing")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routeId === "/spaces/[relay]/goals/[id]") {
|
||||||
|
return makeTitle(event?.content || getTagValue("summary", event?.tags || []) || "Goal")
|
||||||
|
}
|
||||||
|
|
||||||
|
return makeTitle()
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import {fromPairs, parseJson, randomId} from "@welshman/lib"
|
||||||
|
import {FEED, Address} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {
|
||||||
|
makeIntersectionFeed,
|
||||||
|
hasSubFeeds,
|
||||||
|
isTagFeed,
|
||||||
|
isAuthorFeed,
|
||||||
|
isScopeFeed,
|
||||||
|
} from "@welshman/feeds"
|
||||||
|
import type {Feed as IFeed} from "@welshman/feeds"
|
||||||
|
|
||||||
|
export type Feed = {
|
||||||
|
title: string
|
||||||
|
identifier: string
|
||||||
|
description: string
|
||||||
|
definition: IFeed
|
||||||
|
event?: TrustedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PublishedFeed = Omit<Feed, "event"> & {
|
||||||
|
event: TrustedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeFeedDefinition = (feed: IFeed) =>
|
||||||
|
hasSubFeeds(feed) ? feed : makeIntersectionFeed(feed)
|
||||||
|
|
||||||
|
export const makeFeed = (feed: Partial<Feed> = {}): Feed => ({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
identifier: randomId(),
|
||||||
|
definition: makeIntersectionFeed(),
|
||||||
|
...feed,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const readFeed = (event: TrustedEvent) => {
|
||||||
|
const {d: identifier, title = "", description = "", feed = ""} = fromPairs(event.tags)
|
||||||
|
const definition = parseJson(feed) || makeIntersectionFeed()
|
||||||
|
|
||||||
|
return {title, identifier, description, definition, event} as PublishedFeed
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createFeed = ({identifier, definition, title, description}: Feed) => ({
|
||||||
|
kind: FEED,
|
||||||
|
content: "",
|
||||||
|
tags: [
|
||||||
|
["d", identifier],
|
||||||
|
["alt", title],
|
||||||
|
["title", title],
|
||||||
|
["description", description],
|
||||||
|
["feed", JSON.stringify(definition)],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const editFeed = (feed: PublishedFeed) => ({
|
||||||
|
kind: FEED,
|
||||||
|
content: feed.event.content,
|
||||||
|
tags: Object.entries({
|
||||||
|
...fromPairs(feed.event.tags),
|
||||||
|
title: feed.title,
|
||||||
|
alt: feed.title,
|
||||||
|
description: feed.description,
|
||||||
|
feed: JSON.stringify(feed.definition),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const displayFeed = (feed?: Feed) => feed?.title || "[no name]"
|
||||||
|
|
||||||
|
export const isTopicFeed = (f: IFeed) => isTagFeed(f) && f[1] === "#t"
|
||||||
|
|
||||||
|
export const isMentionFeed = (f: IFeed) => isTagFeed(f) && f[1] === "#p"
|
||||||
|
|
||||||
|
export const isAddressFeed = (f: IFeed) => isTagFeed(f) && f[1] === "#a"
|
||||||
|
|
||||||
|
export const isContextFeed = (f: IFeed) =>
|
||||||
|
isTagFeed(f) && f[1] === "#a" && f.slice(2).every(Address.isAddress)
|
||||||
|
|
||||||
|
export const isPeopleFeed = (f: IFeed) => isAuthorFeed(f) || isScopeFeed(f)
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import {sha256} from "@noble/hashes/sha2.js"
|
|
||||||
import {bytesToHex, hexToBytes} from "@welshman/lib"
|
|
||||||
import {decodeInvoice} from "./decoder"
|
|
||||||
import type {InvoiceArgs, SuccessAction} from "./types"
|
|
||||||
|
|
||||||
export class Invoice {
|
|
||||||
paymentRequest: string
|
|
||||||
paymentHash: string
|
|
||||||
preimage: string | undefined
|
|
||||||
verify: string | undefined
|
|
||||||
satoshi: number
|
|
||||||
expiry: number | undefined
|
|
||||||
timestamp: number
|
|
||||||
createdDate: Date
|
|
||||||
expiryDate: Date | undefined
|
|
||||||
description: string | undefined
|
|
||||||
successAction: SuccessAction | undefined
|
|
||||||
|
|
||||||
constructor(args: InvoiceArgs) {
|
|
||||||
this.paymentRequest = args.pr
|
|
||||||
if (!this.paymentRequest) {
|
|
||||||
throw new Error("Invalid payment request")
|
|
||||||
}
|
|
||||||
|
|
||||||
const decoded = decodeInvoice(this.paymentRequest)
|
|
||||||
if (!decoded) {
|
|
||||||
throw new Error("Failed to decode payment request")
|
|
||||||
}
|
|
||||||
|
|
||||||
this.paymentHash = decoded.paymentHash
|
|
||||||
this.satoshi = decoded.satoshi
|
|
||||||
this.timestamp = decoded.timestamp
|
|
||||||
this.expiry = decoded.expiry
|
|
||||||
this.createdDate = new Date(this.timestamp * 1000)
|
|
||||||
this.expiryDate = this.expiry ? new Date((this.timestamp + this.expiry) * 1000) : undefined
|
|
||||||
this.description = decoded.description
|
|
||||||
this.verify = args.verify
|
|
||||||
this.preimage = args.preimage
|
|
||||||
this.successAction = args.successAction
|
|
||||||
}
|
|
||||||
|
|
||||||
async isPaid(): Promise<boolean> {
|
|
||||||
if (this.preimage) return this.validatePreimage(this.preimage)
|
|
||||||
if (this.verify) return this.verifyPayment()
|
|
||||||
throw new Error("Could not verify payment")
|
|
||||||
}
|
|
||||||
|
|
||||||
validatePreimage(preimage: string): boolean {
|
|
||||||
if (!preimage || !this.paymentHash) return false
|
|
||||||
|
|
||||||
try {
|
|
||||||
const preimageHash = bytesToHex(sha256(hexToBytes(preimage)))
|
|
||||||
return this.paymentHash === preimageHash
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async verifyPayment(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
if (!this.verify) {
|
|
||||||
throw new Error("LNURL verify not available")
|
|
||||||
}
|
|
||||||
const response = await fetch(this.verify)
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Verification request failed: ${response.status} ${response.statusText}`)
|
|
||||||
}
|
|
||||||
const json = (await response.json()) as {preimage?: string; settled?: boolean}
|
|
||||||
if (json.preimage) {
|
|
||||||
this.preimage = json.preimage
|
|
||||||
}
|
|
||||||
|
|
||||||
return Boolean(json.settled)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to check LNURL-verify", error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hasExpired() {
|
|
||||||
if (this.expiryDate) {
|
|
||||||
return this.expiryDate.getTime() < Date.now()
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
const DIVISORS = {
|
|
||||||
m: 1_000n,
|
|
||||||
u: 1_000_000n,
|
|
||||||
n: 1_000_000_000n,
|
|
||||||
p: 1_000_000_000_000n,
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_MILLISATS = 2_100_000_000_000_000_000n
|
|
||||||
const MILLISATS_PER_BTC = 100_000_000_000n
|
|
||||||
|
|
||||||
export const hrpToMillisats = (hrpAmount: string) => {
|
|
||||||
if (!hrpAmount) {
|
|
||||||
throw new Error("Missing amount")
|
|
||||||
}
|
|
||||||
|
|
||||||
let divisor: keyof typeof DIVISORS | undefined
|
|
||||||
let value = hrpAmount
|
|
||||||
|
|
||||||
const last = hrpAmount.slice(-1)
|
|
||||||
if (/^[munp]$/.test(last)) {
|
|
||||||
divisor = last as keyof typeof DIVISORS
|
|
||||||
value = hrpAmount.slice(0, -1)
|
|
||||||
} else if (/^[^0-9]$/.test(last)) {
|
|
||||||
throw new Error("Invalid amount multiplier")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/^\d+$/.test(value)) {
|
|
||||||
throw new Error("Invalid amount value")
|
|
||||||
}
|
|
||||||
|
|
||||||
const valueBig = BigInt(value)
|
|
||||||
|
|
||||||
const millisats = divisor
|
|
||||||
? (valueBig * MILLISATS_PER_BTC) / DIVISORS[divisor]
|
|
||||||
: valueBig * MILLISATS_PER_BTC
|
|
||||||
|
|
||||||
if (divisor === "p" && valueBig % 10n !== 0n) {
|
|
||||||
throw new Error("Invalid pico bitcoin amount")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (millisats > MAX_MILLISATS) {
|
|
||||||
throw new Error("Amount is outside of valid range")
|
|
||||||
}
|
|
||||||
|
|
||||||
return millisats
|
|
||||||
}
|
|
||||||
|
|
||||||
export const millisatsToSats = (millisats: bigint) => Number(millisats / 1_000n)
|
|
||||||
|
|
||||||
export const parseHrpAmount = (hrp: string) => {
|
|
||||||
const match = hrp.match(/^lnbc(\d+[munp]?)$/)
|
|
||||||
if (!match) {
|
|
||||||
if (hrp === "lnbc") return undefined
|
|
||||||
throw new Error("Invalid bolt11 prefix")
|
|
||||||
}
|
|
||||||
|
|
||||||
const millisats = hrpToMillisats(match[1])
|
|
||||||
|
|
||||||
return {
|
|
||||||
millisats,
|
|
||||||
satoshi: millisatsToSats(millisats),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
|
||||||
|
|
||||||
const CHARSET_REV = new Map<string, number>(CHARSET.split("").map((char, index) => [char, index]))
|
|
||||||
|
|
||||||
const GENERATORS = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
|
||||||
|
|
||||||
const hrpExpand = (hrp: string) => {
|
|
||||||
const result: number[] = []
|
|
||||||
for (let i = 0; i < hrp.length; i += 1) {
|
|
||||||
const code = hrp.charCodeAt(i)
|
|
||||||
result.push(code >> 5)
|
|
||||||
}
|
|
||||||
result.push(0)
|
|
||||||
for (let i = 0; i < hrp.length; i += 1) {
|
|
||||||
const code = hrp.charCodeAt(i)
|
|
||||||
result.push(code & 31)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
const polymod = (values: number[]) => {
|
|
||||||
let chk = 1
|
|
||||||
for (const value of values) {
|
|
||||||
const top = chk >> 25
|
|
||||||
chk = ((chk & 0x1ffffff) << 5) ^ value
|
|
||||||
for (let i = 0; i < GENERATORS.length; i += 1) {
|
|
||||||
if (((top >> i) & 1) !== 0) {
|
|
||||||
chk ^= GENERATORS[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return chk
|
|
||||||
}
|
|
||||||
|
|
||||||
const verifyChecksum = (hrp: string, data: number[]) => polymod([...hrpExpand(hrp), ...data]) === 1
|
|
||||||
|
|
||||||
export const bech32Decode = (input: string) => {
|
|
||||||
if (input.length < 8) {
|
|
||||||
throw new Error("Invalid bech32 length")
|
|
||||||
}
|
|
||||||
|
|
||||||
const lower = input.toLowerCase()
|
|
||||||
const upper = input.toUpperCase()
|
|
||||||
if (input !== lower && input !== upper) {
|
|
||||||
throw new Error("Invalid bech32 casing")
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = lower
|
|
||||||
const separator = normalized.lastIndexOf("1")
|
|
||||||
if (separator < 1 || separator + 7 > normalized.length) {
|
|
||||||
throw new Error("Invalid bech32 separator")
|
|
||||||
}
|
|
||||||
|
|
||||||
const hrp = normalized.slice(0, separator)
|
|
||||||
const dataPart = normalized.slice(separator + 1)
|
|
||||||
const data: number[] = []
|
|
||||||
|
|
||||||
for (const char of dataPart) {
|
|
||||||
const value = CHARSET_REV.get(char)
|
|
||||||
if (value === undefined) {
|
|
||||||
throw new Error("Invalid bech32 character")
|
|
||||||
}
|
|
||||||
data.push(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!verifyChecksum(hrp, data)) {
|
|
||||||
throw new Error("Invalid bech32 checksum")
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
hrp,
|
|
||||||
words: data.slice(0, -6),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const convertBits = (
|
|
||||||
data: number[] | Uint8Array,
|
|
||||||
fromBits: number,
|
|
||||||
toBits: number,
|
|
||||||
pad: boolean,
|
|
||||||
) => {
|
|
||||||
let acc = 0
|
|
||||||
let bits = 0
|
|
||||||
const result: number[] = []
|
|
||||||
const maxValue = (1 << toBits) - 1
|
|
||||||
|
|
||||||
for (const value of data) {
|
|
||||||
if (value < 0 || value >> fromBits !== 0) {
|
|
||||||
throw new Error("Invalid value for conversion")
|
|
||||||
}
|
|
||||||
acc = (acc << fromBits) | value
|
|
||||||
bits += fromBits
|
|
||||||
while (bits >= toBits) {
|
|
||||||
bits -= toBits
|
|
||||||
result.push((acc >> bits) & maxValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pad) {
|
|
||||||
if (bits > 0) {
|
|
||||||
result.push((acc << (toBits - bits)) & maxValue)
|
|
||||||
}
|
|
||||||
} else if (bits >= fromBits || ((acc << (toBits - bits)) & maxValue) !== 0) {
|
|
||||||
throw new Error("Invalid padding")
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import {bytesToHex, textDecoder} from "@welshman/lib"
|
|
||||||
import {bech32Decode, convertBits} from "./bech32"
|
|
||||||
import {parseHrpAmount} from "./amount"
|
|
||||||
|
|
||||||
const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
|
||||||
const SIGNATURE_WORDS = 104
|
|
||||||
|
|
||||||
export type DecodedInvoice = {
|
|
||||||
paymentHash: string
|
|
||||||
satoshi: number
|
|
||||||
timestamp: number
|
|
||||||
expiry: number | undefined
|
|
||||||
description: string | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
type TagResult = {
|
|
||||||
paymentHash?: string
|
|
||||||
description?: string
|
|
||||||
expiry?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const wordsToNumber = (words: number[]) => words.reduce((acc, word) => acc * 32 + word, 0)
|
|
||||||
|
|
||||||
const decodeTagData = (tag: string, words: number[]) => {
|
|
||||||
if (tag === "p") {
|
|
||||||
const bytes = Uint8Array.from(convertBits(words, 5, 8, false))
|
|
||||||
return {paymentHash: bytesToHex(bytes)}
|
|
||||||
}
|
|
||||||
if (tag === "d") {
|
|
||||||
const bytes = Uint8Array.from(convertBits(words, 5, 8, false))
|
|
||||||
return {description: textDecoder.decode(bytes)}
|
|
||||||
}
|
|
||||||
if (tag === "x") {
|
|
||||||
return {expiry: wordsToNumber(words)}
|
|
||||||
}
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const decodeTags = (words: number[]) => {
|
|
||||||
const dataEnd = words.length - SIGNATURE_WORDS
|
|
||||||
if (dataEnd < 7) {
|
|
||||||
throw new Error("Invalid bolt11 data length")
|
|
||||||
}
|
|
||||||
|
|
||||||
const timestamp = wordsToNumber(words.slice(0, 7))
|
|
||||||
const tagResult: TagResult = {}
|
|
||||||
|
|
||||||
let index = 7
|
|
||||||
while (index < dataEnd) {
|
|
||||||
const tagValue = words[index]
|
|
||||||
const tag = CHARSET[tagValue]
|
|
||||||
index += 1
|
|
||||||
|
|
||||||
if (!tag) {
|
|
||||||
throw new Error("Invalid bolt11 tag")
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataLength = (words[index] << 5) + words[index + 1]
|
|
||||||
index += 2
|
|
||||||
|
|
||||||
const tagWords = words.slice(index, index + dataLength)
|
|
||||||
index += dataLength
|
|
||||||
|
|
||||||
Object.assign(tagResult, decodeTagData(tag, tagWords))
|
|
||||||
}
|
|
||||||
|
|
||||||
return {timestamp, tagResult}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const decodeInvoice = (invoice: string): DecodedInvoice | null => {
|
|
||||||
if (!invoice) return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const {hrp, words} = bech32Decode(invoice)
|
|
||||||
const amount = parseHrpAmount(hrp)
|
|
||||||
const {timestamp, tagResult} = decodeTags(words)
|
|
||||||
|
|
||||||
if (!tagResult.paymentHash) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
paymentHash: tagResult.paymentHash,
|
|
||||||
satoshi: amount?.satoshi ?? 0,
|
|
||||||
timestamp,
|
|
||||||
expiry: tagResult.expiry,
|
|
||||||
description: tagResult.description,
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export {Invoice} from "./Invoice"
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
export type InvoiceArgs = {
|
|
||||||
pr: string
|
|
||||||
verify?: string
|
|
||||||
preimage?: string
|
|
||||||
successAction?: SuccessAction
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SuccessAction =
|
|
||||||
| {
|
|
||||||
tag: "message"
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
tag: "url"
|
|
||||||
description: string
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export * as bolt11 from "./bolt11"
|
|
||||||
export * as nwc from "./nwc"
|
|
||||||
export * as wallet from "./wallet"
|
|
||||||
@@ -1,380 +0,0 @@
|
|||||||
import {uniq} from "@welshman/lib"
|
|
||||||
import {PublishStatus, publish, request} from "@welshman/net"
|
|
||||||
import {Nip01Signer, decrypt} from "@welshman/signer"
|
|
||||||
import {
|
|
||||||
getPubkey,
|
|
||||||
makeEvent,
|
|
||||||
normalizeRelayUrl,
|
|
||||||
type SignedEvent,
|
|
||||||
type TrustedEvent,
|
|
||||||
} from "@welshman/util"
|
|
||||||
import type {
|
|
||||||
Nip47EncryptionType,
|
|
||||||
Nip47GetBalanceResponse,
|
|
||||||
Nip47GetInfoResponse,
|
|
||||||
Nip47MakeInvoiceRequest,
|
|
||||||
Nip47PayInvoiceRequest,
|
|
||||||
Nip47PayResponse,
|
|
||||||
Nip47TimeoutValues,
|
|
||||||
Nip47Transaction,
|
|
||||||
Nip47Method,
|
|
||||||
} from "./types"
|
|
||||||
import {
|
|
||||||
Nip47NetworkError,
|
|
||||||
Nip47PublishError,
|
|
||||||
Nip47PublishTimeoutError,
|
|
||||||
Nip47ReplyTimeoutError,
|
|
||||||
Nip47ResponseDecodingError,
|
|
||||||
Nip47ResponseValidationError,
|
|
||||||
Nip47UnsupportedEncryptionError,
|
|
||||||
Nip47WalletError,
|
|
||||||
} from "./types"
|
|
||||||
|
|
||||||
const NIP47_INFO_KIND = 13194
|
|
||||||
const NIP47_REQUEST_KIND = 23194
|
|
||||||
const NIP47_RESPONSE_KIND = 23195
|
|
||||||
const NIP47_VERSION = "1.0"
|
|
||||||
const OUTBOUND_ENCRYPTION: Nip47EncryptionType = "nip44_v2"
|
|
||||||
|
|
||||||
const hexKeyPattern = /^[0-9a-f]{64}$/i
|
|
||||||
|
|
||||||
const normalizeHexKey = (key: string, label: string) => {
|
|
||||||
const normalized = key.trim()
|
|
||||||
|
|
||||||
if (!hexKeyPattern.test(normalized)) {
|
|
||||||
throw new Error(`Invalid ${label}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized.toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
type Nip47Response<T> = {
|
|
||||||
result?: T
|
|
||||||
error?: {
|
|
||||||
message?: string
|
|
||||||
code?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const withTimeout = async <T>(timeoutMs: number, f: (signal: AbortSignal) => Promise<T>) => {
|
|
||||||
const controller = new AbortController()
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await f(controller.signal)
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type NWCOptions = {
|
|
||||||
relayUrls: string[]
|
|
||||||
relayUrl: string
|
|
||||||
walletPubkey: string
|
|
||||||
secret: string
|
|
||||||
lud16?: string
|
|
||||||
nostrWalletConnectUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type NewNWCClientOptions = {
|
|
||||||
relayUrls?: string[]
|
|
||||||
relayUrl?: string
|
|
||||||
secret?: string
|
|
||||||
walletPubkey?: string
|
|
||||||
nostrWalletConnectUrl?: string
|
|
||||||
lud16?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseWalletConnectUrl = (walletConnectUrl: string): NewNWCClientOptions => {
|
|
||||||
const normalized = walletConnectUrl
|
|
||||||
.replace("nostrwalletconnect://", "http://")
|
|
||||||
.replace("nostr+walletconnect://", "http://")
|
|
||||||
.replace("nostrwalletconnect:", "http://")
|
|
||||||
.replace("nostr+walletconnect:", "http://")
|
|
||||||
|
|
||||||
const url = new URL(normalized)
|
|
||||||
const relayParams = url.searchParams.getAll("relay")
|
|
||||||
|
|
||||||
if (!relayParams.length) {
|
|
||||||
throw new Error("No relay URL found in connection string")
|
|
||||||
}
|
|
||||||
|
|
||||||
const options: NewNWCClientOptions = {
|
|
||||||
walletPubkey: url.host,
|
|
||||||
relayUrls: relayParams,
|
|
||||||
relayUrl: relayParams[0],
|
|
||||||
nostrWalletConnectUrl: walletConnectUrl,
|
|
||||||
}
|
|
||||||
|
|
||||||
const secret = url.searchParams.get("secret")
|
|
||||||
if (secret) {
|
|
||||||
options.secret = secret
|
|
||||||
}
|
|
||||||
|
|
||||||
const lud16 = url.searchParams.get("lud16")
|
|
||||||
if (lud16) {
|
|
||||||
options.lud16 = lud16
|
|
||||||
}
|
|
||||||
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
export class NWCClient {
|
|
||||||
signer: Nip01Signer
|
|
||||||
relayUrls: string[]
|
|
||||||
walletPubkey: string
|
|
||||||
secret: string
|
|
||||||
lud16: string | undefined
|
|
||||||
options: NWCOptions
|
|
||||||
private encryptionReady = false
|
|
||||||
|
|
||||||
constructor(options: NewNWCClientOptions = {}) {
|
|
||||||
if (options.nostrWalletConnectUrl) {
|
|
||||||
options = {
|
|
||||||
...parseWalletConnectUrl(options.nostrWalletConnectUrl),
|
|
||||||
...options,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const relayUrls = options.relayUrls || (options.relayUrl ? [options.relayUrl] : [])
|
|
||||||
|
|
||||||
if (!relayUrls.length) {
|
|
||||||
throw new Error("Missing relay url")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!options.walletPubkey) {
|
|
||||||
throw new Error("Missing wallet pubkey")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!options.secret) {
|
|
||||||
throw new Error("Missing secret key")
|
|
||||||
}
|
|
||||||
|
|
||||||
this.relayUrls = uniq(relayUrls.map(normalizeRelayUrl))
|
|
||||||
this.secret = normalizeHexKey(options.secret, "secret key")
|
|
||||||
this.lud16 = options.lud16
|
|
||||||
this.walletPubkey = normalizeHexKey(options.walletPubkey, "wallet pubkey")
|
|
||||||
this.signer = Nip01Signer.fromSecret(this.secret)
|
|
||||||
|
|
||||||
const nostrWalletConnectUrl =
|
|
||||||
options.nostrWalletConnectUrl || this.buildNostrWalletConnectUrl(true)
|
|
||||||
|
|
||||||
this.options = {
|
|
||||||
relayUrls: this.relayUrls,
|
|
||||||
relayUrl: this.relayUrls[0],
|
|
||||||
walletPubkey: this.walletPubkey,
|
|
||||||
secret: this.secret,
|
|
||||||
lud16: this.lud16,
|
|
||||||
nostrWalletConnectUrl,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getInfo(): Promise<Nip47GetInfoResponse> {
|
|
||||||
return await this.executeNip47Request<Nip47GetInfoResponse>(
|
|
||||||
"get_info",
|
|
||||||
{},
|
|
||||||
response => Array.isArray(response.methods),
|
|
||||||
{replyTimeout: 10000},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBalance(): Promise<Nip47GetBalanceResponse> {
|
|
||||||
return await this.executeNip47Request<Nip47GetBalanceResponse>(
|
|
||||||
"get_balance",
|
|
||||||
{},
|
|
||||||
response => typeof response.balance === "number",
|
|
||||||
{replyTimeout: 10000},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async payInvoice(request: Nip47PayInvoiceRequest): Promise<Nip47PayResponse> {
|
|
||||||
return await this.executeNip47Request<Nip47PayResponse>(
|
|
||||||
"pay_invoice",
|
|
||||||
request,
|
|
||||||
response => response !== undefined,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async makeInvoice(request: Nip47MakeInvoiceRequest): Promise<Nip47Transaction> {
|
|
||||||
if (!request.amount) {
|
|
||||||
throw new Error("No amount specified")
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.executeNip47Request<Nip47Transaction>("make_invoice", request, response =>
|
|
||||||
Boolean(response.invoice),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
// No-op. The welshman network layer manages relay socket lifecycle.
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildNostrWalletConnectUrl(includeSecret = true) {
|
|
||||||
let url = `nostr+walletconnect://${this.walletPubkey}?relay=${this.relayUrls.join(
|
|
||||||
"&relay=",
|
|
||||||
)}&pubkey=${this.publicKey}`
|
|
||||||
|
|
||||||
if (includeSecret) {
|
|
||||||
url = `${url}&secret=${this.secret}`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.lud16) {
|
|
||||||
url = `${url}&lud16=${this.lud16}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
private get publicKey() {
|
|
||||||
return getPubkey(this.secret)
|
|
||||||
}
|
|
||||||
|
|
||||||
private async executeNip47Request<T>(
|
|
||||||
nip47Method: Nip47Method,
|
|
||||||
params: unknown,
|
|
||||||
resultValidator: (result: T) => boolean,
|
|
||||||
timeoutValues?: Nip47TimeoutValues,
|
|
||||||
): Promise<T> {
|
|
||||||
await this.assertWalletSupportsNip44()
|
|
||||||
|
|
||||||
const command = {method: nip47Method, params}
|
|
||||||
const encryptedCommand = await this.signer.nip44.encrypt(
|
|
||||||
this.walletPubkey,
|
|
||||||
JSON.stringify(command),
|
|
||||||
)
|
|
||||||
const template = makeEvent(NIP47_REQUEST_KIND, {
|
|
||||||
tags: [
|
|
||||||
["p", this.walletPubkey],
|
|
||||||
["v", NIP47_VERSION],
|
|
||||||
["encryption", OUTBOUND_ENCRYPTION],
|
|
||||||
],
|
|
||||||
content: encryptedCommand,
|
|
||||||
})
|
|
||||||
const event = await this.signer.sign(template)
|
|
||||||
const replyTimeout = timeoutValues?.replyTimeout || 60_000
|
|
||||||
const publishTimeout = timeoutValues?.publishTimeout || 5_000
|
|
||||||
const responseListener = this.listenForResponse(event.id, replyTimeout)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.publishRequest(event, publishTimeout)
|
|
||||||
} catch (error) {
|
|
||||||
responseListener.cancel()
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseEvent = await responseListener.promise
|
|
||||||
let response: Nip47Response<T>
|
|
||||||
|
|
||||||
try {
|
|
||||||
const decryptedContent = await decrypt(this.signer, this.walletPubkey, responseEvent.content)
|
|
||||||
response = JSON.parse(decryptedContent) as Nip47Response<T>
|
|
||||||
} catch {
|
|
||||||
throw new Nip47ResponseDecodingError("failed to deserialize response", "INTERNAL")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.result) {
|
|
||||||
if (resultValidator(response.result)) {
|
|
||||||
return response.result
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Nip47ResponseValidationError(
|
|
||||||
"response from NWC failed validation: " + JSON.stringify(response.result),
|
|
||||||
"INTERNAL",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Nip47WalletError(
|
|
||||||
response.error?.message || "unknown Error",
|
|
||||||
response.error?.code || "INTERNAL",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private listenForResponse(eventId: string, timeoutMs: number) {
|
|
||||||
const controller = new AbortController()
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
|
||||||
let responseEvent: TrustedEvent | undefined
|
|
||||||
|
|
||||||
const promise = request({
|
|
||||||
relays: this.relayUrls,
|
|
||||||
filters: [{kinds: [NIP47_RESPONSE_KIND], authors: [this.walletPubkey], "#e": [eventId]}],
|
|
||||||
signal: controller.signal,
|
|
||||||
onEvent: (event: TrustedEvent) => {
|
|
||||||
if (!responseEvent) {
|
|
||||||
responseEvent = event
|
|
||||||
controller.abort()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
if (!responseEvent) {
|
|
||||||
throw new Nip47ReplyTimeoutError(`reply timeout: event ${eventId}`, "INTERNAL")
|
|
||||||
}
|
|
||||||
|
|
||||||
return responseEvent
|
|
||||||
})
|
|
||||||
.finally(() => clearTimeout(timeoutId))
|
|
||||||
|
|
||||||
return {
|
|
||||||
promise,
|
|
||||||
cancel: () => controller.abort(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async publishRequest(event: SignedEvent, timeout: number) {
|
|
||||||
const resultsByRelay = await publish({
|
|
||||||
relays: this.relayUrls,
|
|
||||||
event,
|
|
||||||
timeout,
|
|
||||||
})
|
|
||||||
const results = Object.values(resultsByRelay)
|
|
||||||
|
|
||||||
if (results.some(result => result.status === PublishStatus.Success)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (results.every(result => result.status === PublishStatus.Timeout)) {
|
|
||||||
throw new Nip47PublishTimeoutError(`publish timeout: ${event.id}`, "INTERNAL")
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Nip47PublishError(
|
|
||||||
"failed to publish: " + results.map(result => `${result.relay}:${result.status}`).join(", "),
|
|
||||||
"INTERNAL",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private async assertWalletSupportsNip44() {
|
|
||||||
if (this.encryptionReady) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const infoEvents = await withTimeout(5_000, signal =>
|
|
||||||
request({
|
|
||||||
relays: this.relayUrls,
|
|
||||||
filters: [{kinds: [NIP47_INFO_KIND], authors: [this.walletPubkey], limit: 1}],
|
|
||||||
signal,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const info = infoEvents[0]
|
|
||||||
|
|
||||||
if (!info) {
|
|
||||||
throw new Nip47NetworkError("no info event (kind 13194) returned from relay", "OTHER")
|
|
||||||
}
|
|
||||||
|
|
||||||
const encryptionTag = info.tags.find(tag => tag[0] === "encryption")
|
|
||||||
const supportedEncryptions = (encryptionTag?.[1] || "").split(" ").filter(Boolean)
|
|
||||||
const isNip44Supported =
|
|
||||||
supportedEncryptions.length > 0
|
|
||||||
? supportedEncryptions.includes(OUTBOUND_ENCRYPTION)
|
|
||||||
: info.tags.some(tag => tag[0] === "v" && tag[1]?.includes(NIP47_VERSION))
|
|
||||||
|
|
||||||
if (!isNip44Supported) {
|
|
||||||
throw new Nip47UnsupportedEncryptionError(
|
|
||||||
"wallet does not support required nip44_v2 encryption",
|
|
||||||
"UNSUPPORTED_ENCRYPTION",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.encryptionReady = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export {NWCClient} from "./NWCClient"
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
export type Nip47EncryptionType = "nip44_v2"
|
|
||||||
|
|
||||||
export type Nip47Method =
|
|
||||||
| "get_info"
|
|
||||||
| "get_balance"
|
|
||||||
| "make_invoice"
|
|
||||||
| "pay_invoice"
|
|
||||||
| (string & {})
|
|
||||||
|
|
||||||
export type Nip47GetInfoResponse = {
|
|
||||||
alias?: string
|
|
||||||
color?: string
|
|
||||||
pubkey?: string
|
|
||||||
network?: string
|
|
||||||
block_height?: number
|
|
||||||
block_hash?: string
|
|
||||||
methods?: Nip47Method[]
|
|
||||||
notifications?: string[]
|
|
||||||
metadata?: unknown
|
|
||||||
lud16?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Nip47GetBalanceResponse = {
|
|
||||||
balance: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Nip47PayInvoiceRequest = {
|
|
||||||
invoice: string
|
|
||||||
amount?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Nip47PayResponse = {
|
|
||||||
preimage?: string
|
|
||||||
fees_paid?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Nip47MakeInvoiceRequest = {
|
|
||||||
amount: number
|
|
||||||
description?: string
|
|
||||||
description_hash?: string
|
|
||||||
expiry?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Nip47Transaction = {
|
|
||||||
invoice: string
|
|
||||||
amount?: number
|
|
||||||
description?: string
|
|
||||||
payment_hash?: string
|
|
||||||
preimage?: string
|
|
||||||
created_at?: number
|
|
||||||
expires_at?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Nip47TimeoutValues = {
|
|
||||||
replyTimeout?: number
|
|
||||||
publishTimeout?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Nip47Error extends Error {
|
|
||||||
code: string
|
|
||||||
constructor(message: string, code: string) {
|
|
||||||
super(message)
|
|
||||||
this.code = code
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Nip47NetworkError extends Nip47Error {}
|
|
||||||
|
|
||||||
export class Nip47WalletError extends Nip47Error {}
|
|
||||||
|
|
||||||
export class Nip47TimeoutError extends Nip47Error {}
|
|
||||||
export class Nip47PublishTimeoutError extends Nip47TimeoutError {}
|
|
||||||
export class Nip47ReplyTimeoutError extends Nip47TimeoutError {}
|
|
||||||
export class Nip47PublishError extends Nip47Error {}
|
|
||||||
export class Nip47ResponseDecodingError extends Nip47Error {}
|
|
||||||
export class Nip47ResponseValidationError extends Nip47Error {}
|
|
||||||
export class Nip47UnsupportedEncryptionError extends Nip47Error {}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
export type WalletPayInvoiceParams = {
|
|
||||||
invoice: string
|
|
||||||
msats?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WalletCreateInvoiceParams = {
|
|
||||||
sats: number
|
|
||||||
description?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IWallet {
|
|
||||||
getBalanceSats: () => Promise<number>
|
|
||||||
payInvoice: (params: WalletPayInvoiceParams) => Promise<unknown>
|
|
||||||
createInvoice: (params: WalletCreateInvoiceParams) => Promise<string>
|
|
||||||
close: () => void
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import {fromMsats} from "@welshman/util"
|
|
||||||
import type {NWCInfo} from "@welshman/util"
|
|
||||||
import {NWCClient} from "../nwc"
|
|
||||||
import type {IWallet, WalletCreateInvoiceParams, WalletPayInvoiceParams} from "./IWallet"
|
|
||||||
|
|
||||||
export class NWCWallet implements IWallet {
|
|
||||||
readonly client: NWCClient
|
|
||||||
|
|
||||||
constructor(info: NWCInfo) {
|
|
||||||
this.client = info.nostrWalletConnectUrl
|
|
||||||
? new NWCClient({nostrWalletConnectUrl: info.nostrWalletConnectUrl})
|
|
||||||
: new NWCClient(info)
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBalanceSats() {
|
|
||||||
const response = await this.client.getBalance()
|
|
||||||
|
|
||||||
return fromMsats(response.balance || 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
payInvoice({invoice, msats}: WalletPayInvoiceParams) {
|
|
||||||
const params: {invoice: string; amount?: number} = {invoice}
|
|
||||||
|
|
||||||
if (msats) {
|
|
||||||
params.amount = msats
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.client.payInvoice(params)
|
|
||||||
}
|
|
||||||
|
|
||||||
async createInvoice({
|
|
||||||
sats,
|
|
||||||
description = "Receive via lightning",
|
|
||||||
}: WalletCreateInvoiceParams): Promise<string> {
|
|
||||||
const response = await this.client.makeInvoice({
|
|
||||||
amount: sats * 1000,
|
|
||||||
description,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.invoice) {
|
|
||||||
throw new Error("NWC wallet failed to return an invoice")
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.invoice
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.client.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import type {WebLNInfo} from "@welshman/util"
|
|
||||||
import type {IWallet, WalletCreateInvoiceParams, WalletPayInvoiceParams} from "./IWallet"
|
|
||||||
|
|
||||||
export type WebLNBalanceResponse = {
|
|
||||||
balance?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WebLNInvoiceResponse =
|
|
||||||
| string
|
|
||||||
| {
|
|
||||||
paymentRequest?: string
|
|
||||||
pr?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WebLNProvider = {
|
|
||||||
enable: () => Promise<void>
|
|
||||||
getInfo?: () => Promise<WebLNInfo>
|
|
||||||
getBalance?: () => Promise<WebLNBalanceResponse>
|
|
||||||
sendPayment?: (invoice: string) => Promise<unknown>
|
|
||||||
makeInvoice?: (args: {amount: number; defaultMemo?: string}) => Promise<WebLNInvoiceResponse>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getWebLn = () => (window as unknown as {webln?: WebLNProvider}).webln
|
|
||||||
|
|
||||||
export class WebLNWallet implements IWallet {
|
|
||||||
constructor(private provider: WebLNProvider | undefined) {}
|
|
||||||
|
|
||||||
async getBalanceSats() {
|
|
||||||
const provider = this.requireProvider()
|
|
||||||
|
|
||||||
if (!provider.getBalance) {
|
|
||||||
throw new Error("WebLN wallet does not support balance checks")
|
|
||||||
}
|
|
||||||
|
|
||||||
await provider.enable()
|
|
||||||
|
|
||||||
const response = await provider.getBalance()
|
|
||||||
|
|
||||||
return Math.floor(response.balance || 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
async payInvoice({invoice, msats}: WalletPayInvoiceParams) {
|
|
||||||
const provider = this.requireProvider()
|
|
||||||
|
|
||||||
if (msats) {
|
|
||||||
throw new Error("Unable to pay zero invoices with webln")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!provider.sendPayment) {
|
|
||||||
throw new Error("WebLN wallet does not support sending payments")
|
|
||||||
}
|
|
||||||
|
|
||||||
await provider.enable()
|
|
||||||
|
|
||||||
return provider.sendPayment(invoice)
|
|
||||||
}
|
|
||||||
|
|
||||||
async createInvoice({
|
|
||||||
sats,
|
|
||||||
description = "Receive via lightning",
|
|
||||||
}: WalletCreateInvoiceParams): Promise<string> {
|
|
||||||
const provider = this.requireProvider()
|
|
||||||
|
|
||||||
if (!provider.makeInvoice) {
|
|
||||||
throw new Error("WebLN wallet does not support creating invoices")
|
|
||||||
}
|
|
||||||
|
|
||||||
await provider.enable()
|
|
||||||
|
|
||||||
const response = await provider.makeInvoice({
|
|
||||||
amount: sats,
|
|
||||||
defaultMemo: description,
|
|
||||||
})
|
|
||||||
|
|
||||||
const paymentRequest =
|
|
||||||
typeof response === "string" ? response : response.paymentRequest || response.pr || ""
|
|
||||||
|
|
||||||
if (!paymentRequest) {
|
|
||||||
throw new Error("Invalid payment request returned from WebLN")
|
|
||||||
}
|
|
||||||
|
|
||||||
return paymentRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
// WebLN providers are managed by the browser extension.
|
|
||||||
}
|
|
||||||
|
|
||||||
private requireProvider() {
|
|
||||||
if (!this.provider) {
|
|
||||||
throw new Error("WebLN not available")
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.provider
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import {isNWCWallet, isWebLNWallet, type Wallet} from "@welshman/util"
|
|
||||||
import {NWCWallet} from "./NWCWallet"
|
|
||||||
import {WebLNWallet, getWebLn} from "./WebLNWallet"
|
|
||||||
import type {IWallet} from "./IWallet"
|
|
||||||
|
|
||||||
export const createWalletAdapter = (wallet: Wallet): IWallet => {
|
|
||||||
if (isNWCWallet(wallet)) {
|
|
||||||
return new NWCWallet(wallet.info)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isWebLNWallet(wallet)) {
|
|
||||||
return new WebLNWallet(getWebLn())
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Unsupported wallet type")
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export {createWalletAdapter} from "./factory"
|
|
||||||
export type {IWallet, WalletCreateInvoiceParams, WalletPayInvoiceParams} from "./IWallet"
|
|
||||||
export {NWCWallet} from "./NWCWallet"
|
|
||||||
export {WebLNWallet, getWebLn} from "./WebLNWallet"
|
|
||||||
export type {WebLNProvider} from "./WebLNWallet"
|
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
import {App, type URLOpenListenerEvent} from "@capacitor/app"
|
import {App, type URLOpenListenerEvent} from "@capacitor/app"
|
||||||
import {dev} from "$app/environment"
|
import {dev} from "$app/environment"
|
||||||
import {goto} from "$app/navigation"
|
import {goto} from "$app/navigation"
|
||||||
|
import {page} from "$app/stores"
|
||||||
import {sync, throttled} from "@welshman/store"
|
import {sync, throttled} from "@welshman/store"
|
||||||
import {call} from "@welshman/lib"
|
import {call} from "@welshman/lib"
|
||||||
import {defaultSocketPolicies} from "@welshman/net"
|
import {defaultSocketPolicies} from "@welshman/net"
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
import * as notifications from "@app/util/notifications"
|
import * as notifications from "@app/util/notifications"
|
||||||
import * as storage from "@app/util/storage"
|
import * as storage from "@app/util/storage"
|
||||||
import {syncKeyboard} from "@app/util/keyboard"
|
import {syncKeyboard} from "@app/util/keyboard"
|
||||||
|
import {getPageTitle} from "@app/util/title"
|
||||||
import NewNotificationSound from "@src/app/components/NewNotificationSound.svelte"
|
import NewNotificationSound from "@src/app/components/NewNotificationSound.svelte"
|
||||||
|
|
||||||
const {children} = $props()
|
const {children} = $props()
|
||||||
@@ -199,6 +201,10 @@
|
|||||||
App.removeAllListeners()
|
App.removeAllListeners()
|
||||||
unsubscribe.then(call)
|
unsubscribe.then(call)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
document.title = getPageTitle({page: $page, pubkey: $pubkey})
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
+66
-193
@@ -1,204 +1,77 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {derived, writable} from "svelte/store"
|
import {goto} from "$app/navigation"
|
||||||
import {batch, call, sortBy, uniqBy} from "@welshman/lib"
|
import {shouldUnwrap} from "@welshman/app"
|
||||||
import {
|
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||||
NOTE,
|
import Compass from "@assets/icons/compass.svg?dataurl"
|
||||||
MESSAGE,
|
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
||||||
THREAD,
|
|
||||||
CLASSIFIED,
|
|
||||||
ZAP_GOAL,
|
|
||||||
EVENT_TIME,
|
|
||||||
COMMENT,
|
|
||||||
getTagValue,
|
|
||||||
getTagValues,
|
|
||||||
getIdAndAddress,
|
|
||||||
} from "@welshman/util"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
|
||||||
import {
|
|
||||||
makeKindFeed,
|
|
||||||
makeRelayFeed,
|
|
||||||
makeScopeFeed,
|
|
||||||
makeIntersectionFeed,
|
|
||||||
makeUnionFeed,
|
|
||||||
Scope,
|
|
||||||
} from "@welshman/feeds"
|
|
||||||
import {repository, tracker, makeFeedController, loadUserFollowList} from "@welshman/app"
|
|
||||||
import History from "@assets/icons/history.svg?dataurl"
|
|
||||||
import {createScroller} from "@lib/html"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import PageBar from "@lib/components/PageBar.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import PageContent from "@lib/components/PageContent.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import NoteItem from "@app/components/NoteItem.svelte"
|
import CardButton from "@lib/components/CardButton.svelte"
|
||||||
import ThreadItem from "@app/components/ThreadItem.svelte"
|
import SpaceAdd from "@app/components/SpaceAdd.svelte"
|
||||||
import ClassifiedItem from "@app/components/ClassifiedItem.svelte"
|
import ChatEnable from "@app/components/ChatEnable.svelte"
|
||||||
import GoalItem from "@app/components/GoalItem.svelte"
|
import {pushModal} from "@app/util/modal"
|
||||||
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
|
import {goToSpace} from "@app/util/routes"
|
||||||
import RecentConversation from "@app/components/RecentConversation.svelte"
|
import {PLATFORM_NAME, PLATFORM_RELAYS} from "@app/core/state"
|
||||||
import {makeRoomId, userSpaceUrls, loadUserGroupList, CONTENT_KINDS} from "@app/core/state"
|
|
||||||
|
|
||||||
type Activity = {
|
const addSpace = () => pushModal(SpaceAdd)
|
||||||
type: "message" | "content"
|
|
||||||
event: TrustedEvent
|
|
||||||
timestamp: number
|
|
||||||
count: number
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = new AbortController()
|
const openChat = () => ($shouldUnwrap ? goto("/chat") : pushModal(ChatEnable, {next: "/chat"}))
|
||||||
const events = writable<TrustedEvent[]>([])
|
|
||||||
const limit = writable(0)
|
|
||||||
|
|
||||||
const recentActivity = derived([events, limit], ([$events, $limit]) => {
|
onMount(async () => {
|
||||||
const activity: Activity[] = []
|
if (PLATFORM_RELAYS.length > 0) {
|
||||||
const activityByRoom = new Map<string, Activity>()
|
goToSpace(PLATFORM_RELAYS[0])
|
||||||
const latestActivityByKey = new Map<string, number>()
|
|
||||||
|
|
||||||
for (const event of $events.slice(0, $limit)) {
|
|
||||||
if (event.kind === MESSAGE) {
|
|
||||||
const h = getTagValue("h", event.tags)
|
|
||||||
|
|
||||||
if (!h) continue
|
|
||||||
|
|
||||||
for (const url of tracker.getRelays(event.id)) {
|
|
||||||
const id = makeRoomId(url, h)
|
|
||||||
|
|
||||||
const item = activityByRoom.get(id)
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
activityByRoom.set(id, {
|
|
||||||
type: "message",
|
|
||||||
event,
|
|
||||||
timestamp: event.created_at,
|
|
||||||
count: 1,
|
|
||||||
url,
|
|
||||||
})
|
|
||||||
} else if (item.timestamp < event.created_at) {
|
|
||||||
item.count++
|
|
||||||
item.event = event
|
|
||||||
item.timestamp = event.created_at
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (event.kind === COMMENT) {
|
|
||||||
for (const k of getTagValues(["E", "A"], event.tags)) {
|
|
||||||
latestActivityByKey.set(k, Math.max(latestActivityByKey.get(k) || 0, event.created_at))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (const k of getIdAndAddress(event)) {
|
|
||||||
latestActivityByKey.set(k, Math.max(latestActivityByKey.get(k) || 0, event.created_at))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const item of activityByRoom.values()) {
|
|
||||||
activity.push(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [address, timestamp] of latestActivityByKey.entries()) {
|
|
||||||
const event = repository.getEvent(address)
|
|
||||||
|
|
||||||
if (event) {
|
|
||||||
for (const url of tracker.getRelays(event.id)) {
|
|
||||||
activity.push({type: "content", event, timestamp, url, count: 1})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sortBy(
|
|
||||||
a => -a.timestamp,
|
|
||||||
uniqBy(a => a.event.id, activity),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
let loading = $state(true)
|
|
||||||
let element: Element | undefined = $state()
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const promise = call(async () => {
|
|
||||||
await Promise.all([loadUserGroupList(), loadUserFollowList()])
|
|
||||||
|
|
||||||
const ctrl = makeFeedController({
|
|
||||||
useWindowing: true,
|
|
||||||
signal: controller.signal,
|
|
||||||
feed: makeUnionFeed(
|
|
||||||
makeIntersectionFeed(
|
|
||||||
makeRelayFeed(...$userSpaceUrls),
|
|
||||||
makeKindFeed(COMMENT, ...CONTENT_KINDS),
|
|
||||||
),
|
|
||||||
makeIntersectionFeed(makeScopeFeed(Scope.Follows), makeKindFeed(NOTE)),
|
|
||||||
),
|
|
||||||
onEvent: batch(100, (evts: TrustedEvent[]) => {
|
|
||||||
events.update($events => [...$events, ...evts])
|
|
||||||
}),
|
|
||||||
onExhausted: () => {
|
|
||||||
loading = false
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const scroller = createScroller({
|
|
||||||
element: element!,
|
|
||||||
delay: 800,
|
|
||||||
threshold: 3000,
|
|
||||||
onScroll: async () => {
|
|
||||||
console.log("scroll")
|
|
||||||
limit.update($limit => {
|
|
||||||
if ($events.length - $limit < 50) {
|
|
||||||
ctrl.load(50)
|
|
||||||
}
|
|
||||||
|
|
||||||
return $limit + 10
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
scroller.stop()
|
|
||||||
controller.abort()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => promise.then(call)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageBar>
|
<div class="hero min-h-screen overflow-auto pb-8">
|
||||||
{#snippet icon()}
|
<div class="hero-content">
|
||||||
<div class="center">
|
<div class="column content gap-4">
|
||||||
<Icon icon={History} />
|
<h1 class="text-center text-5xl">Welcome to</h1>
|
||||||
</div>
|
<h1 class="mb-4 text-center text-5xl font-bold uppercase">{PLATFORM_NAME}</h1>
|
||||||
{/snippet}
|
<div class="col-3">
|
||||||
{#snippet title()}
|
<Button onclick={addSpace}>
|
||||||
<strong>Recent Activity</strong>
|
<CardButton class="btn-neutral">
|
||||||
{/snippet}
|
{#snippet icon()}
|
||||||
{#snippet action()}
|
<Icon icon={AddCircle} size={7} />
|
||||||
<div class="row-2"></div>
|
{/snippet}
|
||||||
{/snippet}
|
{#snippet title()}
|
||||||
</PageBar>
|
<div>Add a space</div>
|
||||||
|
{/snippet}
|
||||||
<PageContent class="flex flex-col gap-2 p-2 pt-4" bind:element>
|
{#snippet info()}
|
||||||
{#each $recentActivity as { type, event, url, count } (event.id)}
|
<div>Use an invite link, or create your own space.</div>
|
||||||
{#if type === "message"}
|
{/snippet}
|
||||||
<RecentConversation {url} {event} {count} />
|
</CardButton>
|
||||||
{:else if event.kind === THREAD}
|
</Button>
|
||||||
<ThreadItem {url} {event} />
|
<Link href="/discover">
|
||||||
{:else if event.kind === CLASSIFIED}
|
<CardButton class="btn-neutral">
|
||||||
<ClassifiedItem {url} {event} />
|
{#snippet icon()}
|
||||||
{:else if event.kind === ZAP_GOAL}
|
<Icon icon={Compass} size={7} />
|
||||||
<GoalItem {url} {event} />
|
{/snippet}
|
||||||
{:else if event.kind === EVENT_TIME}
|
{#snippet title()}
|
||||||
<CalendarEventItem {url} {event} />
|
<div>Browse the network</div>
|
||||||
{:else}
|
{/snippet}
|
||||||
<NoteItem {url} {event} />
|
{#snippet info()}
|
||||||
{/if}
|
<div>Find communities on the nostr network.</div>
|
||||||
{:else}
|
{/snippet}
|
||||||
{#if loading}
|
</CardButton>
|
||||||
<div class="flex justify-center items-center py-20">
|
</Link>
|
||||||
<span class="loading loading-spinner mr-3"></span>
|
<Button onclick={openChat}>
|
||||||
Loading recent activity...
|
<CardButton class="btn-neutral">
|
||||||
|
{#snippet icon()}
|
||||||
|
<Icon icon={ChatRound} size={7} />
|
||||||
|
{/snippet}
|
||||||
|
{#snippet title()}
|
||||||
|
<div>Start a conversation</div>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet info()}
|
||||||
|
<div>Use nostr's encrypted group chats to stay in touch.</div>
|
||||||
|
{/snippet}
|
||||||
|
</CardButton>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
</div>
|
||||||
<p class="flex flex-col items-center py-20 text-center">No recent activity found!</p>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
{/each}
|
|
||||||
</PageContent>
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {nwc} from "@getalby/sdk"
|
||||||
import {LOCALE} from "@welshman/lib"
|
import {LOCALE} from "@welshman/lib"
|
||||||
import {displayRelayUrl, isNWCWallet} from "@welshman/util"
|
import {displayRelayUrl, isNWCWallet, fromMsats} from "@welshman/util"
|
||||||
import {session, pubkey, profilesByPubkey} from "@welshman/app"
|
import {session, pubkey, profilesByPubkey} from "@welshman/app"
|
||||||
import DownloadMinimalistic from "@assets/icons/download-minimalistic.svg?dataurl"
|
import DownloadMinimalistic from "@assets/icons/download-minimalistic.svg?dataurl"
|
||||||
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
|
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
|
||||||
@@ -12,7 +13,7 @@
|
|||||||
import WalletDisconnect from "@app/components/WalletDisconnect.svelte"
|
import WalletDisconnect from "@app/components/WalletDisconnect.svelte"
|
||||||
import WalletUpdateReceivingAddress from "@app/components/WalletUpdateReceivingAddress.svelte"
|
import WalletUpdateReceivingAddress from "@app/components/WalletUpdateReceivingAddress.svelte"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
import {getWalletBalance} from "@app/core/commands"
|
import {getWebLn} from "@app/core/commands"
|
||||||
import Wallet2 from "@assets/icons/wallet.svg?dataurl"
|
import Wallet2 from "@assets/icons/wallet.svg?dataurl"
|
||||||
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
||||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||||
@@ -71,10 +72,12 @@
|
|||||||
</p>
|
</p>
|
||||||
<p class="flex gap-2 whitespace-nowrap">
|
<p class="flex gap-2 whitespace-nowrap">
|
||||||
Balance:
|
Balance:
|
||||||
{#await getWalletBalance()}
|
{#await getWebLn()
|
||||||
|
?.enable()
|
||||||
|
.then(() => getWebLn().getBalance())}
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
{:then balance}
|
{:then res}
|
||||||
{new Intl.NumberFormat(LOCALE).format(balance)}
|
{new Intl.NumberFormat(LOCALE).format(res?.balance || 0)}
|
||||||
{:catch}
|
{:catch}
|
||||||
[unknown]
|
[unknown]
|
||||||
{/await}
|
{/await}
|
||||||
@@ -82,17 +85,17 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if $session.wallet.type === "nwc"}
|
{:else if $session.wallet.type === "nwc"}
|
||||||
{@const {lud16, relayUrl} = $session.wallet.info}
|
{@const {lud16, relayUrl, nostrWalletConnectUrl} = $session.wallet.info}
|
||||||
<div class="flex flex-col justify-between gap-2 lg:flex-row">
|
<div class="flex flex-col justify-between gap-2 lg:flex-row">
|
||||||
<p>
|
<p>
|
||||||
Connected to <strong>{lud16}</strong> via <strong>{displayRelayUrl(relayUrl)}</strong>
|
Connected to <strong>{lud16}</strong> via <strong>{displayRelayUrl(relayUrl)}</strong>
|
||||||
</p>
|
</p>
|
||||||
<p class="flex gap-2 whitespace-nowrap">
|
<p class="flex gap-2 whitespace-nowrap">
|
||||||
Balance:
|
Balance:
|
||||||
{#await getWalletBalance()}
|
{#await new nwc.NWCClient({nostrWalletConnectUrl}).getBalance()}
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
{:then balance}
|
{:then res}
|
||||||
{new Intl.NumberFormat(LOCALE).format(balance)}
|
{new Intl.NumberFormat(LOCALE).format(fromMsats(res?.balance || 0))}
|
||||||
{:catch}
|
{:catch}
|
||||||
[unknown]
|
[unknown]
|
||||||
{/await}
|
{/await}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {insertAt, removeAt} from "@welshman/lib"
|
||||||
import SettingsMinimalistic from "@assets/icons/settings-minimalistic.svg?dataurl"
|
import SettingsMinimalistic from "@assets/icons/settings-minimalistic.svg?dataurl"
|
||||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
@@ -9,9 +10,88 @@
|
|||||||
import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte"
|
import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte"
|
||||||
import SpaceAdd from "@app/components/SpaceAdd.svelte"
|
import SpaceAdd from "@app/components/SpaceAdd.svelte"
|
||||||
import {userSpaceUrls, loadUserGroupList, PLATFORM_RELAYS} from "@app/core/state"
|
import {userSpaceUrls, loadUserGroupList, PLATFORM_RELAYS} from "@app/core/state"
|
||||||
|
import {setSpaceMembershipOrder} from "@app/core/commands"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
|
|
||||||
const addSpace = () => pushModal(SpaceAdd)
|
const addSpace = () => pushModal(SpaceAdd)
|
||||||
|
|
||||||
|
const reconcileUrls = (currentUrls: string[], nextUrls: string[]) => {
|
||||||
|
const mergedUrls = currentUrls.filter(url => nextUrls.includes(url))
|
||||||
|
|
||||||
|
for (const url of nextUrls) {
|
||||||
|
if (!mergedUrls.includes(url)) {
|
||||||
|
mergedUrls.push(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergedUrls
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSameOrder = (a: string[], b: string[]) =>
|
||||||
|
a.length === b.length && a.every((url, index) => url === b[index])
|
||||||
|
|
||||||
|
const reorderSpaceUrls = (targetUrl: string) => {
|
||||||
|
if (!draggedUrl) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceIndex = orderedSpaceUrls.indexOf(draggedUrl)
|
||||||
|
const targetIndex = orderedSpaceUrls.indexOf(targetUrl)
|
||||||
|
|
||||||
|
if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
orderedSpaceUrls = insertAt(
|
||||||
|
targetIndex,
|
||||||
|
orderedSpaceUrls[sourceIndex],
|
||||||
|
removeAt(sourceIndex, orderedSpaceUrls),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragStart = (e: DragEvent, url: string) => {
|
||||||
|
draggedUrl = url
|
||||||
|
dragStartOrder = [...orderedSpaceUrls]
|
||||||
|
|
||||||
|
if (e.dataTransfer) {
|
||||||
|
e.dataTransfer.effectAllowed = "move"
|
||||||
|
e.dataTransfer.setData("text/plain", url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragOver = (e: DragEvent, targetUrl: string) => {
|
||||||
|
e.preventDefault()
|
||||||
|
reorderSpaceUrls(targetUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDrop = (e: DragEvent, targetUrl: string) => {
|
||||||
|
e.preventDefault()
|
||||||
|
reorderSpaceUrls(targetUrl)
|
||||||
|
draggedUrl = undefined
|
||||||
|
|
||||||
|
if (dragStartOrder && !isSameOrder(dragStartOrder, orderedSpaceUrls)) {
|
||||||
|
void setSpaceMembershipOrder(orderedSpaceUrls).catch(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
dragStartOrder = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragEnd = () => {
|
||||||
|
draggedUrl = undefined
|
||||||
|
dragStartOrder = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const nextUrls = reconcileUrls(orderedSpaceUrls, $userSpaceUrls)
|
||||||
|
|
||||||
|
if (!isSameOrder(nextUrls, orderedSpaceUrls)) {
|
||||||
|
orderedSpaceUrls = nextUrls
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let orderedSpaceUrls = $state<string[]>([])
|
||||||
|
let draggedUrl = $state<string | undefined>()
|
||||||
|
let dragStartOrder = $state<string[] | undefined>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Page class="cw-full">
|
<Page class="cw-full">
|
||||||
@@ -43,8 +123,17 @@
|
|||||||
Loading your spaces...
|
Loading your spaces...
|
||||||
</div>
|
</div>
|
||||||
{:then}
|
{:then}
|
||||||
{#each $userSpaceUrls as url (url)}
|
{#each orderedSpaceUrls as url (url)}
|
||||||
<MenuSpacesItem {url} />
|
<div
|
||||||
|
class:opacity-60={draggedUrl === url}
|
||||||
|
draggable="true"
|
||||||
|
role="listitem"
|
||||||
|
ondragstart={e => onDragStart(e, url)}
|
||||||
|
ondragover={e => onDragOver(e, url)}
|
||||||
|
ondrop={e => onDrop(e, url)}
|
||||||
|
ondragend={onDragEnd}>
|
||||||
|
<MenuSpacesItem {url} />
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-col gap-8 items-center py-20">
|
<div class="flex flex-col gap-8 items-center py-20">
|
||||||
<p>You haven't added any spaces yet!</p>
|
<p>You haven't added any spaces yet!</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user