Add custom feeds

This commit is contained in:
Jonathan Staab 2023-04-13 13:42:39 -05:00
parent 598b6da8c3
commit b2cd58cf8b
27 changed files with 516 additions and 109 deletions

View File

@ -1,13 +1,13 @@
# Current
- [ ] Buttons on profile detail is broken
- [ ] Topics
- [x] Improve topic suggestions and rendering
- [x] Add topic search, keep cache of topics
- [ ] Add ability to follow topics
- [ ] Relays bounty
- [ ] Ability to create custom feeds
- [ ] Add global/following/network tabs to relay detail
- [x] Ability to create custom feeds
- [ ] Bookmark icon opens "create feed" dialog with form pre-filled
- [ ] Add ability to follow topics - bookmark icon?
- [ ] Claim relays bounty
- [ ] Add person to feed button (maybe lists make more sense for this?)
- [ ] Some lnurls aren't working npub1y3k2nheva29y9ej8a22e07epuxrn04rvgy28wvs54y57j7vsxxuq0gvp4j
- [ ] Global search modal that searches within current feed
- [ ] Fix force relays on login: http://localhost:5173/messages/npub1l66wvfm7dxhd6wmvpukpjpyhvwtlxzu0qqajqxjfpr4rlfa8hl5qlkfr3q
@ -22,6 +22,7 @@
# Core
- [ ] Make mutes private
- [ ] Write multi-relay pagination into paravel and open source it
- https://github.com/nostr-protocol/nips/pull/408
- nevent1qqszpjf3307ccam3cl957yc7k3h5c7vpt7gz2vdzgwkqszsyvdj6e0cpzfmhxue69uhk7enxvd5xz6tw9ec82csgdxq30

View File

@ -35,6 +35,9 @@ const setPetnames = petnames => new PublishableEvent(3, {tags: petnames})
const setMutes = mutes => new PublishableEvent(10000, {tags: mutes})
const setFeeds = feeds =>
new PublishableEvent(30078, {content: JSON.stringify(feeds), tags: [["d", "coracle/feeds"]]})
const createRoom = room =>
new PublishableEvent(40, {content: JSON.stringify(pick(roomAttrs, room))})
@ -169,7 +172,7 @@ class PublishableEvent {
const createdAt = Math.round(new Date().valueOf() / 1000)
if (tagClient) {
tags = tags.filter(t => t[0] !== 'client').concat([["client", "coracle"]])
tags = tags.filter(t => t[0] !== "client").concat([["client", "coracle"]])
}
this.event = {kind, content, tags, pubkey, created_at: createdAt}
@ -199,6 +202,7 @@ export default {
setRelays,
setPetnames,
setMutes,
setFeeds,
createRoom,
updateRoom,
createChatMessage,

View File

@ -1,7 +1,7 @@
import type {Writable} from "svelte/store"
import Loki from "lokijs"
import IncrementalIndexedDBAdapter from "lokijs/src/incremental-indexeddb-adapter"
import {partition, sortBy, prop, always, pluck, without, is} from "ramda"
import {partition, uniqBy, sortBy, prop, always, pluck, without, is} from "ramda"
import {throttle} from "throttle-debounce"
import {writable} from "svelte/store"
import {ensurePlural, noop, createMap} from "hurdak/lib/hurdak"
@ -80,9 +80,14 @@ class Table {
}
}
patch(items) {
const [updates, creates] = partition(item => this.get(item[this.pk]), ensurePlural(items))
const [updates, creates] = partition(
item => this.get(item[this.pk]),
uniqBy(prop(this.pk), ensurePlural(items))
)
if (creates.length > 0) {
// Something internal to loki is broken
this._coll.changes = this._coll.changes || []
this._coll.insert(creates)
}
@ -231,3 +236,9 @@ export const searchPeople = watch("people", t =>
keys: ["kind0.name", "kind0.about", "pubkey"],
})
)
export const searchTopics = watch("topics", t => fuzzy(t.all(), {keys: ["name"]}))
export const searchRelays = watch("relays", t =>
fuzzy(t.all(), {keys: ["name", "description", "url"]})
)

View File

