feat: implement bookmarks page with list management and deep-link scroll targeting #196

Open
bhavishy2801 wants to merge 5 commits from bhavishy2801/flotilla:feat-bookmarks into dev
23 changed files with 1932 additions and 82 deletions
+1 -1
View File
@@ -327,7 +327,7 @@
.note-editor .tiptap {
--tiptap-object-bg: var(--color-base-200);
@apply input rounded-box block h-auto min-h-32 w-full p-[.65rem] pb-6;
@apply input rounded-box h-auto min-h-32 p-[.65rem] pb-6;
}
.input-editor .tiptap {
+142
View File
@@ -0,0 +1,142 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {displayPubkey} from "@welshman/util"
import {formatTimestamp} from "@welshman/lib"
import {displayProfileByPubkey} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
type BookmarkItem = {
key: string
event: TrustedEvent
href: string
external: boolean
image: string | undefined
video: string | undefined
contentType: "image" | "video" | "text"
preview: string
pollOptions: string[]
searchable: string
}
type Props = {
item: BookmarkItem
showAddToSaved: boolean
onOpen: (item: BookmarkItem) => Promise<void> | void
onCopyLink: (item: BookmarkItem) => Promise<void> | void
onAddToSaved: (item: BookmarkItem) => Promise<void> | void
onRemove: (item: BookmarkItem) => Promise<void> | void
}
const {item, showAddToSaved, onOpen, onCopyLink, onAddToSaved, onRemove}: Props = $props()
let openMenu = $state(false)
const closeMenu = () => {
openMenu = false
}
const toggleMenu = (event: Event) => {
event.preventDefault()
event.stopPropagation()
openMenu = !openMenu
}
const openItem = async (event: Event) => {
event.preventDefault()
event.stopPropagation()
closeMenu()
await onOpen(item)
}
const copyLink = async (event: Event) => {
event.preventDefault()
event.stopPropagation()
closeMenu()
await onCopyLink(item)
}
const addToSaved = async (event: Event) => {
event.preventDefault()
event.stopPropagation()
closeMenu()
await onAddToSaved(item)
}
const removeFromList = async (event: Event) => {
event.preventDefault()
event.stopPropagation()
closeMenu()
await onRemove(item)
}
</script>
<svelte:window onclick={closeMenu} />
<Link
href={item.href}
external={item.external}
class="card2 bg-alt col-2 w-full gap-3 p-3 transition-colors hover:bg-base-100 md:w-[calc(50%-0.375rem)] xl:w-[calc(33.333%-0.5rem)]">
<div class="flex items-start justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
<ProfileCircle pubkey={item.event.pubkey} size={5} />
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-primary">
{displayProfileByPubkey(item.event.pubkey)}
</p>
<p class="truncate text-[11px] opacity-60">
{displayPubkey(item.event.pubkey)} · {formatTimestamp(item.event.created_at)}
</p>
</div>
</div>
<div class="relative" role="presentation">
<Button class="btn btn-ghost btn-xs btn-square" onclick={toggleMenu}>
<Icon size={4} icon={MenuDots} />
</Button>
{#if openMenu}
<ul
class="menu absolute right-0 top-7 z-popover w-44 rounded-box bg-base-100 p-2 shadow-xl"
role="menu">
<li>
<Button class="justify-start" onclick={openItem}>Open bookmark</Button>
</li>
<li>
<Button class="justify-start" onclick={copyLink}>Copy link</Button>
</li>
{#if showAddToSaved}
<li>
<Button class="justify-start" onclick={addToSaved}>Add to Saved Items</Button>
</li>
{/if}
<li>
<Button class="justify-start text-error" onclick={removeFromList}
>Remove from this list</Button>
</li>
</ul>
{/if}
</div>
</div>
{#if item.image}
<ContentLinkBlockImage
value={{url: item.image}}
event={item.event}
class="max-h-[28rem] w-full rounded-lg object-contain" />
{:else if item.video}
<video
src={item.video}
class="max-h-[28rem] w-full rounded-lg object-contain"
muted
playsinline
preload="metadata"></video>
{/if}
<NoteContentMinimal event={item.event} />
</Link>
@@ -0,0 +1,23 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
import BookmarkListPicker from "@app/components/BookmarkListPicker.svelte"
import {pushModal} from "@app/util/modal"
type Props = {
event: TrustedEvent
}
const {event}: Props = $props()
const bookmark = () => pushModal(BookmarkListPicker, {event})
</script>
<li>
<Button onclick={bookmark}>
<Icon size={4} icon={Bookmark} />
Bookmark
</Button>
</li>
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import BookmarkCard from "@app/components/BookmarkCard.svelte"
type BookmarkItem = {
key: string
event: TrustedEvent
href: string
external: boolean
image: string | undefined
video: string | undefined
contentType: "image" | "video" | "text"
preview: string
pollOptions: string[]
searchable: string
}
type Props = {
items: BookmarkItem[]
showAddToSaved: boolean
onOpen: (item: BookmarkItem) => Promise<void> | void
onCopyLink: (item: BookmarkItem) => Promise<void> | void
onAddToSaved: (item: BookmarkItem) => Promise<void> | void
onRemove: (item: BookmarkItem) => Promise<void> | void
}
const {items, showAddToSaved, onOpen, onCopyLink, onAddToSaved, onRemove}: Props = $props()
</script>
<div class="flex w-full flex-wrap content-start items-stretch gap-3">
{#each items as item (item.key)}
<BookmarkCard {item} {showAddToSaved} {onOpen} {onCopyLink} {onAddToSaved} {onRemove} />
{:else}
<div class="card2 bg-alt col-2 max-w-lg p-5 text-sm opacity-80">
No items match your current filters.
</div>
{/each}
</div>
@@ -0,0 +1,304 @@
<script lang="ts">
import {onMount} from "svelte"
import {first, uniqBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {
Address,
DELETE,
getAddress,
getTagValue,
getTagValues,
sortEventsDesc,
} from "@welshman/util"
import {load} from "@welshman/net"
import {pubkey, waitForThunkError} from "@welshman/app"
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
import Folder from "@assets/icons/folder.svg?dataurl"
import Add from "@assets/icons/add.svg?dataurl"
import TrashBinMinimalistic from "@assets/icons/trash-bin-minimalistic.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {createBookmarkList, addEventBookmark, deleteBookmarkList} from "@app/core/commands"
import {
BOOKMARKS,
BOOKMARK_LISTS,
deriveEvents,
INDEXER_RELAYS,
shouldIgnoreError,
} from "@app/core/state"
import {pushToast} from "@app/util/toast"
hodlbod marked this conversation as resolved
Review

Import these from @welshman/util

Import these from @welshman/util
Review

Done. Now these are imported from @welshman/util

Done. Now these are imported from @welshman/util
type Props = {
event?: TrustedEvent
}
type BookmarkList = {
key: string
label: string
count: number
}
const {event}: Props = $props()
const listEvents = deriveEvents([{kinds: [10003, 30003, DELETE]}])
Review

These kinds shouldn't be hard-coded

These kinds shouldn't be hard-coded
Review

Also, no need to derive deletes, deriveEvents automatically filters out deleted stuff.

Also, no need to derive deletes, deriveEvents automatically filters out deleted stuff.
const getListKey = (listEvent: TrustedEvent) =>
listEvent.kind === BOOKMARKS
? new Address(BOOKMARKS, listEvent.pubkey, "").toString()
: getAddress(listEvent)
const getListLabel = (listEvent: TrustedEvent) =>
getTagValue("title", listEvent.tags) ||
getTagValue("d", listEvent.tags) ||
Review

Remove the d fallback, if it doesn't have a title we should just show untitled.

Remove the d fallback, if it doesn't have a title we should just show untitled.
(listEvent.kind === BOOKMARKS ? "Saved Items" : "Untitled List")
const userLists = $derived(
sortEventsDesc($listEvents).filter(
listEvent => listEvent.pubkey === $pubkey && listEvent.kind !== DELETE,
),
)
const userDeletes = $derived(
sortEventsDesc($listEvents).filter(
listEvent => listEvent.pubkey === $pubkey && listEvent.kind === DELETE,
),
)
const isDeletedList = (listEvent: TrustedEvent) => {
const address = getListKey(listEvent)
return userDeletes.some(deleteEvent => {
if (deleteEvent.created_at < listEvent.created_at) {
return false
}
return (
getTagValues("e", deleteEvent.tags).includes(listEvent.id) ||
getTagValues("a", deleteEvent.tags).includes(address)
)
})
}
const lists = $derived.by(() => {
const uniqueEvents = uniqBy(getListKey, userLists).filter(
Review

Events are automatically deduplicated based on replacement rules/address, no need to do that here.

Events are automatically deduplicated based on replacement rules/address, no need to do that here.
listEvent => !isDeletedList(listEvent),
)
const mapped = uniqueEvents.map(
(listEvent): BookmarkList => ({
key: getListKey(listEvent),
label: getListLabel(listEvent),
count: getTagValues(["e", "a", "p", "r"], listEvent.tags).length,
}),
)
Review

Just return the events themselves here and save the rendering stuff for elsewhere

Just return the events themselves here and save the rendering stuff for elsewhere
const savedItems = first(mapped.filter(list => Address.from(list.key).kind === BOOKMARKS)) || {
key: $pubkey ? new Address(BOOKMARKS, $pubkey, "").toString() : "10003:",
label: "Saved Items",
count: 0,
}
return [savedItems, ...mapped.filter(list => Address.from(list.key).kind !== BOOKMARKS)]
})
let listName = $state("")
let creating = $state(false)
let selecting = $state(false)
let deleting = $state(false)
let loadedListPubkey: string | undefined = $state()
const close = () => history.back()
const isIgnorableBookmarkListError = (error: string) =>
shouldIgnoreError(error) || error.includes("only accepts kind 10002 events")
const createList = async () => {
if (!listName.trim() || creating) {
return
}
creating = true
try {
const thunk = await createBookmarkList(listName)
if (!thunk) {
pushToast({message: "List already exists"})
return
}
Review

This pattern in wrong, commands should not be doing validation, they should just be sending the provided thunk. Move the validation into this function. Actually, the listName empty check is duplicated above, so this code will never run. The error message is also wrong.

This pattern in wrong, commands should not be doing validation, they should just be sending the provided thunk. Move the validation into this function. Actually, the listName empty check is duplicated above, so this code will never run. The error message is also wrong.
const error = await waitForThunkError(thunk)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "List created"})
listName = ""
}
} finally {
creating = false
}
}
const addToList = async (key: string, label: string) => {
if (!event || selecting) {
return
}
selecting = true
try {
const thunk = await addEventBookmark(event, key)
if (!thunk) {
pushToast({message: "Already bookmarked"})
return
}
const error = await waitForThunkError(thunk)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: `Saved to ${label}`})
close()
}
} finally {
selecting = false
}
}
const deleteList = async (key: string, label: string) => {
if (deleting || Address.from(key).kind === BOOKMARKS) {
return
}
if (!confirm(`Delete \"${label}\"? This cannot be undone.`)) {
return
}
deleting = true
try {
const thunk = await deleteBookmarkList(key)
if (!thunk) {
pushToast({theme: "error", message: "Unable to delete this list"})
return
}
const error = await waitForThunkError(thunk)
if (error && !isIgnorableBookmarkListError(error)) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: `Deleted ${label}`})
}
} finally {
deleting = false
}
}
const loadBookmarkLists = async () => {
if (!$pubkey || loadedListPubkey === $pubkey) {
return
}
loadedListPubkey = $pubkey
await load({
relays: INDEXER_RELAYS,
filters: [{kinds: [BOOKMARKS, BOOKMARK_LISTS, DELETE], authors: [$pubkey]}],
})
}
onMount(() => {
void loadBookmarkLists()
})
$effect(() => {
if ($pubkey && loadedListPubkey !== $pubkey) {
void loadBookmarkLists()
}
})
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>{event ? "Add to Bookmark List" : "Manage Bookmark Lists"}</ModalTitle>
</ModalHeader>
<div class="col-2 gap-3">
<Field>
{#snippet label()}
New list name
{/snippet}
{#snippet input()}
<div class="row-2 min-w-0 grow gap-2">
<label class="input input-bordered flex grow items-center gap-2">
<Icon icon={Folder} />
<input bind:value={listName} class="grow" type="text" placeholder="e.g. AI threads" />
</label>
<Button
class="btn btn-neutral"
onclick={createList}
disabled={creating || !listName.trim()}>
<Icon icon={Add} />
Create
</Button>
</div>
{/snippet}
</Field>
<div class="col-2 gap-2">
{#each lists as list (list.key)}
{#if event}
<Button
class="card2 card2-sm bg-alt flex items-center justify-between"
onclick={() => addToList(list.key, list.label)}
disabled={selecting}>
<span class="inline-flex items-center gap-2">
<Icon
size={4}
icon={Address.from(list.key).kind === BOOKMARKS ? Bookmark : Folder} />
{list.label}
</span>
<span class="badge badge-sm badge-neutral">{list.count}</span>
</Button>
{:else}
<div class="card2 card2-sm bg-alt flex items-center justify-between gap-2">
<span class="min-w-0 truncate inline-flex items-center gap-2">
<Icon
size={4}
icon={Address.from(list.key).kind === BOOKMARKS ? Bookmark : Folder} />
{list.label}
</span>
<div class="flex items-center gap-2">
<span class="badge badge-sm badge-neutral">{list.count}</span>
{#if Address.from(list.key).kind !== BOOKMARKS}
<Button
class="btn btn-ghost btn-xs btn-square text-error"
onclick={() => deleteList(list.key, list.label)}
disabled={deleting}>
<Icon size={4} icon={TrashBinMinimalistic} />
</Button>
{/if}
</div>
</div>
{/if}
{/each}
</div>
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={close}>Close</Button>
</ModalFooter>
</Modal>
+247
View File
@@ -0,0 +1,247 @@
<script lang="ts">
import {type TrustedEvent} from "@welshman/util"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import SecondaryNav from "@lib/components/SecondaryNav.svelte"
import SecondaryNavHeader from "@lib/components/SecondaryNavHeader.svelte"
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
import Folder from "@assets/icons/folder.svg?dataurl"
import Add from "@assets/icons/add.svg?dataurl"
type BookmarkList = {
key: string
label: string
count: number
event?: TrustedEvent
}
type Props = {
lists: BookmarkList[]
selectedKey: string
totalCount: number
onOpenManager: () => void
onSelect: (key: string) => void
onRename: (key: string, label: string, nextLabel: string) => Promise<void>
onDelete: (key: string, label: string) => Promise<void>
}
const {lists, selectedKey, totalCount, onOpenManager, onSelect, onRename, onDelete}: Props =
$props()
let menuOpen = $state(false)
let menuX = $state(0)
let menuY = $state(0)
let menuListKey = $state("")
let menuListLabel = $state("")
let listDialogMode: "rename" | "delete" | undefined = $state()
let dialogListKey = $state("")
let dialogListLabel = $state("")
let nextListLabel = $state("")
let dialogPending = $state(false)
const closeMenu = () => {
menuOpen = false
}
const openListMenu = (event: MouseEvent, key: string, label: string) => {
event.preventDefault()
menuOpen = true
menuX = event.clientX
menuY = event.clientY
menuListKey = key
menuListLabel = label
}
const openMenuList = () => {
closeMenu()
onSelect(menuListKey)
}
const openRenameDialog = () => {
closeMenu()
listDialogMode = "rename"
dialogListKey = menuListKey
dialogListLabel = menuListLabel
nextListLabel = menuListLabel
}
const openDeleteDialog = () => {
closeMenu()
listDialogMode = "delete"
dialogListKey = menuListKey
dialogListLabel = menuListLabel
nextListLabel = menuListLabel
}
const closeDialog = (force = false) => {
if (!force && dialogPending) {
return
}
listDialogMode = undefined
dialogListKey = ""
dialogListLabel = ""
nextListLabel = ""
}
const submitRename = async () => {
if (listDialogMode !== "rename") {
return
}
dialogPending = true
try {
await onRename(dialogListKey, dialogListLabel, nextListLabel)
closeDialog(true)
} finally {
dialogPending = false
}
}
const submitDelete = async () => {
if (listDialogMode !== "delete") {
return
}
dialogPending = true
try {
await onDelete(dialogListKey, dialogListLabel)
closeDialog(true)
} finally {
dialogPending = false
}
}
const handleWindowKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeMenu()
closeDialog()
}
}
</script>
<svelte:window onclick={closeMenu} onkeydown={handleWindowKeydown} />
<SecondaryNav class="relative">
<SecondaryNavSection>
<SecondaryNavHeader>
<span class="flex items-center gap-2 uppercase tracking-wide">
<Icon icon={Bookmark} />
Bookmarks
</span>
<span class="badge badge-sm badge-neutral">{totalCount}</span>
</SecondaryNavHeader>
<div
class="flex items-center justify-between px-1 pt-1 text-xs uppercase tracking-wide opacity-70">
<span>My Lists</span>
<Button class="btn btn-ghost btn-xs btn-square" onclick={onOpenManager}>
<Icon size={3.5} icon={Add} />
</Button>
</div>
</SecondaryNavSection>
<div class="col-2 gap-2 overflow-y-auto px-2 pb-2">
{#each lists as list (list.key)}
<div
role="button"
tabindex="-1"
oncontextmenu={event => openListMenu(event, list.key, list.label)}>
<Link href={`/bookmarks?list=${encodeURIComponent(list.key)}`}>
<div
class={`card2 card2-sm bg-alt col-2 gap-1 transition-colors hover:bg-base-100 ${selectedKey === list.key ? "bg-base-100" : ""}`}>
<div class="flex items-center justify-between gap-2">
<p class="truncate font-medium">
<span class="inline-flex items-center gap-2">
<Icon size={4} icon={list.key.startsWith("10003:") ? Bookmark : Folder} />
{list.label}
</span>
</p>
<span class="badge badge-sm badge-neutral">{list.count}</span>
</div>
</div>
</Link>
</div>
{:else}
<div class="card2 card2-sm bg-alt text-sm opacity-70">No lists found yet.</div>
{/each}
</div>
{#if menuOpen}
<div class="fixed inset-0 z-popover" role="presentation">
<div
class="menu rounded-box bg-base-100 shadow-xl"
role="menu"
style={`position: fixed; left: ${menuX}px; top: ${menuY}px; min-width: 11rem;`}>
<li>
<Button class="justify-start" onclick={openMenuList}>Open List</Button>
</li>
{#if menuListKey !== selectedKey}
<li>
<Button class="justify-start" onclick={openRenameDialog}>Rename List</Button>
</li>
<li>
<Button class="justify-start text-error" onclick={openDeleteDialog}>Delete List</Button>
</li>
{/if}
</div>
</div>
{/if}
{#if listDialogMode}
<div class="fixed inset-0 z-modal bg-black/45 p-4 md:p-6">
<button
type="button"
class="absolute inset-0"
aria-label="Close dialog"
onclick={() => closeDialog()}></button>
<div class="center relative h-full w-full">
<div
class="card2 bg-base-100 w-full max-w-md gap-4 p-5 shadow-2xl"
role="dialog"
aria-modal="true"
tabindex="-1">
{#if listDialogMode === "rename"}
<div class="col-2 gap-1">
<h3 class="text-lg font-semibold">Rename List</h3>
<p class="text-sm opacity-70">Choose a new name for "{dialogListLabel}".</p>
</div>
<label class="input input-bordered flex items-center gap-2">
<Icon icon={Folder} />
<input bind:value={nextListLabel} class="grow" type="text" placeholder="List name" />
</label>
<div class="flex items-center justify-end gap-2">
<Button class="btn btn-ghost" onclick={() => closeDialog()} disabled={dialogPending}
>Cancel</Button>
<Button
class="btn btn-neutral"
onclick={submitRename}
disabled={dialogPending || !nextListLabel.trim()}>
Save
</Button>
</div>
{:else}
<div class="col-2 gap-1">
<h3 class="text-lg font-semibold">Delete List?</h3>
<p class="text-sm opacity-70">
This will remove "{dialogListLabel}" and cannot be undone.
</p>
</div>
<div class="flex items-center justify-end gap-2">
<Button class="btn btn-ghost" onclick={() => closeDialog()} disabled={dialogPending}
>Cancel</Button>
<Button class="btn btn-error" onclick={submitDelete} disabled={dialogPending}
>Delete</Button>
</div>
{/if}
</div>
</div>
</div>
{/if}
</SecondaryNav>
@@ -12,6 +12,7 @@
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
import BookmarkEventMenuItem from "@app/components/BookmarkEventMenuItem.svelte"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {makeCalendarPath, makeSpacePath} from "@app/util/routes"
import {pushModal} from "@app/util/modal"
@@ -51,6 +52,7 @@
{/if}
<EventActions {url} {event} noun="Event">
{#snippet customActions()}
<BookmarkEventMenuItem {event} />
{#if event.pubkey === $pubkey}
<li>
<Button onclick={editEvent}>
+86 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {onMount} from "svelte"
import {onMount, tick} from "svelte"
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import {
ago,
@@ -198,6 +199,71 @@
let compose: ChatCompose | undefined = $state()
let parent: TrustedEvent | undefined = $state()
let eventToEdit: TrustedEvent | undefined = $state()
let userHasScrolled = $state(false)
let isProgrammaticScroll = $state(false)
let programmaticScrollTimeout: ReturnType<typeof setTimeout>
const at = $derived(parseInt($page.url.searchParams.get("at")!))
const targetId = $derived($page.url.searchParams.get("e"))
$effect(() => {
void at
void targetId
userHasScrolled = false
})
const manageScrollPosition = () => {
if (!userHasScrolled && (!isNaN(at) || targetId)) {
const targetEvent = targetId
? ($chat?.messages || []).find(event => event.id === targetId)
: sortBy(e => -e.created_at, $chat?.messages || []).find(event => event.created_at <= at)
if (targetEvent) {
const target = document.querySelector(`[data-event="${targetEvent.id}"]`)
if (target instanceof HTMLElement) {
isProgrammaticScroll = true
clearTimeout(programmaticScrollTimeout)
programmaticScrollTimeout = setTimeout(() => {
isProgrammaticScroll = false
}, 300)
target.scrollIntoView({block: "center"})
if (target.dataset.highlighted !== "true") {
target.dataset.highlighted = "true"
target.style.filter = "brightness(1.5)"
target.style.transitionProperty = "all"
target.style.transitionDuration = "400ms"
setTimeout(() => {
target.style.transitionDuration = "300ms"
target.style.filter = ""
}, 800)
}
}
}
}
}
let isInteracting = false
let interactionTimeout: ReturnType<typeof setTimeout>
const markInteraction = () => {
isInteracting = true
clearTimeout(interactionTimeout)
interactionTimeout = setTimeout(() => {
isInteracting = false
}, 500)
}
const onScroll = () => {
if (!isProgrammaticScroll) {
if (isInteracting) {
userHasScrolled = true
}
manageScrollPosition()
}
}
const elements = $derived.by(() => {
const elements = []
@@ -229,6 +295,11 @@
return elements.reverse()
})
$effect(() => {
void elements
tick().then(manageScrollPosition)
})
onMount(() => {
for (const pubkey of others) {
loadMessagingRelayList(pubkey)
@@ -279,7 +350,20 @@
</div>
</PageBar>
<PageContent class="flex flex-col-reverse gap-2 py-4">
<PageContent
onscroll={onScroll}
onwheel={markInteraction}
ontouchmove={markInteraction}
onpointerdown={markInteraction}
onpointermove={(e: PointerEvent) => {
if (e.buttons > 0) markInteraction()
}}
onkeydown={(e: KeyboardEvent) => {
if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(e.key)) {
markInteraction()
}
}}
class="flex flex-col-reverse gap-2 pt-4">
{#if missingRelayLists.length > 0}
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
+10
View File
@@ -2,8 +2,10 @@
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ChatMessageEmojiButton from "@app/components/ChatMessageEmojiButton.svelte"
import BookmarkListPicker from "@app/components/BookmarkListPicker.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import {pushModal} from "@app/util/modal"
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
@@ -17,6 +19,11 @@
popover.hide()
pushModal(EventInfo, {event})
}
const bookmark = () => {
popover.hide()
pushModal(BookmarkListPicker, {event})
}
</script>
<div class="join border border-solid border-neutral text-xs">
@@ -31,6 +38,9 @@
<Icon size={4} icon={Pen} />
</Button>
{/if}
<Button class="btn join-item btn-xs" onclick={bookmark}>
<Icon size={4} icon={Bookmark} />
</Button>
<Button class="btn join-item btn-xs" onclick={showInfo}>
<Icon size={4} icon={Code2} />
</Button>
@@ -12,10 +12,12 @@
import ModalBody from "@lib/components/ModalBody.svelte"
import Button from "@lib/components/Button.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
import BookmarkListPicker from "@app/components/BookmarkListPicker.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import {makeReaction} from "@app/core/commands"
import {pushModal} from "@app/util/modal"
import {clip} from "@app/util/toast"
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
type Props = {
pubkeys: string[]
@@ -52,6 +54,11 @@
clip(event.content)
}
const bookmarkMessage = () => {
history.back()
pushModal(BookmarkListPicker, {event}, {replaceState: true})
}
const showInfo = () => pushModal(EventInfo, {event}, {replaceState: true})
</script>
@@ -66,6 +73,10 @@
<Icon size={4} icon={Copy} />
Copy Text
</Button>
<Button class="btn btn-neutral w-full" onclick={bookmarkMessage}>
<Icon size={4} icon={Bookmark} />
Bookmark Message
</Button>
<Button class="btn btn-neutral w-full" onclick={sendReply}>
<Icon size={4} icon={Reply} />
Send Reply
@@ -15,6 +15,7 @@
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import ClassifiedEdit from "@app/components/ClassifiedEdit.svelte"
import BookmarkEventMenuItem from "@app/components/BookmarkEventMenuItem.svelte"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {makeClassifiedPath, makeSpacePath} from "@app/util/routes"
import {pushModal} from "@app/util/modal"
@@ -64,6 +65,7 @@
{/if}
<EventActions {url} {event} noun="Listing">
{#snippet customActions()}
<BookmarkEventMenuItem {event} />
{#if event.pubkey === $pubkey}
<li>
<Button onclick={editClassified}>
+6 -1
View File
@@ -4,6 +4,7 @@
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import BookmarkEventMenuItem from "@app/components/BookmarkEventMenuItem.svelte"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {makeSpacePath} from "@app/util/routes"
@@ -34,6 +35,10 @@
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Comment" />
<EventActions {url} {event} noun="Comment">
{#snippet customActions()}
<BookmarkEventMenuItem {event} />
{/snippet}
</EventActions>
</div>
</div>
+109 -6
View File
@@ -1,17 +1,38 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {userProfile} from "@welshman/app"
import {onMount} from "svelte"
import {uniqBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {
Address,
DELETE,
getAddress,
getTagValue,
getTagValues,
sortEventsDesc,
} from "@welshman/util"
import {load} from "@welshman/net"
import {pubkey, userProfile} from "@welshman/app"
import Letter from "@assets/icons/letter.svg?dataurl"
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import Widget from "@assets/icons/widget-4.svg?dataurl"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Settings from "@assets/icons/settings.svg?dataurl"
import ImageIcon from "@lib/components/ImageIcon.svelte"
import Divider from "@lib/components/Divider.svelte"
import MenuSettings from "@app/components/MenuSettings.svelte"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import PrimaryNavSpaces from "@app/components/PrimaryNavSpaces.svelte"
import {userSpaceUrls, PLATFORM_RELAYS} from "@app/core/state"
import {
BOOKMARKS,
BOOKMARK_LISTS,
deriveEvents,
INDEXER_RELAYS,
loadUserBookmarkCollections,
loadUserBookmarkList,
userSpaceUrls,
PLATFORM_RELAYS,
} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {notifications} from "@app/util/notifications"
import {goToChat, makeSpacePath} from "@app/util/routes"
@@ -29,16 +50,91 @@
const anySpaceNotifications = $derived(
$userSpaceUrls.some(p => $notifications.has(makeSpacePath(p))),
)
const bookmarkListEvents = deriveEvents([{kinds: [BOOKMARKS, BOOKMARK_LISTS, DELETE]}])
let loadedBookmarkCountPubkey: string | undefined = $state()
const getListKey = (event: TrustedEvent) =>
event.kind === BOOKMARKS
? new Address(BOOKMARKS, event.pubkey, "").toString()
: getAddress(event)
const isDeletedList = (event: TrustedEvent, deleteEvents: TrustedEvent[]) => {
const address =
event.kind === BOOKMARKS
? new Address(BOOKMARKS, event.pubkey, "").toString()
: `${event.kind}:${event.pubkey}:${getTagValue("d", event.tags) || ""}`
return deleteEvents.some(deleteEvent => {
if (deleteEvent.created_at < event.created_at) {
return false
}
return (
getTagValues("e", deleteEvent.tags).includes(event.id) ||
getTagValues("a", deleteEvent.tags).includes(address)
)
})
}
const bookmarkListCount = $derived.by(() => {
if (!$pubkey) {
return 0
}
const ownEvents = sortEventsDesc($bookmarkListEvents).filter(event => event.pubkey === $pubkey)
const deleteEvents = ownEvents.filter(event => event.kind === DELETE)
const listEvents = ownEvents.filter(
event => event.kind === BOOKMARKS || event.kind === BOOKMARK_LISTS,
)
const visibleLists = uniqBy(getListKey, listEvents).filter(
event => !isDeletedList(event, deleteEvents),
)
const hasSavedItems = visibleLists.some(event => event.kind === BOOKMARKS)
return visibleLists.length + (hasSavedItems ? 0 : 1)
})
const loadBookmarkCountData = async () => {
if (!$pubkey || loadedBookmarkCountPubkey === $pubkey) {
return
}
loadedBookmarkCountPubkey = $pubkey
await Promise.all([
loadUserBookmarkList(),
loadUserBookmarkCollections(),
load({
relays: INDEXER_RELAYS,
filters: [{kinds: [DELETE], authors: [$pubkey]}],
}),
])
}
onMount(() => {
void loadBookmarkCountData()
})
$effect(() => {
if ($pubkey && loadedBookmarkCountPubkey !== $pubkey) {
void loadBookmarkCountData()
}
})
</script>
<div
class="ml-sai mt-sai mb-sai relative z-popover isolate hidden w-14 shrink-0 bg-base-200 pt-2 md:block">
<div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}>
<PrimaryNavSpaces />
{#if PLATFORM_RELAYS.length > 0}
<Divider />
{/if}
<div class="flex flex-col">
<PrimaryNavItem title="Bookmarks" href="/bookmarks" prefix="/bookmarks">
<div class="relative">
<ImageIcon alt="Bookmarks" src={Bookmark} size={8} />
<span class="badge badge-xs badge-neutral absolute -right-2 -top-2"
>{bookmarkListCount}</span>
</div>
</PrimaryNavItem>
<PrimaryNavItem title="Settings" href="/settings/profile" prefix="/settings">
{#if $userProfile?.picture}
<ImageIcon alt="Settings" src={$userProfile?.picture} class="rounded-full" size={10} />
@@ -78,6 +174,13 @@
notification={$notifications.has("/chat")}>
<ImageIcon alt="Messages" src={Letter} size={8} />
</PrimaryNavItem>
<PrimaryNavItem title="Bookmarks" href="/bookmarks" prefix="/bookmarks">
<div class="relative">
<ImageIcon alt="Bookmarks" src={Bookmark} size={8} />
<span class="badge badge-xs badge-neutral absolute -right-2 -top-2"
>{bookmarkListCount}</span>
</div>
</PrimaryNavItem>
{#if PLATFORM_RELAYS.length !== 1}
<PrimaryNavItem title="Spaces" href="/spaces" notification={anySpaceNotifications}>
<ImageIcon alt="Spaces" src={Widget} size={8} />
+14 -1
View File
@@ -3,14 +3,16 @@
import {ManagementMethod} from "@welshman/util"
import {pubkey, manageRelay, repository} from "@welshman/app"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import Bookmark from "@assets/icons/bookmark.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"
import Icon from "@lib/components/Icon.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import BookmarkListPicker from "@app/components/BookmarkListPicker.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import Report from "@app/components/Report.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import Report from "@app/components/Report.svelte"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {deriveUserIsSpaceAdmin} from "@app/core/state"
@@ -40,6 +42,11 @@
pushModal(EventDeleteConfirm, {url, event})
}
const bookmarkMessage = () => {
onClick()
pushModal(BookmarkListPicker, {event})
}
const showAdminDelete = () =>
pushModal(Confirm, {
title: `Delete Message`,
@@ -68,6 +75,12 @@
Show JSON
</Button>
</li>
<li>
<Button onclick={bookmarkMessage}>
<Icon size={4} icon={Bookmark} />
Bookmark Message
</Button>
</li>
{#if event.pubkey === $pubkey}
<li>
<Button onclick={showDelete} class="text-error">
+15 -4
View File
@@ -5,6 +5,7 @@
import Bolt from "@assets/icons/bolt.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
@@ -14,13 +15,14 @@
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import BookmarkListPicker from "@app/components/BookmarkListPicker.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
import {ENABLE_ZAPS} from "@app/core/state"
import {publishReaction, canEnforceNip70} from "@app/core/commands"
import {getRoomItemPath} from "@app/util/routes"
import {canEnforceNip70, publishReaction} from "@app/core/commands"
import {pushModal} from "@app/util/modal"
import {getRoomItemPath} from "@app/util/routes"
type Props = {
url: string
@@ -54,6 +56,11 @@
const showInfo = () => pushModal(EventInfo, {url, event}, {replaceState: true})
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
const bookmarkMessage = () => {
history.back()
pushModal(BookmarkListPicker, {event}, {replaceState: true})
}
</script>
<Modal>
@@ -69,6 +76,10 @@
<Icon size={4} icon={Code2} />
Message Info
</Button>
<Button class="btn btn-neutral w-full" onclick={bookmarkMessage}>
<Icon size={4} icon={Bookmark} />
Bookmark Message
</Button>
{#if path}
<Link class="btn btn-neutral" href={path}>
<Icon size={4} icon={MenuDots} />
+6 -1
View File
@@ -7,6 +7,7 @@
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import BookmarkEventMenuItem from "@app/components/BookmarkEventMenuItem.svelte"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {makeThreadPath, makeSpacePath} from "@app/util/routes"
@@ -41,5 +42,9 @@
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Thread" />
<EventActions {url} {event} noun="Thread">
{#snippet customActions()}
<BookmarkEventMenuItem {event} />
{/snippet}
</EventActions>
</div>
+137
View File
@@ -14,6 +14,7 @@ import {
simpleCache,
normalizeUrl,
nthNe,
randomId,
} from "@welshman/lib"
import {Nip01Signer} from "@welshman/signer"
import type {UploadTask} from "@welshman/editor"
@@ -37,12 +38,15 @@ import {
makeEvent,
normalizeRelayUrl,
makeList,
Address,
addToListPublicly,
removeFromListByPredicate,
updateList,
getTag,
getTagValue,
getListTags,
getRelayTagValues,
getEventTagValues,
toNostrURI,
RelayMode,
getTagValues,
@@ -82,10 +86,14 @@ import {
import {compressFile} from "@lib/html"
import type {SettingsValues, SpaceNotificationSettings} from "@app/core/state"
import {
BOOKMARKS,
BOOKMARK_LISTS,
SETTINGS,
PROTECTED,
INDEXER_RELAYS,
DEFAULT_BLOSSOM_SERVERS,
getBookmarkList,
getBookmarkCollection,
userSpaceUrls,
userSettingsValues,
getSetting,
@@ -167,6 +175,135 @@ export const broadcastUserData = async (relays: string[]) => {
hodlbod marked this conversation as resolved Outdated
Outdated
Review

These can be imported from @welshman/util

These can be imported from @welshman/util
Outdated
Review

Done. Now these are imported from @welshman/util

Done. Now these are imported from @welshman/util
// List updates
const getSavedItemsList = (owner = pubkey.get() || "") => {
if (!owner) {
return makeList({kind: BOOKMARKS})
}
return getBookmarkList().get(owner) || makeList({kind: BOOKMARKS})
}
const getBookmarkListFromAddress = (address: string) => {
const parsed = Address.from(address)
if (parsed.kind === BOOKMARKS) {
return getSavedItemsList(parsed.pubkey)
}
if (parsed.kind === BOOKMARK_LISTS) {
return getBookmarkCollection().get(address)
}
return undefined
}
export const createBookmarkList = async (title: string) => {
hodlbod marked this conversation as resolved
Review

This should go in state and should be as simple as:

export const bookmarkListsByPubkey = deriveItemsByKey({
  repository,
  eventToItem: event => readList(asDecryptedEvent(event)),
  filters: [{kinds: [BOOKMARKS]}],
  getKey: list => list.event.pubkey,
})

export const getBookmarkList = (pubkey: string) => getBookmarkList().get(pubkey)

export const loadBookmarkList = makeLoadItem(makeOutboxLoader(BOOKMARKS), getBookmarkList)

export const userBookmarkList = makeUserData(bookmarkListsByPubkey, loadBookmarkList)

See @welshman/app's user.ts file for examples.

This should go in `state` and should be as simple as: ``` export const bookmarkListsByPubkey = deriveItemsByKey({ repository, eventToItem: event => readList(asDecryptedEvent(event)), filters: [{kinds: [BOOKMARKS]}], getKey: list => list.event.pubkey, }) export const getBookmarkList = (pubkey: string) => getBookmarkList().get(pubkey) export const loadBookmarkList = makeLoadItem(makeOutboxLoader(BOOKMARKS), getBookmarkList) export const userBookmarkList = makeUserData(bookmarkListsByPubkey, loadBookmarkList) ``` See `@welshman/app`'s `user.ts` file for examples.
Review

I think part of the problem is that you're using the same function to handle two different kinds of list. You should create separate functions for 10003 vs 30003 since they work quite differently.

I think part of the problem is that you're using the same function to handle two different kinds of list. You should create separate functions for 10003 vs 30003 since they work quite differently.
Review

Done.

Done.
const label = title.trim()
if (!label) {
hodlbod marked this conversation as resolved Outdated
Outdated
Review

d should be randomly generated, not a slug of the title

`d` should be randomly generated, not a slug of the title
Outdated
Review

Fixed. d is now opaque/random, and title is stored separately.

Fixed. d is now opaque/random, and title is stored separately.
return
}
const list = makeList({kind: BOOKMARK_LISTS})
const event = await updateList(list, {
publicTags: [
["d", randomId()],
["title", label],
],
}).reconcile(nip44EncryptToSelf)
const relays = Router.get().FromUser().getUrls()
return publishThunk({event, relays})
}
export const addEventBookmark = async (target: TrustedEvent, address?: string) => {
const list = address ? getBookmarkListFromAddress(address) : getSavedItemsList()
if (!list) {
return
}
const existing = new Set(getEventTagValues(getListTags(list)))
if (existing.has(target.id)) {
return
}
const event = await addToListPublicly(list, ["e", target.id]).reconcile(nip44EncryptToSelf)
const relays = Router.get().FromUser().getUrls()
return publishThunk({event, relays})
}
export const removeEventBookmark = async (target: TrustedEvent, address?: string) => {
const list = address ? getBookmarkListFromAddress(address) : getSavedItemsList()
hodlbod marked this conversation as resolved
Review

The bookmark list shouldn't be published to relays based on its content, just publish it to the user's outbox

The bookmark list shouldn't be published to relays based on its content, just publish it to the user's outbox
Review

Fixed. Bookmark list updates are now published only to user outbox relays.

Fixed. Bookmark list updates are now published only to user outbox relays.
if (!list) {
return
}
const targetD = getTagValue("d", target.tags)
const targetAddress = targetD ? `${target.kind}:${target.pubkey}:${targetD}` : undefined
const hasMatch = getListTags(list).some(
tag =>
(tag[0] === "e" && tag[1] === target.id) ||
(targetAddress !== undefined && tag[0] === "a" && tag[1] === targetAddress),
)
if (!hasMatch) {
return
}
const event = await removeFromListByPredicate(
list,
tag =>
(tag[0] === "e" && tag[1] === target.id) ||
(targetAddress !== undefined && tag[0] === "a" && tag[1] === targetAddress),
).reconcile(nip44EncryptToSelf)
const relays = Router.get().FromUser().getUrls()
return publishThunk({event, relays})
}
export const deleteBookmarkList = async (address: string) => {
const list = getBookmarkCollection().get(address)
const {kind} = Address.from(address)
if (kind !== BOOKMARK_LISTS || !list?.event) {
return
}
const relays = Router.get().FromUser().getUrls()
return publishDelete({protect: false, event: list.event, tags: [["a", address]], relays})
}
export const renameBookmarkList = async (address: string, title: string) => {
const list = getBookmarkCollection().get(address)
const {kind} = Address.from(address)
const nextTitle = title.trim()
if (kind !== BOOKMARK_LISTS || !nextTitle || !list?.event) {
return
}
const currentTitle =
getTagValue("title", list.event.tags) || getTagValue("d", list.event.tags) || ""
if (nextTitle === currentTitle) {
return
}
const publicTags = [
["d", getTagValue("d", list.event.tags) || randomId()],
["title", nextTitle],
]
const event = await updateList(list, {publicTags}).reconcile(nip44EncryptToSelf)
const relays = Router.get().FromUser().getUrls()
return publishThunk({event, relays})
}
export const addSpaceMembership = async (url: string) => {
const list = get(userGroupList) || makeList({kind: ROOMS})
const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf)
2
+50
View File
@@ -159,6 +159,12 @@ export const ROOM = "h"
export const PROTECTED = ["-"]
export const MESSAGE_KINDS = [MESSAGE]
export const BOOKMARKS = 10003
export const BOOKMARK_LISTS = 30003
export const IMAGE_CONTENT_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]
export const VIDEO_CONTENT_TYPES = ["video/quicktime", "video/webm", "video/mp4"]
@@ -704,6 +710,50 @@ export const deriveOtherVoiceRooms = (url: string) =>
// User space/room lists
export const bookmarkListsByPubkey = deriveItemsByKey({
repository,
filters: [{kinds: [BOOKMARKS]}],
getKey: list => list.event.pubkey,
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
})
export const getBookmarkList = getter(bookmarkListsByPubkey)
export const loadBookmarkList = makeLoadItem(makeOutboxLoader(BOOKMARKS), getBookmarkList)
export const userBookmarkList = makeUserData(bookmarkListsByPubkey, loadBookmarkList)
export const loadUserBookmarkList = makeUserLoader(loadBookmarkList)
export const bookmarkCollectionsByAddress = deriveItemsByKey({
repository,
filters: [{kinds: [BOOKMARK_LISTS]}],
getKey: list => getAddress(list.event),
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
})
export const getBookmarkCollection = getter(bookmarkCollectionsByAddress)
export const bookmarkCollections = deriveItems(bookmarkCollectionsByAddress)
export const bookmarkCollectionsByPubkey = derived(bookmarkCollections, $lists =>
groupBy(list => list.event.pubkey, $lists),
)
export const getBookmarkCollections = getter(bookmarkCollectionsByPubkey)
export const loadBookmarkCollections = makeLoadItem(
makeOutboxLoader(BOOKMARK_LISTS),
getBookmarkCollections,
)
export const userBookmarkCollections = makeUserData(
bookmarkCollectionsByPubkey,
loadBookmarkCollections,
)
export const loadUserBookmarkCollections = makeUserLoader(loadBookmarkCollections)
export const groupListsByPubkey = deriveItemsByKey({
repository,
filters: [{kinds: [ROOMS]}],
+15 -6
View File
@@ -26,7 +26,16 @@ import ChatEnable from "@app/components/ChatEnable.svelte"
// Chat
export const makeChatPath = (pubkeys: string[]) => `/chat/${makeChatId(pubkeys)}`
export const makeChatPath = (pubkeys: string[], event?: TrustedEvent) => {
let path = `/chat/${makeChatId(pubkeys)}`
if (event) {
const qp = new URLSearchParams({at: String(event.created_at), e: event.id})
path += "?" + qp.toString()
}
return path
}
export const makeRoomPath = (url: string, h: string) => `/spaces/${encodeRelay(url)}/${h}`
@@ -63,11 +72,11 @@ export const goToSpace = async (url: string) => {
const prevPath = lastPageBySpaceUrl.get(encodeRelay(url))
if (prevPath && prevPath !== makeSpacePath(url)) {
goto(prevPath, {replaceState: true})
goto(prevPath)
} else if (window.matchMedia(`(min-width: ${theme.screens.md})`).matches) {
goto(makeSpacePath(url, "recent"), {replaceState: true})
goto(makeSpacePath(url, "recent"))
} else {
goto(makeSpacePath(url), {replaceState: true})
goto(makeSpacePath(url))
}
}
@@ -76,7 +85,7 @@ export const goToSpace = async (url: string) => {
export const makeMessagePath = (url: string, event: TrustedEvent) => {
const h = getTagValue(ROOM, event.tags)
const path = h ? makeRoomPath(url, h) : makeSpaceChatPath(url)
const qp = new URLSearchParams({at: String(event.created_at)})
const qp = new URLSearchParams({at: String(event.created_at), e: event.id})
return path + "?" + qp.toString()
}
@@ -127,7 +136,7 @@ export const goToEvent = (event: TrustedEvent, options: Record<string, any> = {}
export const getEventPath = (event: TrustedEvent, urls: string[]) => {
if (DM_KINDS.includes(event.kind)) {
return makeChatPath([event.pubkey, ...getPubkeyTagValues(event.tags)])
return makeChatPath([event.pubkey, ...getPubkeyTagValues(event.tags)], event)
}
if (urls.length > 0) {
+1
View File
@@ -19,6 +19,7 @@ const staticTitles = new Map<string, string>([
["/spaces/[relay]/goals", "Goals"],
["/spaces/[relay]/polls", "Polls"],
["/chat", "Messages"],
["/bookmarks", "Bookmarks"],
["/join", "Join Space"],
["/people", "Find People"],
["/settings/about", "About"],
+589
View File
@@ -0,0 +1,589 @@
<script lang="ts">
import {onMount} from "svelte"
import {goto} from "$app/navigation"
import {page} from "$app/stores"
import {first, uniqBy} from "@welshman/lib"
import {load} from "@welshman/net"
import {
DELETE,
getIdFilters,
getTags,
getTagValue,
getTagValues,
sortEventsDesc,
tagsFromIMeta,
Address,
getAddress,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {loadProfile, pubkey, repository, tracker, waitForThunkError} from "@welshman/app"
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import SliderMinimalisticHorizontal from "@assets/icons/slider-minimalistic-horizontal.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Page from "@lib/components/Page.svelte"
import BookmarkListPicker from "@app/components/BookmarkListPicker.svelte"
import BookmarkGrid from "@app/components/BookmarkGrid.svelte"
import BookmarkSidebar from "@app/components/BookmarkSidebar.svelte"
import {
addEventBookmark,
deleteBookmarkList,
removeEventBookmark,
renameBookmarkList,
} from "@app/core/commands"
import {
BOOKMARKS,
BOOKMARK_LISTS,
deriveEvents,
IMAGE_CONTENT_TYPES,
INDEXER_RELAYS,
loadUserBookmarkCollections,
loadUserBookmarkList,
shouldIgnoreError,
VIDEO_CONTENT_TYPES,
} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {getEventPath} from "@app/util/routes"
import {pushToast} from "@app/util/toast"
type ContentType = "all" | "image" | "video" | "text"
type SidebarList = {
key: string
label: string
count: number
event: TrustedEvent | undefined
}
type BookmarkItem = {
key: string
event: TrustedEvent
href: string
external: boolean
image: string | undefined
video: string | undefined
contentType: "image" | "video" | "text"
preview: string
pollOptions: string[]
searchable: string
}
const savedItemsKey = $derived($pubkey ? new Address(BOOKMARKS, $pubkey, "").toString() : "")
const listEvents = deriveEvents([{kinds: [BOOKMARKS, BOOKMARK_LISTS, DELETE]}])
const isImageUrl = (url: string) => /\.(png|jpe?g|gif|webp|avif|svg)(\?|#|$)/i.test(url)
const isVideoUrl = (url: string) => /\.(mp4|webm|mov|m4v)(\?|#|$)/i.test(url)
const findUrls = (content: string) => content.match(/https?:\/\/\S+/g) || []
const findFirstImageUrl = (content: string) => findUrls(content).find(isImageUrl)
const findFirstVideoUrl = (content: string) => findUrls(content).find(isVideoUrl)
const isImageMime = (mime: string | undefined) =>
!!mime && (IMAGE_CONTENT_TYPES.includes(mime) || mime.startsWith("image/"))
const isVideoMime = (mime: string | undefined) =>
!!mime && (VIDEO_CONTENT_TYPES.includes(mime) || mime.startsWith("video/"))
const getMediaPreview = (event: TrustedEvent) => {
const imageFromContent = findFirstImageUrl(event.content || "")
const videoFromContent = findFirstVideoUrl(event.content || "")
const imetas = getTags("imeta", event.tags).map(tagsFromIMeta)
const imageFromImeta = imetas.find(meta => {
const url = getTagValue("url", meta)
const mime = getTagValue("m", meta)
return !!url && (isImageMime(mime) || isImageUrl(url))
})
const videoFromImeta = imetas.find(meta => {
const url = getTagValue("url", meta)
const mime = getTagValue("m", meta)
return !!url && (isVideoMime(mime) || isVideoUrl(url))
})
const imageFromTags = getTagValues(["image", "thumb"], event.tags).find(isImageUrl)
const videoFromTags = getTagValues(["video"], event.tags).find(isVideoUrl)
return {
image: imageFromContent || getTagValue("url", imageFromImeta || []) || imageFromTags,
video: videoFromContent || getTagValue("url", videoFromImeta || []) || videoFromTags,
}
}
const getPollQuestion = (event: TrustedEvent) => {
const lines = (event.content || "")
.split("\n")
.map(line => line.trim())
.filter(Boolean)
return lines[0] || "Untitled poll"
}
const getPreviewText = (event: TrustedEvent, pollOptions: string[]) => {
if (pollOptions.length > 0) {
return getPollQuestion(event)
}
const clean = (event.content || "")
.replace(/https?:\/\/\S+/g, "")
.replace(/\s+/g, " ")
.trim()
if (!clean) {
return "(no text content)"
}
return clean.length > 140 ? `${clean.slice(0, 140).trim()}...` : clean
}
const detectContentType = (event: TrustedEvent): "image" | "video" | "text" => {
const media = getMediaPreview(event)
if (media.image) {
return "image"
}
if (media.video) {
return "video"
}
return "text"
}
const getListKey = (event: TrustedEvent) =>
event.kind === BOOKMARKS
? new Address(BOOKMARKS, event.pubkey, "").toString()
: getAddress(event)
const getListLabel = (event: TrustedEvent) =>
getTagValue("title", event.tags) ||
getTagValue("d", event.tags) ||
(event.kind === BOOKMARKS ? "Saved Items" : "Untitled List")
const userListEvents = $derived(
sortEventsDesc($listEvents).filter(event => event.pubkey === $pubkey && event.kind !== DELETE),
)
const userDeleteEvents = $derived(
sortEventsDesc($listEvents).filter(event => event.pubkey === $pubkey && event.kind === DELETE),
)
const isDeletedList = (event: TrustedEvent) => {
const d = getTagValue("d", event.tags) || ""
const address = `${event.kind}:${event.pubkey}:${d}`
return userDeleteEvents.some(deleteEvent => {
if (deleteEvent.created_at < event.created_at) {
return false
}
return (
getTagValues("e", deleteEvent.tags).includes(event.id) ||
getTagValues("a", deleteEvent.tags).includes(address)
)
})
}
const sidebarLists = $derived.by(() => {
const mapped = uniqBy(getListKey, userListEvents)
.map(
(event): SidebarList => ({
key: getListKey(event),
label: getListLabel(event),
count: getTagValues(["e", "a"], event.tags).length,
event,
}),
)
.filter(list => (list.event ? !isDeletedList(list.event) : true))
const savedItems = first(mapped.filter(list => list.key === savedItemsKey)) || {
key: savedItemsKey,
label: "Saved Items",
count: 0,
event: undefined,
}
return [savedItems, ...mapped.filter(list => Address.from(list.key).kind !== BOOKMARKS)]
})
const defaultListKey = $derived(sidebarLists[0]?.key || "")
const selectedKey = $derived($page.url.searchParams.get("list") || defaultListKey)
const selectedList = $derived(sidebarLists.find(item => item.key === selectedKey))
const selectedListTags = $derived(selectedList?.event?.tags || [])
const selectedEventIds = $derived(getTagValues("e", selectedListTags))
const selectedAddresses = $derived(getTagValues("a", selectedListTags))
const totalListCount = $derived(sidebarLists.length)
let selectedEvents = $state<TrustedEvent[]>([])
let searchTerm = $state("")
let showSearch = $state(false)
let contentType = $state<ContentType>("all")
let listsReady = $state(false)
let loadedListPubkey: string | undefined = $state()
let loadedSelectionKey: string | undefined = $state()
const setFilterAll = () => {
contentType = "all"
}
const setFilterImage = () => {
contentType = "image"
}
const setFilterVideo = () => {
contentType = "video"
}
const setFilterText = () => {
contentType = "text"
}
const toggleSearch = () => {
showSearch = !showSearch
}
const openListManager = () => {
pushModal(BookmarkListPicker)
}
const isIgnorableBookmarkListError = (error: string) =>
shouldIgnoreError(error) || error.includes("only accepts kind 10002 events")
const openBookmarkFromMenu = async (item: BookmarkItem) => {
if (item.external) {
window.open(item.href, "_blank", "noopener,noreferrer")
return
}
await goto(item.href, {noScroll: true})
}
const copyBookmarkLinkFromMenu = async (item: BookmarkItem) => {
try {
await navigator.clipboard.writeText(item.href)
pushToast({message: "Link copied"})
} catch {
pushToast({theme: "error", message: "Unable to copy link"})
}
}
const addBookmarkToSavedItems = async (item: BookmarkItem) => {
if (!savedItemsKey) {
return
}
const thunk = await addEventBookmark(item.event, savedItemsKey)
if (!thunk) {
pushToast({message: "Already in Saved Items"})
return
}
const error = await waitForThunkError(thunk)
if (error && !isIgnorableBookmarkListError(error)) {
pushToast({theme: "error", message: error})
return
}
pushToast({message: "Added to Saved Items"})
}
const removeBookmarkFromCurrentList = async (item: BookmarkItem) => {
if (!selectedKey) {
return
}
const thunk = await removeEventBookmark(item.event, selectedKey)
if (!thunk) {
selectedEvents = selectedEvents.filter(event => event.id !== item.event.id)
pushToast({message: "Bookmark already removed"})
return
}
const error = await waitForThunkError(thunk)
if (error && !isIgnorableBookmarkListError(error)) {
pushToast({theme: "error", message: error})
return
}
selectedEvents = selectedEvents.filter(event => event.id !== item.event.id)
pushToast({message: "Removed from this list"})
}
const deleteListFromSidebar = async (key: string, label: string) => {
if (key.startsWith(`${BOOKMARKS}:`)) {
return
}
const thunk = await deleteBookmarkList(key)
if (!thunk) {
pushToast({theme: "error", message: "Unable to delete this list"})
return
}
const error = await waitForThunkError(thunk)
if (error && !isIgnorableBookmarkListError(error)) {
pushToast({theme: "error", message: error})
return
}
pushToast({message: `Deleted ${label}`})
if (selectedKey === key) {
await goto(selectedListHref(savedItemsKey), {replaceState: true, noScroll: true})
}
}
const renameListFromSidebar = async (key: string, label: string, nextLabel: string) => {
if (key.startsWith(`${BOOKMARKS}:`)) {
return
}
const normalized = nextLabel.trim()
if (!normalized || normalized === label) {
return
}
const thunk = await renameBookmarkList(key, normalized)
if (!thunk) {
pushToast({theme: "error", message: "Unable to rename list"})
return
}
const error = await waitForThunkError(thunk)
if (error && !isIgnorableBookmarkListError(error)) {
pushToast({theme: "error", message: error})
return
}
pushToast({message: `Renamed to ${normalized}`})
}
const normalizedTerm = $derived(searchTerm.trim().toLowerCase())
const items = $derived.by(() =>
selectedEvents.map((event): BookmarkItem => {
const href = getEventPath(event, Array.from(tracker.getRelays(event.id)))
const pollOptions = getTagValues(["option", "poll_option"], event.tags)
const media = getMediaPreview(event)
return {
key: event.id,
event,
href,
external: href.includes("://"),
image: media.image,
video: media.video,
contentType: detectContentType(event),
preview: getPreviewText(event, pollOptions),
pollOptions,
searchable: `${event.content || ""} ${pollOptions.join(" ")}`.toLowerCase(),
}
}),
)
const filteredItems = $derived(
items.filter(item => {
const matchesFilter = contentType === "all" || item.contentType === contentType
const matchesTerm = !normalizedTerm || item.searchable.includes(normalizedTerm)
return matchesFilter && matchesTerm
}),
)
const sidebarListsWithDisplayCount = $derived(
sidebarLists.map(list => ({
...list,
displayCount: list.key === selectedKey ? items.length : list.count,
})),
)
const selectedListHref = (key: string) => `/bookmarks?list=${encodeURIComponent(key)}`
const loadBookmarkLists = async () => {
if (!$pubkey) {
listsReady = false
return
}
if (loadedListPubkey === $pubkey && listsReady) {
return
}
loadedListPubkey = $pubkey
listsReady = false
try {
await Promise.all([
loadUserBookmarkList(),
loadUserBookmarkCollections(),
load({
relays: INDEXER_RELAYS,
filters: [{kinds: [DELETE], authors: [$pubkey]}],
}),
])
} finally {
listsReady = true
}
// Ensure selected-list references are reloaded once list metadata arrives.
loadedSelectionKey = undefined
}
const loadSelectedReferences = async () => {
if (!selectedList) {
selectedEvents = []
return
}
const references = [...selectedEventIds, ...selectedAddresses]
const referenceKey = `${selectedList.key}:${references.slice().sort().join("|")}`
selectedEvents = sortEventsDesc(
uniqBy(
event => event.id,
references
.map(id => repository.getEvent(id))
.filter((event): event is TrustedEvent => Boolean(event)),
),
)
if (loadedSelectionKey === referenceKey) {
return
}
if (references.length > 0) {
await load({
relays: INDEXER_RELAYS,
filters: getIdFilters(references),
})
selectedEvents = sortEventsDesc(
uniqBy(
event => event.id,
references
.map(id => repository.getEvent(id))
.filter((event): event is TrustedEvent => Boolean(event)),
),
)
}
loadedSelectionKey = referenceKey
for (const event of selectedEvents) {
loadProfile(event.pubkey)
}
}
onMount(() => {
void loadBookmarkLists()
})
$effect(() => {
if ($pubkey && loadedListPubkey !== $pubkey) {
void loadBookmarkLists()
}
})
$effect(() => {
if (selectedList) {
void loadSelectedReferences()
} else {
selectedEvents = []
}
})
</script>
<BookmarkSidebar
lists={listsReady ? sidebarListsWithDisplayCount : []}
{selectedKey}
totalCount={listsReady ? totalListCount : 0}
onOpenManager={openListManager}
onSelect={key => goto(selectedListHref(key), {replaceState: true, noScroll: true})}
onRename={renameListFromSidebar}
onDelete={deleteListFromSidebar} />
<Page class="overflow-hidden">
<div class="col-3 gap-3 p-3 md:p-4">
<div class="card2 card2-sm bg-alt flex items-center justify-between gap-2 p-2">
<div class="flex min-w-0 items-center gap-2">
<Icon size={4} icon={Bookmark} />
<p class="truncate text-sm font-medium">
{#if listsReady}
{selectedList?.label || "Saved Items"}
{:else}
Loading bookmarks...
{/if}
</p>
<span class="badge badge-sm badge-neutral">{filteredItems.length}</span>
</div>
<div class="flex items-center gap-2">
<Button class="btn btn-neutral btn-sm btn-square" onclick={toggleSearch}>
<Icon size={4} icon={Magnifier} />
</Button>
<details class="dropdown dropdown-end">
<summary class="btn btn-neutral btn-sm btn-square" aria-label="Filter">
<Icon size={4} icon={SliderMinimalisticHorizontal} />
</summary>
<ul class="menu dropdown-content z-popover mt-2 w-44 rounded-box bg-base-100 p-2 shadow">
<li>
<Button class="justify-start" onclick={setFilterAll}>All</Button>
</li>
<li>
<Button class="justify-start" onclick={setFilterImage}>Image</Button>
</li>
<li>
<Button class="justify-start" onclick={setFilterVideo}>Video</Button>
</li>
<li>
<Button class="justify-start" onclick={setFilterText}>Text</Button>
</li>
</ul>
</details>
</div>
</div>
<div class="flex w-full flex-wrap gap-2 px-1 text-[11px] uppercase tracking-wide opacity-70">
<span>{filteredItems.length} items</span>
<span></span>
<span>{listsReady ? selectedList?.label || "Saved Items" : "Loading"}</span>
</div>
{#if showSearch}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Magnifier} />
<input bind:value={searchTerm} class="grow" type="text" placeholder="Search this list..." />
</label>
{/if}
{#if listsReady}
<BookmarkGrid
items={filteredItems}
showAddToSaved={selectedKey !== savedItemsKey}
onOpen={openBookmarkFromMenu}
onCopyLink={copyBookmarkLinkFromMenu}
onAddToSaved={addBookmarkToSavedItems}
onRemove={removeBookmarkFromCurrentList} />
{:else}
<div class="card2 card2-sm bg-alt text-sm opacity-70">Loading bookmark lists...</div>
{/if}
</div>
</Page>
+60 -29
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import {onMount} from "svelte"
import {onMount, tick} from "svelte"
import {readable} from "svelte/store"
import {page} from "$app/stores"
import {goto} from "$app/navigation"
@@ -37,6 +37,7 @@
deriveRoom,
deriveUserRoomMembershipStatus,
getRoomType,
MESSAGE_KINDS,
MembershipStatus,
PROTECTED,
RoomType,
@@ -104,7 +105,13 @@
const shouldProtect = canEnforceNip70(url)
const membershipStatus = deriveUserRoomMembershipStatus(url, h)
const at = $derived(parseInt($page.url.searchParams.get("at")!))
const shouldVirtualize = $derived(isNaN(at))
const targetId = $derived($page.url.searchParams.get("e"))
$effect(() => {
void at
void targetId
userHasScrolled = false
})
const showRoomDetail = () => pushModal(RoomDetail, {url, h})
@@ -228,27 +235,56 @@
}
}
if (!userHasScrolled && !isNaN(at)) {
const targetEvent = $events.find(event => event.created_at >= at)
if (!userHasScrolled && (!isNaN(at) || targetId)) {
const targetEvent = targetId
? $events.find(event => event.id === targetId)
: $events.find(event => event.created_at <= at)
if (targetEvent) {
const target = element?.querySelector(`[data-event="${targetEvent.id}"]`)
if (target instanceof HTMLElement) {
isProgrammaticScroll = true
clearTimeout(programmaticScrollTimeout)
programmaticScrollTimeout = setTimeout(() => {
isProgrammaticScroll = false
}, 300)
target.scrollIntoView({block: "center"})
if (target.dataset.highlighted !== "true") {
target.dataset.highlighted = "true"
target.style.filter = "brightness(1.5)"
target.style.transitionProperty = "all"
target.style.transitionDuration = "400ms"
setTimeout(() => {
target.style.transitionDuration = "300ms"
target.style.filter = ""
}, 800)
}
}
}
}
}
let isInteracting = false
let interactionTimeout: ReturnType<typeof setTimeout>
const markInteraction = () => {
isInteracting = true
clearTimeout(interactionTimeout)
interactionTimeout = setTimeout(() => {
isInteracting = false
}, 500)
}
const onScroll = () => {
if (!isProgrammaticScroll) {
userHasScrolled = true
if (isInteracting) {
userHasScrolled = true
}
manageScrollPosition()
}
isProgrammaticScroll = false
}
const scrollToNewMessages = () =>
@@ -266,6 +302,7 @@
let leaving = $state(false)
let userHasScrolled = $state(false)
let isProgrammaticScroll = $state(false)
let programmaticScrollTimeout: ReturnType<typeof setTimeout>
let loadingBackward = $state(true)
let loadingForward = $state(true)
let share = $state(popKey<TrustedEvent | undefined>("share"))
@@ -352,9 +389,8 @@
})
$effect(() => {
if (elements.length > 0) {
requestAnimationFrame(manageScrollPosition)
}
void elements
tick().then(manageScrollPosition)
})
const start = () => {
@@ -364,7 +400,7 @@
url,
at: at || now(),
element: element!,
filters: [{kinds: [MESSAGE, ROOM_ADD_MEMBER], "#h": [h]}],
filters: [{kinds: [...MESSAGE_KINDS, ROOM_ADD_MEMBER], "#h": [h]}],
onBackwardExhausted: () => {
loadingBackward = false
},
@@ -439,6 +475,17 @@
<PageContent
bind:element
onscroll={onScroll}
onwheel={markInteraction}
ontouchmove={markInteraction}
onpointerdown={markInteraction}
onpointermove={(e: PointerEvent) => {
if (e.buttons > 0) markInteraction()
}}
onkeydown={(e: KeyboardEvent) => {
if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(e.key)) {
markInteraction()
}
}}
class={cx(
showMobileVideoPanel
? "hidden flex-col-reverse pt-4 md:flex md:flex-col-reverse"
@@ -472,7 +519,7 @@
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p>
{/if}
{#each elements as { type, id, value, showPubkey, addSpaceBelow }, i (id)}
{#each elements as { type, id, value, showPubkey, addSpaceBelow } (id)}
{#if type === "new-messages"}
<div
{id}
@@ -484,24 +531,8 @@
</div>
{:else if type === "date"}
<Divider>{value}</Divider>
{:else if shouldVirtualize}
{@const event = value as TrustedEvent}
{#if event.kind === ROOM_ADD_MEMBER}
<RoomItemAddMember {url} {event} />
{:else}
<div class="cv">
<RoomItem
{url}
{event}
{replyTo}
{showPubkey}
{addSpaceBelow}
canEdit={canEditEvent}
onEdit={onEditEvent} />
</div>
{/if}
{:else}
{@const event = value as TrustedEvent}
{@const event = $state.snapshot(value as TrustedEvent)}
{#if event.kind === ROOM_ADD_MEMBER}
<RoomItemAddMember {url} {event} />
{:else}
+64 -31
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import {onMount} from "svelte"
import {onMount, tick} from "svelte"
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import type {Readable} from "svelte/store"
@@ -25,7 +25,7 @@
import RoomCompose from "@app/components/RoomCompose.svelte"
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
import {userSettingsValues, decodeRelay, PROTECTED} from "@app/core/state"
import {userSettingsValues, decodeRelay, PROTECTED, MESSAGE_KINDS} from "@app/core/state"
import {prependParent, canEnforceNip70, publishDelete} from "@app/core/commands"
import {checked} from "@app/util/notifications"
import {pushToast} from "@app/util/toast"
@@ -37,7 +37,13 @@
const url = decodeRelay($page.params.relay!)
const shouldProtect = canEnforceNip70(url)
const at = $derived(parseInt($page.url.searchParams.get("at")!))
const shouldVirtualize = $derived(isNaN(at))
const targetId = $derived($page.url.searchParams.get("e"))
$effect(() => {
void at
void targetId
userHasScrolled = false
})
const replyTo = (event: TrustedEvent) => {
parent = event
@@ -123,27 +129,56 @@
}
}
if (!userHasScrolled && !isNaN(at)) {
const targetEvent = $events.find(event => event.created_at >= at)
if (!userHasScrolled && (!isNaN(at) || targetId)) {
const targetEvent = targetId
? $events.find(event => event.id === targetId)
: $events.find(event => event.created_at <= at)
if (targetEvent) {
const target = element?.querySelector(`[data-event="${targetEvent.id}"]`)
if (target instanceof HTMLElement) {
isProgrammaticScroll = true
clearTimeout(programmaticScrollTimeout)
programmaticScrollTimeout = setTimeout(() => {
isProgrammaticScroll = false
}, 300)
target.scrollIntoView({block: "center"})
if (target.dataset.highlighted !== "true") {
target.dataset.highlighted = "true"
target.style.filter = "brightness(1.5)"
target.style.transitionProperty = "all"
target.style.transitionDuration = "400ms"
setTimeout(() => {
target.style.transitionDuration = "300ms"
target.style.filter = ""
}, 800)
}
}
}
}
}
let isInteracting = false
let interactionTimeout: ReturnType<typeof setTimeout>
const markInteraction = () => {
isInteracting = true
clearTimeout(interactionTimeout)
interactionTimeout = setTimeout(() => {
isInteracting = false
}, 500)
}
const onScroll = () => {
if (!isProgrammaticScroll) {
userHasScrolled = true
if (isInteracting) {
userHasScrolled = true
}
manageScrollPosition()
}
isProgrammaticScroll = false
}
const scrollToNewMessages = () =>
@@ -161,6 +196,7 @@
let loadingForward = $state(true)
let userHasScrolled = $state(false)
let isProgrammaticScroll = $state(false)
let programmaticScrollTimeout: ReturnType<typeof setTimeout>
let share = $state(popKey<TrustedEvent | undefined>("share"))
let parent: TrustedEvent | undefined = $state()
let element: HTMLElement | undefined = $state()
@@ -245,9 +281,8 @@
})
$effect(() => {
if (elements.length > 0) {
requestAnimationFrame(manageScrollPosition)
}
void elements
tick().then(manageScrollPosition)
})
const start = () => {
@@ -257,7 +292,7 @@
url,
at: at || now(),
element: element!,
filters: [{kinds: [MESSAGE, RELAY_ADD_MEMBER]}],
filters: [{kinds: [...MESSAGE_KINDS, RELAY_ADD_MEMBER]}],
onBackwardExhausted: () => {
loadingBackward = false
},
@@ -304,13 +339,27 @@
{/snippet}
</SpaceBar>
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4 mb-14 md:mb-0">
<PageContent
bind:element
onscroll={onScroll}
onwheel={markInteraction}
ontouchmove={markInteraction}
onpointerdown={markInteraction}
onpointermove={(e: PointerEvent) => {
if (e.buttons > 0) markInteraction()
}}
onkeydown={(e: KeyboardEvent) => {
if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(e.key)) {
markInteraction()
}
}}
class="flex flex-col-reverse pt-4 mb-14 md:mb-0">
{#if loadingForward}
<p class="py-20 flex justify-center">
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p>
{/if}
{#each elements as { type, id, value, showPubkey, addSpaceBelow }, i (id)}
{#each elements as { type, id, value, showPubkey, addSpaceBelow } (id)}
{#if type === "new-messages"}
<div
{id}
@@ -322,24 +371,8 @@
</div>
{:else if type === "date"}
<Divider>{value}</Divider>
{:else if shouldVirtualize}
{@const event = value as TrustedEvent}
{#if event.kind === RELAY_ADD_MEMBER}
<RoomItemAddMember {url} {event} />
{:else}
<div>
<RoomItem
{url}
{event}
{replyTo}
{showPubkey}
canEdit={canEditEvent}
onEdit={onEditEvent}
{addSpaceBelow} />
</div>
{/if}
{:else}
{@const event = value as TrustedEvent}
{@const event = $state.snapshot(value as TrustedEvent)}
{#if event.kind === RELAY_ADD_MEMBER}
<RoomItemAddMember {url} {event} />
{:else}