mirror of
https://github.com/coracle-social/coracle.git
synced 2024-09-18 19:23:40 +00:00
Add advanced search
This commit is contained in:
parent
5fc98007d2
commit
7dd6edaac5
@ -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
|
||||
|
||||
|
@ -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}) {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
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 scroller = createScroller(loadMore, {element: getModal()})
|
||||
|
||||
return () => {
|
||||
scroller.stop()
|
||||
const stop = () => {
|
||||
notes = []
|
||||
notesBuffer = []
|
||||
scroller?.stop()
|
||||
feedScroller?.stop()
|
||||
sub.then(s => s?.unsub())
|
||||
sub?.then(s => s?.unsub())
|
||||
key = Math.random()
|
||||
}
|
||||
|
||||
const start = (_overrides = {}) => {
|
||||
if (!equals(_overrides, overrides)) {
|
||||
stop()
|
||||
|
||||
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>
|
||||
|
125
src/app/shared/FeedAdvanced.svelte
Normal file
125
src/app/shared/FeedAdvanced.svelte
Normal 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}
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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",
|
||||
})
|
||||
)
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
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
|
||||
|
@ -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}
|
||||
|
@ -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"
|
||||
|
@ -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":
|
||||
|
Loading…
Reference in New Issue
Block a user