From b2cd58cf8b45352d0b8d14c460f766e08dcf74c3 Mon Sep 17 00:00:00 2001 From: Jonathan Staab Date: Thu, 13 Apr 2023 13:42:39 -0500 Subject: [PATCH] Add custom feeds --- ROADMAP.md | 11 +- src/agent/cmd.ts | 6 +- src/agent/db.ts | 15 ++- src/agent/sync.ts | 9 ++ src/agent/user.ts | 26 ++++- src/app/Modal.svelte | 3 + src/app/Routes.svelte | 4 +- src/app/SideNav.svelte | 4 +- src/app/shared/NoteContent.svelte | 4 +- src/app/shared/NoteReply.svelte | 11 +- src/app/views/FeedEdit.svelte | 113 ++++++++++++++++++ src/app/views/Feeds.svelte | 186 +++++++++++++++++++++++++++--- src/app/views/FeedsFollows.svelte | 18 --- src/app/views/FeedsNetwork.svelte | 20 ---- src/app/views/LoginConnect.svelte | 2 +- src/app/views/NotFound.svelte | 2 +- src/app/views/Onboarding.svelte | 2 +- src/app/views/UserSettings.svelte | 16 ++- src/partials/Chip.svelte | 17 +++ src/partials/MultiSelect.svelte | 95 +++++++++++++++ src/partials/Popover.svelte | 6 +- src/partials/Suggestions.svelte | 4 +- src/partials/Tabs.svelte | 31 ++--- src/partials/Textarea.svelte | 2 +- src/partials/state.ts | 6 +- src/util/misc.ts | 4 +- src/util/types.ts | 8 ++ 27 files changed, 516 insertions(+), 109 deletions(-) create mode 100644 src/app/views/FeedEdit.svelte delete mode 100644 src/app/views/FeedsFollows.svelte delete mode 100644 src/app/views/FeedsNetwork.svelte create mode 100644 src/partials/Chip.svelte create mode 100644 src/partials/MultiSelect.svelte diff --git a/ROADMAP.md b/ROADMAP.md index 7318e73b..747bb154 100644 --- a/ROADMAP.md +++ b/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 diff --git a/src/agent/cmd.ts b/src/agent/cmd.ts index 82e5081f..a813e6a2 100644 --- a/src/agent/cmd.ts +++ b/src/agent/cmd.ts @@ -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, diff --git a/src/agent/db.ts b/src/agent/db.ts index 71eb485f..dd18d140 100644 --- a/src/agent/db.ts +++ b/src/agent/db.ts @@ -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"]}) +) diff --git a/src/agent/sync.ts b/src/agent/sync.ts index 358975e0..f719a1cb 100644 --- a/src/agent/sync.ts +++ b/src/agent/sync.ts @@ -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 => { diff --git a/src/agent/user.ts b/src/agent/user.ts index 0cdd1fea..9e62763e 100644 --- a/src/agent/user.ts +++ b/src/agent/user.ts @@ -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> const mutes = derived(profile, prop("mutes")) as Readable> +const feeds = derived(profile, prop("feeds")) as Readable> 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)) + }, } diff --git a/src/app/Modal.svelte b/src/app/Modal.svelte index b085561d..02c6efff 100644 --- a/src/app/Modal.svelte +++ b/src/app/Modal.svelte @@ -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} {/key} + {:else if m.type === "feed/edit"} + {:else if m.type === "message"}
{m.message}
diff --git a/src/app/Routes.svelte b/src/app/Routes.svelte index 26042e91..203668e8 100644 --- a/src/app/Routes.svelte +++ b/src/app/Routes.svelte @@ -41,9 +41,9 @@ - + - + diff --git a/src/app/SideNav.svelte b/src/app/SideNav.svelte index e0b8eb93..335c4311 100644 --- a/src/app/SideNav.svelte +++ b/src/app/SideNav.svelte @@ -49,9 +49,7 @@ {/if}
  • - + Feed
  • diff --git a/src/app/shared/NoteContent.svelte b/src/app/shared/NoteContent.svelte index 7ecfcb97..3d0ec403 100644 --- a/src/app/shared/NoteContent.svelte +++ b/src/app/shared/NoteContent.svelte @@ -123,7 +123,7 @@
    {/each} {:else if type === "topic"} - openTopic(value)}>#{value} + { e.stopPropagation(); openTopic(value) }}>#{value} {:else if type === "link"} {value.replace(/https?:\/\/(www\.)?/, "")} @@ -150,7 +150,7 @@ {/if} {#if showMedia && entities.length > 0} -
    e.stopPropagation()}> +
    e.stopPropagation()}> {#each entities as { value }} openQuote(value.id)}> {#await loadQuote(value)} diff --git a/src/app/shared/NoteReply.svelte b/src/app/shared/NoteReply.svelte index ed7af2ce..a725497a 100644 --- a/src/app/shared/NoteReply.svelte +++ b/src/app/shared/NoteReply.svelte @@ -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 @@
    -
    +
    {#each data.mentions as p} -
    -
    + {:else}
    No mentions
    {/each} diff --git a/src/app/views/FeedEdit.svelte b/src/app/views/FeedEdit.svelte new file mode 100644 index 00000000..42895be6 --- /dev/null +++ b/src/app/views/FeedEdit.svelte @@ -0,0 +1,113 @@ + + +
    + + {values.id ? "Edit" : "Add"} custom feed +
    +
    + Feed name + +

    + Custom feeds are identified by their name, so this has to be unique. +

    +
    +
    + Limit by author + +
    + +
    +
    +

    + Only notes by the given authors will be shown in the feed. +

    +
    +
    + Limit by topic + +
    + #{item.name} +
    +
    +

    + Only notes with the given topics will be shown in the feed. +

    +
    +
    + Limit by relay + +
    + {item.url} +
    +
    +

    + Only notes found on the given relays will be shown in the feed. +

    +
    + +
    +
    +
    diff --git a/src/app/views/Feeds.svelte b/src/app/views/Feeds.svelte index 3142bbf8..5f96ee1b 100644 --- a/src/app/views/Feeds.svelte +++ b/src/app/views/Feeds.svelte @@ -1,18 +1,115 @@ - @@ -25,11 +122,72 @@ {/if}
    - - {#if activeTab === "follows"} - - {:else} - - {/if} + + {#if $feeds.length > 0} + + +
    + {#each $feeds as feed (feed.name)} + + {/each} + +
    +
    + {:else} + + {/if} +
    + {#key $activeTab} + + {/key}
    + +{#if modalIsOpen} + + +
    + Custom Feeds + + Feed + +
    +

    + You custom feeds are listed below. You can create new custom feeds by handing using the "add + feed" button, or by clicking the icon that appears throughout + Coracle. +

    + {#each $feeds as feed (feed.name)} +
    + removeFeed(feed)} /> +
    +
    + {feed.name} +

    + {feed.topics ? quantify(feed.topics.length, "topic") : ""} + {feed.authors ? quantify(feed.authors.length, "author") : ""} + {feed.relays ? quantify(feed.relays.length, "relay") : ""} +

    +
    + editFeed(feed)}>Edit +
    +
    + {:else} +

    You don't have any custom feeds yet.

    + {/each} +
    +
    +{/if} diff --git a/src/app/views/FeedsFollows.svelte b/src/app/views/FeedsFollows.svelte deleted file mode 100644 index 9ffaac00..00000000 --- a/src/app/views/FeedsFollows.svelte +++ /dev/null @@ -1,18 +0,0 @@ - - - diff --git a/src/app/views/FeedsNetwork.svelte b/src/app/views/FeedsNetwork.svelte deleted file mode 100644 index cd666d22..00000000 --- a/src/app/views/FeedsNetwork.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - diff --git a/src/app/views/LoginConnect.svelte b/src/app/views/LoginConnect.svelte index af737586..be29eb9c 100644 --- a/src/app/views/LoginConnect.svelte +++ b/src/app/views/LoginConnect.svelte @@ -65,7 +65,7 @@ await Promise.all([loadAppData(user.getPubkey()), sleep(3000)]) - navigate("/notes/follows") + navigate("/notes") } else { pool.disconnect(relay.url) } diff --git a/src/app/views/NotFound.svelte b/src/app/views/NotFound.svelte index 54e67052..e152f936 100644 --- a/src/app/views/NotFound.svelte +++ b/src/app/views/NotFound.svelte @@ -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")) diff --git a/src/app/views/Onboarding.svelte b/src/app/views/Onboarding.svelte index 2c48a8d0..dad79ff1 100644 --- a/src/app/views/Onboarding.svelte +++ b/src/app/views/Onboarding.svelte @@ -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 diff --git a/src/app/views/UserSettings.svelte b/src/app/views/UserSettings.svelte index ef5d511f..7c7cf38d 100644 --- a/src/app/views/UserSettings.svelte +++ b/src/app/views/UserSettings.svelte @@ -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" -
    +
    App Settings @@ -43,7 +41,7 @@ Show images and link previews
    -

    +

    If enabled, coracle will automatically retrieve a link preview for the last link in any note.

    @@ -53,7 +51,7 @@ Default zap amount
    -

    +

    The default amount of sats to use when sending a lightning tip.

    @@ -73,7 +71,7 @@ -

    +

    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 here -

    +

    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 Report errors and analytics -

    +

    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.

    diff --git a/src/partials/Chip.svelte b/src/partials/Chip.svelte new file mode 100644 index 00000000..c4cec2eb --- /dev/null +++ b/src/partials/Chip.svelte @@ -0,0 +1,17 @@ + + +
    +
    +
    +
    diff --git a/src/partials/MultiSelect.svelte b/src/partials/MultiSelect.svelte new file mode 100644 index 00000000..152ec69c --- /dev/null +++ b/src/partials/MultiSelect.svelte @@ -0,0 +1,95 @@ + + +
    input.focus()}> + {#each value as item} + remove(item)}> + + {item} + + + {/each} + +
    + +{#if search} +
    +
    + +
    + + {item} + +
    +
    +
    +
    +{/if} diff --git a/src/partials/Popover.svelte b/src/partials/Popover.svelte index cf0915bf..a57cabf2 100644 --- a/src/partials/Popover.svelte +++ b/src/partials/Popover.svelte @@ -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() } }) diff --git a/src/partials/Suggestions.svelte b/src/partials/Suggestions.svelte index ee19a4c9..8c102981 100644 --- a/src/partials/Suggestions.svelte +++ b/src/partials/Suggestions.svelte @@ -36,11 +36,11 @@
    {#each data as item, i} {/each} diff --git a/src/partials/Tabs.svelte b/src/partials/Tabs.svelte index bd73951b..831421d9 100644 --- a/src/partials/Tabs.svelte +++ b/src/partials/Tabs.svelte @@ -8,17 +8,22 @@ export let getDisplay = tab => ({title: toTitle(tab), badge: null}) -
    - {#each tabs as tab} - {@const {title, badge} = getDisplay(tab)} - - {/each} +
    +
    + {#each tabs as tab} + {@const {title, badge} = getDisplay(tab)} + + {/each} +
    +
    diff --git a/src/partials/Textarea.svelte b/src/partials/Textarea.svelte index 01dd3e5a..85a99464 100644 --- a/src/partials/Textarea.svelte +++ b/src/partials/Textarea.svelte @@ -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" ) diff --git a/src/partials/state.ts b/src/partials/state.ts index 764d20e0..7f59c9f4 100644 --- a/src/partials/state.ts +++ b/src/partials/state.ts @@ -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, 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) } diff --git a/src/util/misc.ts b/src/util/misc.ts index fc2dba04..4b6bad05 100644 --- a/src/util/misc.ts +++ b/src/util/misc.ts @@ -406,8 +406,8 @@ export const annotateMedia = url => { } } -export class WritableList { - _store: Writable> +export class WritableList { + _store: Writable> constructor(init) { this._store = writable(init) } diff --git a/src/util/types.ts b/src/util/types.ts index 575ba009..648d99f5 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -37,3 +37,11 @@ export type Room = { about?: string picture?: string } + +export type CustomFeed = { + id: string + name: string + authors?: "follows" | "network" | Array + topics?: Array + relays?: Array +}