mirror of
https://github.com/coracle-social/coracle.git
synced 2024-09-18 19:23:40 +00:00
Merge lists, feed tabs, and advanced search into one interface
This commit is contained in:
parent
8f5ef24cec
commit
80386a833c
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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}
|
||||
|
||||
|
@ -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,71 +7,103 @@
|
||||
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
|
||||
}
|
||||
|
||||
let modal = null
|
||||
|
||||
const applyFilter = debounce(300, () => {
|
||||
if (modal !== "maxi") {
|
||||
const newFilter = {} as Filter
|
||||
|
||||
if (_filter.since)
|
||||
newFilter.since = createLocalDate(_filter.since).setHours(23, 59, 59, 0) / 1000
|
||||
if (_filter.until)
|
||||
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"])
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
const applySearch = debounce(200, applyFilter)
|
||||
|
||||
const clearSearch = () => {
|
||||
_filter.search = ""
|
||||
applyFilter()
|
||||
}
|
||||
|
||||
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"}>
|
||||
<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={applyFilter}>
|
||||
<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"}
|
||||
@ -87,6 +119,11 @@
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<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">
|
||||
@ -94,6 +131,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</MultiSelect>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<strong>Topics</strong>
|
||||
@ -120,5 +158,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
</Content>
|
||||
</form>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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,8 +57,9 @@
|
||||
</p>
|
||||
</Content>
|
||||
{/if}
|
||||
<Tabs tabs={visibleTabs} activeTab={$feedsTab} {setActiveTab}>
|
||||
{#if $canPublish}
|
||||
{#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" />
|
||||
@ -84,9 +73,7 @@
|
||||
"hover:bg-gray-7": $theme === "dark",
|
||||
"hover:bg-gray-1": $theme === "light",
|
||||
})}
|
||||
on:click={() => {
|
||||
$feedsTab = meta.d
|
||||
}}>
|
||||
on:click={() => loadListFeed(meta.d)}>
|
||||
<i class="fa fa-scroll fa-sm mr-1" />
|
||||
{meta.d}
|
||||
</button>
|
||||
@ -104,9 +91,7 @@
|
||||
{:else}
|
||||
<i class="fa fa-ellipsis-v cursor-pointer p-1" on:click={showLists} />
|
||||
{/if}
|
||||
{/if}
|
||||
</Tabs>
|
||||
{#key $feedsTab}
|
||||
<Feed {relays} {filter} />
|
||||
</div>
|
||||
</Feed>
|
||||
{/key}
|
||||
</Content>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user