Compare commits

...

5 Commits

Author SHA1 Message Date
bhavishy2801 45395bae7f merge upstream 2026-04-14 15:38:42 +00:00
bhavishy2801 0db751bd45 fix: stabilize list loading and show correct list count 2026-04-14 19:45:38 +05:30
bhavishy2801 2cea6c4ef4 merge upstream 2026-04-14 13:42:38 +00:00
bhavishy2801 562886a029 Merge branch 'dev' into feat-bookmarks 2026-04-13 21:19:44 +00:00
bhavishy2801 ad3f882081 feat: implement bookmarks page 2026-04-14 01:21:27 +05:30
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"
type Props = {
event?: TrustedEvent
}
type BookmarkList = {
key: string
label: string
count: number
}
const {event}: Props = $props()
const listEvents = deriveEvents([{kinds: [10003, 30003, DELETE]}])
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) ||
(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(
listEvent => !isDeletedList(listEvent),
)
const mapped = uniqueEvents.map(
(listEvent): BookmarkList => ({
key: getListKey(listEvent),
label: getListLabel(listEvent),
count: getTagValues(["e", "a", "p", "r"], listEvent.tags).length,
}),
)
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
}
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[]) => {
// 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) => {
const label = title.trim()
if (!label) {
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()
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)
+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}