Load the home network feed with the app feed helpers
This commit is contained in:
parent
124f9b47b2
commit
27fa25c35c
3 changed files with 92 additions and 61 deletions
|
|
@ -1576,6 +1576,17 @@ Acceptance:
|
||||||
- Hosting is offered whether or not she hosts a space: a shortcut to the hosting
|
- Hosting is offered whether or not she hosts a space: a shortcut to the hosting
|
||||||
panel when she has one, an invitation to start one when she doesn't.
|
panel when she has one, an invitation to start one when she doesn't.
|
||||||
|
|
||||||
|
### US-117 — See notes from the people I follow
|
||||||
|
|
||||||
|
As alice, I want the home page to show what the people I follow have posted, so
|
||||||
|
that home is worth opening when nothing is waiting for me.
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- The Network section lists notes from her follows, resolved through the relays
|
||||||
|
those people publish to.
|
||||||
|
- Scrolling to the end of the feed loads more rather than asking her to.
|
||||||
|
|
||||||
### US-106 — Share text into the app
|
### US-106 — Share text into the app
|
||||||
|
|
||||||
As alice, I want to hand text to Flotilla and choose where it lands, so that I
|
As alice, I want to hand text to Flotilla and choose where it lands, so that I
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import {neventEncode, npubEncode} from "nostr-tools/nip19"
|
import {neventEncode, npubEncode} from "nostr-tools/nip19"
|
||||||
import {HOUR, MINUTE} from "@welshman/lib"
|
import {HOUR, MINUTE} from "@welshman/lib"
|
||||||
import {displayRelayUrl} from "@welshman/util"
|
import {displayRelayUrl} from "@welshman/util"
|
||||||
import {Classified, MessagingRelayList, RelayList, Thread} from "@welshman/domain"
|
import {Classified, FollowList, MessagingRelayList, Note, RelayList, Thread} from "@welshman/domain"
|
||||||
import type {Locator, Page} from "@playwright/test"
|
import type {Locator, Page} from "@playwright/test"
|
||||||
import {expect, roomPath, spacePath, test, users} from "../harness"
|
import {expect, roomPath, spacePath, test, users} from "../harness"
|
||||||
import type {SeededSpace, TestUser} from "../harness"
|
import type {SeededSpace, TestUser} from "../harness"
|
||||||
|
|
@ -415,6 +415,33 @@ test("US-116 read the home dashboard", async ({seed, as}) => {
|
||||||
await expect(page.getByText("You're all caught up")).toBeVisible()
|
await expect(page.getByText("You're all caught up")).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("US-117 read the network feed on home", async ({seed, as}) => {
|
||||||
|
const note = "the tide charts are wrong again"
|
||||||
|
|
||||||
|
await seed(({relay, user}) => {
|
||||||
|
const space = relay("space")
|
||||||
|
|
||||||
|
space.room("general", {name: "General"})
|
||||||
|
space.join(user.alice, "general")
|
||||||
|
space.join(user.bob, "general")
|
||||||
|
|
||||||
|
// The feed asks each follow's write relays for their notes, so a note is only reachable
|
||||||
|
// through a relay list naming one.
|
||||||
|
seedRelays(space, user.alice)
|
||||||
|
seedRelays(space, user.bob)
|
||||||
|
|
||||||
|
space.event(user.alice, () =>
|
||||||
|
space.kind(FollowList).writer().follow(user.bob.pubkey).renderTemplate(),
|
||||||
|
)
|
||||||
|
space.event(user.bob, () => space.kind(Note).writer().setContent(note).renderTemplate())
|
||||||
|
})
|
||||||
|
|
||||||
|
const page = await as(users.alice, "/home")
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", {name: "Network"})).toBeVisible()
|
||||||
|
await expect(page.getByText(note)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
test("US-106 share text into the app", async ({seed, as}) => {
|
test("US-106 share text into the app", async ({seed, as}) => {
|
||||||
const shared = "the offsite is moving to the 14th"
|
const shared = "the offsite is moving to the 14th"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,83 +1,76 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onDestroy, onMount} from "svelte"
|
import {onDestroy} from "svelte"
|
||||||
import {sortBy, uniqBy} from "@welshman/lib"
|
import {writable} from "svelte/store"
|
||||||
import {NOTE} from "@welshman/util"
|
import type {Writable} from "svelte/store"
|
||||||
|
import {sortBy} from "@welshman/lib"
|
||||||
|
import type {Maybe} from "@welshman/lib"
|
||||||
|
import {NOTE, outbox} from "@welshman/util"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {getReplyTags} from "@welshman/domain"
|
import {getReplyTags} from "@welshman/domain"
|
||||||
import {Feeds} from "@welshman/app"
|
|
||||||
import {Scope, feedFromFilter, makeIntersectionFeed, makeScopeFeed} from "@welshman/feeds"
|
|
||||||
import Planet from "@assets/icons/planet.svg?dataurl"
|
import Planet from "@assets/icons/planet.svg?dataurl"
|
||||||
import {createScroller} from "@lib/html"
|
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import Masonry from "@lib/components/Masonry.svelte"
|
import Masonry from "@lib/components/Masonry.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import HomeSection from "@app/components/HomeSection.svelte"
|
import HomeSection from "@app/components/HomeSection.svelte"
|
||||||
import NoteItem from "@app/components/NoteItem.svelte"
|
import NoteItem from "@app/components/NoteItem.svelte"
|
||||||
import {app, relayLists, user} from "@app/core"
|
import {followLists, relayLists, router, user} from "@app/core"
|
||||||
import {makeFeedContext} from "@app/feeds"
|
import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds"
|
||||||
|
|
||||||
const PAGE_SIZE = 5
|
// The hubs most of the user's follows publish to, which is the same set and the same limit
|
||||||
|
// `syncFollowNetwork` reads their lists from.
|
||||||
|
const RELAY_LIMIT = 8
|
||||||
|
|
||||||
const context = makeFeedContext({relays: $relayLists.readUrls($user.pubkey).get()})
|
const context = makeFeedContext({relays: $relayLists.readUrls($user.pubkey).get()})
|
||||||
|
|
||||||
onDestroy(context.cleanup)
|
const followList = $derived($followLists.one($user.pubkey))
|
||||||
|
const follows = $derived($followList?.pubkeys())
|
||||||
|
|
||||||
|
let element: HTMLElement | undefined = $state()
|
||||||
|
let older: Maybe<ReturnType<typeof makeScrollLoader>> = $state()
|
||||||
|
let events: Writable<TrustedEvent[]> = $state(writable([]))
|
||||||
|
let started = false
|
||||||
|
let stop: Maybe<() => void>
|
||||||
|
|
||||||
// Notes from the people the user follows, resolved against each author's outbox relays.
|
|
||||||
// Replies are left out - without their parent they read as half a conversation.
|
// Replies are left out - without their parent they read as half a conversation.
|
||||||
const controller = $app.use(Feeds).makeFeedController({
|
const notes = $derived(
|
||||||
useWindowing: true,
|
sortBy(
|
||||||
feed: makeIntersectionFeed(makeScopeFeed(Scope.Follows), feedFromFilter({kinds: [NOTE]})),
|
e => -e.created_at,
|
||||||
onEvent: (event: TrustedEvent) => {
|
$events.filter(e => getReplyTags(e.tags).replies.length === 0),
|
||||||
if (getReplyTags(event.tags).replies.length === 0) {
|
),
|
||||||
buffer.push(event)
|
|
||||||
context.add(event)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
let element: Element | undefined = $state()
|
|
||||||
let events = $state<TrustedEvent[]>([])
|
|
||||||
let loading = $state(false)
|
|
||||||
let caughtUp = $state(false)
|
|
||||||
let buffer: TrustedEvent[] = []
|
|
||||||
|
|
||||||
const isEmpty = $derived(caughtUp && events.length === 0)
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
if (!loading) {
|
|
||||||
loading = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
await controller.load(PAGE_SIZE * 4)
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
} finally {
|
|
||||||
loading = false
|
|
||||||
caughtUp = buffer.length === 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const scroller = createScroller({
|
|
||||||
element: element!,
|
|
||||||
delay: 300,
|
|
||||||
threshold: 3000,
|
|
||||||
onScroll: () => {
|
|
||||||
buffer = uniqBy(
|
|
||||||
e => e.id,
|
|
||||||
sortBy(e => -e.created_at, buffer),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
events = uniqBy(e => e.id, [...events, ...buffer.splice(0, PAGE_SIZE)])
|
const loading = $derived(isFeedLoading($older))
|
||||||
|
const exhausted = $derived($older?.status === "exhausted")
|
||||||
|
const isEmpty = $derived(follows?.length === 0 || (exhausted && notes.length === 0))
|
||||||
|
|
||||||
if (buffer.length < PAGE_SIZE * 4) {
|
const start = async (pubkeys: string[]) => {
|
||||||
load()
|
const scenario = await $router.resolve(pubkeys.map(pubkey => outbox(pubkey)))
|
||||||
}
|
const feed = makeFeed({
|
||||||
},
|
relays: scenario.limit(RELAY_LIMIT).getUrls(),
|
||||||
|
filters: [{kinds: [NOTE], authors: pubkeys}],
|
||||||
|
onEvent: context.add,
|
||||||
})
|
})
|
||||||
|
|
||||||
return scroller.stop
|
events = feed.events
|
||||||
|
older = makeScrollLoader(element!, feed.loadOlder)
|
||||||
|
stop = () => {
|
||||||
|
older?.stop()
|
||||||
|
feed.cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The follow list is what the feed is made of, so it waits for one rather than asking about
|
||||||
|
// nobody. Follows added later don't rebuild it - the feed keeps what is already on screen.
|
||||||
|
$effect(() => {
|
||||||
|
if (!started && follows && follows.length > 0) {
|
||||||
|
started = true
|
||||||
|
start(follows)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
stop?.()
|
||||||
|
context.cleanup()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -91,12 +84,12 @@
|
||||||
</p>
|
</p>
|
||||||
<Link href="/spaces" class="button button-neutral button-sm">Browse spaces</Link>
|
<Link href="/spaces" class="button button-neutral button-sm">Browse spaces</Link>
|
||||||
</div>
|
</div>
|
||||||
{:else if events.length === 0}
|
{:else if notes.length === 0}
|
||||||
<div class="flex justify-center pb-4">
|
<div class="flex justify-center pb-4">
|
||||||
<Spinner loading>Looking for notes from people you follow…</Spinner>
|
<Spinner loading>Looking for notes from people you follow…</Spinner>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<Masonry items={events} getKey={event => event.id} columnWidth={80} maxColumns={2} gap={3}>
|
<Masonry items={notes} getKey={event => event.id} columnWidth={80} maxColumns={2} gap={3}>
|
||||||
{#snippet child(event)}
|
{#snippet child(event)}
|
||||||
<NoteItem {event} {context} />
|
<NoteItem {event} {context} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue