mirror of
https://github.com/coracle-social/coracle.git
synced 2024-09-19 11:43:35 +00:00
Add custom feeds
This commit is contained in:
parent
598b6da8c3
commit
b2cd58cf8b
11
ROADMAP.md
11
ROADMAP.md
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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"]})
|
||||
)
|
||||
|
@ -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 => {
|
||||
|
@ -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))
|
||||
},
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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)}
|
||||
|
@ -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}
|
||||
|
113
src/app/views/FeedEdit.svelte
Normal file
113
src/app/views/FeedEdit.svelte
Normal 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>
|
@ -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 />
|
||||
<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}
|
||||
<FeedsNetwork />
|
||||
<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}
|
||||
|
@ -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} />
|
@ -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} />
|
@ -65,7 +65,7 @@
|
||||
|
||||
await Promise.all([loadAppData(user.getPubkey()), sleep(3000)])
|
||||
|
||||
navigate("/notes/follows")
|
||||
navigate("/notes")
|
||||
} else {
|
||||
pool.disconnect(relay.url)
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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
17
src/partials/Chip.svelte
Normal 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>
|
95
src/partials/MultiSelect.svelte
Normal file
95
src/partials/MultiSelect.svelte
Normal 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}
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
@ -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}
|
||||
|
@ -8,7 +8,10 @@
|
||||
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}}>
|
||||
<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
|
||||
@ -22,3 +25,5 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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>
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user