Fix wallet status when wallet is unreachable (#79) #80

Closed
triesap wants to merge 33 commits from fix/79-wallet-status-unavailable into dev
45 changed files with 1284 additions and 432 deletions
+11 -1
View File
@@ -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,16 @@ 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.
**Human-First Simplicity (Jon Staab Style):**
- Prefer direct, readable code over layered abstractions.
- Do not add indirection (extra helpers, wrappers, stores, or derived state) unless it removes real repeated complexity.
- Reuse existing Welshman and Flotilla primitives before introducing new utilities or dependencies.
- Favor linear control flow and explicit naming over clever patterns.
- Remove defensive checks that do not apply in this runtime model.
- When two approaches work, pick the one that feels more human and easier to maintain.
## Common Tasks
+1 -1
View File
@@ -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
Executable
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env zsh
onchange src -ik -- npx svelte-kit sync &
onchange src -ik -- bash -c 'unbuffer npx svelte-check --tsconfig ./tsconfig.json | less -R' &
wait
+2 -2
View File
@@ -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 -2
View File
@@ -7,7 +7,7 @@
"build": "./build.sh",
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"check:watch": "./check.sh",
"lint": "prettier --check src && eslint src",
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
"format:all": "prettier --write src",
@@ -70,7 +70,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",
+6 -20
View File
@@ -110,9 +110,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)
@@ -2048,9 +2048,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 +2208,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 +2998,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'}
@@ -6909,8 +6902,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 +7064,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 +7992,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
+7 -3
View File
@@ -274,7 +274,7 @@
.input-editor,
.chat-editor,
.note-editor {
@apply -m-1 min-h-12 p-1 text-sm;
@apply -m-1 p-1;
}
.tiptap {
@@ -300,7 +300,7 @@
}
.tiptap {
@apply max-h-[350px] overflow-y-auto p-2 px-4;
@apply max-h-[350px] min-h-10 overflow-y-auto p-2 px-4;
}
.tiptap p.is-editor-empty:first-child::before {
@@ -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;
}
+5 -4
View File
@@ -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,6 +1,6 @@
<script lang="ts">
import {onMount, onDestroy} from "svelte"
import {displayUrl} from "@welshman/lib"
import {displayUrl, once} from "@welshman/lib"
import {
getTags,
getBlob,
@@ -27,7 +27,7 @@
const nonce = getTagValue("decryption-nonce", meta)
const algorithm = getTagValue("encryption-algorithm", meta)
const onError = async () => {
const onError = once(async () => {
// If the image failed to load, try authenticating
if (hash && $signer) {
const server = new URL(url).origin
@@ -43,7 +43,7 @@
} else {
hasError = true
}
}
})
let hasError = $state(false)
let src = $state("")
+4 -3
View File
@@ -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 -1
View File
@@ -26,7 +26,7 @@
<Icon icon={Reply} />
<span>{$replies.length} {$replies.length === 1 ? "reply" : "replies"}</span>
</div>
<div class="btn btn-neutral btn-xs relative hidden rounded-full sm:flex">
<div class="btn btn-neutral btn-xs relative rounded-full">
{#if gt(lastActive, $checked)}
<div class="h-2 w-2 rounded-full bg-primary"></div>
{/if}
+22 -14
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import {Capacitor} from "@capacitor/core"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Server from "@assets/icons/server.svg?dataurl"
import Moon from "@assets/icons/moon.svg?dataurl"
@@ -18,8 +20,8 @@
import {pushModal} from "@app/util/modal"
import {theme} from "@app/util/theme"
const back = () => history.back()
const logout = () => pushModal(LogOut)
const toggleTheme = () => theme.set($theme === "dark" ? "light" : "dark")
</script>
@@ -52,19 +54,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()}
@@ -120,6 +124,10 @@
<Button onclick={logout} class="btn btn-neutral">
<Icon icon={Exit} /> Log Out
</Button>
<Button class="btn btn-link w-full md:hidden" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
</div>
</ModalBody>
</Modal>
+2 -3
View File
@@ -2,15 +2,14 @@
import cx from "classnames"
import type {Snippet} from "svelte"
import {formatTimestamp} from "@welshman/lib"
import {getListTags, getPubkeyTagValues} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {userMuteList} from "@welshman/app"
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Profile from "@app/components/Profile.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import {goToEvent} from "@app/util/routes"
import {isEventMuted} from "@app/core/state"
const {
event,
@@ -32,7 +31,7 @@
muted = false
}
let muted = $state(getPubkeyTagValues(getListTags($userMuteList)).includes(event.pubkey))
let muted = $state($isEventMuted(event))
</script>
<div class="flex flex-col gap-2 shadow-md {restProps.class}">
+8 -5
View File
@@ -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>
+35 -7
View File
@@ -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>
+2 -2
View File
@@ -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,
+1 -3
View File
@@ -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 -15
View File
@@ -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">
+44 -19
View File
@@ -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>
+5 -4
View File
@@ -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">
+1 -1
View File
@@ -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()
}
}
+4 -4
View File
@@ -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>
+1 -1
View File
@@ -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>
+7 -1
View File
@@ -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}
+167
View File
@@ -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 -1
View File
@@ -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>
+126 -64
View File
@@ -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)
},
}
}
+152 -3
View File
@@ -27,6 +27,7 @@ import {
randomId,
tryCatch,
fromPairs,
groupBy,
remove,
} from "@welshman/lib"
import type {Override} from "@welshman/lib"
@@ -48,13 +49,19 @@ import {
makeDeriveEvent,
makeLoadItem,
makeDeriveItem,
deriveItems,
deriveItemsByKey,
deriveDeduplicated,
deriveEventsById,
deriveEventsByIdByUrl,
deriveEventsByIdForUrl,
getEventsByIdForUrl,
deriveEventsAsc,
deriveEventsDesc,
} from "@welshman/store"
import {
FEED,
FEEDS,
APP_DATA,
CLIENT_AUTH,
COMMENT,
@@ -87,6 +94,8 @@ import {
ZAP_GOAL,
ZAP_REQUEST,
ZAP_RESPONSE,
REPOST,
GENERIC_REPOST,
asDecryptedEvent,
getGroupTags,
getListTags,
@@ -101,14 +110,29 @@ import {
makeRoomMeta,
ManagementMethod,
sortEventsDesc,
getAddress,
Address,
getIdFilters,
getEventTagValues,
getAddressTagValues,
getParentIds,
getParentAddrs,
} from "@welshman/util"
import type {
TrustedEvent,
RelayProfile,
PublishedList,
PublishedRoomMeta,
List,
Filter,
} from "@welshman/util"
import type {TrustedEvent, RelayProfile, PublishedRoomMeta, List, Filter} from "@welshman/util"
import {routerContext, Router} from "@welshman/router"
import {
pubkey,
repository,
tracker,
createSearch,
userMuteList,
userFollowList,
ensurePlaintext,
makeOutboxLoader,
@@ -118,7 +142,9 @@ import {
makeUserLoader,
manageRelay,
displayProfileByPubkey,
getProfile,
} from "@welshman/app"
import {readFeed} from "@lib/feeds"
export const fromCsv = (s: string) => (s || "").split(",").filter(identity)
@@ -126,6 +152,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 +259,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())),
@@ -269,6 +308,8 @@ export const makeCommentFilter = (kinds: number[], extra: Filter = {}) => ({
...extra,
})
export const REPOST_KINDS = [REPOST, GENERIC_REPOST]
export const REACTION_KINDS = [REPORT, DELETE, REACTION]
if (ENABLE_ZAPS) {
@@ -434,7 +475,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 +507,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)),
@@ -896,6 +940,111 @@ export const deriveUserCanCreateRoom = (url: string) => {
)
}
// Feeds
export const feedsByAddress = deriveItemsByKey({
repository,
getKey: feed => getAddress(feed.event),
filters: [{kinds: [FEED]}],
eventToItem: readFeed,
})
export const getFeedsByAddress = getter(feedsByAddress)
export const feeds = deriveItems(feedsByAddress)
export const getFeeds = getter(feeds)
export const getFeed = (address: string) => getFeedsByAddress().get(address)
export const fetchFeed = (address: string) => {
const {pubkey} = Address.from(address)
return load({
relays: Router.get().FromPubkey(pubkey).getUrls(),
filters: getIdFilters([address]),
})
}
export const loadFeed = makeLoadItem(fetchFeed, getFeed)
export const deriveFeed = makeDeriveItem(feedsByAddress, loadFeed)
// Feeds by pubkey
export const feedsByPubkey = derived(feeds, $feeds => groupBy(f => f.event.pubkey, $feeds))
export const getFeedsByPubkey = getter(feedsByPubkey)
export const getFeedsForPubkey = (pubkey: string) => getFeedsByPubkey().get(pubkey)
export const loadFeedsForPubkey = makeLoadItem(makeOutboxLoader(FEED), getFeedsForPubkey)
export const userFeeds = makeUserData(feedsByPubkey, loadFeedsForPubkey)
export const loadUserFeeds = makeUserLoader(loadFeedsForPubkey)
// Feed favorites
export const feedFavoritesByPubkey = deriveItemsByKey<PublishedList>({
repository,
getKey: list => list.event.pubkey,
filters: [{kinds: [FEEDS]}],
eventToItem: async event =>
readList(
asDecryptedEvent(event, {
content: await ensurePlaintext(event),
}),
),
})
export const getFeedFavoritesByPubkey = getter(feedFavoritesByPubkey)
export const getFeedFavorites = (pubkey: string) => getFeedFavoritesByPubkey().get(pubkey)
export const loadFeedFavorites = makeLoadItem(makeOutboxLoader(FEEDS), getFeedFavorites)
export const userFeedFavorites = makeUserData(feedFavoritesByPubkey, loadFeedFavorites)
export const loadUserFeedFavorites = makeUserLoader(loadFeedFavorites)
// Mutes
export const isEventMuted = withGetter(
derived(userMuteList, $userMuteList => {
const pubkey = $userMuteList?.event.pubkey
const tags = getListTags($userMuteList)
const mutedEvents = new Set(getEventTagValues(tags))
const mutedPubkeys = new Set(getPubkeyTagValues(tags))
const mutedAddresses = new Set(getAddressTagValues(tags))
const mutedTopics = new Set(getTagValues("t", tags))
const mutedWords = getTagValues("word", tags)
const regex =
mutedWords.length > 0
? new RegExp(`\\b(${mutedWords.map(w => w.toLowerCase().trim()).join("|")})\\b`)
: null
return (e: TrustedEvent) => {
if (!pubkey) return false
if (pubkey === e.pubkey) return false
if (mutedPubkeys.has(e.pubkey)) return true
if (mutedEvents.has(e.id)) return true
if (mutedAddresses.has(getAddress(e))) return true
if (getParentIds(e).some(id => mutedEvents.has(id))) return true
if (getParentAddrs(e).some(address => mutedAddresses.has(address))) return true
if (getTagValues("t", e.tags).some(t => mutedTopics.has(t))) return true
if (regex) {
if (e.content?.toLowerCase().match(regex)) return true
if (displayProfileByPubkey(e.pubkey).toLowerCase().match(regex)) return true
if (tryCatch(() => getProfile(e.pubkey)?.nip05?.match(regex))) return true
}
return false
}
}),
)
// Other utils
export const encodeRelay = (url: string) =>
+2
View File
@@ -52,6 +52,7 @@ import {
getSpaceUrlsFromGroupList,
getSpaceRoomsFromGroupList,
makeCommentFilter,
loadFeedsForPubkey,
} from "@app/core/state"
import {hasBlossomSupport} from "@app/core/commands"
@@ -200,6 +201,7 @@ const syncUserData = () => {
loadMuteList($userRelayList.event.pubkey)
loadProfile($userRelayList.event.pubkey)
loadSettings($userRelayList.event.pubkey)
loadFeedsForPubkey($userRelayList.event.pubkey)
}
})
+2 -1
View File
@@ -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
View File
@@ -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)
}
}
+142
View File
@@ -0,0 +1,142 @@
import {append, identity, uniq} from "@welshman/lib"
import {repository} from "@welshman/app"
import {displayPubkey, getTagValue} from "@welshman/util"
import {PLATFORM_NAME, decodeRelay, getRoom, makeRoomId, splitChatId} from "@app/core/state"
const FALLBACK_APP_NAME = "Flotilla"
const staticTitles = new Map<string, string>([
["/", "Redirecting"],
["/home", "Home"],
["/discover", "Discover Spaces"],
["/spaces", "Your Spaces"],
["/spaces/create", "Create a Space"],
["/spaces/[relay]", "Space"],
["/spaces/[relay]/chat", "Space Chat"],
["/spaces/[relay]/recent", "Recent Activity"],
["/spaces/[relay]/threads", "Threads"],
["/spaces/[relay]/classifieds", "Classifieds"],
["/spaces/[relay]/calendar", "Calendar"],
["/spaces/[relay]/goals", "Goals"],
["/chat", "Messages"],
["/join", "Join Space"],
["/people", "Find People"],
["/settings/about", "About"],
["/settings/profile", "Profile Settings"],
["/settings/content", "Content Settings"],
["/settings/privacy", "Privacy Settings"],
["/settings/relays", "Relay Settings"],
["/settings/alerts", "Alert Settings"],
["/settings/wallet", "Wallet Settings"],
["/[bech32]", "Opening Link"],
])
const eventRoutes = new Set([
"/spaces/[relay]/threads/[id]",
"/spaces/[relay]/goals/[id]",
"/spaces/[relay]/calendar/[address]",
"/spaces/[relay]/classifieds/[address]",
])
type RouteParams = Record<string, string | undefined>
type TitlePage = {
route: {id: string | null}
params: RouteParams
}
type PageTitleContext = {
page: TitlePage
pubkey: string | undefined
}
const getRoomTitle = (params: RouteParams) => {
const relay = params.relay
const h = params.h
if (!relay || !h) {
return "Room"
}
const url = decodeRelay(relay)
return getRoom(makeRoomId(url, h))?.name || "Room"
}
const getEventForTitle = (routeId: string, params: RouteParams) => {
if (!eventRoutes.has(routeId)) {
return
}
const eventId = params.id || params.address
if (!eventId) {
return
}
return repository.getEvent(eventId)
}
const getChatTitle = (chatId: string | undefined, pubkey: string | undefined) => {
if (!chatId) {
return "Chat"
}
const chatPeers = pubkey ? uniq(append(pubkey, splitChatId(chatId))) : splitChatId(chatId)
const others = pubkey ? chatPeers.filter(pk => pk !== pubkey) : chatPeers
if (others.length === 1) {
return `Chat with ${displayPubkey(others[0])}`
}
if (others.length > 1) {
return `Group chat (${others.length})`
}
return "Chat"
}
export const makeTitle = (...parts: Array<string | undefined>) =>
parts
.map(part => part?.trim() || "")
.filter(identity)
.join(" · ") ||
PLATFORM_NAME ||
FALLBACK_APP_NAME
export const getPageTitle = ({page, pubkey}: PageTitleContext) => {
const routeId = page.route.id || ""
const staticTitle = staticTitles.get(routeId)
if (staticTitle) {
return makeTitle(staticTitle)
}
if (routeId === "/chat/[chat]") {
return makeTitle(getChatTitle(page.params.chat, pubkey))
}
if (routeId === "/spaces/[relay]/[h]") {
return makeTitle(getRoomTitle(page.params))
}
const event = getEventForTitle(routeId, page.params)
if (routeId === "/spaces/[relay]/threads/[id]") {
return makeTitle(getTagValue("title", event?.tags || []) || "Thread")
}
if (routeId === "/spaces/[relay]/calendar/[address]") {
return makeTitle(getTagValue("title", event?.tags || []) || "Event")
}
if (routeId === "/spaces/[relay]/classifieds/[address]") {
return makeTitle(getTagValue("title", event?.tags || []) || "Listing")
}
if (routeId === "/spaces/[relay]/goals/[id]") {
return makeTitle(event?.content || getTagValue("summary", event?.tags || []) || "Goal")
}
return makeTitle()
}
+7 -5
View File
@@ -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>
+78
View File
@@ -0,0 +1,78 @@
import {fromPairs, parseJson, randomId} from "@welshman/lib"
import {FEED, Address} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {
makeIntersectionFeed,
hasSubFeeds,
isTagFeed,
isAuthorFeed,
isScopeFeed,
} from "@welshman/feeds"
import type {Feed as IFeed} from "@welshman/feeds"
export type Feed = {
title: string
identifier: string
description: string
definition: IFeed
event?: TrustedEvent
}
export type PublishedFeed = Omit<Feed, "event"> & {
event: TrustedEvent
}
export const normalizeFeedDefinition = (feed: IFeed) =>
hasSubFeeds(feed) ? feed : makeIntersectionFeed(feed)
export const makeFeed = (feed: Partial<Feed> = {}): Feed => ({
title: "",
description: "",
identifier: randomId(),
definition: makeIntersectionFeed(),
...feed,
})
export const readFeed = (event: TrustedEvent) => {
const {d: identifier, title = "", description = "", feed = ""} = fromPairs(event.tags)
const definition = parseJson(feed) || makeIntersectionFeed()
return {title, identifier, description, definition, event} as PublishedFeed
}
export const createFeed = ({identifier, definition, title, description}: Feed) => ({
kind: FEED,
content: "",
tags: [
["d", identifier],
["alt", title],
["title", title],
["description", description],
["feed", JSON.stringify(definition)],
],
})
export const editFeed = (feed: PublishedFeed) => ({
kind: FEED,
content: feed.event.content,
tags: Object.entries({
...fromPairs(feed.event.tags),
title: feed.title,
alt: feed.title,
description: feed.description,
feed: JSON.stringify(feed.definition),
}),
})
export const displayFeed = (feed?: Feed) => feed?.title || "[no name]"
export const isTopicFeed = (f: IFeed) => isTagFeed(f) && f[1] === "#t"
export const isMentionFeed = (f: IFeed) => isTagFeed(f) && f[1] === "#p"
export const isAddressFeed = (f: IFeed) => isTagFeed(f) && f[1] === "#a"
export const isContextFeed = (f: IFeed) =>
isTagFeed(f) && f[1] === "#a" && f.slice(2).every(Address.isAddress)
export const isPeopleFeed = (f: IFeed) => isAuthorFeed(f) || isScopeFeed(f)
+16 -41
View File
@@ -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
}
+2
View File
@@ -26,3 +26,5 @@ export const buildUrl = (base: string | URL, ...pathname: string[]) => {
return url.toString()
}
export const addPeriod = (s: string) => (s + ".").replace(/\.+$/, ".")
+6
View File
@@ -7,6 +7,7 @@
import {App, type URLOpenListenerEvent} from "@capacitor/app"
import {dev} from "$app/environment"
import {goto} from "$app/navigation"
import {page} from "$app/stores"
import {sync, throttled} from "@welshman/store"
import {call} from "@welshman/lib"
import {defaultSocketPolicies} from "@welshman/net"
@@ -42,6 +43,7 @@
import * as notifications from "@app/util/notifications"
import * as storage from "@app/util/storage"
import {syncKeyboard} from "@app/util/keyboard"
import {getPageTitle} from "@app/util/title"
import NewNotificationSound from "@src/app/components/NewNotificationSound.svelte"
const {children} = $props()
@@ -199,6 +201,10 @@
App.removeAllListeners()
unsubscribe.then(call)
})
$effect(() => {
document.title = getPageTitle({page: $page, pubkey: $pubkey})
})
</script>
<svelte:head>
+2 -1
View File
@@ -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>
+110 -40
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import {nwc} from "@getalby/sdk"
import {LOCALE} from "@welshman/lib"
import cx from "classnames"
import {LOCALE, always, call, sleep} from "@welshman/lib"
import {displayRelayUrl, isNWCWallet, fromMsats} from "@welshman/util"
import {session, pubkey, profilesByPubkey} from "@welshman/app"
import DownloadMinimalistic from "@assets/icons/download-minimalistic.svg?dataurl"
@@ -13,7 +13,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 {getNwcClient, getWebLn} 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"
@@ -40,6 +40,53 @@
pushModal(WalletConnect)
}
}
let walletStatus = $state<"checking" | "connected" | "unavailable">("checking")
let walletStatusRequestId = 0
const isWalletAvailable = $derived(Boolean($session?.wallet) && walletStatus === "connected")
const statusClass = $derived(
cx("flex items-center gap-2 text-sm", {
"text-success": walletStatus === "connected",
"text-warning": walletStatus === "unavailable",
}),
)
const connectionVerb = $derived(walletStatus === "connected" ? "Connected to" : "Configured for")
const startWalletStatusCheck = (wallet = $session?.wallet) => {
if (!wallet) {
walletStatus = "checking"
return
}
const requestId = ++walletStatusRequestId
hodlbod marked this conversation as resolved
Review

Just do const ok = await Promise.race(getNwcClient().getInfo().then(always("connected")), sleep(5000).then(always("unavailable"))). Replaces 40 lines of code with 4, and it's much easier to follow.

Just do `const ok = await Promise.race(getNwcClient().getInfo().then(always("connected")), sleep(5000).then(always("unavailable")))`. Replaces 40 lines of code with 4, and it's much easier to follow.
walletStatus = "checking"
call(async () => {
const webLn = getWebLn()
const nextStatus =
wallet.type === "nwc"
? await Promise.race([
getNwcClient().getInfo().then(always("connected")).catch(always("unavailable")),
sleep(5000).then(always("unavailable")),
])
: webLn
? await Promise.race([
webLn.getInfo().then(always("connected")).catch(always("unavailable")),
sleep(5000).then(always("unavailable")),
])
: "unavailable"
if (requestId === walletStatusRequestId) {
walletStatus = nextStatus
}
})
}
$effect(() => {
startWalletStatusCheck($session?.wallet)
})
</script>
<div class="content column gap-4">
@@ -50,9 +97,17 @@
Wallet
</strong>
{#if $session?.wallet}
<div class="flex items-center gap-2 text-sm text-success">
<Icon icon={CheckCircle} size={4} />
Connected
<div class={statusClass}>
{#if walletStatus === "checking"}
<span class="loading loading-spinner loading-xs"></span>
Checking
{:else if walletStatus === "connected"}
<Icon icon={CheckCircle} size={4} />
Connected
{:else}
<Icon icon={InfoCircle} size={4} />
Unavailable
{/if}
</div>
{:else}
<Button class="btn btn-primary btn-sm" onclick={connect}>
1
@@ -67,39 +122,54 @@
{@const {node, version} = $session.wallet.info}
<div class="flex flex-col justify-between gap-2 lg:flex-row">
<p>
Connected to <strong>{node?.alias || version || "unknown wallet"}</strong>
{connectionVerb} <strong>{node?.alias || version || "unknown wallet"}</strong>
via <strong>{$session.wallet.type}</strong>
</p>
<p class="flex gap-2 whitespace-nowrap">
Balance:
{#await getWebLn()
?.enable()
.then(() => getWebLn().getBalance())}
{#if walletStatus === "connected"}
Balance:
{#await getWebLn()
?.enable()
.then(() => getWebLn().getBalance())}
<span class="loading loading-spinner loading-sm"></span>
{:then res}
{new Intl.NumberFormat(LOCALE).format(res?.balance || 0)}
{:catch}
[unknown]
{/await}
sats
{:else if walletStatus === "checking"}
Balance:
<span class="loading loading-spinner loading-sm"></span>
{:then res}
{new Intl.NumberFormat(LOCALE).format(res?.balance || 0)}
{:catch}
[unknown]
{/await}
sats
{:else}
Balance unavailable
{/if}
</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>
{connectionVerb} <strong>{lud16}</strong> via
<strong>{displayRelayUrl(relayUrl)}</strong>
</p>
<p class="flex gap-2 whitespace-nowrap">
Balance:
{#await new nwc.NWCClient({nostrWalletConnectUrl}).getBalance()}
{#if walletStatus === "connected"}
Balance:
{#await getNwcClient().getBalance()}
<span class="loading loading-spinner loading-sm"></span>
{:then res}
{new Intl.NumberFormat(LOCALE).format(fromMsats(res?.balance || 0))}
{:catch}
[unknown]
{/await}
sats
{:else if walletStatus === "checking"}
Balance:
<span class="loading loading-spinner loading-sm"></span>
{:then res}
{new Intl.NumberFormat(LOCALE).format(fromMsats(res?.balance || 0))}
{:catch}
[unknown]
{/await}
sats
{:else}
Balance unavailable
{/if}
</p>
</div>
{/if}
@@ -109,13 +179,17 @@
Disconnect Wallet
</Button>
<div class="flex w-full gap-4 lg:w-auto">
<Button class="btn btn-primary btn-sm flex-1 justify-center lg:flex-none" onclick={pay}>
<Button
class="btn btn-primary btn-sm flex-1 justify-center lg:flex-none"
onclick={pay}
disabled={!isWalletAvailable}>
<Icon icon={UploadMinimalistic} />
Send
</Button>
<Button
class="btn btn-secondary btn-sm flex-1 justify-center lg:flex-none"
onclick={receive}>
onclick={receive}
disabled={!isWalletAvailable}>
<Icon icon={DownloadMinimalistic} />
Receive
</Button>
@@ -126,17 +200,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}
+125 -79
View File
@@ -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}
+112 -59
View File
@@ -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
},
})
+1 -1
View File
@@ -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
},
})