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!")
|
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()
|
||||||
|
|
|
||||||
|
|
@ -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]})
|
||||||
|
|
||||||
|
for (const link of ["https://handbook.test/style-guide", `nostr:${nevent}`]) {
|
||||||
await page.getByRole("button", {name: "Add a link"}).click()
|
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!")
|
||||||
|
|
|
||||||
20
package.json
20
package.json
|
|
@ -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",
|
||||||
|
|
|
||||||
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 {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,13 +229,16 @@ 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 (metadata) {
|
||||||
if (cache.size > 500) {
|
if (cache.size > 500) {
|
||||||
cache.clear()
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
|
||||||
return pushToast({theme: "error", message: error})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (publication.getUrlsWithStatus(PublishStatus.Success).length > 0) {
|
||||||
draftKey.clear()
|
draftKey.clear()
|
||||||
history.back()
|
history.back()
|
||||||
|
} else {
|
||||||
|
pushToast({theme: "error", message: publication.getError() ?? "Failed to post."})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,60 +36,43 @@
|
||||||
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]
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
e.currentTarget.value = ""
|
||||||
|
uploadingLink = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const {references: uploaded, errors} = await uploadFilesAsReferences(url, files)
|
const {result, error} = await uploadFile(await compressFileForUpload(file), {url})
|
||||||
|
|
||||||
value = [...references, ...uploaded].join("\n")
|
if (result) {
|
||||||
|
value = result.url
|
||||||
if (errors.length > 0) {
|
} else {
|
||||||
pushToast({theme: "error", message: errors.join(" ")})
|
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 {
|
} finally {
|
||||||
$uploading = false
|
uploadingLink = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onFileChange = (e: Event) => {
|
const submit = async () => {
|
||||||
if (e.target instanceof HTMLInputElement && e.target.files?.length) {
|
if (pinReference) {
|
||||||
upload(Array.from(e.target.files))
|
loading = true
|
||||||
e.target.value = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDrop = (e: Event) => {
|
try {
|
||||||
dropActive = false
|
const ed = await editor
|
||||||
|
|
||||||
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(
|
const eventWriter = await addressWriterToSpace(
|
||||||
pin ? writer(Pin, pin) : writer(Pin).setIdentifier(),
|
pin ? writer(Pin, pin) : writer(Pin).setIdentifier(),
|
||||||
url,
|
url,
|
||||||
|
|
@ -99,43 +82,19 @@
|
||||||
eventWriter.addBoard(address)
|
eventWriter.addBoard(address)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!setPinReference(eventWriter, target)) {
|
eventWriter
|
||||||
return `${target} is not a valid URL or nostr link.`
|
.setReference(pinReference)
|
||||||
}
|
.setTitle(title)
|
||||||
|
.setTopics(topics)
|
||||||
eventWriter.setTitle(fields.title).setTopics(fields.topics).setContent(fields.content)
|
.setContent(ed.getText({blockSeparator: "\n"}).trim())
|
||||||
|
|
||||||
const publication = await command(eventWriter).then(publish)
|
const publication = await command(eventWriter).then(publish)
|
||||||
|
const error = (await publication.settled()).getError()
|
||||||
return (await publication.settled()).getError()
|
|
||||||
}
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
loading = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const ed = await editor
|
|
||||||
const content = ed.getText({blockSeparator: "\n"}).trim()
|
|
||||||
|
|
||||||
// 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[]}
|
|
||||||
|
|
||||||
const errors = await Promise.all(references.map(target => savePin(target, fields)))
|
|
||||||
const error = errors.find(Boolean)
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
pushToast({theme: "error", message: 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 {
|
} else {
|
||||||
pushToast({message: "Link added!"})
|
pushToast({message: pin ? "Link updated!" : "Link added!"})
|
||||||
clearModals()
|
clearModals()
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -145,6 +104,7 @@
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const editor = makeEditor({url, submit, uploading, content: pin?.content() ?? ""})
|
const editor = makeEditor({url, submit, uploading, content: pin?.content() ?? ""})
|
||||||
|
|
||||||
|
|
@ -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,53 +136,43 @@
|
||||||
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))}>
|
|
||||||
<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
|
<label
|
||||||
for={fileInputId}
|
class={cx("button button-neutral button-input", {
|
||||||
class="button button-neutral button-sm cursor-pointer self-start">
|
"button-disabled": loading || uploadingLink,
|
||||||
{#if $uploading}
|
})}>
|
||||||
|
{#if uploadingLink}
|
||||||
<Spinner size="xs" />
|
<Spinner size="xs" />
|
||||||
{:else}
|
{:else}
|
||||||
<Icon size={4} icon={CloudUpload} />
|
<Icon icon={UploadMinimalistic} size={4} />
|
||||||
{/if}
|
{/if}
|
||||||
Upload files
|
Upload
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
class="hidden"
|
||||||
|
disabled={loading || uploadingLink}
|
||||||
|
onchange={onFileChange} />
|
||||||
</label>
|
</label>
|
||||||
<input id={fileInputId} type="file" multiple class="hidden" onchange={onFileChange} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Field>
|
</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>
|
<Field>
|
||||||
{#snippet label()}
|
{#snippet label()}
|
||||||
Title
|
Title
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<input bind:value={title} class="input w-full" placeholder="Optional title" />
|
<input bind:value={title} class="input w-full" aria-label="Title" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
|
|
@ -256,8 +206,6 @@
|
||||||
<StringMultiInput bind:value={topics} placeholder="Add a topic..." />
|
<StringMultiInput bind:value={topics} placeholder="Add a topic..." />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Field>
|
</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}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@
|
||||||
|
|
||||||
const togglePin = () => {
|
const togglePin = () => {
|
||||||
onClick()
|
onClick()
|
||||||
toggleRoomPin(url, h, event.id)
|
toggleRoomPin(url, h, event)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -46,11 +46,7 @@ export const pushState = withGetter(
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const notificationSettings = withGetter(
|
const defaultNotificationSettings = {
|
||||||
synced({
|
|
||||||
key: "notificationSettings",
|
|
||||||
storage: kv,
|
|
||||||
defaultValue: {
|
|
||||||
push: false,
|
push: false,
|
||||||
sound: true,
|
sound: true,
|
||||||
badge: false,
|
badge: false,
|
||||||
|
|
@ -59,10 +55,21 @@ export const notificationSettings = withGetter(
|
||||||
messages: true,
|
messages: true,
|
||||||
events: true,
|
events: true,
|
||||||
reminder_minutes: [5],
|
reminder_minutes: [5],
|
||||||
},
|
}
|
||||||
|
|
||||||
|
export const notificationSettings = withGetter(
|
||||||
|
synced({
|
||||||
|
key: "notificationSettings",
|
||||||
|
storage: kv,
|
||||||
|
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 {
|
export interface IPushAdapter {
|
||||||
request: (prompt?: boolean) => Promise<string>
|
request: (prompt?: boolean) => Promise<string>
|
||||||
disable: () => Promise<void>
|
disable: () => Promise<void>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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: () => {
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue