Fix regressions found auditing the changes since 1.11.2

This commit is contained in:
Jon Staab 2026-09-30 13:51:08 -07:00
parent 90edc93014
commit 96c0073f3a
35 changed files with 368 additions and 310 deletions

View file

@ -291,6 +291,8 @@ test("US-094 invite people to a space", async ({seed, as}) => {
await expect(page.getByRole("alert")).toContainText("Members have successfully been added!")
// Adding members opened over the invite, so saving goes back to it.
await invite.getByRole("button", {name: "Done"}).click()
await page.locator(".secondary-nav").getByRole("link", {name: "Directory"}).click()
await expect(memberCard(page, "Nadia Newcomer")).toBeVisible()

View file

@ -782,20 +782,18 @@ test("US-054 curate the library", async ({seed, as}) => {
await expect(page.getByRole("heading", {name: "Reading List"})).toBeVisible()
await expect(page.getByText("#reading")).toBeVisible()
// Several links go in at once, each as a card of its own.
// Each link goes in on its own and becomes a card of its own.
const nevent = nip19.neventEncode({id: message.id, kind: MESSAGE, relays: [url]})
await page.getByRole("button", {name: "Add a link"}).click()
for (const link of ["https://handbook.test/style-guide", `nostr:${nevent}`]) {
await page.getByRole("button", {name: "Add a link"}).click()
const links = dialog(page, "Add a Link")
const links = dialog(page, "Add a Link")
await links
.getByPlaceholder("URL or nevent...")
.fill(`https://handbook.test/style-guide\nnostr:${nevent}`)
// A title and a description are about one link, so a batch is offered neither.
await expect(links.getByPlaceholder("Optional title")).toHaveCount(0)
await links.getByRole("button", {name: "Add 2 links"}).click()
await links.getByLabel("Link", {exact: true}).fill(link)
await links.getByRole("button", {name: "Add link"}).click()
await expect(links).toHaveCount(0)
}
await expect(page.locator('a[href="https://handbook.test/style-guide"]')).toBeVisible()
await expect(page.getByText("the deploy broke again")).toBeVisible()
@ -810,7 +808,7 @@ test("US-054 curate the library", async ({seed, as}) => {
const linkEdit = dialog(page, "Edit Link")
await linkEdit.getByPlaceholder("Optional title").fill("House style guide")
await linkEdit.getByLabel("Title", {exact: true}).fill("House style guide")
await linkEdit.getByRole("button", {name: "Save changes"}).click()
await expect(page.getByRole("alert")).toContainText("Link updated!")
@ -835,7 +833,7 @@ test("US-054 curate the library", async ({seed, as}) => {
const fromPoll = dialog(page, "Add a Link")
await fromPoll.getByPlaceholder("Optional title").fill("Standup poll")
await fromPoll.getByLabel("Title", {exact: true}).fill("Standup poll")
await fromPoll.getByRole("button", {name: "Add link"}).click()
await expect(page.getByRole("alert")).toContainText("Link added!")

View file

@ -98,16 +98,16 @@
"@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^1.0.2",
"@vite-pwa/sveltekit": "^1.1.0",
"@welshman/app": "^0.12.2",
"@welshman/content": "^0.12.2",
"@welshman/domain": "^0.12.2",
"@welshman/editor": "^0.12.2",
"@welshman/feeds": "^0.12.2",
"@welshman/lib": "^0.12.2",
"@welshman/net": "^0.12.2",
"@welshman/signer": "^0.12.2",
"@welshman/store": "^0.12.2",
"@welshman/util": "^0.12.2",
"@welshman/app": "^0.12.3",
"@welshman/content": "^0.12.3",
"@welshman/domain": "^0.12.3",
"@welshman/editor": "^0.12.3",
"@welshman/feeds": "^0.12.3",
"@welshman/lib": "^0.12.3",
"@welshman/net": "^0.12.3",
"@welshman/signer": "^0.12.3",
"@welshman/store": "^0.12.3",
"@welshman/util": "^0.12.3",
"cheerio": "^1.2.0",
"compressorjs-next": "^1.1.2",
"dompurify": "^3.4.13",

Binary file not shown.

View file

@ -3,7 +3,13 @@ import {Hono} from "hono"
import {load} from "cheerio"
import {ParsedType, parse, renderAsText, summarize} from "@welshman/content"
import {MINUTE, ellipsize, first, identity, int, ms, sleep, tryCatch} from "@welshman/lib"
import {displayRelayUrl, getIdFilters, normalizeRelayUrl} from "@welshman/util"
import {
displayRelayUrl,
getIdFilters,
isIPAddress,
isShareableRelayUrl,
normalizeRelayUrl,
} from "@welshman/util"
import {
Article,
Classified,
@ -13,6 +19,7 @@ import {
ZapGoal,
displayPubkey,
} from "@welshman/domain"
import {LOCAL_RELAY_URL, MockAdapter} from "@welshman/net"
import {Domain, Network, Profiles, Relays, Rooms, createApp, makeRoomKey} from "@welshman/app"
const PLATFORM_NAME = process.env.VITE_PLATFORM_NAME
@ -21,7 +28,29 @@ const fromCsv = value => (value || "").split(",").filter(identity)
const defaultRelays = fromCsv(process.env.VITE_DEFAULT_RELAYS).map(normalizeRelayUrl)
// Anyone can put any host in a url or an event's tags, so the server only reaches public relays.
const isPublicRelayUrl = url => isShareableRelayUrl(url) && !isIPAddress(url)
const toRelayUrl = value => {
const url = tryCatch(() => normalizeRelayUrl(value))
return url && isPublicRelayUrl(url) ? url : undefined
}
// Every socket goes through here, so a relay that isn't public refuses rather than being dialed.
const refuseRelay = url => {
const adapter = new MockAdapter(url, ([verb, id]) => {
if (verb === "REQ") {
setTimeout(() => adapter.receive(["CLOSED", id, "blocked: not a public relay"]))
}
})
return adapter
}
const app = createApp({
getAdapter: url =>
url === LOCAL_RELAY_URL || isPublicRelayUrl(url) ? undefined : refuseRelay(url),
config: {
getDefaultRelays: () => defaultRelays,
getIndexerRelays: () => fromCsv(process.env.VITE_INDEXER_RELAYS).map(normalizeRelayUrl),
@ -200,14 +229,17 @@ const remember = async (key, describe) => {
return cached.metadata
}
const metadata = await describe()
// A relay that stalls leaves the page to go out without a preview rather than hold it open.
const metadata = await Promise.race([describe(), sleep(ms(4))])
if (cache.size > 500) {
cache.clear()
if (metadata) {
if (cache.size > 500) {
cache.clear()
}
cache.set(key, {metadata, expiresAt: Date.now() + ms(int(5, MINUTE))})
}
cache.set(key, {metadata, expiresAt: Date.now() + ms(int(5, MINUTE))})
return metadata
}
@ -234,7 +266,7 @@ export const createPreview = template => {
// A space relay answers nothing without auth, so most of these fall back to its nip-11 profile.
const renderSpace = describe =>
render(async (url, params) => {
const relayUrl = tryCatch(() => normalizeRelayUrl(params.relay))
const relayUrl = toRelayUrl(params.relay)
if (relayUrl) {
const space = await loadSpace(relayUrl)
@ -248,7 +280,7 @@ export const createPreview = template => {
preview.get(
"/join",
render(async url => {
const relayUrl = tryCatch(() => normalizeRelayUrl(url.searchParams.get("r")))
const relayUrl = toRelayUrl(url.searchParams.get("r"))
if (relayUrl) {
const space = await loadSpace(relayUrl)

View file

@ -68,6 +68,12 @@ describe("preview metadata", () => {
assert.equal(image, "https://app.flotilla.social/maskable-icon-512x512.png")
})
test("won't reach a relay on a private or local host", async () => {
for (const path of ["/join?r=localhost:8080", "/spaces/127.0.0.1", "/spaces/printer.local"]) {
assert.equal((await request(path)).title, "Flotilla")
}
})
test("leaves the platform's own pages alone", async () => {
for (const path of ["/", "/settings/profile", "/spaces/create", "/people/not-an-npub"]) {
assert.equal((await request(path)).title, "Flotilla")

View file

@ -175,7 +175,7 @@
<Button
data-tip="Join call"
aria-label="Join voice room"
class="button button-primary button-circle shadow-xl"
class="button button-primary button-circle"
onclick={openJoinDialog}>
<Icon icon={PhoneCallingRounded} size={4.5} />
</Button>

View file

@ -1,7 +1,7 @@
<script lang="ts">
import {writable} from "svelte/store"
import type {TrustedEvent} from "@welshman/util"
import {Note} from "@welshman/domain"
import {Comment} from "@welshman/domain"
import {isMobile, preventDefault} from "@lib/html"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import Plane from "@assets/icons/plane-2.svg?dataurl"
@ -37,17 +37,18 @@
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
// A reply to a room event is a NIP-22 comment, and a reply to a kind 1 note is a NIP-10 note.
// A reply to a space event goes to that space, and any other goes out as a public comment.
const publishReply = async (content: string, tags: string[][]) => {
if (url) {
return publishComment({url, event, parent, content, tags})
}
return $publisher.publish({
...(await writer(Note)
...(await writer(Comment)
.setContent(content)
.addTags(...tags)
.setParent(parent ?? event)
.setRootFromEvent(event)
.setParentFromEvent(parent ?? event)
.render()),
delay: await loadSendDelay(),
})

View file

@ -107,7 +107,7 @@
bind:this={form}
onsubmit={preventDefault(submit)}
class="left-content bottom-sai right-sai fixed z-feature mb-14 md:mb-0 w-full md:w-auto pr-2">
<div class="card mx-2 my-2 shadow-md">
<div class="card mx-2 my-2">
{#if parent}
<ComposeParent {url} event={parent} clear={() => onClearParent?.()} verb="Replying to" />
{/if}

View file

@ -2,6 +2,7 @@
import {writable} from "svelte/store"
import {uniq} from "@welshman/lib"
import {Note} from "@welshman/domain"
import {PublishStatus} from "@welshman/net"
import {isMobile, preventDefault} from "@lib/html"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
@ -59,14 +60,17 @@
relays: uniq([...relays, ...$userSpaceUrls]),
})
const error = (await publication.settled()).getError()
await publication.settled()
if (error) {
return pushToast({theme: "error", message: error})
if (publication.getUrlsWithStatus(PublishStatus.Success).length > 0) {
draftKey.clear()
history.back()
} else {
pushToast({theme: "error", message: publication.getError() ?? "Failed to post."})
}
draftKey.clear()
history.back()
} catch (e) {
console.error(e)
pushToast({theme: "error", message: "Failed to post."})
} finally {
loading = false
}
@ -96,7 +100,7 @@
<ModalBody>
<ModalHeader>
<ModalTitle>Create a Public Post</ModalTitle>
<ModalSubtitle>Published to your outbox and all favorited spaces.</ModalSubtitle>
<ModalSubtitle>Broadcast to the wider network.</ModalSubtitle>
</ModalHeader>
<div class="relative">
<div class="note-editor grow overflow-hidden">

View file

@ -15,10 +15,14 @@
const reference = $derived(pin.reference())
const quote = $derived.by(() => {
if (reference?.type === "event" || reference?.type === "address") {
const raw = pinToReference(pin)
const raw = pinToReference(pin)
return {raw, value: nip19.decode(raw).data}
if (raw && reference?.type !== "external") {
const decoded = nip19.decode(raw)
if (decoded.type === "nevent" || decoded.type === "naddr") {
return {raw, value: decoded.data}
}
}
return undefined

View file

@ -1,14 +1,13 @@
<script lang="ts">
import cx from "classnames"
import {writable} from "svelte/store"
import {randomId} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {Pin} from "@welshman/domain"
import {publish} from "@welshman/app"
import {preventDefault, stopPropagation} from "@lib/html"
import {slide} from "@lib/transition"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import CloudUpload from "@assets/icons/cloud-upload.svg?dataurl"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
@ -24,8 +23,9 @@
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
import {command, reader, writer} from "@app/core"
import {pinToReference, setPinReference, uploadFilesAsReferences} from "@app/pinboards"
import {parsePinReference, pinToReference} from "@app/pinboards"
import {addressWriterToSpace} from "@app/rooms"
import {compressFileForUpload, uploadFile} from "@app/uploads"
import {pushToast} from "@app/toast"
import {clearModals} from "@app/modal"
@ -36,113 +36,73 @@
event?: TrustedEvent
}
type Fields = {
title: string
content: string
topics: string[]
}
const {url, address, reference = "", event}: Props = $props()
const fileInputId = randomId()
const pin = event ? reader(Pin)(event) : undefined
const uploading = writable(false)
const back = () => history.back()
const upload = async (files: File[]) => {
$uploading = true
const onFileChange = async (e: Event & {currentTarget: HTMLInputElement}) => {
const file = e.currentTarget.files?.[0]
try {
const {references: uploaded, errors} = await uploadFilesAsReferences(url, files)
if (file) {
e.currentTarget.value = ""
uploadingLink = true
value = [...references, ...uploaded].join("\n")
try {
const {result, error} = await uploadFile(await compressFileForUpload(file), {url})
if (errors.length > 0) {
pushToast({theme: "error", message: errors.join(" ")})
if (result) {
value = result.url
} else {
pushToast({theme: "error", message: error ?? `${file.name} could not be uploaded.`})
}
} catch (e) {
console.error(e)
pushToast({theme: "error", message: `${file.name} could not be uploaded.`})
} finally {
uploadingLink = false
}
} finally {
$uploading = false
}
}
const onFileChange = (e: Event) => {
if (e.target instanceof HTMLInputElement && e.target.files?.length) {
upload(Array.from(e.target.files))
e.target.value = ""
}
}
const onDrop = (e: Event) => {
dropActive = false
if (e instanceof DragEvent && e.dataTransfer?.files.length) {
upload(Array.from(e.dataTransfer.files))
}
}
const onDragOver = () => {
dropActive = true
}
const onDragLeave = () => {
dropActive = false
}
const savePin = async (target: string, fields: Fields) => {
const eventWriter = await addressWriterToSpace(
pin ? writer(Pin, pin) : writer(Pin).setIdentifier(),
url,
)
if (address && !pin) {
eventWriter.addBoard(address)
}
if (!setPinReference(eventWriter, target)) {
return `${target} is not a valid URL or nostr link.`
}
eventWriter.setTitle(fields.title).setTopics(fields.topics).setContent(fields.content)
const publication = await command(eventWriter).then(publish)
return (await publication.settled()).getError()
}
const submit = async () => {
loading = true
if (pinReference) {
loading = true
try {
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
try {
const ed = await editor
const eventWriter = await addressWriterToSpace(
pin ? writer(Pin, pin) : writer(Pin).setIdentifier(),
url,
)
// A title and a description are about one link, so a batch is filed with neither.
const fields =
references.length === 1
? {title, content, topics}
: {title: "", content: "", topics: [] as string[]}
if (address && !pin) {
eventWriter.addBoard(address)
}
const errors = await Promise.all(references.map(target => savePin(target, fields)))
const error = errors.find(Boolean)
eventWriter
.setReference(pinReference)
.setTitle(title)
.setTopics(topics)
.setContent(ed.getText({blockSeparator: "\n"}).trim())
if (error) {
pushToast({theme: "error", message: error})
} else if (pin) {
pushToast({message: "Link updated!"})
clearModals()
} else if (references.length > 1) {
pushToast({message: `${references.length} links added!`})
clearModals()
} else {
pushToast({message: "Link added!"})
clearModals()
const publication = await command(eventWriter).then(publish)
const error = (await publication.settled()).getError()
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: pin ? "Link updated!" : "Link added!"})
clearModals()
}
} catch (e) {
console.error(e)
pushToast({theme: "error", message: "Failed to save the link."})
} finally {
loading = false
}
} catch (e) {
console.error(e)
pushToast({theme: "error", message: "Failed to save the link."})
} finally {
loading = false
}
}
@ -154,9 +114,9 @@
let topics = $state<string[]>(pin?.topics() ?? [])
let value = $state(pin ? pinToReference(pin) : reference)
let loading = $state(false)
let dropActive = $state(false)
let uploadingLink = $state(false)
const references = $derived(value.split(/\s+/).filter(Boolean))
const pinReference = $derived(parsePinReference(value))
</script>
<Modal>
@ -176,88 +136,76 @@
Link
{/snippet}
{#snippet info()}
<p>
A URL or a nostr link (note, nevent, naddr, npub, or nprofile). Drop files here to upload
them. Paste several links, one per line, to file them all at once.
</p>
{#if value.trim() && !pinReference}
<span class="text-error">Please enter a valid URL or nostr link.</span>
{:else}
<span>A URL or a nostr link, or upload a file.</span>
{/if}
{/snippet}
{#snippet input()}
<div
class="flex flex-col gap-2"
role="presentation"
ondragover={stopPropagation(preventDefault(onDragOver))}
ondragleave={stopPropagation(preventDefault(onDragLeave))}
ondrop={stopPropagation(preventDefault(onDrop))}>
<textarea
bind:value
rows={pin ? 2 : 3}
class="textarea input w-full"
style:border-color={dropActive ? "var(--primary)" : undefined}
placeholder="URL or nevent..."></textarea>
{#if !pin}
<label
for={fileInputId}
class="button button-neutral button-sm cursor-pointer self-start">
{#if $uploading}
<Spinner size="xs" />
{:else}
<Icon size={4} icon={CloudUpload} />
{/if}
Upload files
</label>
<input id={fileInputId} type="file" multiple class="hidden" onchange={onFileChange} />
{/if}
<div class="flex gap-2">
<label class="input flex min-w-0 grow items-center gap-2">
<Icon icon={LinkRound} />
<input bind:value class="grow" aria-label="Link" placeholder="https://..." />
</label>
<label
class={cx("button button-neutral button-input", {
"button-disabled": loading || uploadingLink,
})}>
{#if uploadingLink}
<Spinner size="xs" />
{:else}
<Icon icon={UploadMinimalistic} size={4} />
{/if}
Upload
<input
type="file"
class="hidden"
disabled={loading || uploadingLink}
onchange={onFileChange} />
</label>
</div>
{/snippet}
</Field>
{#if references.length > 1}
<p transition:slide class="card card-sm text-sm opacity-75">
{references.length} links, each filed on its own. A title and a description describe one link,
so add them from its menu once it is on the shelf.
</p>
{:else if references.length === 1}
<div transition:slide class="card card-sm flex flex-col gap-4 opacity-75">
<Field>
{#snippet label()}
Title
{/snippet}
{#snippet input()}
<input bind:value={title} class="input w-full" placeholder="Optional title" />
{/snippet}
</Field>
<Field>
{#snippet label()}
Description
{/snippet}
{#snippet input()}
<div class="relative z-feature flex gap-2">
<div class="input-editor grow overflow-hidden">
<EditorContent {editor} />
</div>
<Button
data-tip="Add an image"
class="button button-neutral button-input tip"
onclick={selectFiles}
disabled={loading}>
{#if $uploading}
<Spinner size="xs" />
{:else}
<Icon icon={GallerySend} />
{/if}
</Button>
</div>
{/snippet}
</Field>
<Field>
{#snippet label()}
Topics
{/snippet}
{#snippet input()}
<StringMultiInput bind:value={topics} placeholder="Add a topic..." />
{/snippet}
</Field>
</div>
{/if}
<Field>
{#snippet label()}
Title
{/snippet}
{#snippet input()}
<input bind:value={title} class="input w-full" aria-label="Title" />
{/snippet}
</Field>
<Field>
{#snippet label()}
Description
{/snippet}
{#snippet input()}
<div class="relative z-feature flex gap-2">
<div class="input-editor grow overflow-hidden">
<EditorContent {editor} />
</div>
<Button
data-tip="Add an image"
class="button button-neutral button-input tip"
onclick={selectFiles}
disabled={loading}>
{#if $uploading}
<Spinner size="xs" />
{:else}
<Icon icon={GallerySend} />
{/if}
</Button>
</div>
{/snippet}
</Field>
<Field>
{#snippet label()}
Topics
{/snippet}
{#snippet input()}
<StringMultiInput bind:value={topics} placeholder="Add a topic..." />
{/snippet}
</Field>
</ModalBody>
<ModalFooter>
<Button class="button button-link" onclick={back}>
@ -267,12 +215,10 @@
<Button
class="button button-primary"
onclick={submit}
disabled={loading || $uploading || references.length === 0}>
disabled={loading || uploadingLink || $uploading || !pinReference}>
<Spinner {loading}>
{#if pin}
Save changes
{:else if references.length > 1}
Add {references.length} links
{:else}
Add link
{/if}

View file

@ -1,8 +1,8 @@
<script lang="ts">
import {onDestroy} from "svelte"
import {formatTimestampRelative} from "@welshman/lib"
import {formatTimestampRelative, now} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {POLL_RESPONSE, tagSpec, tagValues, relay} from "@welshman/util"
import {POLL_RESPONSE, relay, stamp, tagSpec, tagValues} from "@welshman/util"
import {Poll, PollResponse} from "@welshman/domain"
import type {Publication} from "@welshman/app"
import PollOption from "@app/components/PollOption.svelte"
@ -46,8 +46,11 @@
const responseCommand = await command(eventWriter)
// Give the user time to check more boxes before the vote actually goes out.
// A vote is read by its timestamp, so a change within the same second has to sort after it.
const created_at = Math.max(now(), (ownResponse?.created_at ?? 0) + 1)
activePublication = $publisher.publish({
event: responseCommand.event,
event: stamp(responseCommand.event, created_at),
relays: responseCommand.relays,
delay: pollType === "multiplechoice" ? 1000 : undefined,
})

View file

@ -85,7 +85,7 @@
const togglePin = () => {
onClick()
toggleRoomPin(url, h, event.id)
toggleRoomPin(url, h, event)
}
</script>

View file

@ -112,7 +112,7 @@
const togglePin = () => {
history.back()
toggleRoomPin(url, h, event.id)
toggleRoomPin(url, h, event)
}
let showMore = $state(false)

View file

@ -46,7 +46,7 @@
</ModalHeader>
<div class="flex flex-col gap-2">
{#each $pinnedEvents as event (event.id)}
{@const onUnpin = () => toggleRoomPin(url, h, event.id)}
{@const onUnpin = () => toggleRoomPin(url, h, event)}
{@const onJump = () => jumpToMessage(event)}
<div class="card card-sm flex flex-col gap-3 p-4">
<div class="flex items-start gap-3">

View file

@ -24,7 +24,7 @@
const back = () => history.back()
const addMembers = () => pushModal(SpaceMembersAdd, {url}, {replaceState: true})
const addMembers = () => pushModal(SpaceMembersAdd, {url})
</script>
<Modal>

View file

@ -1,7 +1,7 @@
<script lang="ts">
import "leaflet/dist/leaflet.css"
import {mount, onMount, unmount} from "svelte"
import {mount, onMount, unmount, untrack} from "svelte"
import type {Map as LeafletMap, Marker, TileLayer} from "leaflet"
import type {Maybe} from "@welshman/lib"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
@ -20,6 +20,11 @@
const basemap = $derived($activeTheme === "dark" ? "dark_all" : "light_all")
// Any profile loading re-emits the pins, so markers are redrawn only when a pin actually changes.
const pinsKey = $derived(
pins.map(pin => [pin.pubkey, pin.latitude, pin.longitude].join()).join(";"),
)
const openProfile = (pubkey: string) => pushModal(ProfileDetail, {pubkey, url})
let element: HTMLElement
@ -59,8 +64,8 @@
const markers: Marker[] = []
const avatars: Record<string, unknown>[] = []
if (leaflet && map) {
for (const pin of pins) {
if (leaflet && map && pinsKey) {
for (const pin of untrack(() => pins)) {
const target = document.createElement("div")
target.className =

View file

@ -6,7 +6,27 @@
import {roomLists} from "@app/core"
import {navigate, pushModal} from "@app/modal"
import {makeSpacePath} from "@app/routes"
import {createHostedRelay, getHostedRelayUrl, hosting, isPaidPlan} from "@app/hosting"
import {pushToast} from "@app/toast"
import {
createHostedRelay,
getHostedRelayUrl,
hosting,
hostingErrorMessage,
isPaidPlan,
} from "@app/hosting"
// The space exists by now, so a failed billing check mustn't keep the user on the create form.
const onPaymentLookupError = (e: unknown) => {
pushToast({
theme: "error",
message: hostingErrorMessage(
e,
"Your space was created, but its billing couldn't be checked.",
),
})
return undefined
}
const submit = async (values: RelayFormValues) => {
const relay = await createHostedRelay(values)
@ -17,7 +37,9 @@
// Join the space now, or the space layout prompts for it and displaces the payment modals.
await $roomLists.addRelay(url).then(publish)
const due = isPaidPlan(values.plan_id) ? await $hosting.findPaymentDue() : undefined
const due = isPaidPlan(values.plan_id)
? await $hosting.findPaymentDue().catch(onPaymentLookupError)
: undefined
await navigate(makeSpacePath(url, "admin"), {replaceState: true})

View file

@ -1,7 +1,7 @@
import * as nip19 from "nostr-tools/nip19"
import {derived} from "svelte/store"
import type {Readable} from "svelte/store"
import {removeUndefined, sortBy} from "@welshman/lib"
import {isHex32, removeUndefined, sortBy} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import {Address, PIN, PINBOARD, fromNostrURI, getAddress, isReplaceable} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
@ -22,7 +22,19 @@ import {
} from "@app/core"
import {addressWriterToSpace} from "@app/rooms"
import {isImageUrl} from "@app/content"
import {compressFileForUpload, uploadFile} from "@app/uploads"
// Tags from another client may be malformed, and nip19 throws on anything that isn't hex.
const isEncodable = (reference: PinReference) => {
if (reference.type === "event") {
return isHex32(reference.id)
}
if (reference.type === "address") {
return Address.isAddress(reference.address) && isHex32(Address.from(reference.address).pubkey)
}
return true
}
const encodeReference = (reference: PinReference) => {
if (reference.type === "event") {
@ -46,7 +58,7 @@ const encodeReference = (reference: PinReference) => {
export const pinToReference = (pin: PinReader) => {
const reference = pin.reference()
return reference ? encodeReference(reference) : ""
return reference && isEncodable(reference) ? encodeReference(reference) : ""
}
export const eventToReference = (event: TrustedEvent) =>
@ -56,37 +68,37 @@ export const eventToReference = (event: TrustedEvent) =>
: {type: "event", id: event.id},
)
// Point a pin at a user-entered nostr link or external url, false if it's neither.
export const setPinReference = (writer: PinWriter, reference: string) => {
const trimmed = reference.trim()
// Read a user-entered nostr link or external url as a pin reference.
export const parsePinReference = (input: string): Maybe<PinReference> => {
const trimmed = input.trim()
try {
const decoded = nip19.decode(fromNostrURI(trimmed))
if (decoded.type === "note") {
writer.setEvent(decoded.data)
return true
return {type: "event", id: decoded.data}
}
if (decoded.type === "nevent") {
writer.setEvent(decoded.data.id, decoded.data.relays?.[0])
return true
return {type: "event", id: decoded.data.id, relay: decoded.data.relays?.[0]}
}
// Pins have no pubkey reference type, so a person is stored as the external id the user gave.
if (decoded.type === "npub" || decoded.type === "nprofile") {
writer.setExternal(fromNostrURI(trimmed))
return true
return {type: "external", id: fromNostrURI(trimmed)}
}
if (decoded.type === "naddr") {
const {kind, pubkey, identifier, relays} = decoded.data
writer.setAddress(new Address(kind, pubkey, identifier).toString(), relays?.[0])
return true
return {
type: "address",
address: new Address(kind, pubkey, identifier).toString(),
relay: relays?.[0],
}
}
return false
return undefined
} catch {
// Not a nostr entity; fall through to external url handling.
}
@ -94,11 +106,10 @@ export const setPinReference = (writer: PinWriter, reference: string) => {
const parsed = parse({content: trimmed})
if (parsed.length === 1 && isLink(parsed[0])) {
writer.setExternal(trimmed)
return true
return {type: "external", id: trimmed}
}
return false
return undefined
}
export const deriveShelves = (url: string): Readable<PinboardReader[]> =>
@ -178,20 +189,3 @@ export const deleteLibraryEvent = async (url: string, event: TrustedEvent) => {
export const deletePins = (url: string, pins: PinReader[]) =>
Promise.all(pins.map(pin => deleteLibraryEvent(url, pin.event)))
export const uploadFilesAsReferences = async (url: string, files: File[]) => {
const references: string[] = []
const errors: string[] = []
for (const file of files) {
const {result, error} = await uploadFile(await compressFileForUpload(file), {url})
if (result) {
references.push(result.url)
} else {
errors.push(error ?? `${file.name} could not be uploaded.`)
}
}
return {references, errors}
}

View file

@ -46,23 +46,30 @@ export const pushState = withGetter(
}),
)
const defaultNotificationSettings = {
push: false,
sound: true,
badge: false,
spaces: true,
mentions: true,
messages: true,
events: true,
reminder_minutes: [5],
}
export const notificationSettings = withGetter(
synced({
key: "notificationSettings",
storage: kv,
defaultValue: {
push: false,
sound: true,
badge: false,
spaces: true,
mentions: true,
messages: true,
events: true,
reminder_minutes: [5],
},
defaultValue: defaultNotificationSettings,
}),
)
// synced restores the saved object whole, so fill in fields added since it was saved.
notificationSettings.ready.then(() =>
notificationSettings.update(settings => ({...defaultNotificationSettings, ...settings})),
)
export interface IPushAdapter {
request: (prompt?: boolean) => Promise<string>
disable: () => Promise<void>

View file

@ -90,7 +90,7 @@ export const saveNotificationSettings = async (
if (permission.startsWith("granted")) {
notificationSettings.set(values)
syncEventReminders()
syncEventReminders().catch(console.error)
}
return permission

View file

@ -1,5 +1,5 @@
import {MINUTE, first, formatTimestampRelative, int, ms, now, uniq} from "@welshman/lib"
import {EVENT_RSVP, getIdFilters, isSignedEvent} from "@welshman/util"
import {EVENT_RSVP, getAddress, getIdFilters, isSignedEvent} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {CalendarRsvp} from "@welshman/domain"
import {getEventStart, getEventTitle, isAttending} from "@app/calendar"
@ -7,6 +7,7 @@ import {app, network, reader, roomLists} from "@app/core"
import {notificationSettings, pushState} from "@app/push/adapters/common"
import {deleteReminderSchedule, putReminderSchedule} from "@app/push/server"
import type {Reminder} from "@app/push/server"
import {pushToast} from "@app/toast"
const getLeadTimes = () => {
const {events, reminder_minutes} = notificationSettings.get()
@ -28,7 +29,7 @@ const putSchedule = async (relay: string, event: TrustedEvent, reminders: Remind
if (subscription) {
const res = await putReminderSchedule(subscription.key, {
id: event.id,
id: getAddress(event),
relay,
event: isSignedEvent(event) ? event : undefined,
notifications: reminders,
@ -40,10 +41,15 @@ const putSchedule = async (relay: string, event: TrustedEvent, reminders: Remind
}
}
export const setEventReminders = (relay: string, event: TrustedEvent, attending: boolean) => {
export const setEventReminders = async (relay: string, event: TrustedEvent, attending: boolean) => {
const start = getEventStart(event)
return putSchedule(relay, event, attending && start ? makeReminders(event, start) : [])
try {
await putSchedule(relay, event, attending && start ? makeReminders(event, start) : [])
} catch (e) {
console.error(e)
pushToast({theme: "error", message: "Your event reminders couldn't be set."})
}
}
const loadAttendedEvents = async (pubkey: string) => {

View file

@ -52,13 +52,13 @@ export const deriveRoomPinnedEvents = (url: string, h: string): Readable<Trusted
}
})
export const toggleRoomPin = async (url: string, h: string, id: string) => {
export const toggleRoomPin = async (url: string, h: string, event: TrustedEvent) => {
const pins = roomPinLists.get().pins(url, h).get()
// The optimistic write flips the pin while the publish is in flight, so read it before it goes out.
const wasPinned = pins.includes(id)
const keys = [event.id, getIdOrAddress(event)]
const wasPinned = pins.some(pin => keys.includes(pin))
const command = await roomPinLists
.get()
.setPins(url, h, wasPinned ? pins.filter(pin => pin !== id) : [...pins, id])
.setPins(url, h, wasPinned ? pins.filter(pin => !keys.includes(pin)) : [...pins, event.id])
const error = (await command.publishToRelays([url]).settled()).getError()
if (error) {

View file

@ -1,6 +1,7 @@
import {derived, get, readable} from "svelte/store"
import {ago, avg, ms, now, spec} from "@welshman/lib"
import {makeHttpAuth, makeHttpAuthHeader} from "@welshman/util"
import {throttled} from "@welshman/store"
import {Logger} from "@welshman/app"
import type {LogMessage} from "@welshman/app"
import {fromApp, user} from "@app/core"
@ -70,8 +71,9 @@ export const signerHealth = derived([signerRequests, clock], ([$requests]) => {
}
})
// Checked on a timer rather than on change, so an alert held back by another toast still goes out.
export const syncSignerAlerts = () =>
derived(signerHealth, $signerHealth => $signerHealth.unresponsive).subscribe(unresponsive => {
throttled(ms(10), signerHealth).subscribe(({unresponsive}) => {
if (unresponsive && !get(toast)) {
pushToast({
theme: "error",

View file

@ -5,6 +5,7 @@
type Props = {
children: Snippet
onclick?: (event: Event) => unknown
onpointerdown?: (event: PointerEvent) => unknown
type?: "button" | "submit"
class?: string
style?: string

View file

@ -32,14 +32,33 @@
"aria-label": ariaLabel,
}: Props = $props()
const showPopover = () => tippy?.show()
const hidePopover = () => tippy?.hide()
// Tippy hides an open menu on the press, before the click lands, so note whether it was open then.
const onPointerDown = () => {
wasOpen = Boolean(tippy?.visible)
}
const toggle = () => {
if (wasOpen) {
hidePopover()
} else {
tippy?.show()
}
wasOpen = false
}
let tippy: Maybe<TippyController> = $state()
let wasOpen = false
</script>
<Button class={className} aria-label={ariaLabel} {disabled} onclick={showPopover}>
<Button
class={className}
aria-label={ariaLabel}
{disabled}
onpointerdown={onPointerDown}
onclick={toggle}>
<Tippy
bind:controller={tippy}
{component}

View file

@ -34,7 +34,7 @@
<div transition:fade class="absolute right-4 bottom-20 z-nav mb-sai md:bottom-4 md:mb-0">
<Button
aria-label="Scroll to top"
class="button button-neutral button-circle shadow-xl"
class="button button-neutral button-circle"
onclick={scrollToTop}>
<Icon icon={AltArrowUp} />
</Button>

View file

@ -7,8 +7,8 @@
import {goto} from "$app/navigation"
import {decodePubkey} from "@lib/util"
import Spinner from "@lib/components/Spinner.svelte"
import {network} from "@app/core"
import {goToEvent, goToNote, makeProfilePath} from "@app/routes"
import {app, network} from "@app/core"
import {goToEvent, goToNote, makeEventPath, makeProfilePath} from "@app/routes"
import type {PageProps} from "./$types"
const {params}: PageProps = $props()
@ -24,10 +24,13 @@
onEvent: (event: TrustedEvent) => {
found = true
if (event.kind === NOTE) {
goToNote({id: event.id, author: event.pubkey, relays})
} else {
const path = makeEventPath(event, Array.from(app.get().tracker.getRelays(event.id)))
// An event with no page of its own opens over home, not over this spinner.
if (path && event.kind !== NOTE) {
goToEvent(event, {replaceState: true})
} else {
goToNote({id: event.id, author: event.pubkey, relays})
}
},
onClose: () => {

View file

@ -73,7 +73,7 @@
<form {onsubmit}>
<PageContent>
<div class="card flex flex-col gap-4 shadow-md">
<div class="card flex flex-col gap-4">
<strong class="flex items-center gap-3 text-lg">
<Icon icon={Bell} />
Alert Settings
@ -112,7 +112,7 @@
</FieldInline>
</div>
<div
class={cx("card bg-surface flex flex-col gap-4 shadow-md", {
class={cx("card bg-surface flex flex-col gap-4", {
"pointer-events-none opacity-50": !settings.badge && !settings.sound && !settings.push,
})}>
<strong class="text-lg">Alert Types</strong>
@ -176,7 +176,7 @@
</div>
{/if}
</div>
<div class="card sticky -bottom-3 shadow-md flex flex-row items-center justify-between gap-4">
<div class="card sticky -bottom-3 flex flex-row items-center justify-between gap-4">
<Button class="button button-neutral" onclick={reset} disabled={loading}
>Discard Changes</Button>
<Button type="submit" class="button button-primary" disabled={loading}>

View file

@ -38,6 +38,8 @@
const setTheme = (value: string) => () => theme.set(value)
const saveFontSize = () => publishSettings({font_size: $settings.font_size})
const addServer = () => {
blossomServers = [...blossomServers, ""]
}
@ -204,11 +206,12 @@
max="1.3"
step="0.05"
aria-label="Font size"
bind:value={$settings.font_size} />
bind:value={$settings.font_size}
onchange={saveFontSize} />
{/snippet}
</Field>
</div>
<div class="card flex flex-col gap-4 shadow-md">
<div class="card flex flex-col gap-4">
<strong class="text-lg">Editor Settings</strong>
<Field>
{#snippet label()}
@ -273,7 +276,7 @@
{/snippet}
</Field>
</div>
<div class="card sticky -bottom-3 shadow-md flex flex-row items-center justify-between gap-4">
<div class="card sticky -bottom-3 flex flex-row items-center justify-between gap-4">
<Button class="button button-neutral" onclick={reset} disabled={loading}
>Discard Changes</Button>
<Button class="button button-primary" type="submit" disabled={loading}>

View file

@ -201,7 +201,7 @@
{/snippet}
</Field>
</div>
<div class="card sticky -bottom-3 shadow-md flex flex-row items-center justify-between gap-4">
<div class="card sticky -bottom-3 flex flex-row items-center justify-between gap-4">
<Button class="button button-neutral" onclick={reset} disabled={loading}
>Discard Changes</Button>
<Button class="button button-primary" type="submit" disabled={loading}>

View file

@ -94,7 +94,7 @@
</script>
<PageContent class="@container">
<div class="card shadow-md flex flex-col gap-2">
<div class="card flex flex-col gap-2">
<div class="flex justify-between gap-2">
<Link href={makeProfilePath($user.pubkey)} class="flex max-w-full gap-3">
<div class="py-1">
@ -119,7 +119,7 @@
</div>
<ProfileAbout pubkey={$user.pubkey} />
</div>
<div class="card flex flex-col gap-4 shadow-md">
<div class="card flex flex-col gap-4">
{#if pomadeEmail}
<FieldInline>
{#snippet label()}
@ -192,7 +192,7 @@
</div>
{/if}
</div>
<div class="card shadow-md">
<div class="card">
<div class="flex items-center justify-between">
<strong class="flex items-center gap-3">
<Icon icon={Settings} />

View file

@ -54,7 +54,7 @@
const back = () => history.back()
const addLinks = () => pushModal(PinSave, {url, address})
const addLink = () => pushModal(PinSave, {url, address})
const showContributors = () =>
pushModal(ProfileList, {
@ -91,7 +91,7 @@
<h1 class="text-xl">{$board?.title() || "Shelf"}</h1>
{/snippet}
{#snippet action()}
<Button class="button button-primary button-sm" onclick={addLinks}>
<Button class="button button-primary button-sm" onclick={addLink}>
<Icon icon={AddCircle} />
Add a link
</Button>
@ -160,7 +160,7 @@
<p class="opacity-70">Nothing on this shelf matches that.</p>
{:else}
<p class="opacity-70">This shelf doesn't have any links yet.</p>
<Button class="button button-primary" onclick={addLinks}>
<Button class="button button-primary" onclick={addLink}>
<Icon icon={AddCircle} />
Add the first link
</Button>

View file

@ -228,7 +228,7 @@
<div transition:fade class="absolute right-4 bottom-20 z-nav mb-sai md:bottom-4 md:mb-0">
<Button
aria-label="Scroll to newest"
class="button button-neutral button-circle shadow-xl"
class="button button-neutral button-circle"
onclick={scrollToBottom}>
<Icon icon={AltArrowDown} />
</Button>