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!") 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 page.locator(".secondary-nav").getByRole("link", {name: "Directory"}).click()
await expect(memberCard(page, "Nadia Newcomer")).toBeVisible() 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.getByRole("heading", {name: "Reading List"})).toBeVisible()
await expect(page.getByText("#reading")).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]}) 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 await links.getByLabel("Link", {exact: true}).fill(link)
.getByPlaceholder("URL or nevent...") await links.getByRole("button", {name: "Add link"}).click()
.fill(`https://handbook.test/style-guide\nnostr:${nevent}`) await expect(links).toHaveCount(0)
// 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 expect(page.locator('a[href="https://handbook.test/style-guide"]')).toBeVisible() await expect(page.locator('a[href="https://handbook.test/style-guide"]')).toBeVisible()
await expect(page.getByText("the deploy broke again")).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") 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 linkEdit.getByRole("button", {name: "Save changes"}).click()
await expect(page.getByRole("alert")).toContainText("Link updated!") 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") 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 fromPoll.getByRole("button", {name: "Add link"}).click()
await expect(page.getByRole("alert")).toContainText("Link added!") await expect(page.getByRole("alert")).toContainText("Link added!")

View file

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

Binary file not shown.

View file

@ -3,7 +3,13 @@ import {Hono} from "hono"
import {load} from "cheerio" import {load} from "cheerio"
import {ParsedType, parse, renderAsText, summarize} from "@welshman/content" import {ParsedType, parse, renderAsText, summarize} from "@welshman/content"
import {MINUTE, ellipsize, first, identity, int, ms, sleep, tryCatch} from "@welshman/lib" 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 { import {
Article, Article,
Classified, Classified,
@ -13,6 +19,7 @@ import {
ZapGoal, ZapGoal,
displayPubkey, displayPubkey,
} from "@welshman/domain" } from "@welshman/domain"
import {LOCAL_RELAY_URL, MockAdapter} from "@welshman/net"
import {Domain, Network, Profiles, Relays, Rooms, createApp, makeRoomKey} from "@welshman/app" import {Domain, Network, Profiles, Relays, Rooms, createApp, makeRoomKey} from "@welshman/app"
const PLATFORM_NAME = process.env.VITE_PLATFORM_NAME 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) 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({ const app = createApp({
getAdapter: url =>
url === LOCAL_RELAY_URL || isPublicRelayUrl(url) ? undefined : refuseRelay(url),
config: { config: {
getDefaultRelays: () => defaultRelays, getDefaultRelays: () => defaultRelays,
getIndexerRelays: () => fromCsv(process.env.VITE_INDEXER_RELAYS).map(normalizeRelayUrl), getIndexerRelays: () => fromCsv(process.env.VITE_INDEXER_RELAYS).map(normalizeRelayUrl),
@ -200,14 +229,17 @@ const remember = async (key, describe) => {
return cached.metadata 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) { if (metadata) {
cache.clear() 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 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. // A space relay answers nothing without auth, so most of these fall back to its nip-11 profile.
const renderSpace = describe => const renderSpace = describe =>
render(async (url, params) => { render(async (url, params) => {
const relayUrl = tryCatch(() => normalizeRelayUrl(params.relay)) const relayUrl = toRelayUrl(params.relay)
if (relayUrl) { if (relayUrl) {
const space = await loadSpace(relayUrl) const space = await loadSpace(relayUrl)
@ -248,7 +280,7 @@ export const createPreview = template => {
preview.get( preview.get(
"/join", "/join",
render(async url => { render(async url => {
const relayUrl = tryCatch(() => normalizeRelayUrl(url.searchParams.get("r"))) const relayUrl = toRelayUrl(url.searchParams.get("r"))
if (relayUrl) { if (relayUrl) {
const space = await loadSpace(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") 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 () => { test("leaves the platform's own pages alone", async () => {
for (const path of ["/", "/settings/profile", "/spaces/create", "/people/not-an-npub"]) { for (const path of ["/", "/settings/profile", "/spaces/create", "/people/not-an-npub"]) {
assert.equal((await request(path)).title, "Flotilla") assert.equal((await request(path)).title, "Flotilla")

View file

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

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {writable} from "svelte/store" import {writable} from "svelte/store"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {Note} from "@welshman/domain" import {Comment} from "@welshman/domain"
import {isMobile, preventDefault} from "@lib/html" import {isMobile, preventDefault} from "@lib/html"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl" import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import Plane from "@assets/icons/plane-2.svg?dataurl" import Plane from "@assets/icons/plane-2.svg?dataurl"
@ -37,17 +37,18 @@
const selectFiles = () => editor.then(ed => ed.commands.selectFiles()) 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[][]) => { const publishReply = async (content: string, tags: string[][]) => {
if (url) { if (url) {
return publishComment({url, event, parent, content, tags}) return publishComment({url, event, parent, content, tags})
} }
return $publisher.publish({ return $publisher.publish({
...(await writer(Note) ...(await writer(Comment)
.setContent(content) .setContent(content)
.addTags(...tags) .addTags(...tags)
.setParent(parent ?? event) .setRootFromEvent(event)
.setParentFromEvent(parent ?? event)
.render()), .render()),
delay: await loadSendDelay(), delay: await loadSendDelay(),
}) })

View file

@ -107,7 +107,7 @@
bind:this={form} bind:this={form}
onsubmit={preventDefault(submit)} 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"> 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} {#if parent}
<ComposeParent {url} event={parent} clear={() => onClearParent?.()} verb="Replying to" /> <ComposeParent {url} event={parent} clear={() => onClearParent?.()} verb="Replying to" />
{/if} {/if}

View file

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

View file

@ -15,10 +15,14 @@
const reference = $derived(pin.reference()) const reference = $derived(pin.reference())
const quote = $derived.by(() => { 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 return undefined

View file

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

View file

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import {onDestroy} from "svelte" import {onDestroy} from "svelte"
import {formatTimestampRelative} from "@welshman/lib" import {formatTimestampRelative, now} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" 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 {Poll, PollResponse} from "@welshman/domain"
import type {Publication} from "@welshman/app" import type {Publication} from "@welshman/app"
import PollOption from "@app/components/PollOption.svelte" import PollOption from "@app/components/PollOption.svelte"
@ -46,8 +46,11 @@
const responseCommand = await command(eventWriter) const responseCommand = await command(eventWriter)
// Give the user time to check more boxes before the vote actually goes out. // 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({ activePublication = $publisher.publish({
event: responseCommand.event, event: stamp(responseCommand.event, created_at),
relays: responseCommand.relays, relays: responseCommand.relays,
delay: pollType === "multiplechoice" ? 1000 : undefined, delay: pollType === "multiplechoice" ? 1000 : undefined,
}) })

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import "leaflet/dist/leaflet.css" 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 {Map as LeafletMap, Marker, TileLayer} from "leaflet"
import type {Maybe} from "@welshman/lib" import type {Maybe} from "@welshman/lib"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
@ -20,6 +20,11 @@
const basemap = $derived($activeTheme === "dark" ? "dark_all" : "light_all") 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}) const openProfile = (pubkey: string) => pushModal(ProfileDetail, {pubkey, url})
let element: HTMLElement let element: HTMLElement
@ -59,8 +64,8 @@
const markers: Marker[] = [] const markers: Marker[] = []
const avatars: Record<string, unknown>[] = [] const avatars: Record<string, unknown>[] = []
if (leaflet && map) { if (leaflet && map && pinsKey) {
for (const pin of pins) { for (const pin of untrack(() => pins)) {
const target = document.createElement("div") const target = document.createElement("div")
target.className = target.className =

View file

@ -6,7 +6,27 @@
import {roomLists} from "@app/core" import {roomLists} from "@app/core"
import {navigate, pushModal} from "@app/modal" import {navigate, pushModal} from "@app/modal"
import {makeSpacePath} from "@app/routes" 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 submit = async (values: RelayFormValues) => {
const relay = await createHostedRelay(values) 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. // Join the space now, or the space layout prompts for it and displaces the payment modals.
await $roomLists.addRelay(url).then(publish) 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}) await navigate(makeSpacePath(url, "admin"), {replaceState: true})

View file

@ -1,7 +1,7 @@
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import {derived} from "svelte/store" import {derived} from "svelte/store"
import type {Readable} 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 type {Maybe} from "@welshman/lib"
import {Address, PIN, PINBOARD, fromNostrURI, getAddress, isReplaceable} from "@welshman/util" import {Address, PIN, PINBOARD, fromNostrURI, getAddress, isReplaceable} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
@ -22,7 +22,19 @@ import {
} from "@app/core" } from "@app/core"
import {addressWriterToSpace} from "@app/rooms" import {addressWriterToSpace} from "@app/rooms"
import {isImageUrl} from "@app/content" 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) => { const encodeReference = (reference: PinReference) => {
if (reference.type === "event") { if (reference.type === "event") {
@ -46,7 +58,7 @@ const encodeReference = (reference: PinReference) => {
export const pinToReference = (pin: PinReader) => { export const pinToReference = (pin: PinReader) => {
const reference = pin.reference() const reference = pin.reference()
return reference ? encodeReference(reference) : "" return reference && isEncodable(reference) ? encodeReference(reference) : ""
} }
export const eventToReference = (event: TrustedEvent) => export const eventToReference = (event: TrustedEvent) =>
@ -56,37 +68,37 @@ export const eventToReference = (event: TrustedEvent) =>
: {type: "event", id: event.id}, : {type: "event", id: event.id},
) )
// Point a pin at a user-entered nostr link or external url, false if it's neither. // Read a user-entered nostr link or external url as a pin reference.
export const setPinReference = (writer: PinWriter, reference: string) => { export const parsePinReference = (input: string): Maybe<PinReference> => {
const trimmed = reference.trim() const trimmed = input.trim()
try { try {
const decoded = nip19.decode(fromNostrURI(trimmed)) const decoded = nip19.decode(fromNostrURI(trimmed))
if (decoded.type === "note") { if (decoded.type === "note") {
writer.setEvent(decoded.data) return {type: "event", id: decoded.data}
return true
} }
if (decoded.type === "nevent") { if (decoded.type === "nevent") {
writer.setEvent(decoded.data.id, decoded.data.relays?.[0]) return {type: "event", id: decoded.data.id, relay: decoded.data.relays?.[0]}
return true
} }
// Pins have no pubkey reference type, so a person is stored as the external id the user gave. // 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") { if (decoded.type === "npub" || decoded.type === "nprofile") {
writer.setExternal(fromNostrURI(trimmed)) return {type: "external", id: fromNostrURI(trimmed)}
return true
} }
if (decoded.type === "naddr") { if (decoded.type === "naddr") {
const {kind, pubkey, identifier, relays} = decoded.data const {kind, pubkey, identifier, relays} = decoded.data
writer.setAddress(new Address(kind, pubkey, identifier).toString(), relays?.[0]) return {
return true type: "address",
address: new Address(kind, pubkey, identifier).toString(),
relay: relays?.[0],
}
} }
return false return undefined
} catch { } catch {
// Not a nostr entity; fall through to external url handling. // 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}) const parsed = parse({content: trimmed})
if (parsed.length === 1 && isLink(parsed[0])) { if (parsed.length === 1 && isLink(parsed[0])) {
writer.setExternal(trimmed) return {type: "external", id: trimmed}
return true
} }
return false return undefined
} }
export const deriveShelves = (url: string): Readable<PinboardReader[]> => 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[]) => export const deletePins = (url: string, pins: PinReader[]) =>
Promise.all(pins.map(pin => deleteLibraryEvent(url, pin.event))) 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( export const notificationSettings = withGetter(
synced({ synced({
key: "notificationSettings", key: "notificationSettings",
storage: kv, storage: kv,
defaultValue: { defaultValue: defaultNotificationSettings,
push: false,
sound: true,
badge: false,
spaces: true,
mentions: true,
messages: true,
events: true,
reminder_minutes: [5],
},
}), }),
) )
// 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 { export interface IPushAdapter {
request: (prompt?: boolean) => Promise<string> request: (prompt?: boolean) => Promise<string>
disable: () => Promise<void> disable: () => Promise<void>

View file

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

View file

@ -1,5 +1,5 @@
import {MINUTE, first, formatTimestampRelative, int, ms, now, uniq} from "@welshman/lib" 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 type {TrustedEvent} from "@welshman/util"
import {CalendarRsvp} from "@welshman/domain" import {CalendarRsvp} from "@welshman/domain"
import {getEventStart, getEventTitle, isAttending} from "@app/calendar" 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 {notificationSettings, pushState} from "@app/push/adapters/common"
import {deleteReminderSchedule, putReminderSchedule} from "@app/push/server" import {deleteReminderSchedule, putReminderSchedule} from "@app/push/server"
import type {Reminder} from "@app/push/server" import type {Reminder} from "@app/push/server"
import {pushToast} from "@app/toast"
const getLeadTimes = () => { const getLeadTimes = () => {
const {events, reminder_minutes} = notificationSettings.get() const {events, reminder_minutes} = notificationSettings.get()
@ -28,7 +29,7 @@ const putSchedule = async (relay: string, event: TrustedEvent, reminders: Remind
if (subscription) { if (subscription) {
const res = await putReminderSchedule(subscription.key, { const res = await putReminderSchedule(subscription.key, {
id: event.id, id: getAddress(event),
relay, relay,
event: isSignedEvent(event) ? event : undefined, event: isSignedEvent(event) ? event : undefined,
notifications: reminders, 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) 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) => { 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() 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 keys = [event.id, getIdOrAddress(event)]
const wasPinned = pins.includes(id) const wasPinned = pins.some(pin => keys.includes(pin))
const command = await roomPinLists const command = await roomPinLists
.get() .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() const error = (await command.publishToRelays([url]).settled()).getError()
if (error) { if (error) {

View file

@ -1,6 +1,7 @@
import {derived, get, readable} from "svelte/store" import {derived, get, readable} from "svelte/store"
import {ago, avg, ms, now, spec} from "@welshman/lib" import {ago, avg, ms, now, spec} from "@welshman/lib"
import {makeHttpAuth, makeHttpAuthHeader} from "@welshman/util" import {makeHttpAuth, makeHttpAuthHeader} from "@welshman/util"
import {throttled} from "@welshman/store"
import {Logger} from "@welshman/app" import {Logger} from "@welshman/app"
import type {LogMessage} from "@welshman/app" import type {LogMessage} from "@welshman/app"
import {fromApp, user} from "@app/core" 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 = () => export const syncSignerAlerts = () =>
derived(signerHealth, $signerHealth => $signerHealth.unresponsive).subscribe(unresponsive => { throttled(ms(10), signerHealth).subscribe(({unresponsive}) => {
if (unresponsive && !get(toast)) { if (unresponsive && !get(toast)) {
pushToast({ pushToast({
theme: "error", theme: "error",

View file

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

View file

@ -32,14 +32,33 @@
"aria-label": ariaLabel, "aria-label": ariaLabel,
}: Props = $props() }: Props = $props()
const showPopover = () => tippy?.show()
const hidePopover = () => tippy?.hide() 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 tippy: Maybe<TippyController> = $state()
let wasOpen = false
</script> </script>
<Button class={className} aria-label={ariaLabel} {disabled} onclick={showPopover}> <Button
class={className}
aria-label={ariaLabel}
{disabled}
onpointerdown={onPointerDown}
onclick={toggle}>
<Tippy <Tippy
bind:controller={tippy} bind:controller={tippy}
{component} {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"> <div transition:fade class="absolute right-4 bottom-20 z-nav mb-sai md:bottom-4 md:mb-0">
<Button <Button
aria-label="Scroll to top" aria-label="Scroll to top"
class="button button-neutral button-circle shadow-xl" class="button button-neutral button-circle"
onclick={scrollToTop}> onclick={scrollToTop}>
<Icon icon={AltArrowUp} /> <Icon icon={AltArrowUp} />
</Button> </Button>

View file

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

View file

@ -73,7 +73,7 @@
<form {onsubmit}> <form {onsubmit}>
<PageContent> <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"> <strong class="flex items-center gap-3 text-lg">
<Icon icon={Bell} /> <Icon icon={Bell} />
Alert Settings Alert Settings
@ -112,7 +112,7 @@
</FieldInline> </FieldInline>
</div> </div>
<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, "pointer-events-none opacity-50": !settings.badge && !settings.sound && !settings.push,
})}> })}>
<strong class="text-lg">Alert Types</strong> <strong class="text-lg">Alert Types</strong>
@ -176,7 +176,7 @@
</div> </div>
{/if} {/if}
</div> </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} <Button class="button button-neutral" onclick={reset} disabled={loading}
>Discard Changes</Button> >Discard Changes</Button>
<Button type="submit" class="button button-primary" disabled={loading}> <Button type="submit" class="button button-primary" disabled={loading}>

View file

@ -38,6 +38,8 @@
const setTheme = (value: string) => () => theme.set(value) const setTheme = (value: string) => () => theme.set(value)
const saveFontSize = () => publishSettings({font_size: $settings.font_size})
const addServer = () => { const addServer = () => {
blossomServers = [...blossomServers, ""] blossomServers = [...blossomServers, ""]
} }
@ -204,11 +206,12 @@
max="1.3" max="1.3"
step="0.05" step="0.05"
aria-label="Font size" aria-label="Font size"
bind:value={$settings.font_size} /> bind:value={$settings.font_size}
onchange={saveFontSize} />
{/snippet} {/snippet}
</Field> </Field>
</div> </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> <strong class="text-lg">Editor Settings</strong>
<Field> <Field>
{#snippet label()} {#snippet label()}
@ -273,7 +276,7 @@
{/snippet} {/snippet}
</Field> </Field>
</div> </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} <Button class="button button-neutral" onclick={reset} disabled={loading}
>Discard Changes</Button> >Discard Changes</Button>
<Button class="button button-primary" type="submit" disabled={loading}> <Button class="button button-primary" type="submit" disabled={loading}>

View file

@ -201,7 +201,7 @@
{/snippet} {/snippet}
</Field> </Field>
</div> </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} <Button class="button button-neutral" onclick={reset} disabled={loading}
>Discard Changes</Button> >Discard Changes</Button>
<Button class="button button-primary" type="submit" disabled={loading}> <Button class="button button-primary" type="submit" disabled={loading}>

View file

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

View file

@ -54,7 +54,7 @@
const back = () => history.back() const back = () => history.back()
const addLinks = () => pushModal(PinSave, {url, address}) const addLink = () => pushModal(PinSave, {url, address})
const showContributors = () => const showContributors = () =>
pushModal(ProfileList, { pushModal(ProfileList, {
@ -91,7 +91,7 @@
<h1 class="text-xl">{$board?.title() || "Shelf"}</h1> <h1 class="text-xl">{$board?.title() || "Shelf"}</h1>
{/snippet} {/snippet}
{#snippet action()} {#snippet action()}
<Button class="button button-primary button-sm" onclick={addLinks}> <Button class="button button-primary button-sm" onclick={addLink}>
<Icon icon={AddCircle} /> <Icon icon={AddCircle} />
Add a link Add a link
</Button> </Button>
@ -160,7 +160,7 @@
<p class="opacity-70">Nothing on this shelf matches that.</p> <p class="opacity-70">Nothing on this shelf matches that.</p>
{:else} {:else}
<p class="opacity-70">This shelf doesn't have any links yet.</p> <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} /> <Icon icon={AddCircle} />
Add the first link Add the first link
</Button> </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"> <div transition:fade class="absolute right-4 bottom-20 z-nav mb-sai md:bottom-4 md:mb-0">
<Button <Button
aria-label="Scroll to newest" aria-label="Scroll to newest"
class="button button-neutral button-circle shadow-xl" class="button button-neutral button-circle"
onclick={scrollToBottom}> onclick={scrollToBottom}>
<Icon icon={AltArrowDown} /> <Icon icon={AltArrowDown} />
</Button> </Button>