Fix regressions found auditing the changes since 1.11.2
This commit is contained in:
parent
90edc93014
commit
96c0073f3a
35 changed files with 368 additions and 310 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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!")
|
||||
|
|
|
|||
20
package.json
20
package.json
|
|
@ -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",
|
||||
|
|
|
|||
BIN
pnpm-lock.yaml
BIN
pnpm-lock.yaml
Binary file not shown.
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@
|
|||
|
||||
const togglePin = () => {
|
||||
onClick()
|
||||
toggleRoomPin(url, h, event.id)
|
||||
toggleRoomPin(url, h, event)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@
|
|||
|
||||
const togglePin = () => {
|
||||
history.back()
|
||||
toggleRoomPin(url, h, event.id)
|
||||
toggleRoomPin(url, h, event)
|
||||
}
|
||||
|
||||
let showMore = $state(false)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
|
||||
const back = () => history.back()
|
||||
|
||||
const addMembers = () => pushModal(SpaceMembersAdd, {url}, {replaceState: true})
|
||||
const addMembers = () => pushModal(SpaceMembersAdd, {url})
|
||||
</script>
|
||||
|
||||
<Modal>
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ export const saveNotificationSettings = async (
|
|||
|
||||
if (permission.startsWith("granted")) {
|
||||
notificationSettings.set(values)
|
||||
syncEventReminders()
|
||||
syncEventReminders().catch(console.error)
|
||||
}
|
||||
|
||||
return permission
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
type Props = {
|
||||
children: Snippet
|
||||
onclick?: (event: Event) => unknown
|
||||
onpointerdown?: (event: PointerEvent) => unknown
|
||||
type?: "button" | "submit"
|
||||
class?: string
|
||||
style?: string
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: () => {
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue