Merge lists, feed tabs, and advanced search into one interface

This commit is contained in:
Jonathan Staab 2023-06-16 10:37:54 -07:00
parent 8f5ef24cec
commit 80386a833c
9 changed files with 246 additions and 195 deletions

View File

@ -2,7 +2,8 @@
# 0.2.32
- [x] Add note preview
- [x] Add note preview when composing
- [x] Merge advanced search, feed options, and lists
# 0.2.31

View File

@ -1,6 +1,6 @@
# Current
- [ ] Clean up lists/advanced search
- [ ] Support other list types
- [ ] Use vida to stream development
- [ ] Fix connection management stuff. Have GPT help
- [ ] Add preview proxy thing

View File

@ -1,12 +1,12 @@
<script lang="ts">
import type {Filter} from "nostr-tools"
import type {DynamicFilter} from "src/util/types"
import {onMount, onDestroy} from "svelte"
import {debounce} from "throttle-debounce"
import {last, equals, partition, always, uniqBy, sortBy, prop} from "ramda"
import {fly} from "svelte/transition"
import {quantify} from "hurdak/lib/hurdak"
import {fuzzy, createScroller, now, timedelta} from "src/util/misc"
import {asDisplayEvent, mergeFilter} from "src/util/nostr"
import {asDisplayEvent} from "src/util/nostr"
import Spinner from "src/partials/Spinner.svelte"
import Modal from "src/partials/Modal.svelte"
import Content from "src/partials/Content.svelte"
@ -16,11 +16,11 @@
import Note from "src/app/shared/Note.svelte"
import user from "src/agent/user"
import network from "src/agent/network"
import {getUserReadRelays} from "src/agent/relays"
import {mergeParents} from "src/app/state"
import {sampleRelays, getAllPubkeyWriteRelays} from "src/agent/relays"
import {mergeParents, compileFilter} from "src/app/state"
export let filter = {} as Filter
export let relays = getUserReadRelays()
export let relays = null
export let filter = {} as DynamicFilter
export let delta = timedelta(6, "hours")
export let shouldDisplay = always(true)
export let parentsTimeout = 500
@ -121,14 +121,17 @@
let p = Promise.resolve()
// If we have a search term we need to use only relays that support search
const getRelays = () => (filter.search ? [{url: "wss://relay.nostr.band"}] : relays)
const getRelays = () =>
filter.search
? [{url: "wss://relay.nostr.band"}]
: sampleRelays(relays || getAllPubkeyWriteRelays(compileFilter(filter).authors || []))
const loadMore = async () => {
const _key = key
// Wait for this page to load before trying again
await cursor.loadPage({
filter,
filter: compileFilter(filter),
onChunk: chunk => {
// Stack promises to avoid too many concurrent subscriptions
p = p.then(() => key === _key && onChunk(chunk))
@ -136,7 +139,7 @@
})
}
const stop = () => {
export const stop = () => {
notes = []
notesBuffer = []
scroller?.stop()
@ -145,19 +148,21 @@
key = Math.random()
}
const start = (newFilter = {}) => {
export const start = (newFilter = null) => {
if (!equals(newFilter, filter)) {
stop()
const _key = key
filter = {...filter, ...newFilter}
if (newFilter) {
filter = newFilter
}
// No point in subscribing if we have an end date
if (!filter.until) {
sub = network.listen({
relays: getRelays(),
filter: mergeFilter(filter, {since}),
filter: compileFilter({...filter, since}),
onChunk: chunk => {
p = p.then(() => _key === key && onChunk(chunk))
},
@ -178,7 +183,7 @@
onDestroy(stop)
</script>
<Content size="inherit">
<Content size="inherit" gap="gap-6">
{#if notesBuffer.length > 0}
<div class="pointer-events-none fixed left-0 top-0 z-10 mt-20 flex w-full justify-center">
<button
@ -195,7 +200,9 @@
{#if !hideControls}
<div class="flex justify-between gap-4" in:fly={{y: 20}}>
<FilterSummary {filter} />
<FeedAdvanced {filter} onChange={start} />
<FeedAdvanced {filter} onChange={start}>
<slot name="controls" slot="controls" />
</FeedAdvanced>
</div>
{/if}

View File

@ -1,5 +1,5 @@
<script lang="ts">
import type {Filter} from "nostr-tools"
import type {DynamicFilter} from "src/util/types"
import {pluck, objOf} from "ramda"
import {debounce} from "throttle-debounce"
import {createLocalDate} from "src/util/misc"
@ -7,118 +7,157 @@
import Anchor from "src/partials/Anchor.svelte"
import Modal from "src/partials/Modal.svelte"
import Content from "src/partials/Content.svelte"
import SelectButton from "src/partials/SelectButton.svelte"
import MultiSelect from "src/partials/MultiSelect.svelte"
import PersonBadge from "src/app/shared/PersonBadge.svelte"
import {searchTopics, searchPeople, getPersonWithFallback} from "src/agent/db"
export let filter
export let onChange
export let filter = {} as Filter
let _filter = {
since: filter.since,
until: filter.since,
search: filter.search || "",
authors: (filter.authors || []).map(getPersonWithFallback),
"#t": (filter["#t"] || []).map(objOf("name")),
"#p": (filter["#p"] || []).map(getPersonWithFallback),
const applyFilter = () => {
const newFilter = {} as DynamicFilter
if (_filter.since) {
newFilter.since = createLocalDate(_filter.since).setHours(23, 59, 59, 0) / 1000
}
if (_filter.until) {
newFilter.until = createLocalDate(_filter.until).setHours(23, 59, 59, 0) / 1000
}
if (_filter.search) {
newFilter.search = _filter.search
}
if (_filter["#t"].length > 0) {
newFilter["#t"] = pluck("name", _filter["#t"])
}
if (_filter["#p"].length > 0) {
newFilter["#p"] = pluck("pubkey", _filter["#p"])
}
if (Array.isArray(_filter.authors)) {
newFilter.authors = _filter.authors.length > 0 ? pluck("pubkey", _filter.authors) : "global"
} else {
newFilter.authors = _filter.authors
}
onChange(newFilter)
}
let modal = null
const applySearch = debounce(200, applyFilter)
const applyFilter = debounce(300, () => {
if (modal !== "maxi") {
const newFilter = {} as Filter
const clearSearch = () => {
_filter.search = ""
applyFilter()
}
if (_filter.since)
newFilter.since = createLocalDate(_filter.since).setHours(23, 59, 59, 0) / 1000
if (_filter.until)
newFilter.until = createLocalDate(_filter.until).setHours(23, 59, 59, 0) / 1000
if (_filter.authors.length > 0) newFilter.authors = pluck("pubkey", _filter.authors)
if (_filter.search) newFilter.search = _filter.search
if (_filter["#t"].length > 0) newFilter["#t"] = pluck("name", _filter["#t"])
if (_filter["#p"].length > 0) newFilter["#p"] = pluck("pubkey", _filter["#p"])
onChange(newFilter)
}
})
const onScopeChange = scope => {
_filter = {..._filter, authors: scope === "custom" ? [] : scope}
}
const open = () => {
modal = "maxi"
}
const submit = () => {
applyFilter()
onEscape()
}
const onEscape = () => {
modal = null
}
const submit = () => {
console.log("====")
onEscape()
applyFilter()
}
let modal = null
let _filter = {
since: filter.since,
until: filter.since,
search: filter.search || "",
authors: Array.isArray(filter.authors)
? filter.authors.map(getPersonWithFallback)
: filter.authors || "network",
"#t": (filter["#t"] || []).map(objOf("name")),
"#p": (filter["#p"] || []).map(getPersonWithFallback),
}
</script>
<div class="flex justify-end gap-3 p-2">
<div class="flex justify-end gap-1">
<i
class="fa fa-search cursor-pointer"
class="fa fa-search cursor-pointer p-2"
on:click={() => {
modal = modal ? null : "mini"
}} />
<i class="fa fa-sliders cursor-pointer" on:click={open} />
<i class="fa fa-sliders cursor-pointer p-2" on:click={open} />
<slot name="controls" />
</div>
{#if modal}
<Modal {onEscape} mini={modal === "mini"}>
<Content size="lg">
<div class="flex flex-col gap-1">
<strong>Search</strong>
<Input autofocus bind:value={_filter.search} on:input={applyFilter}>
<i slot="before" class="fa fa-search" />
</Input>
</div>
{#if modal === "maxi"}
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col gap-1">
<strong>Since</strong>
<Input type="date" bind:value={_filter.since} />
<form on:submit|preventDefault={submit}>
<Content size="lg">
<div class="flex flex-col gap-1">
<strong>Search</strong>
<Input autofocus bind:value={_filter.search} on:input={applySearch}>
<i slot="before" class="fa fa-search" />
<i slot="after" class="fa fa-times cursor-pointer" on:click={clearSearch} />
</Input>
</div>
{#if modal === "maxi"}
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col gap-1">
<strong>Since</strong>
<Input type="date" bind:value={_filter.since} />
</div>
<div class="flex flex-col gap-1">
<strong>Until</strong>
<Input type="date" bind:value={_filter.until} />
</div>
</div>
<div class="flex flex-col gap-1">
<strong>Until</strong>
<Input type="date" bind:value={_filter.until} />
<strong>Authors</strong>
<SelectButton
onChange={onScopeChange}
value={typeof _filter.authors === "string" ? _filter.authors : "custom"}
options={["follows", "network", "global", "custom"]} />
{#if Array.isArray(_filter.authors)}
<MultiSelect search={$searchPeople} bind:value={_filter.authors}>
<div slot="item" let:item>
<div class="-my-1">
<PersonBadge inert person={getPersonWithFallback(item.pubkey)} />
</div>
</div>
</MultiSelect>
{/if}
</div>
</div>
<div class="flex flex-col gap-1">
<strong>Authors</strong>
<MultiSelect search={$searchPeople} bind:value={_filter.authors}>
<div slot="item" let:item>
<div class="-my-1">
<PersonBadge inert person={getPersonWithFallback(item.pubkey)} />
<div class="flex flex-col gap-1">
<strong>Topics</strong>
<MultiSelect search={$searchTopics} bind:value={_filter["#t"]}>
<div slot="item" let:item>
<div class="-my-1">
#{item.name}
</div>
</div>
</div>
</MultiSelect>
</div>
<div class="flex flex-col gap-1">
<strong>Topics</strong>
<MultiSelect search={$searchTopics} bind:value={_filter["#t"]}>
<div slot="item" let:item>
<div class="-my-1">
#{item.name}
</MultiSelect>
</div>
<div class="flex flex-col gap-1">
<strong>Mentions</strong>
<MultiSelect search={$searchPeople} bind:value={_filter["#p"]}>
<div slot="item" let:item>
<div class="-my-1">
<PersonBadge inert person={getPersonWithFallback(item.pubkey)} />
</div>
</div>
</div>
</MultiSelect>
</div>
<div class="flex flex-col gap-1">
<strong>Mentions</strong>
<MultiSelect search={$searchPeople} bind:value={_filter["#p"]}>
<div slot="item" let:item>
<div class="-my-1">
<PersonBadge inert person={getPersonWithFallback(item.pubkey)} />
</div>
</div>
</MultiSelect>
</div>
<div class="flex justify-end">
<Anchor type="button-accent" on:click={submit}>Apply Filters</Anchor>
</div>
{/if}
</Content>
</MultiSelect>
</div>
<div class="flex justify-end">
<Anchor type="button-accent" on:click={submit}>Apply Filters</Anchor>
</div>
{/if}
</Content>
</form>
</Modal>
{/if}

View File

@ -16,18 +16,9 @@
const getFilterParts = f => {
const parts = []
if (filter.since && filter.until) {
const since = formatTimestampAsDate(filter.since)
const until = formatTimestampAsDate(filter.until)
parts.push(`Between ${since} and ${until}`)
} else if (filter.since) {
parts.push(`After ${formatTimestampAsDate(filter.since)}`)
} else if (filter.until) {
parts.push(`Before ${formatTimestampAsDate(filter.until)}`)
}
if (filter.authors?.length > 0) {
if (typeof filter.authors === "string") {
parts.push(`From ${filter.authors}`)
} else if (filter.authors?.length > 0) {
parts.push(`By ${displayPeople(filter.authors)}`)
}
@ -43,6 +34,17 @@
parts.push(`Matching ${filter.search}`)
}
if (filter.since && filter.until) {
const since = formatTimestampAsDate(filter.since)
const until = formatTimestampAsDate(filter.until)
parts.push(`Between ${since} and ${until}`)
} else if (filter.since) {
parts.push(`After ${formatTimestampAsDate(filter.since)}`)
} else if (filter.until) {
parts.push(`Before ${formatTimestampAsDate(filter.until)}`)
}
return parts
}

View File

@ -1,4 +1,5 @@
import type {DisplayEvent} from "src/util/types"
import type {Filter} from "nostr-tools"
import type {DisplayEvent, DynamicFilter} from "src/util/types"
import Bugsnag from "@bugsnag/js"
import {nip19} from "nostr-tools"
import {navigate} from "svelte-routing"
@ -7,7 +8,7 @@ import {writable} from "svelte/store"
import {max, omit, pluck, sortBy, find, slice, propEq} from "ramda"
import {createMap, doPipe, first} from "hurdak/lib/hurdak"
import {warn} from "src/util/logger"
import {hash, sleep, clamp} from "src/util/misc"
import {hash, shuffle, sleep, clamp} from "src/util/misc"
import {now, timedelta} from "src/util/misc"
import {Tags, isNotification, userKinds} from "src/util/nostr"
import {findReplyId} from "src/util/nostr"
@ -18,7 +19,7 @@ import keys from "src/agent/keys"
import network from "src/agent/network"
import pool from "src/agent/pool"
import {getUserReadRelays, getUserRelays} from "src/agent/relays"
import {getUserFollows} from "src/agent/social"
import {getUserFollows, getUserNetwork} from "src/agent/social"
import user from "src/agent/user"
// Routing
@ -83,10 +84,6 @@ export const logUsage = async name => {
}
}
// Feed
export const feedsTab = writable("Follows")
// State
export const newNotifications = derived(
@ -289,3 +286,17 @@ export const publishWithToast = (relays, thunk) =>
toast.show("info", message, pending.size ? null : 5)
})
// Feeds
export const compileFilter = (filter: DynamicFilter): Filter => {
if (filter.authors === "global") {
filter = omit(["authors"], filter)
} else if (filter.authors === "follows") {
filter = {...filter, authors: shuffle(getUserFollows()).slice(0, 256)}
} else if (filter.authors === "network") {
filter = {...filter, authors: shuffle(getUserNetwork()).slice(0, 256)}
}
return filter
}

View File

@ -1,63 +1,51 @@
<script lang="ts">
import cx from "classnames"
import {prop, indexBy, objOf, filter as _filter} from "ramda"
import {shuffle} from "src/util/misc"
import type {DynamicFilter} from "src/util/types"
import {prop, indexBy, objOf} from "ramda"
import {Tags} from "src/util/nostr"
import {modal, theme} from "src/partials/state"
import Anchor from "src/partials/Anchor.svelte"
import Content from "src/partials/Content.svelte"
import Tabs from "src/partials/Tabs.svelte"
import Popover from "src/partials/Popover.svelte"
import Feed from "src/app/shared/Feed.svelte"
import {getUserFollows, getUserNetwork} from "src/agent/social"
import {sampleRelays, getAllPubkeyWriteRelays, getUserReadRelays} from "src/agent/relays"
import user from "src/agent/user"
import {feedsTab} from "src/app/state"
const {lists, canPublish} = user
const defaultTabs = ["Follows", "Network"]
const {lists} = user
let relays, filter
let relays = null
let filter = {kinds: [1, 1985], authors: "follows"} as DynamicFilter
let key = Math.random()
$: listsByName = indexBy(l => Tags.from(l).getMeta("d"), $lists)
$: allTabs = defaultTabs.concat(Object.keys(listsByName))
$: $feedsTab = allTabs.includes($feedsTab) ? $feedsTab : defaultTabs[0]
$: visibleTabs = defaultTabs.includes($feedsTab) ? defaultTabs : [defaultTabs[0], $feedsTab]
$: {
if ($feedsTab === "Follows") {
const authors = shuffle(getUserFollows()).slice(0, 256)
filter = {authors}
relays = sampleRelays(getAllPubkeyWriteRelays(authors))
} else if ($feedsTab === "Network") {
const authors = shuffle(getUserNetwork()).slice(0, 256)
filter = {authors}
relays = sampleRelays(getAllPubkeyWriteRelays(authors))
} else {
const list = listsByName[$feedsTab]
const tags = Tags.from(list)
const authors = tags.type("p").values().all()
const topics = tags.type("t").values().all()
const urls = tags.type("r").values().all()
filter = _filter(prop("length"), {authors, "#t": topics})
relays = urls.length > 0 ? urls.map(objOf("url")) : sampleRelays(getUserReadRelays())
}
filter = {...filter, kinds: [1, 1985]}
}
const setActiveTab = tab => {
$feedsTab = tab
}
const showLists = () => {
modal.push({type: "list/list"})
}
document.title = $feedsTab
const loadListFeed = name => {
const list = $lists.find(l => Tags.from(l).getMeta("d") === name)
const authors = Tags.from(list).type("p").values().all()
const topics = Tags.from(list).type("t").values().all()
const urls = Tags.from(list).type("r").values().all()
if (urls.length > 0) {
relays = urls.map(objOf("url"))
}
filter = {kinds: [1, 1985], authors: 'global'}
if (authors.length > 0) {
filter = {...filter, authors}
}
if (topics.length > 0) {
filter = {...filter, '#t': topics}
}
key = Math.random()
}
document.title = "Feeds"
</script>
<Content>
@ -69,44 +57,41 @@
</p>
</Content>
{/if}
<Tabs tabs={visibleTabs} activeTab={$feedsTab} {setActiveTab}>
{#if $canPublish}
{#if $lists.length > 0}
<Popover placement="bottom" opts={{hideOnClick: true}} theme="transparent">
<i slot="trigger" class="fa fa-ellipsis-v cursor-pointer p-2" />
<div
slot="tooltip"
class="flex flex-col items-start overflow-hidden rounded border border-solid border-gray-8 bg-black">
{#each $lists as e (e.id)}
{@const meta = Tags.from(e).asMeta()}
{#key key}
<Feed {filter} {relays}>
<div slot="controls">
{#if $lists.length > 0}
<Popover placement="bottom" opts={{hideOnClick: true}} theme="transparent">
<i slot="trigger" class="fa fa-ellipsis-v cursor-pointer p-2" />
<div
slot="tooltip"
class="flex flex-col items-start overflow-hidden rounded border border-solid border-gray-8 bg-black">
{#each $lists as e (e.id)}
{@const meta = Tags.from(e).asMeta()}
<button
class={cx("w-full py-2 px-3 text-left transition-colors", {
"hover:bg-gray-7": $theme === "dark",
"hover:bg-gray-1": $theme === "light",
})}
on:click={() => loadListFeed(meta.d)}>
<i class="fa fa-scroll fa-sm mr-1" />
{meta.d}
</button>
{/each}
<button
on:click={showLists}
class={cx("w-full py-2 px-3 text-left transition-colors", {
"hover:bg-gray-7": $theme === "dark",
"hover:bg-gray-1": $theme === "light",
})}
on:click={() => {
$feedsTab = meta.d
}}>
<i class="fa fa-scroll fa-sm mr-1" />
{meta.d}
})}>
<i class="fa fa-cog fa-sm mr-1" /> Customize
</button>
{/each}
<button
on:click={showLists}
class={cx("w-full py-2 px-3 text-left transition-colors", {
"hover:bg-gray-7": $theme === "dark",
"hover:bg-gray-1": $theme === "light",
})}>
<i class="fa fa-cog fa-sm mr-1" /> Customize
</button>
</div>
</Popover>
{:else}
<i class="fa fa-ellipsis-v cursor-pointer p-1" on:click={showLists} />
{/if}
{/if}
</Tabs>
{#key $feedsTab}
<Feed {relays} {filter} />
</div>
</Popover>
{:else}
<i class="fa fa-ellipsis-v cursor-pointer p-1" on:click={showLists} />
{/if}
</div>
</Feed>
{/key}
</Content>

View File

@ -3,19 +3,21 @@
export let options
export let value
export let onChange = null
</script>
<div>
<div class="inline-block">
<div class="flex cursor-pointer rounded border border-solid border-gray-1">
<div class="flex cursor-pointer overflow-hidden rounded-full border border-solid border-gray-1">
{#each options as option, i}
<div
class={cx("px-4 py-2 transition-all", {
"border-l border-solid border-gray-1": i > 0,
"bg-accent": value === option,
"bg-accent text-white": value === option,
})}
on:click={() => {
value = option
onChange?.(value)
}}>
{option}
</div>

View File

@ -1,4 +1,4 @@
import type {Event} from "nostr-tools"
import type {Event, Filter} from "nostr-tools"
export type Relay = {
url: string
@ -37,3 +37,7 @@ export type Room = {
about?: string
picture?: string
}
export type DynamicFilter = Filter & {
authors?: string | string[]
} & Record<string, any>