Add the ability to save events to the library

This commit is contained in:
Jon Staab 2026-07-02 11:36:05 -07:00
parent 61a8c44750
commit ff5d7aeb87
25 changed files with 207 additions and 126 deletions

View file

@ -52,14 +52,13 @@
<EmojiButton {onEmoji} class="button button-neutral button-xs join-item">
<Icon icon={SmileCircle} size={4} />
</EmojiButton>
<Tippy
bind:popover
class="flex join-item button button-neutral button-xs"
component={EventMenu}
props={{url, noun, event, customActions, onClick: hidePopover}}
params={{trigger: "manual", interactive: true}}>
<Button onclick={showPopover}>
<Button onclick={showPopover} class="flex join-item button button-neutral button-xs">
<Tippy
bind:popover
component={EventMenu}
props={{url, noun, event, customActions, onClick: hidePopover}}
params={{trigger: "manual", interactive: true}}>
<Icon icon={MenuDots} size={4} />
</Button>
</Tippy>
</Tippy>
</Button>
</div>

View file

@ -3,7 +3,7 @@
import {Router} from "@welshman/router"
import {LOCALE, secondsToDate} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {displayRelayUrl} from "@welshman/util"
import {displayRelayUrl, toNostrURI} from "@welshman/util"
import {tracker} from "@welshman/app"
import FileText from "@assets/icons/file-text.svg?dataurl"
import Copy from "@assets/icons/copy.svg?dataurl"
@ -28,7 +28,7 @@
const {url, event}: Props = $props()
const relays = url ? [url] : Router.get().Event(event).getUrls()
const nevent1 = nip19.neventEncode({...event, relays})
const nevent1 = toNostrURI(nip19.neventEncode({...event, relays}))
const seenOn = tracker.getRelays(event.id)
const npub1 = nip19.npubEncode(event.pubkey)
const json = JSON.stringify(event, null, 2)

View file

