Work on feed page

This commit is contained in:
Jon Staab
2026-02-17 17:15:00 -08:00
parent a4e883b09a
commit 4b156ee699
8 changed files with 298 additions and 5 deletions
Executable
+7
View File
@@ -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
+1 -1
View File
@@ -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",
+84 -1
View File
@@ -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<PublishedList>({
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) =>
+2
View File
@@ -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)
}
})
+78
View File
@@ -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<Feed, "event"> & {
event: TrustedEvent
}
export const normalizeFeedDefinition = (feed: IFeed) =>
hasSubFeeds(feed) ? feed : makeIntersectionFeed(feed)
export const makeFeed = (feed: Partial<Feed> = {}): 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)
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {getAddress} from "@welshman/util"
import History from "@assets/icons/history.svg?dataurl"
import Minus from "@assets/icons/minus.svg?dataurl"
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
import SecondaryNavHeader from "@lib/components/SecondaryNavHeader.svelte"
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import SecondaryNav from "@lib/components/SecondaryNav.svelte"
import Page from "@lib/components/Page.svelte"
import Icon from "@lib/components/Icon.svelte"
import {userFeeds} from "@app/core/state"
type Props = {
children?: Snippet
}
const {children}: Props = $props()
</script>
<SecondaryNav>
<SecondaryNavSection>
<SecondaryNavItem href="/home">
<Icon icon={History} /> Recent Activity
</SecondaryNavItem>
</SecondaryNavSection>
<SecondaryNavSection>
<SecondaryNavHeader>Your Feeds</SecondaryNavHeader>
{#each $userFeeds as feed (feed.event.id)}
<SecondaryNavItem href="/home/feed/{getAddress(feed.event)}">
<Icon icon={Minus} />
{feed.title}
</SecondaryNavItem>
{/each}
</SecondaryNavSection>
</SecondaryNav>
<Page>
{@render children?.()}
</Page>
+2 -3
View File
@@ -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 @@
<div class="row-2"></div>
{/snippet}
</PageBar>
<PageContent class="flex flex-col gap-2 p-2 pt-4" bind:element>
{#each $recentActivity as { type, event, url, count } (event.id)}
{#if type === "message"}
@@ -0,0 +1,85 @@
<script lang="ts">
import {onMount} from "svelte"
import {writable} from "svelte/store"
import {batch, call} from "@welshman/lib"
import type {MakeNonOptional} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {makeFeedController} from "@welshman/app"
import {page} from "$app/stores"
import {createScroller} from "@lib/html"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import NoteItem from "@app/components/NoteItem.svelte"
import {deriveFeed} from "@app/core/state"
const {address} = $page.params as MakeNonOptional<typeof $page.params>
const events = writable<TrustedEvent[]>([])
const controller = new AbortController()
const feed = deriveFeed(address)
const limit = writable(0)
let loading = $state(true)
let element: Element | undefined = $state()
onMount(() => {
if ($feed) {
const promise = call(async () => {
const ctrl = makeFeedController({
useWindowing: true,
signal: controller.signal,
feed: $feed.definition,
onEvent: batch(100, (evts: TrustedEvent[]) => {
events.update($events => [...$events, ...evts])
}),
onExhausted: () => {
loading = false
},
})
const scroller = createScroller({
element: element!,
delay: 800,
threshold: 3000,
onScroll: async () => {
limit.update($limit => {
if ($events.length - $limit < 50) {
ctrl.load(50)
}
return $limit + 10
})
},
})
return () => {
scroller.stop()
controller.abort()
}
})
return () => promise.then(call)
}
})
</script>
{#if $feed}
<PageBar>
{#snippet title()}
<h1 class="text-xl">{$feed.title}</h1>
{/snippet}
</PageBar>
<PageContent class="flex flex-col gap-2 p-2 pt-4" bind:element>
{#each $events as event (event.id)}
<NoteItem {event} />
{:else}
{#if loading}
<div class="flex justify-center items-center py-20">
<span class="loading loading-spinner mr-3"></span>
Loading your feed...
</div>
{:else}
<p class="flex flex-col items-center py-20 text-center">No content found!</p>
{/if}
{/each}
</PageContent>
{/if}