forked from coracle/flotilla
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
5a69d305af
|
|||
|
ce62cafd59
|
|||
| 6e865fef06 | |||
| 588bd0f341 | |||
| 69f6abf4b6 | |||
| c8eb4ac31a | |||
| e3e69390ce | |||
| d0b34dfdf8 | |||
| bcdb3dc351 | |||
| a7b0031b8d | |||
| 2c05bc6961 | |||
| c2d0ec92bf | |||
| 407b4dce94 | |||
| 796157384f | |||
| 3446977df6 | |||
| f8016aba99 | |||
| 56d8527ed9 | |||
| 302788bcba | |||
| db075e602a | |||
| 67011d4740 | |||
| a35d867b34 | |||
| 23b59e54d7 | |||
| da2665d2bc |
@@ -157,7 +157,7 @@ src/
|
||||
- Derive all other data inside the component from identifiers
|
||||
- Example: Don't pass `members` prop, derive it from `h` inside component
|
||||
|
||||
**Code Style:**
|
||||
**CRITICAL Code Style Guidelines:**
|
||||
|
||||
- **No `null`** - only use `undefined`
|
||||
- Svelte 5 runes (`$state`, `$derived`, `$effect`) only in UI components
|
||||
@@ -168,6 +168,7 @@ src/
|
||||
- When dynamically building classes, use `cx` from `classnames` rather than embedded ternaries or svelte 4's old `class:` syntax.
|
||||
- When creating forms, use `FieldInline` or `Field` instead of custom elements/tailwindcss
|
||||
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates
|
||||
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
|
||||
|
||||
## Common Tasks
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ If you're deploying a custom version of flotilla, be sure to remove the `plausib
|
||||
|
||||
## Development
|
||||
|
||||
See [./CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
See [CONTRIBUTING.md](AGENTS.md).
|
||||
|
||||
## Deployment
|
||||
|
||||
|
||||
@@ -358,7 +358,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 30;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEVELOPMENT_TEAM = S26U9DYW3A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||
@@ -385,7 +385,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 30;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEVELOPMENT_TEAM = S26U9DYW3A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||
|
||||
+2
-3
@@ -49,9 +49,8 @@
|
||||
"@capacitor/push-notifications": "^8.0.0",
|
||||
"@capawesome/capacitor-android-dark-mode-support": "^8.0.0",
|
||||
"@capawesome/capacitor-badge": "^8.0.0",
|
||||
"@getalby/lightning-tools": "^6.1.0",
|
||||
"@getalby/sdk": "^5.1.2",
|
||||
"@noble/curves": "^1.9.7",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@pomade/core": "^0.0.12",
|
||||
"@poppanator/sveltekit-svg": "^4.2.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
@@ -70,7 +69,7 @@
|
||||
"@welshman/signer": "^0.8.4",
|
||||
"@welshman/store": "^0.8.4",
|
||||
"@welshman/util": "^0.8.4",
|
||||
"compressorjs": "^1.2.1",
|
||||
"compressorjs-next": "^1.1.2",
|
||||
"daisyui": "^4.12.24",
|
||||
"date-picker-svelte": "^2.17.0",
|
||||
"dotenv": "^16.6.1",
|
||||
|
||||
Generated
+9
-69
@@ -47,15 +47,12 @@ importers:
|
||||
'@capawesome/capacitor-badge':
|
||||
specifier: ^8.0.0
|
||||
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':
|
||||
specifier: ^1.9.7
|
||||
version: 1.9.7
|
||||
'@noble/hashes':
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
'@pomade/core':
|
||||
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))
|
||||
@@ -110,9 +107,9 @@ importers:
|
||||
'@welshman/util':
|
||||
specifier: ^0.8.4
|
||||
version: 0.8.4(@noble/curves@1.9.7)(@welshman/lib@0.8.4)(nostr-tools@2.20.0(typescript@5.9.3))
|
||||
compressorjs:
|
||||
specifier: ^1.2.1
|
||||
version: 1.2.1
|
||||
compressorjs-next:
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2
|
||||
daisyui:
|
||||
specifier: ^4.12.24
|
||||
version: 4.12.24(postcss@8.5.6)
|
||||
@@ -999,18 +996,6 @@ packages:
|
||||
'@frostr/bifrost@1.0.7':
|
||||
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':
|
||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@@ -2048,9 +2033,6 @@ packages:
|
||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
blueimp-canvas-to-blob@3.29.0:
|
||||
resolution: {integrity: sha512-0pcSSGxC0QxT+yVkivxIqW0Y4VlO2XSDPofBAqoJ1qJxgH9eiUDLv50Rixij2cDuEfx4M6DpD9UGZpRhT5Q8qg==}
|
||||
|
||||
boolbase@1.0.0:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
|
||||
@@ -2211,8 +2193,8 @@ packages:
|
||||
compare-func@2.0.0:
|
||||
resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==}
|
||||
|
||||
compressorjs@1.2.1:
|
||||
resolution: {integrity: sha512-+geIjeRnPhQ+LLvvA7wxBQE5ddeLU7pJ3FsKFWirDw6veY3s9iLxAQEw7lXGHnhCJvBujEQWuNnGzZcvCvdkLQ==}
|
||||
compressorjs-next@1.1.2:
|
||||
resolution: {integrity: sha512-5nwrVCR3+kSd4cwIzQEB72W4d+uHQ9so8U2C+WBr74DFoG34FM9CXoNZMsCnCTUDhmDKJ/3aI4Di1+QKF8LFow==}
|
||||
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
@@ -3001,10 +2983,6 @@ packages:
|
||||
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-blob@2.1.0:
|
||||
resolution: {integrity: sha512-SZ/fTft5eUhQM6oF/ZaASFDEdbFVe89Imltn9uZr03wdKMcWNVYSMjQPFtg05QuNkt5l5c135ElvXEQG0rk4tw==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
is-boolean-object@1.2.2:
|
||||
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3513,14 +3491,6 @@ packages:
|
||||
peerDependencies:
|
||||
'@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:
|
||||
resolution: {integrity: sha512-Kq/2lMyeOdGvpDsYH2an8HP4H0aFCqwKythhTzxfgZTVv4L3NOgrJw2SxH8jkWlH8xPhWxGfN6lFtC+EAa2qYQ==}
|
||||
peerDependencies:
|
||||
@@ -5800,17 +5770,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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/node@0.16.7':
|
||||
@@ -6909,8 +6868,6 @@ snapshots:
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
|
||||
blueimp-canvas-to-blob@3.29.0: {}
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
|
||||
bplist-creator@0.1.0:
|
||||
@@ -7073,10 +7030,7 @@ snapshots:
|
||||
array-ify: 1.0.0
|
||||
dot-prop: 5.3.0
|
||||
|
||||
compressorjs@1.2.1:
|
||||
dependencies:
|
||||
blueimp-canvas-to-blob: 3.29.0
|
||||
is-blob: 2.1.0
|
||||
compressorjs-next@1.1.2: {}
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
@@ -8004,8 +7958,6 @@ snapshots:
|
||||
dependencies:
|
||||
binary-extensions: 2.3.0
|
||||
|
||||
is-blob@2.1.0: {}
|
||||
|
||||
is-boolean-object@1.2.2:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
@@ -8461,18 +8413,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@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):
|
||||
dependencies:
|
||||
'@noble/ciphers': 0.5.3
|
||||
|
||||
+5
-1
@@ -402,6 +402,10 @@ progress[value]::-webkit-progress-value {
|
||||
@apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)];
|
||||
}
|
||||
|
||||
.ct {
|
||||
@apply top-[calc(var(--sait)+5rem)] md:top-[calc(var(--sait)+3rem)];
|
||||
}
|
||||
|
||||
/* Keyboard open state adjustments */
|
||||
|
||||
body.keyboard-open .cb {
|
||||
@@ -419,5 +423,5 @@ body.keyboard-open .hide-on-keyboard {
|
||||
}
|
||||
|
||||
.chat__scroll-down {
|
||||
@apply fixed bottom-28 right-4 z-feature md:bottom-16;
|
||||
@apply pb-sai fixed bottom-28 right-4 z-feature md:bottom-16;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import {call, ellipsize, displayUrl, postJson} from "@welshman/lib"
|
||||
import {isRelayUrl} from "@welshman/util"
|
||||
import {isRelayUrl, getTagValue} from "@welshman/util"
|
||||
import {preventDefault, stopPropagation} from "@lib/html"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
|
||||
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {dufflepud, PLATFORM_URL} from "@app/core/state"
|
||||
import {dufflepud, PLATFORM_URL, IMAGE_CONTENT_TYPES, VIDEO_CONTENT_TYPES} from "@app/core/state"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
|
||||
const {value, event} = $props()
|
||||
@@ -14,6 +14,7 @@
|
||||
let hideImage = $state(false)
|
||||
|
||||
const url = value.url.toString()
|
||||
const fileType = getTagValue("file-type", event.tags) || ""
|
||||
const [href, external] = call(() => {
|
||||
if (isRelayUrl(url)) return [makeSpacePath(url), false]
|
||||
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
|
||||
@@ -40,11 +41,11 @@
|
||||
|
||||
<Link {external} {href} class="my-2 block">
|
||||
<div class="overflow-hidden rounded-box">
|
||||
{#if url.match(/\.(mov|webm|mp4)$/)}
|
||||
{#if url.match(/\.(mov|webm|mp4)$/) || VIDEO_CONTENT_TYPES.includes(fileType)}
|
||||
<video controls src={url} class="max-h-96 rounded-box object-contain object-center">
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
{:else if url.match(/\.(jpe?g|png|gif|webp)$/)}
|
||||
{:else if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)}
|
||||
<button type="button" onclick={stopPropagation(preventDefault(expand))}>
|
||||
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-box" />
|
||||
</button>
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
<script lang="ts">
|
||||
import {call, displayUrl} from "@welshman/lib"
|
||||
import {isRelayUrl} from "@welshman/util"
|
||||
import {isRelayUrl, getTagValue} from "@welshman/util"
|
||||
import {preventDefault, stopPropagation} from "@lib/html"
|
||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {PLATFORM_URL} from "@app/core/state"
|
||||
import {PLATFORM_URL, IMAGE_CONTENT_TYPES} from "@app/core/state"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
|
||||
const {value, event} = $props()
|
||||
|
||||
const url = value.url.toString()
|
||||
const fileType = getTagValue("file-type", event.tags) || ""
|
||||
const [href, external] = call(() => {
|
||||
if (isRelayUrl(url)) return [makeSpacePath(url), false]
|
||||
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
|
||||
@@ -23,7 +24,7 @@
|
||||
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
|
||||
</script>
|
||||
|
||||
{#if url.match(/\.(jpe?g|png|gif|webp)$/)}
|
||||
{#if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)}
|
||||
<!-- Use a real link so people can copy the href -->
|
||||
<a
|
||||
href={url}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {Capacitor} from "@capacitor/core"
|
||||
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
|
||||
import Server from "@assets/icons/server.svg?dataurl"
|
||||
import Moon from "@assets/icons/moon.svg?dataurl"
|
||||
@@ -52,19 +53,21 @@
|
||||
{/snippet}
|
||||
</CardButton>
|
||||
</Link>
|
||||
<Link replaceState href="/settings/wallet">
|
||||
<CardButton class="btn-neutral">
|
||||
{#snippet icon()}
|
||||
<div><Icon icon={Wallet} size={7} /></div>
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<div>Wallet</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<div>Connect a bitcoin wallet for sending social tips</div>
|
||||
{/snippet}
|
||||
</CardButton>
|
||||
</Link>
|
||||
{#if Capacitor.getPlatform() !== "ios"}
|
||||
<Link replaceState href="/settings/wallet">
|
||||
<CardButton class="btn-neutral">
|
||||
{#snippet icon()}
|
||||
<div><Icon icon={Wallet} size={7} /></div>
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<div>Wallet</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<div>Connect a bitcoin wallet for sending social tips</div>
|
||||
{/snippet}
|
||||
</CardButton>
|
||||
</Link>
|
||||
{/if}
|
||||
<Link replaceState href="/settings/relays">
|
||||
<CardButton class="btn-neutral">
|
||||
{#snippet icon()}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type {Snippet} from "svelte"
|
||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {Router} from "@welshman/router"
|
||||
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
||||
@@ -11,26 +12,28 @@
|
||||
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
children?: Snippet
|
||||
url?: string
|
||||
}
|
||||
|
||||
const {url, event, children}: Props = $props()
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
const relays = url ? [url] : Router.get().Event(event).getUrls()
|
||||
|
||||
const shouldProtect = url ? canEnforceNip70(url) : Promise.resolve(false)
|
||||
|
||||
const deleteReaction = async (event: TrustedEvent) =>
|
||||
publishDelete({relays: [url], event, protect: await shouldProtect})
|
||||
publishDelete({relays, event, protect: await shouldProtect})
|
||||
|
||||
const createReaction = async (template: EventContent) =>
|
||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||
publishReaction({...template, event, relays, protect: await shouldProtect})
|
||||
|
||||
const onEmoji = async (emoji: NativeEmoji) =>
|
||||
publishReaction({
|
||||
event,
|
||||
relays,
|
||||
content: emoji.unicode,
|
||||
relays: [url],
|
||||
protect: await shouldProtect,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import Letter from "@assets/icons/letter-opened.svg?dataurl"
|
||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
||||
import Restart from "@assets/icons/restart.svg?dataurl"
|
||||
import {fly} from "@lib/transition"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||
@@ -30,7 +31,7 @@
|
||||
import EventInfo from "@app/components/EventInfo.svelte"
|
||||
import ProfileBadges from "@app/components/ProfileBadges.svelte"
|
||||
import ChatEnable from "@app/components/ChatEnable.svelte"
|
||||
import {pubkeyLink, deriveUserIsSpaceAdmin} from "@app/core/state"
|
||||
import {pubkeyLink, deriveUserIsSpaceAdmin, deriveSpaceBannedPubkeyItems} from "@app/core/state"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {makeChatPath} from "@app/util/routes"
|
||||
@@ -46,6 +47,10 @@
|
||||
|
||||
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
||||
|
||||
const bannedPubkeys = url ? deriveSpaceBannedPubkeyItems(url) : undefined
|
||||
|
||||
const isBanned = $derived($bannedPubkeys?.some(item => item.pubkey === pubkey) ?? false)
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const chatPath = makeChatPath([pubkey])
|
||||
@@ -81,6 +86,20 @@
|
||||
},
|
||||
})
|
||||
|
||||
const restoreMember = async () => {
|
||||
const {error} = await manageRelay(url!, {
|
||||
method: ManagementMethod.AllowPubkey,
|
||||
params: [pubkey],
|
||||
})
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
} else {
|
||||
pushToast({message: "User has successfully been restored!"})
|
||||
back()
|
||||
}
|
||||
}
|
||||
|
||||
let showMenu = $state(false)
|
||||
|
||||
onMount(() => {
|
||||
@@ -112,12 +131,21 @@
|
||||
</li>
|
||||
{/if}
|
||||
{#if $userIsAdmin}
|
||||
<li>
|
||||
<Button class="text-error" onclick={banMember}>
|
||||
<Icon icon={MinusCircle} />
|
||||
Ban User
|
||||
</Button>
|
||||
</li>
|
||||
{#if isBanned}
|
||||
<li>
|
||||
<Button onclick={restoreMember}>
|
||||
<Icon icon={Restart} />
|
||||
Restore User
|
||||
</Button>
|
||||
</li>
|
||||
{:else}
|
||||
<li>
|
||||
<Button class="text-error" onclick={banMember}>
|
||||
<Icon icon={MinusCircle} />
|
||||
Ban User
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
{/if}
|
||||
</ul>
|
||||
</Popover>
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
data-tip={tooltip}
|
||||
class={cx(
|
||||
reactionClass,
|
||||
"flex-inline btn btn-outline btn-neutral btn-xs flex items-center gap-1 rounded-full text-xs font-normal",
|
||||
"flex-inline btn btn-outline btn-neutral btn-xs flex items-center gap-1 rounded-full text-xs font-normal bg-alt",
|
||||
{
|
||||
tooltip: !noTooltip && !isMobile,
|
||||
"border-neutral-content/20": !isOwn,
|
||||
@@ -162,7 +162,7 @@
|
||||
data-tip={tooltip}
|
||||
class={cx(
|
||||
reactionClass,
|
||||
"flex-inline btn btn-outline btn-neutral btn-xs gap-1 rounded-full font-normal",
|
||||
"flex-inline btn btn-outline btn-neutral btn-xs gap-1 rounded-full font-normal bg-alt",
|
||||
{
|
||||
tooltip: !noTooltip && !isMobile,
|
||||
"border-neutral-content/20": !isOwn,
|
||||
|
||||
@@ -40,9 +40,7 @@
|
||||
<ModalSubtitle>All reports for this event are shown below.</ModalSubtitle>
|
||||
</ModalHeader>
|
||||
{#each $reports.values() as report (report.id)}
|
||||
<div class="card2 card2-sm bg-alt">
|
||||
<ReportItem {url} event={report} {onDelete} />
|
||||
</div>
|
||||
<ReportItem {url} event={report} {onDelete} />
|
||||
{/each}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -3,14 +3,12 @@
|
||||
import {getTag, getIdFilters} from "@welshman/util"
|
||||
import {load, LOCAL_RELAY_URL} from "@welshman/net"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {pubkey} from "@welshman/app"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Profile from "@app/components/Profile.svelte"
|
||||
import ProfileName from "@app/components/ProfileName.svelte"
|
||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||
import NoteContent from "@app/components/NoteContent.svelte"
|
||||
import ReportMenu from "@app/components/ReportMenu.svelte"
|
||||
import {publishDelete, canEnforceNip70} from "@app/core/commands"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {goToEvent} from "@app/util/routes"
|
||||
|
||||
@@ -25,7 +23,6 @@
|
||||
const etag = getTag("e", event.tags)
|
||||
const ptag = getTag("p", event.tags)
|
||||
const reason = etag?.[2] || ptag?.[2]
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const onClick = (e: Event, event: TrustedEvent) => {
|
||||
// @ts-ignore
|
||||
@@ -35,17 +32,12 @@
|
||||
goToEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteReport = async () => {
|
||||
publishDelete({event, relays: [url], protect: await shouldProtect})
|
||||
onDelete?.()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="column gap-4">
|
||||
<div class="column gap-4 card2 card2-sm bg-alt">
|
||||
<div class="flex justify-between">
|
||||
<div>
|
||||
<Profile pubkey={event.pubkey} {url} avatarSize={5} />
|
||||
<ProfileName pubkey={event.pubkey} {url} />
|
||||
<span>
|
||||
Reported this event
|
||||
{#if reason}
|
||||
@@ -53,11 +45,7 @@
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if event.pubkey === $pubkey}
|
||||
<Button class="btn-default btn" onclick={deleteReport}>Delete Report</Button>
|
||||
{:else}
|
||||
<ReportMenu {url} {event} />
|
||||
{/if}
|
||||
<ReportMenu {url} {event} {onDelete} />
|
||||
</div>
|
||||
{#if event.content}
|
||||
<div class="border-l-2 border-primary pl-3">
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
<script lang="ts">
|
||||
import {getTag, ManagementMethod} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {manageRelay, repository, displayProfileByPubkey} from "@welshman/app"
|
||||
import {pubkey, manageRelay, repository, displayProfileByPubkey} from "@welshman/app"
|
||||
import InboxOut from "@assets/icons/inbox-out.svg?dataurl"
|
||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
||||
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||
import Close from "@assets/icons/close.svg?dataurl"
|
||||
import {fly} from "@lib/transition"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Popover from "@lib/components/Popover.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Confirm from "@lib/components/Confirm.svelte"
|
||||
import {deriveUserIsSpaceAdmin} from "@app/core/state"
|
||||
import {publishDelete, canEnforceNip70} from "@app/core/commands"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
onDelete?: () => void
|
||||
}
|
||||
|
||||
const {url, event}: Props = $props()
|
||||
const {url, event, onDelete}: Props = $props()
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
||||
const etag = getTag("e", event.tags)
|
||||
const ptag = getTag("p", event.tags)
|
||||
|
||||
@@ -32,6 +38,11 @@
|
||||
isOpen = false
|
||||
}
|
||||
|
||||
const deleteReport = async () => {
|
||||
publishDelete({event, relays: [url], protect: await shouldProtect})
|
||||
onDelete?.()
|
||||
}
|
||||
|
||||
const dismissReport = async () => {
|
||||
const {error} = await manageRelay(url, {
|
||||
method: ManagementMethod.BanEvent,
|
||||
@@ -43,7 +54,7 @@
|
||||
} else {
|
||||
pushToast({message: "Content has successfully been deleted!"})
|
||||
repository.removeEvent(event.id)
|
||||
history.back()
|
||||
onDelete?.()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +62,7 @@
|
||||
const [_, id, reason = ""] = etag!
|
||||
|
||||
pushModal(Confirm, {
|
||||
title: `Delete Content`,
|
||||
title: `Remove Content`,
|
||||
message: `Are you sure you want to delete this content from the space?`,
|
||||
confirm: async () => {
|
||||
const {error} = await manageRelay(url, {
|
||||
@@ -63,15 +74,17 @@
|
||||
pushToast({theme: "error", message: error})
|
||||
} else {
|
||||
pushToast({message: "Content has successfully been deleted!"})
|
||||
repository.removeEvent(event.id)
|
||||
repository.removeEvent(id)
|
||||
history.back()
|
||||
setTimeout(() => onDelete?.())
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const banMember = () => {
|
||||
const [pubkey, reason = ""] = ptag!
|
||||
const [_, pubkey, reason = ""] = ptag!
|
||||
|
||||
pushModal(Confirm, {
|
||||
title: "Ban User",
|
||||
@@ -86,7 +99,9 @@
|
||||
pushToast({theme: "error", message: error})
|
||||
} else {
|
||||
pushToast({message: "User has successfully been banned!"})
|
||||
repository.removeEvent(event.id)
|
||||
history.back()
|
||||
setTimeout(() => onDelete?.())
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -104,27 +119,37 @@
|
||||
<ul
|
||||
transition:fly
|
||||
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
|
||||
<li>
|
||||
<Button onclick={dismissReport}>
|
||||
<Icon icon={InboxOut} />
|
||||
Dismiss Report
|
||||
</Button>
|
||||
</li>
|
||||
{#if etag}
|
||||
{#if event.pubkey === $pubkey}
|
||||
<li>
|
||||
<Button class="text-error" onclick={banContent}>
|
||||
<Icon icon={TrashBin2} />
|
||||
Remove Content
|
||||
<Button onclick={deleteReport}>
|
||||
<Icon icon={Close} />
|
||||
Delete Report
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
{#if ptag}
|
||||
{#if $userIsAdmin}
|
||||
<li>
|
||||
<Button class="text-error" onclick={banMember}>
|
||||
<Icon icon={MinusCircle} />
|
||||
Ban User
|
||||
<Button onclick={dismissReport}>
|
||||
<Icon icon={InboxOut} />
|
||||
Dismiss Report
|
||||
</Button>
|
||||
</li>
|
||||
{#if etag}
|
||||
<li>
|
||||
<Button class="text-error" onclick={banContent}>
|
||||
<Icon icon={TrashBin2} />
|
||||
Remove Content
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
{#if ptag}
|
||||
<li>
|
||||
<Button class="text-error" onclick={banMember}>
|
||||
<Icon icon={MinusCircle} />
|
||||
Ban User
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
{/if}
|
||||
</ul>
|
||||
</Popover>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import EyeClosed from "@assets/icons/eye-closed.svg?dataurl"
|
||||
import Eye from "@assets/icons/eye.svg?dataurl"
|
||||
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
||||
import Lock from "@assets/icons/lock.svg?dataurl"
|
||||
import Microphone from "@assets/icons/microphone.svg?dataurl"
|
||||
@@ -198,6 +199,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if $room?.about}
|
||||
<p>{$room.about}</p>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2 card2 card2-sm bg-alt">
|
||||
<strong class="text-lg">Room Permissions</strong>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
@@ -233,14 +237,11 @@
|
||||
<Button
|
||||
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
||||
data-tip="This room has no additional access controls.">
|
||||
<Icon size={4} icon={MinusCircle} /> Public
|
||||
<Icon size={4} icon={Eye} /> Public
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if $room?.about}
|
||||
<p>{$room.about}</p>
|
||||
{/if}
|
||||
{#if $members.length > 0}
|
||||
<div class="card2 card2-sm bg-alt flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
if (popover) {
|
||||
const {x, y, width, height} = popover.popper.getBoundingClientRect()
|
||||
|
||||
if (!between([x, x + width], clientX) || !between([y, y + height + 30], clientY)) {
|
||||
if (!between([x, x + width], clientX) || !between([y - 50, y + height + 50], clientY)) {
|
||||
popover.hide()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,12 +172,12 @@
|
||||
</FieldInline>
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<p>Description</p>
|
||||
<p class="flex flex-col items-start h-full">Description</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<input bind:value={values.description} class="grow" type="text" />
|
||||
</label>
|
||||
<textarea
|
||||
bind:value={values.description}
|
||||
class="min-h-24 textarea textarea-bordered flex w-full"></textarea>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
</ModalBody>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
const openMenu = () => pushDrawer(SpaceMenu, {url})
|
||||
</script>
|
||||
|
||||
<Button onclick={openMenu} class="btn btn-neutral btn-sm relative md:hidden">
|
||||
<Button onclick={openMenu} class="btn btn-neutral btn-sm relative md:hidden btn-square">
|
||||
<Icon icon={MenuDots} />
|
||||
{#if $status.theme !== "success"}
|
||||
<div class="absolute right-0 top-0 -mr-1 -mt-1 h-2 w-2 rounded-full bg-{$status.theme}"></div>
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
const reports = deriveEventsForUrl(url, [{kinds: [REPORT]}])
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const onDelete = () => {
|
||||
if ($reports.length === 0) {
|
||||
back()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal>
|
||||
@@ -32,7 +38,7 @@
|
||||
</ModalHeader>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each $reports as event (event.id)}
|
||||
<ReportItem {url} {event} />
|
||||
<ReportItem {url} {event} {onDelete} />
|
||||
{:else}
|
||||
<p class="py-12 text-center">No reports found.</p>
|
||||
{/each}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<script lang="ts">
|
||||
import {tick} from "svelte"
|
||||
import {createSearch} from "@welshman/app"
|
||||
import {formatTimestampAsDate, groupBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {MESSAGE} from "@welshman/util"
|
||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||
import {fly} from "@lib/transition"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import {deriveEventsForUrl} from "@app/core/state"
|
||||
import {goToEvent} from "@app/util/routes"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
h?: string
|
||||
}
|
||||
|
||||
const {url, h}: Props = $props()
|
||||
|
||||
const spaceMessages = deriveEventsForUrl(
|
||||
url,
|
||||
h ? [{kinds: [MESSAGE], "#h": [h]}] : [{kinds: [MESSAGE]}],
|
||||
)
|
||||
|
||||
let term = $state("")
|
||||
let show = $state(false)
|
||||
let input: HTMLInputElement | undefined = $state()
|
||||
|
||||
const open = () => {
|
||||
show = true
|
||||
tick().then(() => input?.focus())
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
show = false
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
term = ""
|
||||
show = false
|
||||
}
|
||||
|
||||
const onInput = () => {
|
||||
show = true
|
||||
}
|
||||
|
||||
const searchIndex = $derived.by(() =>
|
||||
createSearch($spaceMessages, {
|
||||
getValue: event => event.id,
|
||||
fuseOptions: {keys: ["content"]},
|
||||
}),
|
||||
)
|
||||
|
||||
const results = $derived(term ? searchIndex.searchOptions(term) : [])
|
||||
|
||||
const eventsByAge = $derived(groupBy(e => getAgeSection(e.created_at), results))
|
||||
|
||||
const getAgeSection = (createdAt: number) => {
|
||||
const age = now() - createdAt
|
||||
|
||||
if (age <= DAY) {
|
||||
return "day"
|
||||
}
|
||||
|
||||
if (age <= WEEK) {
|
||||
return "week"
|
||||
}
|
||||
|
||||
return "older"
|
||||
}
|
||||
|
||||
const getAgeLabel = (createdAt: number) => {
|
||||
const age = now() - createdAt
|
||||
|
||||
if (age < MINUTE) {
|
||||
return "Just now"
|
||||
}
|
||||
|
||||
if (age < HOUR) {
|
||||
return `${Math.floor(age / MINUTE)}m ago`
|
||||
}
|
||||
|
||||
if (age < DAY) {
|
||||
return `${Math.floor(age / HOUR)}h ago`
|
||||
}
|
||||
|
||||
return `${Math.floor(age / DAY)}d ago`
|
||||
}
|
||||
|
||||
const onRoomSearchResultClick = (event: TrustedEvent) => {
|
||||
close()
|
||||
goToEvent(event, {keepFocus: true})
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={open}>
|
||||
<Icon size={4} icon={Magnifier} />
|
||||
</button>
|
||||
{#if show}
|
||||
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={close}></button>
|
||||
<div class="fixed cw top-0 right-0 z-feature p-2">
|
||||
<div
|
||||
class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md"
|
||||
transition:fly={{y: -40, duration: 150}}>
|
||||
<div class="flex justify-between">
|
||||
<strong>Search</strong>
|
||||
<Button onclick={clear}>
|
||||
<Icon icon={CloseCircle} />
|
||||
</Button>
|
||||
</div>
|
||||
<label class="input input-sm input-bordered flex w-full items-center gap-2">
|
||||
<Icon size={4} icon={Magnifier} />
|
||||
<input
|
||||
bind:this={input}
|
||||
bind:value={term}
|
||||
class="min-w-0 grow"
|
||||
type="text"
|
||||
placeholder={h ? "Search this room..." : "Search this space..."}
|
||||
oninput={onInput} />
|
||||
</label>
|
||||
<div class="max-h-[65vh] overflow-y-auto">
|
||||
{#if !term}
|
||||
<p class="text-sm opacity-70">
|
||||
{h ? "Search for messages in this room." : "Search for messages across this space."}
|
||||
</p>
|
||||
{:else if eventsByAge.size === 0}
|
||||
<p class="text-sm opacity-70">No results found.</p>
|
||||
{:else}
|
||||
<div class="col-2">
|
||||
{#each eventsByAge as [key, events] (key)}
|
||||
<div class="col-2">
|
||||
<p class="text-xs uppercase tracking-wide opacity-60">
|
||||
{#if key === "day"}
|
||||
Last 24 Hours
|
||||
{:else if key === "week"}
|
||||
Last 7 Days
|
||||
{:else}
|
||||
Older
|
||||
{/if}
|
||||
</p>
|
||||
<div class="col-2">
|
||||
{#each events as event (event.id)}
|
||||
<button
|
||||
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
|
||||
onclick={() => onRoomSearchResultClick(event)}>
|
||||
<p class="line-clamp-2 text-sm">
|
||||
{event.content.trim() || "(No text content)"}
|
||||
</p>
|
||||
<div class="row-2 text-xs opacity-70">
|
||||
<span>{getAgeLabel(event.created_at)}</span>
|
||||
<span>{formatTimestampAsDate(event.created_at)}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -2,6 +2,7 @@
|
||||
import {PublishStatus} from "@welshman/net"
|
||||
import {displayRelayUrl} from "@welshman/util"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import {addPeriod} from "@lib/util"
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
@@ -25,7 +26,7 @@
|
||||
|
||||
<div class="card2 bg-alt col-2 shadow-lg">
|
||||
<p>
|
||||
Failed to publish to {displayRelayUrl(url)}: {message}.
|
||||
Failed to publish to {displayRelayUrl(url)}: {addPeriod(message)}
|
||||
</p>
|
||||
<Button class="link" onclick={retry}>Retry</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {debounce} from "throttle-debounce"
|
||||
import {nwc} from "@getalby/sdk"
|
||||
import {nwc} from "@lib/lightning"
|
||||
import {sleep, assoc} from "@welshman/lib"
|
||||
import type {NWCInfo} from "@welshman/util"
|
||||
import {pubkey, userProfile, updateSession} from "@welshman/app"
|
||||
@@ -33,8 +33,14 @@
|
||||
loading = true
|
||||
|
||||
try {
|
||||
await Promise.all([sleep(800), getWebLn().enable()])
|
||||
const info = await getWebLn().getInfo()
|
||||
const webLn = getWebLn()
|
||||
|
||||
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")) {
|
||||
pushToast({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {Invoice} from "@getalby/lightning-tools/bolt11"
|
||||
import {Invoice} from "@lib/lightning/bolt11"
|
||||
import {debounce} from "throttle-debounce"
|
||||
import {session} from "@welshman/app"
|
||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {Invoice} from "@getalby/lightning-tools/bolt11"
|
||||
import {Invoice} from "@lib/lightning/bolt11"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||
import Refresh from "@assets/icons/refresh.svg?dataurl"
|
||||
|
||||
+25
-71
@@ -1,4 +1,5 @@
|
||||
import {nwc} from "@getalby/sdk"
|
||||
import {wallet as lightningWallet} from "@lib/lightning"
|
||||
import type {IWallet} from "@lib/lightning/wallet"
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {get, derived} from "svelte/store"
|
||||
import {
|
||||
@@ -432,43 +433,34 @@ export const publishLeaveRequest = (params: LeaveRequestParams) =>
|
||||
|
||||
// Lightning
|
||||
|
||||
export const getWebLn = () => (window as any).webln
|
||||
export const getWebLn = () => lightningWallet.getWebLn()
|
||||
|
||||
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 getWalletAdapter = (): IWallet => {
|
||||
const $session = session.get()
|
||||
|
||||
if (!$session?.wallet) {
|
||||
throw new Error("No wallet is connected")
|
||||
}
|
||||
|
||||
if ($session.wallet.type === "nwc") {
|
||||
const params: {invoice: string; amount?: number} = {invoice}
|
||||
if (msats) params.amount = msats
|
||||
return getNwcClient().payInvoice(params)
|
||||
} else if ($session.wallet.type === "webln") {
|
||||
if (msats) throw new Error("Unable to pay zero invoices with webln")
|
||||
return getWebLn()
|
||||
.enable()
|
||||
.then(() => getWebLn().sendPayment(invoice))
|
||||
return lightningWallet.createWalletAdapter($session.wallet)
|
||||
}
|
||||
|
||||
const withConnectedWallet = async <T>(f: (wallet: IWallet) => Promise<T>) => {
|
||||
const wallet = getWalletAdapter()
|
||||
|
||||
try {
|
||||
return await f(wallet)
|
||||
} finally {
|
||||
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 = {
|
||||
sats: number
|
||||
description?: string
|
||||
@@ -478,56 +470,18 @@ export const createInvoice = async ({
|
||||
sats,
|
||||
description = "Receive via lightning",
|
||||
}: CreateInvoiceParams) => {
|
||||
const $session = session.get()
|
||||
|
||||
if (!$session?.wallet) {
|
||||
throw new Error("No wallet is connected")
|
||||
}
|
||||
|
||||
const satAmount = Math.floor(sats)
|
||||
|
||||
if (!Number.isFinite(satAmount) || satAmount <= 0) {
|
||||
throw new Error("Invalid satoshi amount")
|
||||
}
|
||||
|
||||
if ($session.wallet.type === "nwc") {
|
||||
const createdInvoice = await getNwcClient().makeInvoice({
|
||||
amount: satAmount * 1000,
|
||||
return withConnectedWallet(wallet =>
|
||||
wallet.createInvoice({
|
||||
sats: satAmount,
|
||||
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
|
||||
|
||||
+126
-64
@@ -1,5 +1,6 @@
|
||||
import {get, writable} from "svelte/store"
|
||||
import {
|
||||
call,
|
||||
uniq,
|
||||
int,
|
||||
YEAR,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
sortBy,
|
||||
now,
|
||||
on,
|
||||
between,
|
||||
isDefined,
|
||||
filterVals,
|
||||
fromPairs,
|
||||
@@ -22,9 +24,8 @@ import {
|
||||
getRelaysFromList,
|
||||
} from "@welshman/util"
|
||||
import type {TrustedEvent, Filter, List} from "@welshman/util"
|
||||
import {feedFromFilters, makeRelayFeed, makeIntersectionFeed} from "@welshman/feeds"
|
||||
import {load, request} from "@welshman/net"
|
||||
import {repository, makeFeedController, loadRelay, tracker} from "@welshman/app"
|
||||
import {repository, loadRelay, tracker} from "@welshman/app"
|
||||
import {createScroller} from "@lib/html"
|
||||
import {daysBetween} from "@lib/util"
|
||||
import {getEventsForUrl} from "@app/core/state"
|
||||
@@ -35,82 +36,131 @@ export const makeFeed = ({
|
||||
url,
|
||||
filters,
|
||||
element,
|
||||
onExhausted,
|
||||
onBackwardExhausted,
|
||||
onForwardExhausted,
|
||||
at = now(),
|
||||
}: {
|
||||
url: string
|
||||
filters: Filter[]
|
||||
element: HTMLElement
|
||||
onExhausted?: () => void
|
||||
onBackwardExhausted?: () => void
|
||||
onForwardExhausted?: () => void
|
||||
at?: number
|
||||
}) => {
|
||||
const seen = new Set<string>()
|
||||
const interval = int(DAY)
|
||||
const controller = new AbortController()
|
||||
const buffer = writable<TrustedEvent[]>([])
|
||||
const events = writable<TrustedEvent[]>([])
|
||||
|
||||
let buffer: TrustedEvent[] = []
|
||||
let backwardWindow = [at - interval, at]
|
||||
let forwardWindow = [at, at + interval]
|
||||
|
||||
const insertEvent = (event: TrustedEvent) => {
|
||||
let handled = false
|
||||
|
||||
if (seen.has(event.id)) {
|
||||
return
|
||||
}
|
||||
if (between([backwardWindow[0], forwardWindow[1]], event.created_at)) {
|
||||
const $events = get(events)
|
||||
|
||||
events.update($events => {
|
||||
for (let i = 0; i < $events.length; i++) {
|
||||
if ($events[i].id === event.id) return $events
|
||||
if ($events[i].created_at < event.created_at) {
|
||||
if ($events[i].created_at > event.created_at) {
|
||||
events.set(insertAt(i, event, $events))
|
||||
handled = true
|
||||
return insertAt(i, event, $events)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return $events
|
||||
})
|
||||
|
||||
if (!handled) {
|
||||
buffer.update($buffer => {
|
||||
for (let i = 0; i < $buffer.length; i++) {
|
||||
if ($buffer[i].id === event.id) return $buffer
|
||||
if ($buffer[i].created_at < event.created_at) return insertAt(i, event, $buffer)
|
||||
if (!handled) {
|
||||
events.set([...$events, event])
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
if (buffer[i].created_at > event.created_at) {
|
||||
buffer.splice(i, 0, event)
|
||||
handled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return [...$buffer, event]
|
||||
})
|
||||
if (!handled) {
|
||||
buffer.push(event)
|
||||
}
|
||||
}
|
||||
|
||||
seen.add(event.id)
|
||||
}
|
||||
|
||||
const unsubscribe = on(repository, "update", ({added, removed}) => {
|
||||
if (removed.size > 0) {
|
||||
buffer.update($buffer => $buffer.filter(e => !removed.has(e.id)))
|
||||
events.update($events => $events.filter(e => !removed.has(e.id)))
|
||||
}
|
||||
|
||||
for (const event of added) {
|
||||
if (matchFilters(filters, event) && tracker.getRelays(event.id).has(url)) {
|
||||
insertEvent(event)
|
||||
const unsubscribers = [
|
||||
on(repository, "update", ({added, removed}) => {
|
||||
if (removed.size > 0) {
|
||||
buffer = buffer.filter(e => !removed.has(e.id))
|
||||
events.update($events => $events.filter(e => !removed.has(e.id)))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const ctrl = makeFeedController({
|
||||
useWindowing: true,
|
||||
signal: controller.signal,
|
||||
feed: makeIntersectionFeed(makeRelayFeed(url), feedFromFilters(filters)),
|
||||
onExhausted,
|
||||
})
|
||||
for (const event of added) {
|
||||
if (matchFilters(filters, event) && tracker.getRelays(event.id).has(url)) {
|
||||
insertEvent(event)
|
||||
}
|
||||
}
|
||||
}),
|
||||
on(tracker, "add", (id: string, trackerUrl: string) => {
|
||||
if (trackerUrl === url) {
|
||||
const event = repository.getEvent(id)
|
||||
|
||||
const scroller = createScroller({
|
||||
if (event && matchFilters(filters, event)) {
|
||||
insertEvent(event)
|
||||
}
|
||||
}
|
||||
}),
|
||||
]
|
||||
|
||||
const loadTimeframe = (since: number, until: number) => {
|
||||
request({
|
||||
relays: [url],
|
||||
autoClose: true,
|
||||
signal: controller.signal,
|
||||
filters: filters.map(filter => ({...filter, since, until})),
|
||||
})
|
||||
}
|
||||
|
||||
const backwardScroller = createScroller({
|
||||
element,
|
||||
delay: 300,
|
||||
threshold: 10_000,
|
||||
onScroll: async () => {
|
||||
const $buffer = get(buffer)
|
||||
threshold: 5000,
|
||||
onScroll: () => {
|
||||
const [since, until] = backwardWindow
|
||||
|
||||
events.update($events => [...$events, ...$buffer.splice(0, 30)])
|
||||
backwardWindow = [since - interval, since]
|
||||
|
||||
if ($buffer.length < 100) {
|
||||
ctrl.load(100)
|
||||
for (const event of buffer.splice(0)) {
|
||||
insertEvent(event)
|
||||
}
|
||||
|
||||
if (until > now() - int(2, YEAR)) {
|
||||
loadTimeframe(since, until)
|
||||
} else if (!buffer.some(e => e.created_at < at)) {
|
||||
backwardScroller.stop()
|
||||
onBackwardExhausted?.()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const forwardScroller = createScroller({
|
||||
element,
|
||||
reverse: true,
|
||||
delay: 300,
|
||||
threshold: 5000,
|
||||
onScroll: () => {
|
||||
const [since, until] = forwardWindow
|
||||
|
||||
forwardWindow = [until, until + interval]
|
||||
|
||||
for (const event of buffer.splice(0)) {
|
||||
insertEvent(event)
|
||||
}
|
||||
|
||||
if (until < now()) {
|
||||
loadTimeframe(since, until)
|
||||
} else if (!buffer.some(e => e.created_at > at)) {
|
||||
forwardScroller.stop()
|
||||
onForwardExhausted?.()
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -122,9 +172,10 @@ export const makeFeed = ({
|
||||
return {
|
||||
events,
|
||||
cleanup: () => {
|
||||
unsubscribe()
|
||||
scroller.stop()
|
||||
controller.abort()
|
||||
forwardScroller.stop()
|
||||
backwardScroller.stop()
|
||||
unsubscribers.forEach(call)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -169,17 +220,28 @@ export const makeCalendarFeed = ({
|
||||
})
|
||||
}
|
||||
|
||||
const unsubscribe = on(repository, "update", ({added, removed}) => {
|
||||
if (removed.size > 0) {
|
||||
events.update($events => $events.filter(e => !removed.has(e.id)))
|
||||
}
|
||||
|
||||
for (const event of added) {
|
||||
if (matchFilters(filters, event)) {
|
||||
insertEvent(event)
|
||||
const unsubscribers = [
|
||||
on(repository, "update", ({added, removed}) => {
|
||||
if (removed.size > 0) {
|
||||
events.update($events => $events.filter(e => !removed.has(e.id)))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for (const event of added) {
|
||||
if (matchFilters(filters, event)) {
|
||||
insertEvent(event)
|
||||
}
|
||||
}
|
||||
}),
|
||||
on(tracker, "add", (id: string, trackerUrl: string) => {
|
||||
if (trackerUrl === url) {
|
||||
const event = repository.getEvent(id)
|
||||
|
||||
if (event && matchFilters(filters, event)) {
|
||||
insertEvent(event)
|
||||
}
|
||||
}
|
||||
}),
|
||||
]
|
||||
|
||||
const loadTimeframe = (since: number, until: number) => {
|
||||
const hashes = daysBetween(since, until).map(String)
|
||||
@@ -234,10 +296,10 @@ export const makeCalendarFeed = ({
|
||||
return {
|
||||
events,
|
||||
cleanup: () => {
|
||||
backwardScroller.stop()
|
||||
forwardScroller.stop()
|
||||
controller.abort()
|
||||
unsubscribe()
|
||||
forwardScroller.stop()
|
||||
backwardScroller.stop()
|
||||
unsubscribers.forEach(call)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+21
-2
@@ -50,9 +50,12 @@ import {
|
||||
makeDeriveItem,
|
||||
deriveItemsByKey,
|
||||
deriveDeduplicated,
|
||||
deriveEventsById,
|
||||
deriveEventsByIdByUrl,
|
||||
deriveEventsByIdForUrl,
|
||||
getEventsByIdForUrl,
|
||||
deriveEventsAsc,
|
||||
deriveEventsDesc,
|
||||
} from "@welshman/store"
|
||||
import {
|
||||
APP_DATA,
|
||||
@@ -126,6 +129,10 @@ export const ROOM = "h"
|
||||
|
||||
export const PROTECTED = ["-"]
|
||||
|
||||
export const IMAGE_CONTENT_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]
|
||||
|
||||
export const VIDEO_CONTENT_TYPES = ["video/quicktime", "video/webm", "video/mp4"]
|
||||
|
||||
export const ENABLE_ZAPS = Capacitor.getPlatform() != "ios"
|
||||
|
||||
export const PUSH_SERVER = import.meta.env.VITE_PUSH_SERVER
|
||||
@@ -229,12 +236,21 @@ export const deriveEvent = makeDeriveEvent({
|
||||
onDerive: (filters: Filter[], relays: string[]) => load({filters, relays}),
|
||||
})
|
||||
|
||||
export const deriveEvents = (filters: Filter[] = [{}]) =>
|
||||
deriveEventsDesc(deriveEventsById({repository, filters}))
|
||||
|
||||
export const getEventsForUrl = (url: string, filters: Filter[] = [{}]) =>
|
||||
getEventsByIdForUrl({url, tracker, repository, filters}).values()
|
||||
|
||||
export const deriveEventsForUrl = (url: string, filters: Filter[] = [{}]) =>
|
||||
deriveArray(deriveEventsByIdForUrl({url, tracker, repository, filters}))
|
||||
|
||||
export const deriveEventsForUrlAsc = (url: string, filters: Filter[] = [{}]) =>
|
||||
deriveEventsAsc(deriveEventsByIdForUrl({url, tracker, repository, filters}))
|
||||
|
||||
export const deriveEventsForUrlDesc = (url: string, filters: Filter[] = [{}]) =>
|
||||
deriveEventsDesc(deriveEventsByIdForUrl({url, tracker, repository, filters}))
|
||||
|
||||
export const deriveLatestEventForUrl = (url: string, filters: Filter[] = [{}]) =>
|
||||
deriveDeduplicated(deriveEventsByIdForUrl({url, tracker, repository, filters}), $eventsById =>
|
||||
first(sortEventsDesc($eventsById.values())),
|
||||
@@ -434,7 +450,10 @@ export const chatsById = call(() => {
|
||||
const pubkeys = getChatPubkeysFromEvent(event)
|
||||
const id = makeChatId(pubkeys)
|
||||
const chat = chatsById.get(id)
|
||||
const messages = sortBy(e => -e.created_at, append(event, chat?.messages || []))
|
||||
const messages = sortBy(
|
||||
e => -e.created_at,
|
||||
uniqBy(e => e.id, append(event, chat?.messages || [])),
|
||||
)
|
||||
const last_activity = Math.max(chat?.last_activity || 0, event.created_at)
|
||||
const updatedChat = addSearchText({id, pubkeys, messages, last_activity})
|
||||
|
||||
@@ -463,7 +482,7 @@ export const chatsById = call(() => {
|
||||
}
|
||||
}
|
||||
|
||||
addEvents(repository.query([{kinds: [DIRECT_MESSAGE, PROFILE]}]))
|
||||
addEvents(repository.query([{kinds: [...DM_KINDS, PROFILE]}]))
|
||||
|
||||
const unsubscribers = [
|
||||
on(repository, "update", ({added}: RepositoryUpdate) => addEvents(added)),
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@welshman/app"
|
||||
import type {FileAttributes} from "@welshman/editor"
|
||||
import {Editor, MentionSuggestion, WelshmanExtension, editorProps} from "@welshman/editor"
|
||||
import {escapeHtml} from "@lib/html"
|
||||
import {makeMentionNodeView} from "@app/editor/MentionNodeView"
|
||||
import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte"
|
||||
import {uploadFile} from "@app/core/commands"
|
||||
@@ -82,7 +83,7 @@ export const makeEditor = async ({
|
||||
)
|
||||
|
||||
return new Editor({
|
||||
content,
|
||||
content: escapeHtml(content),
|
||||
autofocus,
|
||||
editorProps,
|
||||
element: document.createElement("div"),
|
||||
|
||||
+39
-16
@@ -2,16 +2,14 @@ import type {Page} from "@sveltejs/kit"
|
||||
import {get} from "svelte/store"
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {goto} from "$app/navigation"
|
||||
import {nthEq, sleep} from "@welshman/lib"
|
||||
import {page} from "$app/stores"
|
||||
import {nthEq} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {getAddress} from "@welshman/util"
|
||||
import {tracker, loadRelay} from "@welshman/app"
|
||||
import {scrollToEvent} from "@lib/html"
|
||||
import {identity} from "@welshman/lib"
|
||||
import {
|
||||
getTagValue,
|
||||
DIRECT_MESSAGE,
|
||||
DIRECT_MESSAGE_FILE,
|
||||
MESSAGE,
|
||||
THREAD,
|
||||
CLASSIFIED,
|
||||
@@ -26,6 +24,7 @@ import {
|
||||
encodeRelay,
|
||||
userSpaceUrls,
|
||||
hasNip29,
|
||||
DM_KINDS,
|
||||
ROOM,
|
||||
} from "@app/core/state"
|
||||
import {lastPageBySpaceUrl} from "@app/util/history"
|
||||
@@ -63,6 +62,14 @@ export const makeRoomPath = (url: string, h: string) => `/spaces/${encodeRelay(u
|
||||
|
||||
export const makeSpaceChatPath = (url: string) => makeRoomPath(url, "chat")
|
||||
|
||||
export const makeMessagePath = (url: string, event: TrustedEvent) => {
|
||||
const h = getTagValue(ROOM, event.tags)
|
||||
const path = h ? makeRoomPath(url, h) : makeSpaceChatPath(url)
|
||||
const qp = new URLSearchParams({at: String(event.created_at)})
|
||||
|
||||
return path + "?" + qp.toString()
|
||||
}
|
||||
|
||||
export const makeGoalPath = (url: string, id?: string) => makeSpacePath(url, "goals", id)
|
||||
|
||||
export const makeThreadPath = (url: string, id?: string) => makeSpacePath(url, "threads", id)
|
||||
@@ -93,27 +100,43 @@ export const getPrimaryNavItemIndex = ($page: Page) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const goToEvent = async (event: TrustedEvent, options: Record<string, any> = {}) => {
|
||||
export const scrollToEvent = (id: string) => {
|
||||
const element = document.querySelector(`[data-event="${id}"]`) as any
|
||||
|
||||
if (element) {
|
||||
element.scrollIntoView({behavior: "smooth", block: "center"})
|
||||
element.style = "filter: brightness(1.5); transition-property: all; transition-duration: 400ms;"
|
||||
|
||||
setTimeout(() => {
|
||||
element.style = "transition-property: all; transition-duration: 300ms;"
|
||||
}, 800)
|
||||
|
||||
setTimeout(() => {
|
||||
element.style = ""
|
||||
}, 800 + 400)
|
||||
}
|
||||
|
||||
return Boolean(element)
|
||||
}
|
||||
|
||||
export const goToEvent = (event: TrustedEvent, options: Record<string, any> = {}) => {
|
||||
const urls = Array.from(tracker.getRelays(event.id))
|
||||
const path = await getEventPath(event, urls)
|
||||
const path = getEventPath(event, urls)
|
||||
|
||||
if (path.includes("://")) {
|
||||
window.open(path)
|
||||
} else {
|
||||
goto(path, options)
|
||||
} else if (!scrollToEvent(event.id)) {
|
||||
const replaceState = path.replace(/\?.*$/, "") === get(page).url.pathname
|
||||
|
||||
await sleep(300)
|
||||
await scrollToEvent(event.id)
|
||||
goto(path, {replaceState, ...options})
|
||||
}
|
||||
}
|
||||
|
||||
export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
|
||||
if (event.kind === DIRECT_MESSAGE || event.kind === DIRECT_MESSAGE_FILE) {
|
||||
export const getEventPath = (event: TrustedEvent, urls: string[]) => {
|
||||
if (DM_KINDS.includes(event.kind)) {
|
||||
return makeChatPath([event.pubkey, ...getPubkeyTagValues(event.tags)])
|
||||
}
|
||||
|
||||
const h = getTagValue(ROOM, event.tags)
|
||||
|
||||
if (urls.length > 0) {
|
||||
const url = urls[0]
|
||||
|
||||
@@ -134,7 +157,7 @@ export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
|
||||
}
|
||||
|
||||
if (event.kind === MESSAGE) {
|
||||
return h ? makeRoomPath(url, h) : makeSpacePath(url, "chat")
|
||||
return makeMessagePath(url, event)
|
||||
}
|
||||
|
||||
const address = event.tags.find(nthEq(0, "A"))?.[1]
|
||||
@@ -151,7 +174,7 @@ export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
|
||||
}
|
||||
|
||||
if (parseInt(kind) === MESSAGE) {
|
||||
return h ? makeRoomPath(url, h) : makeSpacePath(url, "chat")
|
||||
return makeMessagePath(url, event)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import type {Snippet} from "svelte"
|
||||
|
||||
interface Props {
|
||||
@@ -8,12 +9,13 @@
|
||||
}
|
||||
|
||||
let {children, element = $bindable(), ...props}: Props = $props()
|
||||
|
||||
const className = cx(
|
||||
props.class,
|
||||
"scroll-container cw cb ct fixed z-feature overflow-y-auto overflow-x-hidden",
|
||||
)
|
||||
</script>
|
||||
|
||||
<div
|
||||
{...props}
|
||||
bind:this={element}
|
||||
data-component="PageContent"
|
||||
class="scroll-container cw cb fixed top-[calc(var(--sait)+5rem)] md:top-[calc(var(--sait)+3rem)] z-feature overflow-y-auto overflow-x-hidden {props.class}">
|
||||
<div {...props} bind:this={element} data-component="PageContent" class={className}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
+16
-41
@@ -1,4 +1,4 @@
|
||||
import {sleep, last, randomId} from "@welshman/lib"
|
||||
import {sleep, randomId} from "@welshman/lib"
|
||||
export {preventDefault, stopPropagation} from "svelte/legacy"
|
||||
|
||||
export const copyToClipboard = (text: string) => {
|
||||
@@ -47,9 +47,12 @@ export const createScroller = ({
|
||||
if (container) {
|
||||
// While we have empty space, fill it
|
||||
const {scrollY, innerHeight} = window
|
||||
const {scrollHeight, scrollTop} = container
|
||||
const {scrollHeight, scrollTop, clientHeight} = container
|
||||
const viewHeight = clientHeight || innerHeight
|
||||
const offset = Math.abs(scrollTop || scrollY)
|
||||
const shouldLoad = offset + innerHeight + threshold > scrollHeight
|
||||
const shouldLoad = reverse
|
||||
? offset < threshold
|
||||
: offset + viewHeight + threshold > scrollHeight
|
||||
|
||||
// Only trigger loading the first time we reach the threshold
|
||||
if (shouldLoad) {
|
||||
@@ -100,53 +103,17 @@ export const isIntersecting = async (element: Element) =>
|
||||
observer.observe(element)
|
||||
})
|
||||
|
||||
export const scrollToEvent = async (id: string, attempts = 3): Promise<boolean> => {
|
||||
const element = document.querySelector(`[data-event="${id}"]`) as any
|
||||
const elements = Array.from(document.querySelectorAll("[data-event]"))
|
||||
|
||||
if (element) {
|
||||
element.scrollIntoView({behavior: "smooth", block: "center"})
|
||||
element.style = "filter: brightness(1.5); transition-property: all; transition-duration: 400ms;"
|
||||
|
||||
setTimeout(() => {
|
||||
element.style = "transition-property: all; transition-duration: 300ms;"
|
||||
}, 800)
|
||||
|
||||
setTimeout(() => {
|
||||
element.style = ""
|
||||
}, 800 + 400)
|
||||
|
||||
return true
|
||||
} else if (elements.length > 0) {
|
||||
const lastElement = last(elements)
|
||||
|
||||
if (lastElement && !isIntersecting(lastElement)) {
|
||||
lastElement.scrollIntoView({behavior: "smooth", block: "center"})
|
||||
}
|
||||
|
||||
await sleep(300)
|
||||
|
||||
if (attempts > 0) {
|
||||
return scrollToEvent(id, attempts - 1)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export const compressFile = async (
|
||||
file: File | Blob,
|
||||
options: Record<string, any> = {},
|
||||
): Promise<File> => {
|
||||
const {default: Compressor} = await import("compressorjs")
|
||||
const {default: Compressor} = await import("compressorjs-next")
|
||||
|
||||
return new Promise<File>((resolve, _reject) => {
|
||||
new Compressor(file, {
|
||||
maxWidth: 2048,
|
||||
maxHeight: 2048,
|
||||
convertSize: 10 * 1024 * 1024,
|
||||
convertTypes: ["image/png"],
|
||||
...options,
|
||||
success: result => resolve(result as File),
|
||||
error: e => {
|
||||
@@ -164,3 +131,11 @@ export const compressFile = async (
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const escapeHtml = (html: string) => {
|
||||
const element = document.createElement("div")
|
||||
|
||||
element.innerText = html
|
||||
|
||||
return element.innerHTML
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {Invoice} from "./Invoice"
|
||||
@@ -0,0 +1,17 @@
|
||||
export type InvoiceArgs = {
|
||||
pr: string
|
||||
verify?: string
|
||||
preimage?: string
|
||||
successAction?: SuccessAction
|
||||
}
|
||||
|
||||
export type SuccessAction =
|
||||
| {
|
||||
tag: "message"
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
tag: "url"
|
||||
description: string
|
||||
url: string
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * as bolt11 from "./bolt11"
|
||||
export * as nwc from "./nwc"
|
||||
export * as wallet from "./wallet"
|
||||
@@ -0,0 +1,380 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {NWCClient} from "./NWCClient"
|
||||
@@ -0,0 +1,77 @@
|
||||
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 {}
|
||||
@@ -0,0 +1,16 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
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"
|
||||
@@ -26,3 +26,5 @@ export const buildUrl = (base: string | URL, ...pathname: string[]) => {
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export const addPeriod = (s: string) => (s + ".").replace(/\.+$/, ".")
|
||||
|
||||
+193
-66
@@ -1,77 +1,204 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {goto} from "$app/navigation"
|
||||
import {shouldUnwrap} from "@welshman/app"
|
||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||
import Compass from "@assets/icons/compass.svg?dataurl"
|
||||
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
||||
import {derived, writable} from "svelte/store"
|
||||
import {batch, call, sortBy, uniqBy} from "@welshman/lib"
|
||||
import {
|
||||
NOTE,
|
||||
MESSAGE,
|
||||
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 Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import CardButton from "@lib/components/CardButton.svelte"
|
||||
import SpaceAdd from "@app/components/SpaceAdd.svelte"
|
||||
import ChatEnable from "@app/components/ChatEnable.svelte"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {goToSpace} from "@app/util/routes"
|
||||
import {PLATFORM_NAME, PLATFORM_RELAYS} from "@app/core/state"
|
||||
import PageBar from "@lib/components/PageBar.svelte"
|
||||
import PageContent from "@lib/components/PageContent.svelte"
|
||||
import NoteItem from "@app/components/NoteItem.svelte"
|
||||
import ThreadItem from "@app/components/ThreadItem.svelte"
|
||||
import ClassifiedItem from "@app/components/ClassifiedItem.svelte"
|
||||
import GoalItem from "@app/components/GoalItem.svelte"
|
||||
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
|
||||
import RecentConversation from "@app/components/RecentConversation.svelte"
|
||||
import {makeRoomId, userSpaceUrls, loadUserGroupList, CONTENT_KINDS} from "@app/core/state"
|
||||
|
||||
const addSpace = () => pushModal(SpaceAdd)
|
||||
type Activity = {
|
||||
type: "message" | "content"
|
||||
event: TrustedEvent
|
||||
timestamp: number
|
||||
count: number
|
||||
url: string
|
||||
}
|
||||
|
||||
const openChat = () => ($shouldUnwrap ? goto("/chat") : pushModal(ChatEnable, {next: "/chat"}))
|
||||
const controller = new AbortController()
|
||||
const events = writable<TrustedEvent[]>([])
|
||||
const limit = writable(0)
|
||||
|
||||
onMount(async () => {
|
||||
if (PLATFORM_RELAYS.length > 0) {
|
||||
goToSpace(PLATFORM_RELAYS[0])
|
||||
const recentActivity = derived([events, limit], ([$events, $limit]) => {
|
||||
const activity: Activity[] = []
|
||||
const activityByRoom = new Map<string, Activity>()
|
||||
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>
|
||||
|
||||
<div class="hero min-h-screen overflow-auto pb-8">
|
||||
<div class="hero-content">
|
||||
<div class="column content gap-4">
|
||||
<h1 class="text-center text-5xl">Welcome to</h1>
|
||||
<h1 class="mb-4 text-center text-5xl font-bold uppercase">{PLATFORM_NAME}</h1>
|
||||
<div class="col-3">
|
||||
<Button onclick={addSpace}>
|
||||
<CardButton class="btn-neutral">
|
||||
{#snippet icon()}
|
||||
<Icon icon={AddCircle} size={7} />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<div>Add a space</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<div>Use an invite link, or create your own space.</div>
|
||||
{/snippet}
|
||||
</CardButton>
|
||||
</Button>
|
||||
<Link href="/discover">
|
||||
<CardButton class="btn-neutral">
|
||||
{#snippet icon()}
|
||||
<Icon icon={Compass} size={7} />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<div>Browse the network</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<div>Find communities on the nostr network.</div>
|
||||
{/snippet}
|
||||
</CardButton>
|
||||
</Link>
|
||||
<Button onclick={openChat}>
|
||||
<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>
|
||||
<PageBar>
|
||||
{#snippet icon()}
|
||||
<div class="center">
|
||||
<Icon icon={History} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<strong>Recent Activity</strong>
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<div class="row-2"></div>
|
||||
{/snippet}
|
||||
</PageBar>
|
||||
|
||||
<PageContent class="flex flex-col gap-2 p-2 pt-4" bind:element>
|
||||
{#each $recentActivity as { type, event, url, count } (event.id)}
|
||||
{#if type === "message"}
|
||||
<RecentConversation {url} {event} {count} />
|
||||
{:else if event.kind === THREAD}
|
||||
<ThreadItem {url} {event} />
|
||||
{:else if event.kind === CLASSIFIED}
|
||||
<ClassifiedItem {url} {event} />
|
||||
{:else if event.kind === ZAP_GOAL}
|
||||
<GoalItem {url} {event} />
|
||||
{:else if event.kind === EVENT_TIME}
|
||||
<CalendarEventItem {url} {event} />
|
||||
{:else}
|
||||
<NoteItem {url} {event} />
|
||||
{/if}
|
||||
{:else}
|
||||
{#if loading}
|
||||
<div class="flex justify-center items-center py-20">
|
||||
<span class="loading loading-spinner mr-3"></span>
|
||||
Loading recent activity...
|
||||
</div>
|
||||
{:else}
|
||||
<p class="flex flex-col items-center py-20 text-center">No recent activity found!</p>
|
||||
{/if}
|
||||
{/each}
|
||||
</PageContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
import {Capacitor} from "@capacitor/core"
|
||||
import {fly} from "@lib/transition"
|
||||
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
||||
import Wallet from "@assets/icons/wallet.svg?dataurl"
|
||||
@@ -45,7 +46,7 @@
|
||||
<Icon icon={Bell} /> Alerts
|
||||
</SecondaryNavItem>
|
||||
</div>
|
||||
<div in:fly|local={{delay: 100}}>
|
||||
<div in:fly|local={{delay: 100}} class:hidden={Capacitor.getPlatform() === "ios"}>
|
||||
<SecondaryNavItem href="/settings/wallet">
|
||||
<Icon icon={Wallet} /> Wallet
|
||||
</SecondaryNavItem>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {nwc} from "@getalby/sdk"
|
||||
import {LOCALE} from "@welshman/lib"
|
||||
import {displayRelayUrl, isNWCWallet, fromMsats} from "@welshman/util"
|
||||
import {displayRelayUrl, isNWCWallet} from "@welshman/util"
|
||||
import {session, pubkey, profilesByPubkey} from "@welshman/app"
|
||||
import DownloadMinimalistic from "@assets/icons/download-minimalistic.svg?dataurl"
|
||||
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
|
||||
@@ -13,7 +12,7 @@
|
||||
import WalletDisconnect from "@app/components/WalletDisconnect.svelte"
|
||||
import WalletUpdateReceivingAddress from "@app/components/WalletUpdateReceivingAddress.svelte"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {getWebLn} from "@app/core/commands"
|
||||
import {getWalletBalance} from "@app/core/commands"
|
||||
import Wallet2 from "@assets/icons/wallet.svg?dataurl"
|
||||
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||
@@ -72,12 +71,10 @@
|
||||
</p>
|
||||
<p class="flex gap-2 whitespace-nowrap">
|
||||
Balance:
|
||||
{#await getWebLn()
|
||||
?.enable()
|
||||
.then(() => getWebLn().getBalance())}
|
||||
{#await getWalletBalance()}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:then res}
|
||||
{new Intl.NumberFormat(LOCALE).format(res?.balance || 0)}
|
||||
{:then balance}
|
||||
{new Intl.NumberFormat(LOCALE).format(balance)}
|
||||
{:catch}
|
||||
[unknown]
|
||||
{/await}
|
||||
@@ -85,17 +82,17 @@
|
||||
</p>
|
||||
</div>
|
||||
{:else if $session.wallet.type === "nwc"}
|
||||
{@const {lud16, relayUrl, nostrWalletConnectUrl} = $session.wallet.info}
|
||||
{@const {lud16, relayUrl} = $session.wallet.info}
|
||||
<div class="flex flex-col justify-between gap-2 lg:flex-row">
|
||||
<p>
|
||||
Connected to <strong>{lud16}</strong> via <strong>{displayRelayUrl(relayUrl)}</strong>
|
||||
</p>
|
||||
<p class="flex gap-2 whitespace-nowrap">
|
||||
Balance:
|
||||
{#await new nwc.NWCClient({nostrWalletConnectUrl}).getBalance()}
|
||||
{#await getWalletBalance()}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:then res}
|
||||
{new Intl.NumberFormat(LOCALE).format(fromMsats(res?.balance || 0))}
|
||||
{:then balance}
|
||||
{new Intl.NumberFormat(LOCALE).format(balance)}
|
||||
{:catch}
|
||||
[unknown]
|
||||
{/await}
|
||||
@@ -126,17 +123,13 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="card2 bg-alt flex flex-col shadow-md"
|
||||
class:gap-6={profileLightningAddress && walletLud16 && profile?.lud16 !== walletLud16}>
|
||||
<div class="flex items-center justify-between">
|
||||
<strong>Lightning Address</strong>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={profileLightningAddress ? "" : "text-warning"}>
|
||||
{profileLightningAddress ? profileLightningAddress : "Not set"}
|
||||
</span>
|
||||
<Button class="btn btn-neutral btn-xs ml-3" onclick={updateReceivingAddress}>Update</Button>
|
||||
</div>
|
||||
<div class="card2 bg-alt flex flex-col shadow-md gap-6">
|
||||
<strong>Lightning Address</strong>
|
||||
<div class="flex justify-between items-center gap-2">
|
||||
<span class={profileLightningAddress ? "" : "text-warning"}>
|
||||
{profileLightningAddress ? profileLightningAddress : "Not set"}
|
||||
</span>
|
||||
<Button class="btn btn-neutral btn-xs ml-3" onclick={updateReceivingAddress}>Update</Button>
|
||||
</div>
|
||||
{#if profileLightningAddress && walletLud16 && profile?.lud16 !== walletLud16}
|
||||
<div class="card2 bg-alt flex items-center gap-2 text-xs">
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<script>
|
||||
import {page} from "$app/stores"
|
||||
</script>
|
||||
|
||||
{#key $page.url.searchParams.get("at")}
|
||||
<slot />
|
||||
{/key}
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import {onMount, tick} from "svelte"
|
||||
import {readable} from "svelte/store"
|
||||
import {onMount, onDestroy} from "svelte"
|
||||
import {page} from "$app/stores"
|
||||
import {goto} from "$app/navigation"
|
||||
import type {Readable} from "svelte/store"
|
||||
import type {MakeNonOptional} from "@welshman/lib"
|
||||
import {pubkey, publishThunk, waitForThunkError, joinRoom, leaveRoom} from "@welshman/app"
|
||||
import {now, int, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib"
|
||||
import type {MakeNonOptional} from "@welshman/lib"
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {
|
||||
makeEvent,
|
||||
@@ -13,44 +15,44 @@
|
||||
ROOM_ADD_MEMBER,
|
||||
ROOM_REMOVE_MEMBER,
|
||||
} from "@welshman/util"
|
||||
import {pubkey, publishThunk, waitForThunkError, joinRoom, leaveRoom} from "@welshman/app"
|
||||
import {slide, fade, fly} from "@lib/transition"
|
||||
import InfoCircle from "@assets/icons/info-circle.svg?dataurl"
|
||||
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
|
||||
import Login2 from "@assets/icons/login-3.svg?dataurl"
|
||||
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
|
||||
import InfoCircle from "@assets/icons/info-circle.svg?dataurl"
|
||||
import Login2 from "@assets/icons/login-3.svg?dataurl"
|
||||
import {slide, fade, fly} from "@lib/transition"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import Divider from "@lib/components/Divider.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import PageBar from "@lib/components/PageBar.svelte"
|
||||
import PageContent from "@lib/components/PageContent.svelte"
|
||||
import Divider from "@lib/components/Divider.svelte"
|
||||
import ThunkToast from "@app/components/ThunkToast.svelte"
|
||||
import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
|
||||
import RoomName from "@app/components/RoomName.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import RoomCompose from "@app/components/RoomCompose.svelte"
|
||||
import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
|
||||
import RoomImage from "@app/components/RoomImage.svelte"
|
||||
import RoomDetail from "@app/components/RoomDetail.svelte"
|
||||
import RoomItem from "@app/components/RoomItem.svelte"
|
||||
import RoomName from "@app/components/RoomName.svelte"
|
||||
import SpaceSearch from "@app/components/SpaceSearch.svelte"
|
||||
import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
|
||||
import ThunkToast from "@app/components/ThunkToast.svelte"
|
||||
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
|
||||
import RoomItemRemoveMember from "@src/app/components/RoomItemRemoveMember.svelte"
|
||||
import RoomCompose from "@app/components/RoomCompose.svelte"
|
||||
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
|
||||
import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
|
||||
import RoomItemRemoveMember from "@src/app/components/RoomItemRemoveMember.svelte"
|
||||
import {canEnforceNip70, prependParent, publishDelete} from "@app/core/commands"
|
||||
import {
|
||||
decodeRelay,
|
||||
deriveUserRoomMembershipStatus,
|
||||
deriveRoom,
|
||||
deriveUserRoomMembershipStatus,
|
||||
MESSAGE_KINDS,
|
||||
MembershipStatus,
|
||||
PROTECTED,
|
||||
MESSAGE_KINDS,
|
||||
userSettingsValues,
|
||||
} from "@app/core/state"
|
||||
import {checked} from "@app/util/notifications"
|
||||
import {canEnforceNip70, prependParent, publishDelete} from "@app/core/commands"
|
||||
import {makeFeed} from "@app/core/requests"
|
||||
import {popKey} from "@lib/implicit"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {checked} from "@app/util/notifications"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
|
||||
const {h, relay} = $page.params as MakeNonOptional<typeof $page.params>
|
||||
const mounted = now()
|
||||
@@ -59,6 +61,7 @@
|
||||
const room = deriveRoom(url, h)
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
const membershipStatus = deriveUserRoomMembershipStatus(url, h)
|
||||
const at = $derived(parseInt($page.url.searchParams.get("at")!))
|
||||
|
||||
const showRoomDetail = () => pushModal(RoomDetail, {url, h})
|
||||
|
||||
@@ -110,55 +113,62 @@
|
||||
}
|
||||
|
||||
const onSubmit = async ({content, tags}: EventContent) => {
|
||||
tags.push(["h", h])
|
||||
try {
|
||||
tags.push(["h", h])
|
||||
|
||||
if (await shouldProtect) {
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
if (await shouldProtect) {
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
|
||||
let template: EventContent & {created_at?: number} = {content, tags}
|
||||
let template: EventContent & {created_at?: number} = {content, tags}
|
||||
|
||||
if (eventToEdit) {
|
||||
// Delete previous message, to be republished with same timestamp
|
||||
template.created_at = eventToEdit.created_at
|
||||
publishDelete({
|
||||
if (eventToEdit) {
|
||||
// Don't do anything if message hasn't changed
|
||||
if (eventToEdit.content === content) {
|
||||
return
|
||||
}
|
||||
|
||||
// Delete previous message, to be republished with same timestamp
|
||||
template.created_at = eventToEdit.created_at
|
||||
publishDelete({
|
||||
relays: [url],
|
||||
event: $state.snapshot(eventToEdit),
|
||||
protect: await shouldProtect,
|
||||
})
|
||||
}
|
||||
|
||||
if (share) {
|
||||
template = prependParent(share, template, url)
|
||||
}
|
||||
|
||||
if (parent) {
|
||||
template = prependParent(parent, template, url)
|
||||
}
|
||||
|
||||
const thunk = publishThunk({
|
||||
relays: [url],
|
||||
event: $state.snapshot(eventToEdit),
|
||||
protect: await shouldProtect,
|
||||
event: makeEvent(MESSAGE, template),
|
||||
delay: $userSettingsValues.send_delay,
|
||||
})
|
||||
|
||||
if ($userSettingsValues.send_delay) {
|
||||
pushToast({
|
||||
timeout: 30_000,
|
||||
children: {
|
||||
component: ThunkToast,
|
||||
props: {thunk},
|
||||
},
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
clearParent()
|
||||
clearShare()
|
||||
clearEventToEdit()
|
||||
}
|
||||
|
||||
if (share) {
|
||||
template = prependParent(share, template, url)
|
||||
}
|
||||
|
||||
if (parent) {
|
||||
template = prependParent(parent, template, url)
|
||||
}
|
||||
|
||||
const thunk = publishThunk({
|
||||
relays: [url],
|
||||
event: makeEvent(MESSAGE, template),
|
||||
delay: $userSettingsValues.send_delay,
|
||||
})
|
||||
|
||||
if ($userSettingsValues.send_delay) {
|
||||
pushToast({
|
||||
timeout: 30_000,
|
||||
children: {
|
||||
component: ThunkToast,
|
||||
props: {thunk},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
clearParent()
|
||||
clearShare()
|
||||
clearEventToEdit()
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
showScrollButton = Math.abs(element?.scrollTop || 0) > 1500
|
||||
const manageScrollPosition = () => {
|
||||
showScrollButton = !isNaN(at) || Math.abs(element?.scrollTop || 0) > 1500
|
||||
|
||||
const newMessages = document.getElementById("new-messages")
|
||||
|
||||
@@ -173,16 +183,47 @@
|
||||
showFixedNewMessages = y < 0
|
||||
}
|
||||
}
|
||||
|
||||
if (!userHasScrolled && !isNaN(at)) {
|
||||
const targetEvent = $events.find(event => event.created_at >= at)
|
||||
|
||||
if (targetEvent) {
|
||||
const target = element?.querySelector(`[data-event="${targetEvent.id}"]`)
|
||||
|
||||
if (target instanceof HTMLElement) {
|
||||
isProgrammaticScroll = true
|
||||
target.scrollIntoView({block: "center"})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
if (!isProgrammaticScroll) {
|
||||
userHasScrolled = true
|
||||
manageScrollPosition()
|
||||
}
|
||||
|
||||
isProgrammaticScroll = false
|
||||
}
|
||||
|
||||
const scrollToNewMessages = () =>
|
||||
document.getElementById("new-messages")?.scrollIntoView({behavior: "smooth", block: "center"})
|
||||
|
||||
const scrollToBottom = () => element?.scrollTo({top: 0, behavior: "smooth"})
|
||||
const scrollToBottom = () => {
|
||||
if (!isNaN(at)) {
|
||||
goto($page.url.pathname, {replaceState: true})
|
||||
} else {
|
||||
element?.scrollTo({top: 0, behavior: "smooth"})
|
||||
}
|
||||
}
|
||||
|
||||
let joining = $state(false)
|
||||
let leaving = $state(false)
|
||||
let loadingEvents = $state(true)
|
||||
let userHasScrolled = $state(false)
|
||||
let isProgrammaticScroll = $state(false)
|
||||
let loadingBackward = $state(true)
|
||||
let loadingForward = $state(true)
|
||||
let share = $state(popKey<TrustedEvent | undefined>("share"))
|
||||
let parent: TrustedEvent | undefined = $state()
|
||||
let element: HTMLElement | undefined = $state()
|
||||
@@ -213,7 +254,7 @@
|
||||
const adjustedLastChecked =
|
||||
lastChecked && lastUserEvent ? Math.max(lastUserEvent.created_at, lastChecked) : lastChecked
|
||||
|
||||
for (const event of $events.toReversed()) {
|
||||
for (const event of $events) {
|
||||
if (seen.has(event.id)) {
|
||||
continue
|
||||
}
|
||||
@@ -255,7 +296,7 @@
|
||||
|
||||
elements.reverse()
|
||||
|
||||
setTimeout(onScroll, 100)
|
||||
tick().then(manageScrollPosition)
|
||||
|
||||
return elements
|
||||
})
|
||||
@@ -265,10 +306,14 @@
|
||||
|
||||
const feed = makeFeed({
|
||||
url,
|
||||
at: at || now(),
|
||||
element: element!,
|
||||
filters: [{kinds: [...MESSAGE_KINDS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]}],
|
||||
onExhausted: () => {
|
||||
loadingEvents = false
|
||||
onBackwardExhausted: () => {
|
||||
loadingBackward = false
|
||||
},
|
||||
onForwardExhausted: () => {
|
||||
loadingForward = false
|
||||
},
|
||||
})
|
||||
|
||||
@@ -308,17 +353,15 @@
|
||||
|
||||
observer.observe(chatCompose!)
|
||||
observer.observe(dynamicPadding!)
|
||||
|
||||
start()
|
||||
|
||||
return () => {
|
||||
cleanup()
|
||||
observer.unobserve(chatCompose!)
|
||||
observer.unobserve(dynamicPadding!)
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
cleanup?.()
|
||||
})
|
||||
</script>
|
||||
|
||||
<PageBar>
|
||||
@@ -329,11 +372,9 @@
|
||||
<RoomName {url} {h} />
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<div class="row-2">
|
||||
<Button
|
||||
class="btn btn-neutral btn-sm tooltip tooltip-left"
|
||||
data-tip="Room information"
|
||||
onclick={showRoomDetail}>
|
||||
<div class="row-2 items-center">
|
||||
<SpaceSearch {url} {h} />
|
||||
<Button class="btn btn-neutral btn-sm btn-square" onclick={showRoomDetail}>
|
||||
<Icon size={4} icon={InfoCircle} />
|
||||
</Button>
|
||||
<SpaceMenuButton {url} />
|
||||
@@ -367,6 +408,11 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#if loadingForward}
|
||||
<p class="py-20 flex justify-center">
|
||||
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
|
||||
</p>
|
||||
{/if}
|
||||
{#each elements as { type, id, value, showPubkey } (id)}
|
||||
{#if type === "new-messages"}
|
||||
<div
|
||||
@@ -399,8 +445,8 @@
|
||||
{/if}
|
||||
{/each}
|
||||
<p class="flex h-10 items-center justify-center py-20">
|
||||
{#if loadingEvents}
|
||||
<Spinner loading={loadingEvents}>Looking for messages...</Spinner>
|
||||
{#if loadingBackward}
|
||||
<Spinner loading={loadingBackward}>Looking for messages...</Spinner>
|
||||
{:else}
|
||||
<Spinner>End of message history</Spinner>
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {onMount, tick} from "svelte"
|
||||
import {page} from "$app/stores"
|
||||
import {goto} from "$app/navigation"
|
||||
import type {Readable} from "svelte/store"
|
||||
import {readable} from "svelte/store"
|
||||
import {now, int, formatTimestampAsDate, MINUTE, ago} from "@welshman/lib"
|
||||
@@ -17,6 +18,7 @@
|
||||
import PageContent from "@lib/components/PageContent.svelte"
|
||||
import Divider from "@lib/components/Divider.svelte"
|
||||
import ThunkToast from "@app/components/ThunkToast.svelte"
|
||||
import SpaceSearch from "@app/components/SpaceSearch.svelte"
|
||||
import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
|
||||
import RoomItem from "@app/components/RoomItem.svelte"
|
||||
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
|
||||
@@ -26,7 +28,7 @@
|
||||
import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
|
||||
import {userSettingsValues, decodeRelay, PROTECTED, MESSAGE_KINDS} from "@app/core/state"
|
||||
import {prependParent, canEnforceNip70, publishDelete} from "@app/core/commands"
|
||||
import {setChecked, checked} from "@app/util/notifications"
|
||||
import {checked} from "@app/util/notifications"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {makeFeed} from "@app/core/requests"
|
||||
import {popKey} from "@lib/implicit"
|
||||
@@ -35,6 +37,7 @@
|
||||
const lastChecked = $checked[$page.url.pathname]
|
||||
const url = decodeRelay($page.params.relay!)
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
const at = $derived(parseInt($page.url.searchParams.get("at")!))
|
||||
|
||||
const replyTo = (event: TrustedEvent) => {
|
||||
parent = event
|
||||
@@ -54,49 +57,56 @@
|
||||
}
|
||||
|
||||
const onSubmit = async ({content, tags}: EventContent) => {
|
||||
let template: EventContent & {created_at?: number} = {content, tags}
|
||||
try {
|
||||
let template: EventContent & {created_at?: number} = {content, tags}
|
||||
|
||||
if (eventToEdit) {
|
||||
// Delete previous message, to be republished with same timestamp
|
||||
template.created_at = eventToEdit.created_at
|
||||
publishDelete({relays: [url], event: eventToEdit, protect: await shouldProtect})
|
||||
}
|
||||
if (eventToEdit) {
|
||||
// Don't do anything if message hasn't changed
|
||||
if (eventToEdit.content === content) {
|
||||
return
|
||||
}
|
||||
|
||||
if (await shouldProtect) {
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
// Delete previous message, to be republished with same timestamp
|
||||
template.created_at = eventToEdit.created_at
|
||||
publishDelete({relays: [url], event: eventToEdit, protect: await shouldProtect})
|
||||
}
|
||||
|
||||
if (share) {
|
||||
template = prependParent(share, template, url)
|
||||
}
|
||||
if (await shouldProtect) {
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
|
||||
if (parent) {
|
||||
template = prependParent(parent, template, url)
|
||||
}
|
||||
if (share) {
|
||||
template = prependParent(share, template, url)
|
||||
}
|
||||
|
||||
const thunk = publishThunk({
|
||||
relays: [url],
|
||||
event: makeEvent(MESSAGE, template),
|
||||
delay: $userSettingsValues.send_delay,
|
||||
})
|
||||
if (parent) {
|
||||
template = prependParent(parent, template, url)
|
||||
}
|
||||
|
||||
if ($userSettingsValues.send_delay) {
|
||||
pushToast({
|
||||
timeout: 30_000,
|
||||
children: {
|
||||
component: ThunkToast,
|
||||
props: {thunk},
|
||||
},
|
||||
const thunk = publishThunk({
|
||||
relays: [url],
|
||||
event: makeEvent(MESSAGE, template),
|
||||
delay: $userSettingsValues.send_delay,
|
||||
})
|
||||
}
|
||||
|
||||
clearParent()
|
||||
clearShare()
|
||||
clearEventToEdit()
|
||||
if ($userSettingsValues.send_delay) {
|
||||
pushToast({
|
||||
timeout: 30_000,
|
||||
children: {
|
||||
component: ThunkToast,
|
||||
props: {thunk},
|
||||
},
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
clearParent()
|
||||
clearShare()
|
||||
clearEventToEdit()
|
||||
}
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
showScrollButton = Math.abs(element?.scrollTop || 0) > 1500
|
||||
const manageScrollPosition = () => {
|
||||
showScrollButton = !isNaN(at) || Math.abs(element?.scrollTop || 0) > 1500
|
||||
|
||||
const newMessages = document.getElementById("new-messages")
|
||||
|
||||
@@ -111,14 +121,45 @@
|
||||
showFixedNewMessages = y < 0
|
||||
}
|
||||
}
|
||||
|
||||
if (!userHasScrolled && !isNaN(at)) {
|
||||
const targetEvent = $events.find(event => event.created_at >= at)
|
||||
|
||||
if (targetEvent) {
|
||||
const target = element?.querySelector(`[data-event="${targetEvent.id}"]`)
|
||||
|
||||
if (target instanceof HTMLElement) {
|
||||
isProgrammaticScroll = true
|
||||
target.scrollIntoView({block: "center"})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
if (!isProgrammaticScroll) {
|
||||
userHasScrolled = true
|
||||
manageScrollPosition()
|
||||
}
|
||||
|
||||
isProgrammaticScroll = false
|
||||
}
|
||||
|
||||
const scrollToNewMessages = () =>
|
||||
document.getElementById("new-messages")?.scrollIntoView({behavior: "smooth", block: "center"})
|
||||
|
||||
const scrollToBottom = () => element?.scrollTo({top: 0, behavior: "smooth"})
|
||||
const scrollToBottom = () => {
|
||||
if (!isNaN(at)) {
|
||||
goto($page.url.pathname, {replaceState: true})
|
||||
} else {
|
||||
element?.scrollTo({top: 0, behavior: "smooth"})
|
||||
}
|
||||
}
|
||||
|
||||
let loadingEvents = $state(true)
|
||||
let loadingBackward = $state(true)
|
||||
let loadingForward = $state(true)
|
||||
let userHasScrolled = $state(false)
|
||||
let isProgrammaticScroll = $state(false)
|
||||
let share = $state(popKey<TrustedEvent | undefined>("share"))
|
||||
let parent: TrustedEvent | undefined = $state()
|
||||
let element: HTMLElement | undefined = $state()
|
||||
@@ -149,7 +190,7 @@
|
||||
const adjustedLastChecked =
|
||||
lastChecked && lastUserEvent ? Math.max(lastUserEvent.created_at, lastChecked) : lastChecked
|
||||
|
||||
for (const event of $events.toReversed()) {
|
||||
for (const event of $events) {
|
||||
if (seen.has(event.id)) {
|
||||
continue
|
||||
}
|
||||
@@ -191,11 +232,31 @@
|
||||
|
||||
elements.reverse()
|
||||
|
||||
setTimeout(onScroll, 100)
|
||||
tick().then(manageScrollPosition)
|
||||
|
||||
return elements
|
||||
})
|
||||
|
||||
const start = () => {
|
||||
cleanup?.()
|
||||
|
||||
const feed = makeFeed({
|
||||
url,
|
||||
at: at || now(),
|
||||
element: element!,
|
||||
filters: [{kinds: [...MESSAGE_KINDS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]}],
|
||||
onBackwardExhausted: () => {
|
||||
loadingBackward = false
|
||||
},
|
||||
onForwardExhausted: () => {
|
||||
loadingForward = false
|
||||
},
|
||||
})
|
||||
|
||||
events = feed.events
|
||||
cleanup = feed.cleanup
|
||||
}
|
||||
|
||||
const onEscape = () => {
|
||||
clearParent()
|
||||
clearShare()
|
||||
@@ -230,29 +291,13 @@
|
||||
|
||||
observer.observe(chatCompose!)
|
||||
observer.observe(dynamicPadding!)
|
||||
|
||||
const feed = makeFeed({
|
||||
url,
|
||||
element: element!,
|
||||
filters: [{kinds: [...MESSAGE_KINDS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]}],
|
||||
onExhausted: () => {
|
||||
loadingEvents = false
|
||||
},
|
||||
})
|
||||
|
||||
events = feed.events
|
||||
cleanup = feed.cleanup
|
||||
start()
|
||||
|
||||
return () => {
|
||||
cleanup()
|
||||
controller.abort()
|
||||
observer.unobserve(chatCompose!)
|
||||
observer.unobserve(dynamicPadding!)
|
||||
|
||||
// Sveltekit calls onDestroy at the beginning of the page load for some reason
|
||||
setTimeout(() => {
|
||||
setChecked($page.url.pathname)
|
||||
}, 800)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -267,12 +312,20 @@
|
||||
<strong>Chat</strong>
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<SpaceMenuButton {url} />
|
||||
<div class="row-2 items-center">
|
||||
<SpaceSearch {url} />
|
||||
<SpaceMenuButton {url} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</PageBar>
|
||||
|
||||
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4">
|
||||
<div bind:this={dynamicPadding}></div>
|
||||
{#if loadingForward}
|
||||
<p class="py-20 flex justify-center">
|
||||
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
|
||||
</p>
|
||||
{/if}
|
||||
{#each elements as { type, id, value, showPubkey } (id)}
|
||||
{#if type === "new-messages"}
|
||||
<div
|
||||
@@ -305,8 +358,8 @@
|
||||
{/if}
|
||||
{/each}
|
||||
<p class="flex h-10 items-center justify-center py-20">
|
||||
{#if loadingEvents}
|
||||
<Spinner loading={loadingEvents}>Looking for messages...</Spinner>
|
||||
{#if loadingBackward}
|
||||
<Spinner loading={loadingBackward}>Looking for messages...</Spinner>
|
||||
{:else}
|
||||
<Spinner>End of message history</Spinner>
|
||||
{/if}
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
url,
|
||||
element: element!,
|
||||
filters: [{kinds: [CLASSIFIED]}, makeCommentFilter([CLASSIFIED])],
|
||||
onExhausted: () => {
|
||||
onBackwardExhausted: () => {
|
||||
loading = false
|
||||
},
|
||||
})
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
url,
|
||||
element: element!,
|
||||
filters: [{kinds: [ZAP_GOAL]}, makeCommentFilter([ZAP_GOAL])],
|
||||
onExhausted: () => {
|
||||
onBackwardExhausted: () => {
|
||||
loading = false
|
||||
},
|
||||
})
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
url,
|
||||
element: element!,
|
||||
filters: [{kinds: [THREAD]}, makeCommentFilter([THREAD])],
|
||||
onExhausted: () => {
|
||||
onBackwardExhausted: () => {
|
||||
loading = false
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user