Add advanced search

This commit is contained in:
Jonathan Staab 2023-06-12 17:57:03 -07:00
parent 5fc98007d2
commit 7dd6edaac5
14 changed files with 240 additions and 51 deletions

View File

@ -15,6 +15,9 @@
- https://github.com/bhky/opennsfw2
- [ ] Private groups using nsec bunker
- [ ] Fix unauthenticated experience. Going to an npub just spins
- [ ] Convert app store to nip 89
- [ ] Put search icon in header or hover button, open in modal
- [ ] Advanced search
# Core

View File

@ -125,12 +125,12 @@ class Cursor {
until: Record<string, number>
since: number
seen: Set<string>
constructor({relays, limit = 20, delta = undefined}) {
constructor({relays, limit = 20, delta = undefined, until = now()}) {
this.relays = relays
this.limit = limit
this.delta = delta
this.until = fromPairs(relays.map(({url}) => [url, now()]))
this.since = delta ? now() : 0
this.until = fromPairs(relays.map(({url}) => [url, until]))
this.since = 0
this.seen = new Set()
}
async loadPage({filter, onChunk}) {

View File

@ -65,7 +65,7 @@
// Log modals, keep scroll position on body, but don't allow scrolling
const unsubModal = modal.stack.subscribe($stack => {
if ($stack.length > 0) {
if ($stack.filter(x => !x.mini).length > 0) {
logUsage(btoa(["modal", last($stack).type].join(":")))
// This is not idempotent, so don't duplicate it

View File

@ -1,13 +1,16 @@
<script lang="ts">
import {onMount} from "svelte"
import {last, partition, always, uniqBy, sortBy, prop} from "ramda"
import {onMount, onDestroy} from "svelte"
import {Filter} from "nostr-tools"
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 {createScroller, now, timedelta} from "src/util/misc"
import {fuzzy, createScroller, now, timedelta} from "src/util/misc"
import {asDisplayEvent, mergeFilter} 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"
import FeedAdvanced from "src/app/shared/FeedAdvanced.svelte"
import RelayFeed from "src/app/shared/RelayFeed.svelte"
import Note from "src/app/shared/Note.svelte"
import user from "src/agent/user"
@ -15,7 +18,7 @@
import {getUserReadRelays} from "src/agent/relays"
import {mergeParents} from "src/app/state"
export let filter
export let filter = {} as Filter
export let relays = getUserReadRelays()
export let delta = timedelta(6, "hours")
export let shouldDisplay = always(true)
@ -23,15 +26,20 @@
export let invertColors = false
export let onEvent = null
let sub, scroller, cursor, overrides
let key = Math.random()
let search = ""
let notes = []
let notesBuffer = []
let feedRelay = null
let feedScroller = null
$: searchNotes = debounce(300, fuzzy(notes, {keys: ["content"]}))
$: filteredNotes = search ? searchNotes(search) : notes
const since = now()
const maxNotes = 100
const seen = new Set()
const cursor = new network.Cursor({relays, delta})
const getModal = () => last(document.querySelectorAll(".modal-content"))
const canDisplay = e => [1, 1985].includes(e.kind)
@ -53,6 +61,8 @@
}
const onChunk = async newNotes => {
const _key = key
// Deduplicate and filter out stuff we don't want, apply user preferences
const filtered = user.applyMutes(newNotes.filter(n => !seen.has(n.id) && shouldDisplay(n)))
@ -100,40 +110,72 @@
const [bottom, top] = partition(e => e.created_at < since, merged)
// Slice new notes in case someone leaves the tab open for a long time
notesBuffer = top.concat(notesBuffer).slice(0, maxNotes)
notes = uniqBy(prop("id"), notes.concat(bottom))
if (_key === key) {
notesBuffer = top.concat(notesBuffer).slice(0, maxNotes)
notes = uniqBy(prop("id"), notes.concat(bottom))
}
}
let p = Promise.resolve()
// If we have a search term we need to use only relays that support search
const getRelays = () => (overrides?.search ? [{url: "wss://relay.nostr.band"}] : relays)
const getFilter = () => mergeFilter(filter, {since, ...overrides})
const loadMore = async () => {
const _key = key
// Wait for this page to load before trying again
await cursor.loadPage({
filter,
filter: getFilter(),
onChunk: chunk => {
// Stack promises to avoid too many concurrent subscriptions
p = p.then(() => onChunk(chunk))
p = p.then(() => key === _key && onChunk(chunk))
},
})
}
onMount(() => {
const sub = network.listen({
relays,
filter: mergeFilter(filter, {since}),
onChunk: chunk => {
p = p.then(() => onChunk(chunk))
},
})
const stop = () => {
notes = []
notesBuffer = []
scroller?.stop()
feedScroller?.stop()
sub?.then(s => s?.unsub())
key = Math.random()
}
const scroller = createScroller(loadMore, {element: getModal()})
const start = (_overrides = {}) => {
if (!equals(_overrides, overrides)) {
stop()
return () => {
scroller.stop()
feedScroller?.stop()
sub.then(s => s?.unsub())
const _key = key
overrides = _overrides
// No point in subscribing if we have an end date
if (!filter.until) {
sub = network.listen({
relays: getRelays(),
filter: getFilter(),
onChunk: chunk => {
p = p.then(() => _key === key && onChunk(chunk))
},
})
}
cursor = new network.Cursor({
relays: getRelays(),
until: overrides.until || now(),
delta,
})
scroller = createScroller(loadMore, {element: getModal()})
}
})
}
onMount(start)
onDestroy(stop)
</script>
<Content size="inherit">
@ -150,8 +192,10 @@
</div>
{/if}
<FeedAdvanced onChange={start} hide={Object.keys(filter)} />
<div class="flex flex-col gap-4">
{#each notes as note (note.id)}
{#each filteredNotes as note (note.id)}
<Note depth={2} {note} {feedRelay} {setFeedRelay} {invertColors} />
{/each}
</div>

View File

@ -0,0 +1,125 @@
<script lang="ts">
import {pluck} from 'ramda'
import {Filter} from 'nostr-tools'
import {fly} from "svelte/transition"
import {debounce} from 'throttle-debounce'
import {createLocalDate} from 'src/util/misc'
import Input from "src/partials/Input.svelte"
import Anchor from "src/partials/Anchor.svelte"
import Modal from "src/partials/Modal.svelte"
import Content from "src/partials/Content.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 hide = []
export let onChange
let filter = {
since: null,
until: null,
authors: [],
search: "",
'#t': [],
'#p': [],
}
let modal = null
const applyFilter = debounce(300, () => {
if (modal !== 'maxi') {
const _filter = {} as Filter
if (filter.since) _filter.since = createLocalDate(filter.since).setHours(23, 59, 59, 0) / 1000
if (filter.until) _filter.until = createLocalDate(filter.until).setHours(23, 59, 59, 0) / 1000
if (filter.authors.length > 0) _filter.authors = pluck('pubkey', filter.authors)
if (filter.search) _filter.search = filter.search
if (filter['#t'].length > 0) _filter['#t'] = pluck('name', filter['#t'])
if (filter['#p'].length > 0) _filter['#p'] = pluck('pubkey', filter['#p'])
onChange(_filter)
}
})
const open = () => {
modal = 'maxi'
}
const submit = () => {
applyFilter()
onEscape()
}
const onEscape = () => {
modal = null
}
</script>
<div class="flex gap-2 justify-end" in:fly={{y: 20}}>
<i class="fa fa-search cursor-pointer" on:click={() => {modal = modal ? null : 'mini'}} />
<i class="fa fa-sliders cursor-pointer" on:click={open} />
</div>
{#if modal}
<Modal {onEscape} mini={modal === 'mini'}>
<Content size="lg">
<div class="flex flex-col gap-1">
<strong>Search</strong>
<Input 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} />
</div>
<div class="flex flex-col gap-1">
<strong>Until</strong>
<Input type="date" bind:value={filter.until} />
</div>
</div>
{#if !hide.includes('authors')}
<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>
</div>
</MultiSelect>
</div>
{/if}
{#if !hide.includes('#t')}
<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>
</MultiSelect>
</div>
{/if}
{#if !hide.includes('#p')}
<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>
{/if}
<div class="flex justify-end">
<Anchor type="button-accent" on:click={submit}>Apply Filters</Anchor>
</div>
{/if}
</Content>
</Modal>
{/if}

View File

@ -1,6 +1,5 @@
<script lang="ts">
import {objOf, reverse} from "ramda"
import {nip19} from 'nostr-tools'
import {fly} from "svelte/transition"
import {splice, switcher, switcherFn} from "hurdak/lib/hurdak"
import {warn} from "src/util/logger"
@ -122,19 +121,21 @@
<div class="flex flex-col gap-2 overflow-hidden text-ellipsis">
<p>
{#if rating}
{@const [type, value] = Tags.from(note).reject(t => ['l', 'L'].includes(t[0])).first()}
{@const [type, value] = Tags.from(note)
.reject(t => ["l", "L"].includes(t[0]))
.first()}
{@const action = switcher(type, {
r: () => modal.push({type: 'relay/detail', url: value}),
p: () => modal.push({type: 'person/feed', pubkey: value}),
e: () => modal.push({type: 'note/detail', note: {id: value}}),
r: () => modal.push({type: "relay/detail", url: value}),
p: () => modal.push({type: "person/feed", pubkey: value}),
e: () => modal.push({type: "note/detail", note: {id: value}}),
})}
{@const display = switcherFn(type, {
r: () => displayRelay({url: value}),
p: () => displayPerson(getPersonWithFallback(value)),
e: () => "a note",
default: "something"
default: "something",
})}
<div class="flex items-center gap-2 pl-2 mb-4 border-l-2 border-solid border-gray-5">
<div class="mb-4 flex items-center gap-2 border-l-2 border-solid border-gray-5 pl-2">
Rated
{#if action}
<Anchor on:click={action}>{display}</Anchor>

View File

@ -8,7 +8,7 @@
export let topic
const relays = sampleRelays(getUserReadRelays())
const filter = [{kinds: [1], "#t": [topic]}]
const filter = {kinds: [1], "#t": [topic]}
</script>
<Content>

View File

@ -26,13 +26,13 @@
switcher(type, {
anchor: "underline",
button:
"py-2 px-4 rounded bg-input text-accent whitespace-nowrap border border-solid border-gray-6 hover:bg-input-hover",
"py-2 px-4 rounded-full bg-input text-accent whitespace-nowrap border border-solid border-gray-6 hover:bg-input-hover",
"button-circle":
"w-10 h-10 flex justify-center items-center rounded-full bg-input text-accent whitespace-nowrap border border-solid border-gray-6 hover:bg-input-hover",
"button-circle-dark":
"w-10 h-10 flex justify-center items-center rounded-full bg-gray-8 text-white whitespace-nowrap border border-solid border-gray-7",
"button-accent":
"py-2 px-4 rounded bg-accent text-white whitespace-nowrap border border-solid border-accent-light hover:bg-accent-light",
"py-2 px-4 rounded-full bg-accent text-white whitespace-nowrap border border-solid border-accent-light hover:bg-accent-light",
})
)

View File

@ -7,7 +7,7 @@
const className = cx(
$$props.class,
"py-2 px-4 rounded cursor-pointer border border-solid transition-all",
"py-2 px-4 rounded-full cursor-pointer border border-solid transition-all",
{"text-gray-5": disabled},
switcher(theme, {
default: "bg-input text-accent border-gray-6 hover:bg-input-hover",

View File

@ -7,7 +7,7 @@
const className = cx(
$$props.class,
"rounded shadow-inset py-2 px-4 pr-10 w-full placeholder:text-gray-5",
"rounded-full shadow-inset py-2 px-4 w-full placeholder:text-gray-5",
"bg-input border border-solid border-gray-3 text-black",
{"pl-10": $$slots.before, "pr-10": $$slots.after}
)
@ -21,7 +21,8 @@
</div>
{/if}
{#if $$slots.after}
<div class="absolute top-0 right-0 m-px flex gap-2 rounded px-3 pt-3 text-black opacity-75">
<div
class="absolute top-0 right-0 m-px flex gap-2 rounded-full px-3 pt-3 text-black opacity-75">
<slot name="after" />
</div>
{/if}

View File

@ -4,6 +4,7 @@
import {fly, fade} from "svelte/transition"
import {modal} from "src/partials/state"
export let mini = false
export let index = null
export let virtual = true
export let isOnTop = true
@ -19,7 +20,7 @@
onMount(() => {
if (virtual) {
modal.push({id, virtual: true})
modal.push({id, mini, virtual: true})
}
})
@ -37,17 +38,27 @@
}
}} />
<div class="modal fixed inset-0 z-30" bind:this={root} transition:fade>
<div
class="fixed inset-0 cursor-pointer bg-black opacity-50"
on:click|stopPropagation={_onEscape} />
<div
bind:this={root}
transition:fade
class="modal fixed inset-0 z-30"
class:pointer-events-none={mini}>
{#if !mini}
<div
class="fixed inset-0 cursor-pointer bg-black opacity-50"
on:click|stopPropagation={_onEscape} />
{/if}
<div
class="modal-content h-full overflow-auto"
bind:this={content}
transition:fly={{y: 1000}}
class:overflow-hidden={mini}
class:pointer-events-none={mini}
class:cursor-pointer={_onEscape}
transition:fly={{y: 1000}}
bind:this={content}
on:click={_onEscape}>
<div class="mt-12 min-h-full">
<div
class="pointer-events-auto mt-12 min-h-full transition transition-all duration-500"
style={mini ? "margin-top: 55vh" : ""}>
{#if onEscape}
<div class="pointer-events-none sticky top-0 z-10 flex w-full flex-col items-end gap-2 p-2">
<div

View File

@ -69,7 +69,7 @@
<input
type="text"
class="shadow-inset w-full cursor-text rounded border border-solid border-gray-3 bg-input bg-input py-2
class="shadow-inset w-full cursor-text rounded-full border border-solid border-gray-3 bg-input bg-input py-2
py-2 px-4 text-black outline-0 placeholder:text-gray-5"
{placeholder}
bind:value={term}

View File

@ -33,7 +33,7 @@
</script>
{#if data.length > 0}
<div class="mt-2 flex flex-col rounded border border-solid border-gray-6" in:fly={{y: 20}}>
<div class="z-10 mt-2 flex flex-col rounded border border-solid border-gray-6" in:fly={{y: 20}}>
{#each data as item, i}
<button
class="cursor-pointer border-l-2 border-solid border-black py-2 px-4 text-left text-gray-1 hover:border-accent hover:bg-gray-7"

View File

@ -48,6 +48,10 @@ export const setLocalJson = (k, v) => {
export const now = () => Math.round(new Date().valueOf() / 1000)
export const getTimeZone = () => new Date().toString().match(/GMT[^\s]+/)
export const createLocalDate = dateString => new Date(`${dateString} ${getTimeZone()}`)
export const timedelta = (n, unit = "seconds") => {
switch (unit) {
case "seconds":