@ -3,8 +3,9 @@
import type {Snippet} from "svelte"
import {goto} from "$app/navigation"
import type {TrustedEvent} from "@welshman/util"
import {COMMENT, ManagementMethod} from "@welshman/util"
import {COMMENT, getAddress, ManagementMethod} from "@welshman/util"
import {pubkey, repository, relaysByUrl, manageRelay} from "@welshman/app"
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
@ -17,6 +18,7 @@
import Report from "@app/components/Report.svelte"
import EventShare from "@app/components/EventShare.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import PinboardSelect from "@app/components/PinboardSelect.svelte"
import {hasNip29} from "@app/relays"
import {deriveUserIsSpaceAdmin} from "@app/members"
import {pushModal} from "@app/modal"
@ -40,6 +42,8 @@
const showInfo = () => pushModal(EventInfo, {url, event})
const addToLibrary = () => pushModal(PinboardSelect, {url, address: getAddress(event)})
const share = async () => {
if (hasNip29($relaysByUrl.get(url))) {
pushModal(EventShare, {url, event})
@ -79,6 +83,12 @@
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
<li>
<Button onclick={showInfo}>
<Icon size={4} icon={Code2} />
{noun} Details
</Button>
</li>
{#if isRoot}
<li>
<Button onclick={share}>
@ -88,9 +98,9 @@
</li>
{/if}
<li>
<Button onclick={showInfo}>
<Icon size={4} icon={Code2} />
{noun} Details
<Button onclick={addToLibrary}>
<Icon size={4} icon={GalleryWide} />
Add to Library
</Button>
</li>
{@render customActions?.()}

View file

@ -1,5 +1,4 @@
<script lang="ts">
import cx from "classnames"
import {goto} from "$app/navigation"
import type {TrustedEvent} from "@welshman/util"
import {preventDefault} from "@lib/html"
@ -14,7 +13,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import RoomName from "@app/components/RoomName.svelte"
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
import {roomsByUrl} from "@app/groups"
import {makeRoomPath} from "@app/routes"
@ -40,13 +39,15 @@
<ModalTitle>Share {noun}</ModalTitle>
<ModalSubtitle>Which room would you like to share this event to?</ModalSubtitle>
</ModalHeader>
<div class="grid grid-cols-3 gap-2">
<div class="grid grid-cols-2 gap-2">
{#each $roomsByUrl.get(url) || [] as room (room.h)}
<Button
type="button"
class={cx(`button button-${selection === room.h ? "primary" : "neutral"}`)}
class="flex justify-center button {selection === room.h
? 'button-primary'
: 'button-neutral'}"
onclick={() => toggleRoom(room.h)}>
#<RoomName {...room} />
<RoomNameWithImage {...room} />
</Button>
{/each}
</div>

View file

@ -5,9 +5,10 @@
type Props = {
url: string
address: string
reference?: string
}
const {url, address}: Props = $props()
const {url, address, reference}: Props = $props()
const submit = async ({title, topics, value}: PinFormValues) => {
const params = referenceToPin(value)
@ -18,4 +19,10 @@
}
</script>
<PinForm {url} heading="Add Link" action="Add link" successMessage="Link added!" {submit} />
<PinForm
{url}
heading="Add Link"
action="Add link"
successMessage="Link added!"
values={{value: reference}}
{submit} />

View file

@ -21,6 +21,7 @@
import StringMultiInput from "@lib/components/StringMultiInput.svelte"
import RelayName from "@app/components/RelayName.svelte"
import {pushToast} from "@app/toast"
import {clearModals} from "@app/modal"
type Props = {
url: string
@ -50,7 +51,7 @@
pushToast({theme: "error", message: error})
} else {
pushToast({message: successMessage})
back()
clearModals()
}
} finally {
loading = false

View file

@ -0,0 +1,59 @@
<script lang="ts">
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Link from "@lib/components/Link.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import RelayName from "@app/components/RelayName.svelte"
import PinAdd from "@app/components/PinAdd.svelte"
import {deriveBoards, addressToReference, type Board} from "@app/pinboards"
import {makeSpacePath} from "@app/routes"
import {pushModal} from "@app/modal"
type Props = {
url: string
address: string
}
const {url, address}: Props = $props()
const boards = deriveBoards(url)
const reference = addressToReference(address)
const selectBoard = (board: Board) => pushModal(PinAdd, {url, address: board.address, reference})
</script>
<Modal class="flex flex-col gap-2">
<ModalHeader>
<ModalTitle>Add to Library</ModalTitle>
<ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle>
</ModalHeader>
<ModalBody class="flex flex-col gap-2">
{#if $boards.length === 0}
<div class="flex flex-col items-center gap-4 py-8 text-center">
<p class="opacity-70">This space doesn't have any shelves yet.</p>
<Link href={makeSpacePath(url, "library")} class="button button-primary">
Go to Library
</Link>
</div>
{:else}
{#each $boards as board (board.address)}
<Button
class="card card-sm card-interactive flex flex-row items-center justify-between gap-3"
onclick={() => selectBoard(board)}>
<span class="flex min-w-0 flex-col">
<strong class="ellipsize">{board.title || "Untitled shelf"}</strong>
{#if board.description}
<span class="ellipsize text-sm opacity-70">{board.description}</span>
{/if}
</span>
<Icon size={4} icon={AltArrowRight} class="shrink-0" />
</Button>
{/each}
{/if}
</ModalBody>
</Modal>

View file

@ -1,8 +1,9 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {ManagementMethod} from "@welshman/util"
import {ManagementMethod, getAddress} from "@welshman/util"
import {pubkey, manageRelay, repository} from "@welshman/app"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import Danger from "@assets/icons/danger.svg?dataurl"
import Button from "@lib/components/Button.svelte"
@ -10,6 +11,7 @@
import Confirm from "@lib/components/Confirm.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import Report from "@app/components/Report.svelte"
import PinboardSelect from "@app/components/PinboardSelect.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
@ -25,6 +27,11 @@
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const addToLibrary = () => {
onClick()
pushModal(PinboardSelect, {url, address: getAddress(event)})
}
const report = () => {
onClick()
pushModal(Report, {url, event})
@ -65,7 +72,13 @@
<li>
<Button onclick={showInfo}>
<Icon size={4} icon={Code2} />
Show JSON
Message Details
</Button>
</li>
<li>
<Button onclick={addToLibrary}>
<Icon size={4} icon={GalleryWide} />
Add to Library
</Button>
</li>
{#if event.pubkey === $pubkey}

View file

@ -28,13 +28,12 @@
<svelte:document onmousemove={onMouseMove} />
<Tippy
bind:popover
component={RoomItemMenu}
class="button button-xs button-neutral join-item"
props={{url, event, onClick}}
params={{trigger: "manual", interactive: true}}>
<Button onclick={open}>
<Button onclick={open} class="button button-xs button-neutral join-item">
<Tippy
bind:popover
component={RoomItemMenu}
props={{url, event, onClick}}
params={{trigger: "manual", interactive: true}}>
<Icon icon={MenuDots} size={4} />
</Button>
</Tippy>
</Tippy>
</Button>

View file

@ -80,7 +80,7 @@
</div>
{:else if $members.length === 0}
<div class="card bg-surface p-4">
<span class="text-muted">No members yet</span>
<span class="text-content-muted">No members yet</span>
</div>
{:else}
{#each $members as pubkey (pubkey)}

View file

@ -11,11 +11,9 @@
const {url, h, ...props}: Props = $props()
</script>
<div class="flex grow items-center justify-between gap-4 {props.class}">
<div class="flex items-center gap-2">
<RoomImage {url} {h} />
<div class="min-w-0 overflow-hidden text-ellipsis">
<RoomName {url} {h} />
</div>
<div class="flex min-w-0 items-center gap-2 {props.class}">
<RoomImage {url} {h} />
<div class="min-w-0 overflow-hidden text-ellipsis">
<RoomName {url} {h} />
</div>
</div>

View file

@ -34,7 +34,7 @@
<SecondaryNavItem href={path} title={roomName} {replaceState} {notification}>
<RoomNameWithImage {url} {h} />
{#if showDifferenceIcon}
<Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="opacity-50" />
<Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="ml-auto opacity-50" />
{/if}
</SecondaryNavItem>
{/if}

View file

@ -1,6 +1,6 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import RoomName from "@app/components/RoomName.svelte"
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
import ThreadBoardItem from "@app/components/ThreadBoardItem.svelte"
type Props = {
@ -13,27 +13,28 @@
</script>
<section class="card card-flat overflow-hidden pb-4 p-0">
<header class="flex gap-2 justify-between bg-surface px-4 py-2.5">
<h2 class="text-sm font-bold">
<header
class="flex gap-2 items-center justify-between px-4 py-3 border-b border-line border-solid">
<h2 class="text-lg">
{#if h}
#<RoomName {url} {h} />
<RoomNameWithImage {url} {h} />
{:else}
General
{/if}
</h2>
<span class="text-xs text-muted">
<span class="text-content-muted text-sm">
{threads.length}
{threads.length === 1 ? "topic" : "topics"}
{threads.length === 1 ? "Topic" : "Topics"}
</span>
</header>
<table class="w-full border-collapse">
<thead
class="hidden text-xs font-bold uppercase tracking-wide text-muted sm:table-header-group">
class="hidden text-xs font-bold uppercase tracking-wide text-content-muted sm:table-header-group border-b border-line border-solid bg-surface-less">
<tr>
<th class="px-4 py-2 text-left">Topic</th>
<th class="w-32 px-4 py-2 text-left">Author</th>
<th class="w-20 px-4 py-2 text-center">Replies</th>
<th class="w-32 px-4 py-2 text-right">Last post</th>
<th class="px-4 py-3 text-left">Topic</th>
<th class="w-32 px-4 py-3 text-left">Author</th>
<th class="w-20 px-4 py-3 text-center">Replies</th>
<th class="w-32 px-4 py-3 text-right">Last post</th>
</tr>
</thead>
<tbody>

View file

@ -3,7 +3,6 @@
import type {TrustedEvent} from "@welshman/util"
import {COMMENT, getTagValue} from "@welshman/util"
import {goto} from "$app/navigation"
import Link from "@lib/components/Link.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import {deriveEventsForUrl} from "@app/repository"
@ -25,21 +24,19 @@
const goToThread = () => goto(path)
</script>
<tr class="cursor-pointer transition-colors hover:bg-surface text-sm" onclick={goToThread}>
<td class="px-4 py-2 align-top">
<Link href={path} class="ellipsize font-semibold">
{title || "Untitled thread"}
</Link>
<tr
class="cursor-pointer transition-colors hover:bg-surface-less text-sm border-b border-solid border-line"
onclick={goToThread}>
<td class="px-4 py-3 align-top ellipsize">
{title || "Untitled thread"}
</td>
<td class="px-4 py-2 align-middle">
<div class="flex items-center gap-2">
<ProfileCircle pubkey={event.pubkey} {url} size={5} />
<span class="ellipsize">
<ProfileName pubkey={event.pubkey} {url} />
</span>
</div>
<td class="px-4 py-3 align-middle flex items-center gap-2">
<ProfileCircle pubkey={event.pubkey} {url} size={5} />
<span class="ellipsize">
<ProfileName pubkey={event.pubkey} {url} />
</span>
</td>
<td class="px-4 py-2 align-middle text-right">
<td class="px-4 py-3 align-middle text-right">
{replyCount}
</td>
<td class="whitespace-nowrap px-4 py-2 align-middle text-right">

View file

@ -138,9 +138,9 @@
{:else if $callState === CallState.Connected}
<span class="text-sm font-semibold text-success">Voice Connected</span>
{:else}
<span class="text-sm font-semibold text-muted">Disconnected</span>
<span class="text-sm font-semibold text-content-muted">Disconnected</span>
{/if}
<span class="ellipsize text-xs text-muted">
<span class="ellipsize text-xs text-content-muted">
{roomName} / {spaceName}
</span>
</div>

View file

@ -33,7 +33,6 @@ import {
getTagValue,
matchFilters,
readList,
sortEventsDesc,
} from "@welshman/util"
import type {Filter, PublishedList, TrustedEvent} from "@welshman/util"
import {load, mergeRepositoryUpdates, request} from "@welshman/net"
@ -124,13 +123,8 @@ export const makeFeed = ({
const events = writable<TrustedEvent[]>([])
const seen = new Set<string>()
let interval = int(MONTH)
let buffer = sortEventsDesc(
uniqBy(
e => e.id,
relays.flatMap(url => Array.from(getEventsForUrl(url, filters))),
),
)
let interval = int(6, MONTH)
let buffer: TrustedEvent[] = []
let backwardWindow = [at - interval, at]
let forwardWindow = [at, at + interval]
@ -145,7 +139,7 @@ export const makeFeed = ({
}
// Batch-insert events into the visible store with a single update
const insertEvents = (newEvents: TrustedEvent[]) => {
const insertEvents = (newEvents: Iterable<TrustedEvent>) => {
const visible: TrustedEvent[] = []
for (const event of newEvents) {
@ -153,10 +147,9 @@ export const makeFeed = ({
continue
}
seen.add(event.id)
if (between([backwardWindow[0], forwardWindow[1]], event.created_at)) {
visible.push(event)
seen.add(event.id)
} else {
insertIntoBuffer(event)
}
@ -274,6 +267,10 @@ export const makeFeed = ({
},
})
for (const url of relays) {
insertEvents(getEventsForUrl(url, filters))
}
return {
events,
cleanup: () => {

View file

@ -166,8 +166,8 @@ export const deleteBoard = (url: string, address: string) =>
]),
)
export const pinToReference = (pin: Pin): string => {
const [type, data = "", relay] = pin.value
const valueToReference = (value: string[]): string => {
const [type, data = "", relay] = value
const relays = relay ? [relay] : []
if (type === "e") return nip19.neventEncode({id: data, relays})
@ -181,6 +181,10 @@ export const pinToReference = (pin: Pin): string => {
return data
}
export const pinToReference = (pin: Pin): string => valueToReference(pin.value)
export const addressToReference = (address: string): string => valueToReference(["a", address])
export const referenceToPin = (reference: string): Partial<Pin> | undefined => {
const trimmed = reference.trim()

View file

@ -8,7 +8,7 @@
const {children, ...props}: Props = $props()
</script>
<div class="flex items-center gap-2 p-2 text-xs uppercase text-subtle">
<div class="flex items-center gap-2 p-2 text-xs uppercase text-content-subtle">
<div class="h-px grow" style="background-color: var(--line)"></div>
{#if children}
<p>{@render children?.()}</p>

View file

@ -23,7 +23,7 @@
</div>
</div>
{#if props.info}
<p class="text-sm text-muted">
<p class="text-sm text-content-muted">
{@render props.info()}
</p>
{/if}

View file

@ -119,7 +119,7 @@
ondrop={stopPropagation(preventDefault(onDrop))}>
<div class="flex flex-col items-center gap-2 text-center">
<Icon icon={GallerySend} size={8} />
<p class="text-muted text-sm">Drag and drop images or click to select</p>
<p class="text-content-muted text-sm">Drag and drop images or click to select</p>
</div>
</label>
</div>

View file

@ -8,4 +8,4 @@
const {children}: Props = $props()
</script>
<p class="text-muted text-center text-sm">{@render children()}</p>
<p class="text-content-muted text-center text-sm">{@render children()}</p>

View file

@ -20,8 +20,7 @@
.input:focus-within,
.tiptap:focus,
.tiptap:focus-within {
@apply outline-none;
border: 1px solid var(--primary) !important;
@apply border-primary outline-none;
box-shadow:
0 0 0 4px color-mix(in srgb, var(--primary) 18%, transparent),
inset 0 2px 4px rgba(0, 0, 0, 0.04);

View file

@ -265,7 +265,7 @@
onclick={() => openSpace(url)}>
<div class="flex w-full items-start gap-2">
<div
class="mt-4 flex cursor-grab p-1 text-subtle transition-colors group-hover:text-muted">
class="mt-4 flex cursor-grab p-1 text-content-subtle transition-colors group-hover:text-content-muted">
<Icon icon={DragHandle} />
</div>
<RelaySummary hideFavorites {url} />

View file

@ -128,23 +128,21 @@
</SpaceBar>
<PageContent class="flex flex-col gap-4 p-4">
<div class="card flex flex-col gap-2">
<label class="input input-sm input-group flex w-full items-center gap-2">
<Icon size={4} icon={Magnifier} />
<input
bind:value={term}
class="min-w-0 grow"
type="text"
placeholder="Search people or roles..." />
</label>
{#if visibleMembers.length === 0}
<p class="flex flex-col items-center py-20 text-center">No members found.</p>
{:else}
<div class="grid grid-cols-1 gap-2 lg:grid-cols-2 xl:grid-cols-3">
{#each visibleMembers as { pubkey, roleList } (pubkey)}
<SpaceMember {url} {pubkey} roles={roleList} />
{/each}
</div>
{/if}
</div>
<label class="input input-group flex w-full items-center gap-2 card">
<Icon size={4} icon={Magnifier} />
<input
bind:value={term}
class="min-w-0 grow"
type="text"
placeholder="Search people or roles..." />
</label>
{#if visibleMembers.length === 0}
<p class="flex flex-col items-center py-20 text-center">No members found.</p>
{:else}
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
{#each visibleMembers as { pubkey, roleList } (pubkey)}
<SpaceMember {url} {pubkey} roles={roleList} />
{/each}
</div>
{/if}
</PageContent>

View file

@ -86,22 +86,20 @@
</SpaceBar>
<PageContent class="flex flex-col gap-4 p-4">
<div class="card flex flex-col gap-2">
<label class="input input-sm input-group flex w-full items-center gap-2">
<Icon size={4} icon={Magnifier} />
<input bind:value={term} class="min-w-0 grow" type="text" placeholder="Search library..." />
</label>
<Masonry items={filtered} getKey={board => board.address} columnWidth={80} gap={2}>
{#snippet child(board)}
<PinboardItem {url} {board} />
{/snippet}
</Masonry>
{#if loading}
<p class="flex items-center justify-center py-20">
<Spinner loading>Loading library...</Spinner>
</p>
{:else if filtered.length === 0}
<p class="flex flex-col items-center py-20 text-center">No shelves found.</p>
{/if}
</div>
<label class="card input input-group flex w-full items-center gap-2">
<Icon size={4} icon={Magnifier} />
<input bind:value={term} class="min-w-0 grow" type="text" placeholder="Search library..." />
</label>
<Masonry items={filtered} getKey={board => board.address} columnWidth={80} gap={4}>
{#snippet child(board)}
<PinboardItem {url} {board} />
{/snippet}
</Masonry>
{#if loading}
<p class="flex items-center justify-center py-20">
<Spinner loading>Loading library...</Spinner>
</p>
{:else if filtered.length === 0}
<p class="flex flex-col items-center py-20 text-center">No shelves found.</p>
{/if}
</PageContent>