forked from coracle/flotilla
Work on feed page
This commit is contained in:
@@ -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
@@ -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
@@ -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) =>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user