feat: implement bookmarks page with list management and deep-link scroll targeting #196
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
type Props = {
|
||||
event?: TrustedEvent
|
||||
}
|
||||
|
||||
type BookmarkList = {
|
||||
key: string
|
||||
label: string
|
||||
count: number
|
||||
}
|
||||
|
||||
const {event}: Props = $props()
|
||||
|
||||
const listEvents = deriveEvents([{kinds: [10003, 30003, DELETE]}])
|
||||
|
hodlbod
commented
These kinds shouldn't be hard-coded These kinds shouldn't be hard-coded
hodlbod
commented
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) ||
|
||||
|
hodlbod
commented
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(
|
||||
|
hodlbod
commented
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,
|
||||
}),
|
||||
)
|
||||
|
hodlbod
commented
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
|
||||
}
|
||||
|
hodlbod
commented
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>
|
||||
@@ -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}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
hodlbod
commented
These can be imported from @welshman/util These can be imported from @welshman/util
bhavishy2801
commented
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
hodlbod
commented
This should go in See 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.
hodlbod
commented
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.
bhavishy2801
commented
Done. Done.
|
||||
const label = title.trim()
|
||||
|
||||
if (!label) {
|
||||
|
hodlbod marked this conversation as resolved
Outdated
hodlbod
commented
`d` should be randomly generated, not a slug of the title
bhavishy2801
commented
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
hodlbod
commented
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
bhavishy2801
commented
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)
|
||||
|
||||
@@ -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]}],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Import these from @welshman/util
Done. Now these are imported from @welshman/util