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 # 0.2.32
- [x] Add note preview - [x] Add note preview when composing
- [x] Merge advanced search, feed options, and lists
# 0.2.31 # 0.2.31

View File

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

View File

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

View File

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

View File

@ -16,18 +16,9 @@
const getFilterParts = f => { const getFilterParts = f => {
const parts = [] const parts = []
if (filter.since && filter.until) { if (typeof filter.authors === "string") {
const since = formatTimestampAsDate(filter.since) parts.push(`From ${filter.authors}`)
const until = formatTimestampAsDate(filter.until) } else if (filter.authors?.length > 0) {
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) {
parts.push(`By ${displayPeople(filter.authors)}`) parts.push(`By ${displayPeople(filter.authors)}`)
} }
@ -43,6 +34,17 @@
parts.push(`Matching ${filter.search}`) 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 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 Bugsnag from "@bugsnag/js"
import {nip19} from "nostr-tools" import {nip19} from "nostr-tools"
import {navigate} from "svelte-routing" 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 {max, omit, pluck, sortBy, find, slice, propEq} from "ramda"
import {createMap, doPipe, first} from "hurdak/lib/hurdak" import {createMap, doPipe, first} from "hurdak/lib/hurdak"
import {warn} from "src/util/logger" 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 {now, timedelta} from "src/util/misc"
import {Tags, isNotification, userKinds} from "src/util/nostr" import {Tags, isNotification, userKinds} from "src/util/nostr"
import {findReplyId} 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 network from "src/agent/network"
import pool from "src/agent/pool" import pool from "src/agent/pool"
import {getUserReadRelays, getUserRelays} from "src/agent/relays" 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" import user from "src/agent/user"
// Routing // Routing
@ -83,10 +84,6 @@ export const logUsage = async name => {
} }
} }
// Feed
export const feedsTab = writable("Follows")
// State // State
export const newNotifications = derived( export const newNotifications = derived(
@ -289,3 +286,17 @@ export const publishWithToast = (relays, thunk) =>
toast.show("info", message, pending.size ? null : 5) 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"> <script lang="ts">
import cx from "classnames" import cx from "classnames"
import {prop, indexBy, objOf, filter as _filter} from "ramda" import type {DynamicFilter} from "src/util/types"
import {shuffle} from "src/util/misc" import {prop, indexBy, objOf} from "ramda"
import {Tags} from "src/util/nostr" import {Tags} from "src/util/nostr"
import {modal, theme} from "src/partials/state" import {modal, theme} from "src/partials/state"
import Anchor from "src/partials/Anchor.svelte" import Anchor from "src/partials/Anchor.svelte"
import Content from "src/partials/Content.svelte" import Content from "src/partials/Content.svelte"
import Tabs from "src/partials/Tabs.svelte"
import Popover from "src/partials/Popover.svelte" import Popover from "src/partials/Popover.svelte"
import Feed from "src/app/shared/Feed.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 user from "src/agent/user"
import {feedsTab} from "src/app/state"
const {lists, canPublish} = user const {lists} = user
const defaultTabs = ["Follows", "Network"]
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) $: 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 = () => { const showLists = () => {
modal.push({type: "list/list"}) 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> </script>
<Content> <Content>
@ -69,44 +57,41 @@
</p> </p>
</Content> </Content>
{/if} {/if}
<Tabs tabs={visibleTabs} activeTab={$feedsTab} {setActiveTab}> {#key key}
{#if $canPublish} <Feed {filter} {relays}>
{#if $lists.length > 0} <div slot="controls">
<Popover placement="bottom" opts={{hideOnClick: true}} theme="transparent"> {#if $lists.length > 0}
<i slot="trigger" class="fa fa-ellipsis-v cursor-pointer p-2" /> <Popover placement="bottom" opts={{hideOnClick: true}} theme="transparent">
<div <i slot="trigger" class="fa fa-ellipsis-v cursor-pointer p-2" />
slot="tooltip" <div
class="flex flex-col items-start overflow-hidden rounded border border-solid border-gray-8 bg-black"> slot="tooltip"
{#each $lists as e (e.id)} class="flex flex-col items-start overflow-hidden rounded border border-solid border-gray-8 bg-black">
{@const meta = Tags.from(e).asMeta()} {#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 <button
on:click={showLists}
class={cx("w-full py-2 px-3 text-left transition-colors", { class={cx("w-full py-2 px-3 text-left transition-colors", {
"hover:bg-gray-7": $theme === "dark", "hover:bg-gray-7": $theme === "dark",
"hover:bg-gray-1": $theme === "light", "hover:bg-gray-1": $theme === "light",
})} })}>
on:click={() => { <i class="fa fa-cog fa-sm mr-1" /> Customize
$feedsTab = meta.d
}}>
<i class="fa fa-scroll fa-sm mr-1" />
{meta.d}
</button> </button>
{/each} </div>
<button </Popover>
on:click={showLists} {:else}
class={cx("w-full py-2 px-3 text-left transition-colors", { <i class="fa fa-ellipsis-v cursor-pointer p-1" on:click={showLists} />
"hover:bg-gray-7": $theme === "dark", {/if}
"hover:bg-gray-1": $theme === "light", </div>
})}> </Feed>
<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} />
{/key} {/key}
</Content> </Content>

View File

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

View File

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