From 4b156ee699e3b5237db2e9e1eccd8ce3f2da8009 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Tue, 17 Feb 2026 17:15:00 -0800 Subject: [PATCH] Work on feed page --- check.sh | 7 ++ package.json | 2 +- src/app/core/state.ts | 85 ++++++++++++++++++++- src/app/core/sync.ts | 2 + src/lib/feeds.ts | 78 +++++++++++++++++++ src/routes/home/+layout.svelte | 39 ++++++++++ src/routes/home/+page.svelte | 5 +- src/routes/home/feed/[address]/+page.svelte | 85 +++++++++++++++++++++ 8 files changed, 298 insertions(+), 5 deletions(-) create mode 100755 check.sh create mode 100644 src/lib/feeds.ts create mode 100644 src/routes/home/+layout.svelte create mode 100644 src/routes/home/feed/[address]/+page.svelte diff --git a/check.sh b/check.sh new file mode 100755 index 00000000..9da46b1e --- /dev/null +++ b/check.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env zsh + +onchange src -ik -- npx svelte-kit sync & + +onchange src -ik -- bash -c 'unbuffer npx svelte-check --tsconfig ./tsconfig.json | less -R' & + +wait diff --git a/package.json b/package.json index 49243051..fd83e792 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build": "./build.sh", "release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "check:watch": "./check.sh", "lint": "prettier --check src && eslint src", "format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write", "format:all": "prettier --write src", diff --git a/src/app/core/state.ts b/src/app/core/state.ts index c5a4dcc2..4f53498a 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -27,6 +27,7 @@ import { randomId, tryCatch, fromPairs, + groupBy, remove, } from "@welshman/lib" import type {Override} from "@welshman/lib" @@ -48,6 +49,7 @@ import { makeDeriveEvent, makeLoadItem, makeDeriveItem, + deriveItems, deriveItemsByKey, deriveDeduplicated, deriveEventsById, @@ -58,6 +60,8 @@ import { deriveEventsDesc, } from "@welshman/store" import { + FEED, + FEEDS, APP_DATA, CLIENT_AUTH, COMMENT, @@ -104,8 +108,18 @@ import { makeRoomMeta, ManagementMethod, sortEventsDesc, + getAddress, + Address, + getIdFilters, +} from "@welshman/util" +import type { + TrustedEvent, + RelayProfile, + PublishedList, + PublishedRoomMeta, + List, + Filter, } from "@welshman/util" -import type {TrustedEvent, RelayProfile, PublishedRoomMeta, List, Filter} from "@welshman/util" import {routerContext, Router} from "@welshman/router" import { pubkey, @@ -122,6 +136,7 @@ import { manageRelay, displayProfileByPubkey, } from "@welshman/app" +import {readFeed} from "@lib/feeds" export const fromCsv = (s: string) => (s || "").split(",").filter(identity) @@ -915,6 +930,74 @@ export const deriveUserCanCreateRoom = (url: string) => { ) } +// Feeds + +export const feedsByAddress = deriveItemsByKey({ + repository, + getKey: feed => getAddress(feed.event), + filters: [{kinds: [FEED]}], + eventToItem: readFeed, +}) + +export const getFeedsByAddress = getter(feedsByAddress) + +export const feeds = deriveItems(feedsByAddress) + +export const getFeeds = getter(feeds) + +export const getFeed = (address: string) => getFeedsByAddress().get(address) + +export const fetchFeed = (address: string) => { + const {pubkey} = Address.from(address) + + return load({ + relays: Router.get().FromPubkey(pubkey).getUrls(), + filters: getIdFilters([address]), + }) +} + +export const loadFeed = makeLoadItem(fetchFeed, getFeed) + +export const deriveFeed = makeDeriveItem(feedsByAddress, loadFeed) + +// Feeds by pubkey + +export const feedsByPubkey = derived(feeds, $feeds => groupBy(f => f.event.pubkey, $feeds)) + +export const getFeedsByPubkey = getter(feedsByPubkey) + +export const getFeedsForPubkey = (pubkey: string) => getFeedsByPubkey().get(pubkey) + +export const loadFeedsForPubkey = makeLoadItem(makeOutboxLoader(FEED), getFeedsForPubkey) + +export const userFeeds = makeUserData(feedsByPubkey, loadFeedsForPubkey) + +export const loadUserFeeds = makeUserLoader(loadFeedsForPubkey) + +// Feed favorites + +export const feedFavoritesByPubkey = deriveItemsByKey({ + repository, + getKey: list => list.event.pubkey, + filters: [{kinds: [FEEDS]}], + eventToItem: async event => + readList( + asDecryptedEvent(event, { + content: await ensurePlaintext(event), + }), + ), +}) + +export const getFeedFavoritesByPubkey = getter(feedFavoritesByPubkey) + +export const getFeedFavorites = (pubkey: string) => getFeedFavoritesByPubkey().get(pubkey) + +export const loadFeedFavorites = makeLoadItem(makeOutboxLoader(FEEDS), getFeedFavorites) + +export const userFeedFavorites = makeUserData(feedFavoritesByPubkey, loadFeedFavorites) + +export const loadUserFeedFavorites = makeUserLoader(loadFeedFavorites) + // Other utils export const encodeRelay = (url: string) => diff --git a/src/app/core/sync.ts b/src/app/core/sync.ts index b52699a7..155b9add 100644 --- a/src/app/core/sync.ts +++ b/src/app/core/sync.ts @@ -52,6 +52,7 @@ import { getSpaceUrlsFromGroupList, getSpaceRoomsFromGroupList, makeCommentFilter, + loadFeedsForPubkey, } from "@app/core/state" import {hasBlossomSupport} from "@app/core/commands" @@ -200,6 +201,7 @@ const syncUserData = () => { loadMuteList($userRelayList.event.pubkey) loadProfile($userRelayList.event.pubkey) loadSettings($userRelayList.event.pubkey) + loadFeedsForPubkey($userRelayList.event.pubkey) } }) diff --git a/src/lib/feeds.ts b/src/lib/feeds.ts new file mode 100644 index 00000000..82e4d903 --- /dev/null +++ b/src/lib/feeds.ts @@ -0,0 +1,78 @@ +import {fromPairs, parseJson, randomId} from "@welshman/lib" +import {FEED, Address} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import { + makeIntersectionFeed, + hasSubFeeds, + isTagFeed, + isAuthorFeed, + isScopeFeed, +} from "@welshman/feeds" +import type {Feed as IFeed} from "@welshman/feeds" + +export type Feed = { + title: string + identifier: string + description: string + definition: IFeed + event?: TrustedEvent +} + +export type PublishedFeed = Omit & { + event: TrustedEvent +} + +export const normalizeFeedDefinition = (feed: IFeed) => + hasSubFeeds(feed) ? feed : makeIntersectionFeed(feed) + +export const makeFeed = (feed: Partial = {}): Feed => ({ + title: "", + description: "", + identifier: randomId(), + definition: makeIntersectionFeed(), + ...feed, +}) + +export const readFeed = (event: TrustedEvent) => { + const {d: identifier, title = "", description = "", feed = ""} = fromPairs(event.tags) + const definition = parseJson(feed) || makeIntersectionFeed() + + return {title, identifier, description, definition, event} as PublishedFeed +} + +export const createFeed = ({identifier, definition, title, description}: Feed) => ({ + kind: FEED, + content: "", + tags: [ + ["d", identifier], + ["alt", title], + ["title", title], + ["description", description], + ["feed", JSON.stringify(definition)], + ], +}) + +export const editFeed = (feed: PublishedFeed) => ({ + kind: FEED, + content: feed.event.content, + tags: Object.entries({ + ...fromPairs(feed.event.tags), + title: feed.title, + alt: feed.title, + description: feed.description, + feed: JSON.stringify(feed.definition), + }), +}) + +export const displayFeed = (feed?: Feed) => feed?.title || "[no name]" + +export const isTopicFeed = (f: IFeed) => isTagFeed(f) && f[1] === "#t" + +export const isMentionFeed = (f: IFeed) => isTagFeed(f) && f[1] === "#p" + +export const isAddressFeed = (f: IFeed) => isTagFeed(f) && f[1] === "#a" + +export const isContextFeed = (f: IFeed) => + isTagFeed(f) && f[1] === "#a" && f.slice(2).every(Address.isAddress) + +export const isPeopleFeed = (f: IFeed) => isAuthorFeed(f) || isScopeFeed(f) diff --git a/src/routes/home/+layout.svelte b/src/routes/home/+layout.svelte new file mode 100644 index 00000000..09757de1 --- /dev/null +++ b/src/routes/home/+layout.svelte @@ -0,0 +1,39 @@ + + + + + + Recent Activity + + + + Your Feeds + {#each $userFeeds as feed (feed.event.id)} + + + {feed.title} + + {/each} + + + + {@render children?.()} + diff --git a/src/routes/home/+page.svelte b/src/routes/home/+page.svelte index daaa0eef..4a8e7e28 100644 --- a/src/routes/home/+page.svelte +++ b/src/routes/home/+page.svelte @@ -13,6 +13,7 @@ getTagValue, getTagValues, getIdAndAddress, + getParentIdOrAddr, } from "@welshman/util" import type {TrustedEvent} from "@welshman/util" import { @@ -129,7 +130,7 @@ makeIntersectionFeed(makeScopeFeed(Scope.Follows), makeKindFeed(NOTE)), ), onEvent: batch(100, (evts: TrustedEvent[]) => { - events.update($events => [...$events, ...evts]) + events.update($events => [...$events, ...evts.filter(e => !getParentIdOrAddr(e))]) }), onExhausted: () => { loading = false @@ -141,7 +142,6 @@ delay: 800, threshold: 3000, onScroll: async () => { - console.log("scroll") limit.update($limit => { if ($events.length - $limit < 50) { ctrl.load(50) @@ -175,7 +175,6 @@
{/snippet} - {#each $recentActivity as { type, event, url, count } (event.id)} {#if type === "message"} diff --git a/src/routes/home/feed/[address]/+page.svelte b/src/routes/home/feed/[address]/+page.svelte new file mode 100644 index 00000000..892be43a --- /dev/null +++ b/src/routes/home/feed/[address]/+page.svelte @@ -0,0 +1,85 @@ + + +{#if $feed} + + {#snippet title()} +

{$feed.title}

+ {/snippet} +
+ + {#each $events as event (event.id)} + + {:else} + {#if loading} +
+ + Loading your feed... +
+ {:else} +

No content found!

+ {/if} + {/each} +
+{/if}