@ -221,6 +221,15 @@ addHandler(
})
)
addHandler(
30078,
profileHandler("feeds", (e, p) => {
if (Tags.from(e).type("d").values().first() === "coracle/feeds") {
return tryJson(() => JSON.parse(e.content))
}
})
)
// Rooms
addHandler(40, e => {

View File

@ -1,8 +1,9 @@
import type {Relay} from "src/util/types"
import type {CustomFeed, Relay} from "src/util/types"
import type {Readable} from "svelte/store"
import {
slice,
uniqBy,
reject,
prop,
find,
pipe,
@ -10,7 +11,6 @@ import {
whereEq,
when,
concat,
reject,
nth,
map,
} from "ramda"
@ -37,12 +37,14 @@ const profile = synced("agent/user/profile", {
petnames: [],
relays: [],
mutes: [],
feeds: [],
})
const settings = derived(profile, prop("settings"))
const petnames = derived(profile, prop("petnames"))
const relays = derived(profile, prop("relays")) as Readable<Array<Relay>>
const mutes = derived(profile, prop("mutes")) as Readable<Array<[string, string]>>
const feeds = derived(profile, prop("feeds")) as Readable<Array<CustomFeed>>
const canPublish = derived(
[keys.pubkey, relays],
@ -163,4 +165,24 @@ export default {
removeMute(pubkey) {
return this.updateMutes(reject(t => t[1] === pubkey))
},
// Feeds
feeds,
getFeeds: () => profileCopy.feeds,
updateFeeds(f) {
const $feeds = f(profileCopy.feeds)
profile.update(assoc("feeds", $feeds))
if (keys.canSign()) {
return cmd.setFeeds($feeds).publish(profileCopy.relays)
}
},
addFeed(feed) {
return this.updateFeeds($feeds => $feeds.concat(feed))
},
removeFeed(id) {
return this.updateFeeds($feeds => reject(whereEq({id}), $feeds))
},
}

View File

@ -16,6 +16,7 @@
import PersonProfileInfo from "src/app/views/PersonProfileInfo.svelte"
import PersonShare from "src/app/views/PersonShare.svelte"
import TopicFeed from "src/app/views/TopicFeed.svelte"
import FeedEdit from "src/app/views/FeedEdit.svelte"
import RelayAdd from "src/app/views/RelayAdd.svelte"
const {stack} = modal
@ -58,6 +59,8 @@
{#key m.topic}
<TopicFeed topic={m.topic} />
{/key}
{:else if m.type === "feed/edit"}
<FeedEdit feed={m.feed} />
{:else if m.type === "message"}
<Content size="lg">
<div class="text-center">{m.message}</div>

View File

@ -41,9 +41,9 @@
<Scan />
</EnsureData>
</Route>
<Route path="/notes/:activeTab" let:params>
<Route path="/notes" let:params>
<EnsureData>
<Feeds activeTab={params.activeTab} />
<Feeds />
</EnsureData>
</Route>
<Route path="/people/:npub/:activeTab" let:params>

View File

@ -49,9 +49,7 @@
</li>
{/if}
<li class="cursor-pointer">
<a
class="block px-4 py-2 transition-all hover:bg-accent hover:text-white"
href="/notes/follows">
<a class="block px-4 py-2 transition-all hover:bg-accent hover:text-white" href="/notes">
<i class="fa fa-rss mr-2" /> Feed
</a>
</li>

View File

@ -123,7 +123,7 @@
<br />
{/each}
{:else if type === "topic"}
<Anchor on:click={() => openTopic(value)}>#{value}</Anchor>
<Anchor on:click={e => { e.stopPropagation(); openTopic(value) }}>#{value}</Anchor>
{:else if type === "link"}
<Anchor external href={value}>
{value.replace(/https?:\/\/(www\.)?/, "")}
@ -150,7 +150,7 @@
</div>
{/if}
{#if showMedia && entities.length > 0}
<div class="py-2" on:click={e => e.stopPropagation()}>
<div class="flex flex-col gap-2 py-2" on:click={e => e.stopPropagation()}>
{#each entities as { value }}
<Card interactive invertColors on:click={() => openQuote(value.id)}>
{#await loadQuote(value)}

View File

@ -6,6 +6,7 @@
import {Tags, displayPerson} from "src/util/nostr"
import {toast} from "src/partials/state"
import ImageInput from "src/partials/ImageInput.svelte"
import Chip from "src/partials/Chip.svelte"
import Media from "src/partials/Media.svelte"
import Compose from "src/partials/Compose.svelte"
import {getPersonWithFallback} from "src/agent/db"
@ -129,15 +130,11 @@
<i class="fa fa-at" />
</div>
</div>
<div>
<div on:click|stopPropagation>
{#each data.mentions as p}
<div
class="mr-1 mb-1 inline-block rounded-full border border-solid border-gray-1 py-1 px-2">
<button
class="fa fa-times cursor-pointer"
on:click|stopPropagation={() => removeMention(p)} />
<Chip class="mr-1 mb-1" theme="dark" on:click={() => removeMention(p)}>
{displayPerson(getPersonWithFallback(p))}
</div>
</Chip>
{:else}
<div class="text-gray-3 inline-block py-2">No mentions</div>
{/each}

View File

@ -0,0 +1,113 @@
<script>
import {when, find, objOf, pluck, always, propEq} from "ramda"
import {randomId} from "hurdak/lib/hurdak"
import {modal, toast} from "src/partials/state"
import Heading from "src/partials/Heading.svelte"
import Content from "src/partials/Content.svelte"
import Button from "src/partials/Button.svelte"
import Input from "src/partials/Input.svelte"
import MultiSelect from "src/partials/MultiSelect.svelte"
import PersonBadge from "src/app/shared/PersonBadge.svelte"
import {searchTopics, searchPeople, searchRelays, getPersonWithFallback} from "src/agent/db"
import user from "src/agent/user"
export let feed = {}
let values = {
id: feed.id,
name: feed.name,
authors: (feed.authors || []).map(objOf("pubkey")),
topics: (feed.topics || []).map(objOf("name")),
relays: (feed.relays || []).map(objOf("url")),
}
const {feeds} = user
const submit = () => {
if (!values.name) {
return toast.show("error", "A name is required for your feed")
}
if (find(f => f.id !== values.id && f.name === values.name, $feeds)) {
return toast.show("error", "That name is already in use")
}
const data = {
...values,
authors: values.authors.length > 0 ? pluck("pubkey", values.authors) : null,
topics: values.topics.length > 0 ? pluck("name", values.topics) : null,
relays: values.relays.length > 0 ? pluck("url", values.relays) : null,
}
if (!data.id) {
user.addFeed({id: randomId(), ...data})
} else {
user.updateFeeds($feeds => $feeds.map(when(propEq("id", data.id), always(data))))
}
toast.show("info", "Your feed has been saved!")
modal.pop()
}
</script>
<form on:submit|preventDefault={submit}>
<Content>
<Heading class="text-center">{values.id ? "Edit" : "Add"} custom feed</Heading>
<div class="flex w-full flex-col gap-8">
<div class="flex flex-col gap-1">
<strong>Feed name</strong>
<Input bind:value={values.name} placeholder="My custom feed" />
<p class="text-sm text-gray-4">
Custom feeds are identified by their name, so this has to be unique.
</p>
</div>
<div class="flex flex-col gap-1">
<strong>Limit by author</strong>
<MultiSelect
search={$searchPeople}
bind:value={values.authors}
placeholder="A list of authors">
<div slot="item" let:item>
<PersonBadge inert person={getPersonWithFallback(item.pubkey)} />
</div>
</MultiSelect>
<p class="text-sm text-gray-4">
Only notes by the given authors will be shown in the feed.
</p>
</div>
<div class="flex flex-col gap-1">
<strong>Limit by topic</strong>
<MultiSelect
search={$searchTopics}
bind:value={values.topics}
delimiters={[",", " "]}
termToItem={objOf("name")}
placeholder="A list of topics">
<div slot="item" let:item>
#{item.name}
</div>
</MultiSelect>
<p class="text-sm text-gray-4">
Only notes with the given topics will be shown in the feed.
</p>
</div>
<div class="flex flex-col gap-1">
<strong>Limit by relay</strong>
<MultiSelect
search={$searchRelays}
bind:value={values.relays}
delimiters={[",", " "]}
termToItem={objOf("url")}
placeholder="A list of relays">
<div slot="item" let:item>
{item.url}
</div>
</MultiSelect>
<p class="text-sm text-gray-4">
Only notes found on the given relays will be shown in the feed.
</p>
</div>
<Button type="submit" class="text-center">Save</Button>
</div>
</Content>
</form>

View File

@ -1,18 +1,115 @@
<script>
import {navigate} from "svelte-routing"
import {toTitle} from "hurdak/lib/hurdak"
<script lang="ts">
import type {CustomFeed} from "src/util/types"
import {prop, objOf, find, propEq} from "ramda"
import {quantify} from "hurdak/lib/hurdak"
import {shuffle, synced} from "src/util/misc"
import {modal} from "src/partials/state"
import Anchor from "src/partials/Anchor.svelte"
import Content from "src/partials/Content.svelte"
import Modal from "src/partials/Modal.svelte"
import Heading from "src/partials/Heading.svelte"
import Tabs from "src/partials/Tabs.svelte"
import FeedsFollows from "src/app/views/FeedsFollows.svelte"
import FeedsNetwork from "src/app/views/FeedsNetwork.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"
export let activeTab
const {feeds} = user
const defaultFeeds = [
{id: "follows", name: "Follows", authors: "follows"},
{id: "network", name: "Network", authors: "network"},
] as Array<CustomFeed>
const setActiveTab = tab => navigate(`/notes/${tab}`)
let modalIsOpen = false
let activeTab = synced("views/Feeds/activeTab", "Follows")
let actions = []
let relays, filter, tabs, feed
document.title = toTitle(activeTab)
$: allFeeds = defaultFeeds.concat($feeds)
$: {
tabs = allFeeds.map(prop("name")).slice(0, 2)
if (!tabs.includes($activeTab)) {
tabs = tabs.concat($activeTab)
} else if ($feeds.length > 0) {
tabs = tabs.concat($feeds[0].name)
}
}
$: {
feed = find(propEq("name", $activeTab), allFeeds)
if (!feed) {
feed = allFeeds[0]
$activeTab = feed.name
}
}
$: {
let {authors, topics} = feed
if (authors === "follows") {
authors = shuffle(getUserFollows()).slice(0, 256)
relays = sampleRelays(getAllPubkeyWriteRelays(authors))
} else if (authors === "network") {
authors = shuffle(getUserNetwork()).slice(0, 256)
relays = sampleRelays(getAllPubkeyWriteRelays(authors))
} else if (feed.relays) {
relays = feed.relays.map(objOf("url"))
} else {
relays = sampleRelays(getUserReadRelays())
}
// Separate notes and reactions into two queries since otherwise reactions dominate,
// we never find their parents (or reactions are mostly to a few posts), and the feed sucks
filter = [1, 7].map(kind => {
const filter = {kinds: [kind]} as Record<string, any>
if (authors) {
filter.authors = authors
}
if (topics) {
filter["#t"] = topics
}
return filter
})
}
$: {
actions = []
actions.push({
onClick: toggleModal,
label: "Customize",
icon: "cog",
})
}
const setActiveTab = tab => {
$activeTab = tab
}
const toggleModal = () => {
modalIsOpen = !modalIsOpen
}
const createFeed = () => {
modal.push({type: "feed/edit"})
}
const editFeed = feed => {
modal.push({type: "feed/edit", feed})
}
const removeFeed = feed => {
user.removeFeed(feed.id)
}
document.title = $activeTab
</script>
<Content>
@ -25,11 +122,72 @@
</Content>
{/if}
<div>
<Tabs tabs={["follows", "network"]} {activeTab} {setActiveTab} />
{#if activeTab === "follows"}
<FeedsFollows />
{:else}
<FeedsNetwork />
{/if}
<Tabs {tabs} activeTab={$activeTab} {setActiveTab}>
{#if $feeds.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 rounded border border-solid border-gray-8 bg-black">
{#each $feeds as feed (feed.name)}
<button
class="w-full py-2 px-3 text-left hover:bg-gray-7"
on:click={() => {
$activeTab = feed.name
}}>
<i class="fa fa-scroll fa-sm mr-1" />
{feed.name}
</button>
{/each}
<button on:click={toggleModal} class="w-full py-2 px-3 text-left hover:bg-gray-7">
<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={toggleModal} />
{/if}
</Tabs>
{#key $activeTab}
<Feed {relays} {filter} />
{/key}
</div>
</Content>
{#if modalIsOpen}
<Modal onEscape={toggleModal}>
<Content>
<div class="flex items-center justify-between">
<Heading>Custom Feeds</Heading>
<Anchor type="button-accent" on:click={createFeed}>
<i class="fa fa-plus" /> Feed
</Anchor>
</div>
<p>
You custom feeds are listed below. You can create new custom feeds by handing using the "add
feed" button, or by clicking the <i class="fa fa-scroll px-1" /> icon that appears throughout
Coracle.
</p>
{#each $feeds as feed (feed.name)}
<div class="flex justify-start gap-3">
<i
class="fa fa-sm fa-trash cursor-pointer py-3"
on:click|stopPropagation={() => removeFeed(feed)} />
<div class="flex w-full justify-between">
<div>
<strong>{feed.name}</strong>
<p>
{feed.topics ? quantify(feed.topics.length, "topic") : ""}
{feed.authors ? quantify(feed.authors.length, "author") : ""}
{feed.relays ? quantify(feed.relays.length, "relay") : ""}
</p>
</div>
<Anchor on:click={() => editFeed(feed)}>Edit</Anchor>
</div>
</div>
{:else}
<p class="text-center py-12">You don't have any custom feeds yet.</p>
{/each}
</Content>
</Modal>
{/if}

View File

@ -1,18 +0,0 @@
<script>
import {shuffle} from "src/util/misc"
import Feed from "src/app/shared/Feed.svelte"
import {getUserFollows} from "src/agent/social"
import {sampleRelays, getAllPubkeyWriteRelays} from "src/agent/relays"
const authors = shuffle(getUserFollows()).slice(0, 256)
const relays = sampleRelays(getAllPubkeyWriteRelays(authors))
// Separate notes and reactions into two queries since otherwise reactions dominate,
// we never find their parents (or reactions are mostly to a few posts), and the feed sucks
const filter = [
{kinds: [1], authors},
{kinds: [7], authors},
]
</script>
<Feed {relays} {filter} />

View File

@ -1,20 +0,0 @@
<script>
import {shuffle} from "src/util/misc"
import Feed from "src/app/shared/Feed.svelte"
import {getUserNetwork} from "src/agent/social"
import {sampleRelays, getAllPubkeyWriteRelays} from "src/agent/relays"
// Get first- and second-order follows. shuffle and slice network so we're not
// sending too many pubkeys. This will also result in some variety.
const authors = shuffle(getUserNetwork()).slice(0, 256)
const relays = sampleRelays(getAllPubkeyWriteRelays(authors))
// Separate notes and reactions into two queries since otherwise reactions dominate,
// we never find their parents (or reactions are mostly to a few posts), and the feed sucks
const filter = [
{kinds: [1], authors},
{kinds: [7], authors},
]
</script>
<Feed {relays} {filter} />

View File

@ -65,7 +65,7 @@
await Promise.all([loadAppData(user.getPubkey()), sleep(3000)])
navigate("/notes/follows")
navigate("/notes")
} else {
pool.disconnect(relay.url)
}

View File

@ -3,5 +3,5 @@
import {navigate} from "svelte-routing"
import user from "src/agent/user"
onMount(() => navigate(user.getProfile() ? "/notes/follows" : "/login"))
onMount(() => navigate(user.getProfile() ? "/notes" : "/login"))
</script>

View File

@ -66,7 +66,7 @@
loadAppData(user.getPubkey())
modal.pop()
navigate("/notes/follows")
navigate("/notes")
}
// Prime our people cache for hardcoded follows and a sample of people they follow

View File

@ -20,9 +20,7 @@
}
})
const submit = async event => {
event.preventDefault()
const submit = () => {
user.profile.update($p => ({...$p, settings: values}))
toast.show("info", "Your settings have been saved!")
@ -31,7 +29,7 @@
document.title = "Settings"
</script>
<form on:submit={submit} in:fly={{y: 20}}>
<form on:submit|preventDefault={submit} in:fly={{y: 20}}>
<Content>
<div class="mb-4 flex flex-col items-center justify-center">
<Heading>App Settings</Heading>
@ -43,7 +41,7 @@
<strong>Show images and link previews</strong>
<Toggle bind:value={values.showMedia} />
</div>
<p class="text-sm text-gray-1">
<p class="text-sm text-gray-4">
If enabled, coracle will automatically retrieve a link preview for the last link in any
note.
</p>
@ -53,7 +51,7 @@
<strong>Default zap amount</strong>
<Input bind:value={values.defaultZap} />
</div>
<p class="text-sm text-gray-1">
<p class="text-sm text-gray-4">
The default amount of sats to use when sending a lightning tip.
</p>
</div>
@ -73,7 +71,7 @@
<Input bind:value={values.dufflepudUrl}>
<i slot="before" class="fa-solid fa-server" />
</Input>
<p class="text-sm text-gray-1">
<p class="text-sm text-gray-4">
Enter a custom url for Coracle's helper application. Dufflepud is used for hosting images
and loading link previews. You can find the source code <Anchor
href="https://github.com/coracle-social/dufflepud">here</Anchor
@ -86,7 +84,7 @@
<Input bind:value={values.multiplextrUrl}>
<i slot="before" class="fa-solid fa-server" />
</Input>
<p class="text-sm text-gray-1">
<p class="text-sm text-gray-4">
Enter a custom proxy server for multiplexing relay connections. This can drastically
improve resource usage, but has some privacy trade-offs. Leave blank to connect to
relays directly. You can find the source code <Anchor
@ -100,7 +98,7 @@
<strong>Report errors and analytics</strong>
<Toggle bind:value={values.reportAnalytics} />
</div>
<p class="text-sm text-gray-1">
<p class="text-sm text-gray-4">
Keep this enabled if you would like the Coracle developers to be able to know what
features are used, and to diagnose and fix bugs.
</p>

17
src/partials/Chip.svelte Normal file
View File

@ -0,0 +1,17 @@
<script lang="ts">
import cx from "classnames"
export let theme
const className = cx($$props.class, "inline-block rounded-full border border-solid py-1 px-2", {
"border-gray-1": theme === "dark",
"border-gray-8": theme === "light",
})
</script>
<div class={className}>
<div class="flex items-center gap-2">
<button class="fa fa-times cursor-pointer" on:click|preventDefault />
<slot />
</div>
</div>

View File

@ -0,0 +1,95 @@
<script lang="ts">
import {reject, equals} from "ramda"
import Chip from "src/partials/Chip.svelte"
import Suggestions from "src/partials/Suggestions.svelte"
export let value
export let placeholder = ""
export let delimiters = []
export let search = null
export let termToItem = null
let term = ""
let input
let suggestions
$: suggestions?.setData(term ? search(term).slice(0, 10) : [])
const remove = item => {
value = reject(equals(item), value)
}
const select = item => {
value = value.concat(item)
term = ""
}
const onKeyDown = event => {
if (term && termToItem && delimiters.includes(event.key)) {
event.preventDefault()
select(termToItem(term))
}
if (event.key === "Escape") {
event.stopPropagation()
term = ""
}
if (event.key === "Enter") {
if (suggestions.get()) {
event.preventDefault()
select(suggestions.get())
} else if (term && termToItem) {
event.preventDefault()
select(termToItem(term))
}
}
if (!term && event.key === "Backspace") {
value = value.slice(0, -1)
}
if (suggestions?.get() && event.code === "ArrowUp") {
event.preventDefault()
suggestions.prev()
}
if (suggestions?.get() && event.code === "ArrowDown") {
event.preventDefault()
suggestions.next()
}
}
</script>
<div
class="shadow-inset cursor-text rounded border border-solid border-gray-3 bg-input py-2 px-4 text-black"
on:click={() => input.focus()}>
{#each value as item}
<Chip class="mr-1 mb-1" theme="light" on:click={() => remove(item)}>
<slot name="item" {item}>
{item}
</slot>
</Chip>
{/each}
<input
type="text"
class="w-full bg-input py-2 outline-0 placeholder:text-gray-5"
{placeholder}
bind:value={term}
bind:this={input}
on:keydown={onKeyDown} />
</div>
{#if search}
<div class="relative w-full">
<div class="absolute w-full">
<Suggestions bind:this={suggestions} {select}>
<div slot="item" let:item>
<slot name="item" {item}>
{item}
</slot>
</div>
</Suggestions>
</div>
</div>
{/if}

View File

@ -10,6 +10,9 @@
export let placement = "top"
export let interactive = true
export let arrow = false
export let opts = {} as {
hideOnClick?: boolean
}
let trigger
let tooltip
@ -17,6 +20,7 @@
onMount(() => {
instance = tippy(trigger, {
...opts,
theme,
arrow,
placement: placement as Placement,
@ -33,7 +37,7 @@
instance.popper.querySelector(".tippy-content").appendChild(tooltipContents)
instance.popper.addEventListener("mouseleave", e => instance.hide())
instance.popper.addEventListener("click", e => {
if (e.target.closest(".tippy-close")) {
if (e.target.closest(".tippy-close") || opts.hideOnClick) {
instance.hide()
}
})

View File

@ -36,11 +36,11 @@
<div class="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-white"
class="cursor-pointer border-l-2 border-solid border-black py-2 px-4 text-left text-white"
class:bg-gray-8={index !== i}
class:bg-gray-7={index === i}
class:border-accent={index === i}
on:click={() => select(item)}>
on:click|preventDefault={() => select(item)}>
<slot name="item" {item} />
</button>
{/each}

View File

@ -8,17 +8,22 @@
export let getDisplay = tab => ({title: toTitle(tab), badge: null})
</script>
<div class="flex overflow-auto border-b border-solid border-gray-7 pt-2" in:fly={{y: 20}}>
{#each tabs as tab}
{@const {title, badge} = getDisplay(tab)}
<button
class="flex cursor-pointer gap-2 border-solid border-gray-6 px-8 py-4 hover:border-b"
class:border-b={activeTab === tab}
on:click={() => setActiveTab(tab)}>
<div>{title}</div>
{#if badge}
<div class="h-6 rounded-full bg-gray-6 px-2">{badge}</div>
{/if}
</button>
{/each}
<div
class="flex items-center justify-between overflow-auto border-b border-solid border-gray-7 pt-2"
in:fly={{y: 20}}>
<div class="flex">
{#each tabs as tab}
{@const {title, badge} = getDisplay(tab)}
<button
class="flex cursor-pointer gap-2 border-solid border-gray-6 px-8 py-4 hover:border-b"
class:border-b={activeTab === tab}
on:click={() => setActiveTab(tab)}>
<div>{title}</div>
{#if badge}
<div class="h-6 rounded-full bg-gray-6 px-2">{badge}</div>
{/if}
</button>
{/each}
</div>
<slot />
</div>

View File

@ -9,7 +9,7 @@
const className = cx(
$$props.class,
"rounded shadow-inset py-2 px-4 pr-10 w-full bg-input text-black",
"placeholder:text-gray-4 border border-solid border-gray-3"
"placeholder:text-gray-5 border border-solid border-gray-3"
)
</script>

View File

@ -47,7 +47,7 @@ toast.show = (type, message, timeout = 5) => {
export const openModals = writable(0)
export const modal = {
stack: new WritableList([]),
stack: new WritableList([]) as WritableList<any>,
sync: $stack => {
const hash = $stack.length > 0 ? `#m=${$stack.length}` : ""
@ -58,8 +58,10 @@ export const modal = {
push: data => modal.stack.update($stack => modal.sync($stack.concat(data))),
pop: () => modal.stack.update($stack => modal.sync($stack.slice(0, -1))),
clear: async () => {
const stackSize = (get(modal.stack) as any).length
// Reverse history so the back button doesn't bring our modal back up
while (get(modal.stack)) {
for (let i = 0; i < stackSize; i++) {
history.back()
await sleep(100)
}

View File

@ -406,8 +406,8 @@ export const annotateMedia = url => {
}
}
export class WritableList {
_store: Writable<Array<any>>
export class WritableList<T> {
_store: Writable<Array<T>>
constructor(init) {
this._store = writable(init)
}

View File

@ -37,3 +37,11 @@ export type Room = {
about?: string
picture?: string
}
export type CustomFeed = {
id: string
name: string
authors?: "follows" | "network" | Array<string>
topics?: Array<string>
relays?: Array<string>
}