Remove the space recent activity view in favor of the home inbox

This commit is contained in:
Coracle-Bot 2026-09-09 03:50:46 +00:00 committed by hodlbod
parent 0d1e60bf1d
commit 9027634567
10 changed files with 6 additions and 387 deletions

View file

@ -235,21 +235,7 @@ Acceptance:
appear when they apply.
- A members summary listing admins and newest members links through to the full
directory.
- Content admin has featured renders at the top for every visitor; with none, a
recent-activity summary appears instead.
### US-016 — Catch up on a space's recent activity
As bob, I want one feed of what's new across a space, so that I don't have to
open every room.
Acceptance:
- "Recent Activity" lists the latest message from each visible room alongside
recent posts and threads, newest first.
- A new message in a previously quiet room moves that room's entry to the top.
- Scrolling to the bottom loads older items, and a space with nothing in it
shows "No recent activity found".
- Content admin has featured renders at the top for every visitor.
### US-017 — Search across a space

View file

@ -1,5 +1,5 @@
import {DAY, HOUR, MINUTE, WEEK, sortBy} from "@welshman/lib"
import {ROOMS, THREAD, makeEvent} from "@welshman/util"
import {DAY, HOUR, WEEK, sortBy} from "@welshman/lib"
import {ROOMS} from "@welshman/util"
import type {Page} from "@playwright/test"
import {expect, readCachedEvents, roomPath, spacePath, test, users} from "../harness"
@ -405,7 +405,7 @@ test("US-014 leave a space", async ({seed, as}) => {
})
test("US-015 view a space's details", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
@ -413,7 +413,6 @@ test("US-015 view a space's details", async ({seed, as}) => {
space.join(user.carol, "general")
space.join(user.alice, "general")
space.profile(user.alice, {name: "Alice Anchor"})
space.message(user.alice, "general", "the tide is high", at(1, HOUR))
})
const space = scenario.space("space")
@ -451,10 +450,6 @@ test("US-015 view a space's details", async ({seed, as}) => {
await expect(carol.getByText("Admins")).toBeVisible()
await expect(carol.getByText("New members")).toBeVisible()
// Nothing is featured yet, so recent activity stands in for it
await expect(carol.getByRole("heading", {name: "Recent Activity"})).toBeVisible()
await expect(carol.getByText("the tide is high")).toBeVisible()
const admin = await as(users.admin, spacePath(space.url) + "/about", {relayInfo})
const featuredHeader = admin.getByRole("heading", {name: "Featured"}).locator("xpath=..")
@ -465,7 +460,7 @@ test("US-015 view a space's details", async ({seed, as}) => {
await expect(admin.getByText("Featured content updated!")).toBeVisible()
// What admin featured reaches every visitor, and takes the top slot from recent activity
// What admin featured reaches every visitor
await expect(carol.getByRole("heading", {name: "Featured"})).toBeVisible()
await expect(carol.getByText("Start with the harbor rules")).toBeVisible()
@ -474,84 +469,6 @@ test("US-015 view a space's details", async ({seed, as}) => {
await expect(carol).toHaveURL(/\/spaces\/space\.test\/directory/)
})
test("US-016 catch up on a space's recent activity", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
const other = relay("other")
space.room("general", {name: "General"})
space.room("quiet", {name: "Quiet Corner"})
space.join(user.alice, "general", "quiet")
space.join(user.bob, "general", "quiet")
space.message(user.alice, "general", "the tide is high", at(30, MINUTE))
space.message(user.alice, "quiet", "anyone still here?", at(3, HOUR))
space.event(
user.alice,
makeEvent(THREAD, {
created_at: at(2, HOUR),
content: "Where should we sail next?",
tags: [
["h", "general"],
["title", "Next voyage"],
],
}),
)
// More items than the page renders at once, so the oldest is only reachable by scrolling
for (let day = 1; day <= 20; day++) {
space.event(
user.alice,
makeEvent(THREAD, {
created_at: at(day, DAY),
content: `Log entry ${day}`,
tags: [
["h", "general"],
["title", `Old voyage ${day}`],
],
}),
)
}
// A space with a member and nothing else
other.join(user.bob)
})
const space = scenario.space("space")
const other = scenario.space("other")
const bob = await as(users.bob, spacePath(space.url) + "/recent")
const items = bob.locator(".cv")
await expect(bob.getByText("the tide is high")).toBeVisible()
await expect(bob.getByText("anyone still here?")).toBeVisible()
await expect(bob.getByText("Next voyage")).toBeVisible()
// Newest first: the latest message in each room alongside the thread, in one feed
await expect(items.first()).toContainText("the tide is high")
await bob.mouse.move(640, 400)
await bob.mouse.wheel(0, 8000)
await expect(bob.getByText("Old voyage 20")).toBeVisible()
// A message in the room that had gone quiet pulls it back to the top
const alice = await as(users.alice, roomPath(space.url, "quiet"))
await alice.locator(".chat-editor [contenteditable=true]").pressSequentially("still here!")
await alice.locator(".chat-editor [contenteditable=true]").press("Enter")
await expect(alice.getByText("still here!")).toBeVisible()
// The feed is assembled when the page loads rather than kept up to date behind the reader, so
// this is the order bob finds when he comes back to it.
await bob.reload()
await expect(items.first()).toContainText("Quiet Corner")
await bob.goto(spacePath(other.url) + "/recent")
await expect(bob.getByText("No recent activity found!")).toBeVisible()
})
test("US-017 search across a space", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")

View file

@ -1,59 +0,0 @@
<script lang="ts">
import {navigate} from "@app/modal"
import {formatTimestamp} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {tagValue, tagSpec} from "@welshman/util"
import ChatRoundDots from "@assets/icons/chat-round-dots.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Cv from "@lib/components/Cv.svelte"
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
import {makeRoomPath, makeSpaceChatPath} from "@app/routes"
type Props = {
url: string
event: TrustedEvent
count: number
}
const {url, event, count}: Props = $props()
const h = tagValue(tagSpec("h"), event.tags)
const onClick = () => navigate(h ? makeRoomPath(url, h) : makeSpaceChatPath(url))
</script>
<Cv tag={Button} class="card card-interactive" onclick={onClick}>
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2 text-sm">
{#if h}
<RoomNameWithImage {url} {h} class="font-semibold" />
{:else}
<Icon icon={ChatRoundDots} class="h-6 w-6 opacity-50" />
<span class="truncate font-semibold">Chat</span>
{/if}
<span class="ml-auto text-nowrap opacity-50">
{formatTimestamp(event.created_at)}
</span>
</div>
<div class="flex items-start gap-3">
<ProfileCircle pubkey={event.pubkey} size={10} />
<div class="min-w-0 flex-1">
<NoteContentMinimal {event} />
</div>
</div>
<div class="flex items-center justify-between gap-2 text-xs">
<span class="opacity-50">
{count}
recent messages{count === 1 ? "" : "s"}
</span>
<Button class="button button-primary button-xs button-pill" onclick={onClick}>
View Conversation
<Icon icon={AltArrowRight} />
</Button>
</div>
</div>
</Cv>

View file

@ -1,39 +0,0 @@
<script lang="ts">
import {THREAD, CLASSIFIED, LONG_FORM, ZAP_GOAL, EVENT_TIME, POLL} from "@welshman/util"
import NoteItem from "@app/components/NoteItem.svelte"
import ThreadItem from "@app/components/ThreadItem.svelte"
import ClassifiedItem from "@app/components/ClassifiedItem.svelte"
import ArticleItem from "@app/components/ArticleItem.svelte"
import GoalItem from "@app/components/GoalItem.svelte"
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
import PollItem from "@app/components/PollItem.svelte"
import RecentConversation from "@app/components/RecentConversation.svelte"
import type {FeedContext} from "@app/feeds"
import type {RecentActivityItem} from "@app/recent"
type Props = {
url: string
item: RecentActivityItem
context: FeedContext
}
const {url, item, context}: Props = $props()
</script>
{#if item.type === "message"}
<RecentConversation {url} event={item.event} count={item.count} />
{:else if item.event.kind === THREAD}
<ThreadItem {url} {context} event={item.event} />
{:else if item.event.kind === CLASSIFIED}
<ClassifiedItem {url} {context} event={item.event} />
{:else if item.event.kind === LONG_FORM}
<ArticleItem {url} {context} event={item.event} />
{:else if item.event.kind === ZAP_GOAL}
<GoalItem {url} {context} event={item.event} />
{:else if item.event.kind === EVENT_TIME}
<CalendarEventItem {url} {context} event={item.event} />
{:else if item.event.kind === POLL}
<PollItem {url} {context} event={item.event} />
{:else}
<NoteItem {url} {context} event={item.event} />
{/if}

View file

@ -6,7 +6,6 @@
import Card from "@lib/components/Card.svelte"
import Content from "@app/components/Content.svelte"
import EditFeaturedContent from "@app/components/EditFeaturedContent.svelte"
import SpaceRecentSummary from "@app/components/SpaceRecentSummary.svelte"
import {deriveFeaturedContent} from "@app/featured"
import {deriveUserIsSpaceAdmin} from "@app/management"
import {pushModal} from "@app/modal"
@ -46,6 +45,4 @@
</div>
{/if}
</Card>
{:else}
<SpaceRecentSummary {url} />
{/if}

View file

@ -7,7 +7,6 @@
import Home from "@assets/icons/home.svg?dataurl"
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
import History from "@assets/icons/history.svg?dataurl"
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic-2.svg?dataurl"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
@ -63,11 +62,7 @@
<SecondaryNavItem href={makeSpacePath(url, "about")}>
<Icon icon={Home} /> Space Details
</SecondaryNavItem>
{#if hasNip29}
<SecondaryNavItem href={makeSpacePath(url, "recent")}>
<Icon icon={History} /> Recent Activity
</SecondaryNavItem>
{:else}
{#if !hasNip29}
<SecondaryNavItem href={chatPath} notification={$notifications.has(chatPath)}>
<Icon icon={ChatRound} /> Chat
</SecondaryNavItem>

View file

@ -1,43 +0,0 @@
<script lang="ts">
import {onDestroy} from "svelte"
import History from "@assets/icons/history.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import RecentItem from "@app/components/RecentItem.svelte"
import {makeFeedContext} from "@app/feeds"
import {deriveRecentActivity} from "@app/recent"
import {makeSpacePath} from "@app/routes"
type Props = {
url: string
}
const {url}: Props = $props()
const context = makeFeedContext({relays: [url]})
const recentActivity = deriveRecentActivity(url)
const recentPath = makeSpacePath(url, "recent")
onDestroy(context.cleanup)
</script>
<div class="card flex flex-col gap-3">
<h3 class="flex items-center gap-2 text-lg font-bold">
<Icon icon={History} />
Recent Activity
</h3>
{#if $recentActivity.length === 0}
<p class="text-sm opacity-70">No recent activity yet.</p>
{:else}
<div class="flex flex-col gap-2">
{#each $recentActivity.slice(0, 3) as item (item.event.id)}
<RecentItem {url} {item} {context} />
{/each}
</div>
{/if}
<Link href={recentPath} class="button button-neutral button-sm">
View all recent activity
<Icon icon={AltArrowRight} size={4} />
</Link>
</div>

View file

@ -1,67 +0,0 @@
import {derived} from "svelte/store"
import {groupBy, first, sortBy, uniqBy, ago, MONTH} from "@welshman/lib"
import {MESSAGE, COMMENT, getIdAndAddress, tagSpec, tagValue, tagValues} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {app} from "@app/core"
import {deriveEventsForUrl} from "@app/repository"
import {CONTENT_KINDS} from "@app/content"
export type RecentActivityItem = {
type: "message" | "content"
event: TrustedEvent
count: number
timestamp: number
}
// Recent activity for a space: latest message per room plus content with the
// most recent activity (post or comment), sorted newest first.
export const deriveRecentActivity = (url: string) => {
const since = ago(3, MONTH)
const messages = deriveEventsForUrl(url, [{kinds: [MESSAGE], since}])
const content = deriveEventsForUrl(url, [{kinds: CONTENT_KINDS, since}])
const comments = deriveEventsForUrl(url, [{kinds: [COMMENT], since}])
return derived([messages, content, comments], ([$messages, $content, $comments]) => {
const activity: RecentActivityItem[] = []
const byRoom = groupBy(e => tagValue(tagSpec("h"), e.tags), $messages)
for (const roomMessages of byRoom.values()) {
const latest = first(roomMessages)
if (latest) {
activity.push({
type: "message",
event: latest,
count: roomMessages.length,
timestamp: latest.created_at,
})
}
}
const latestActivityByKey = new Map<string, number>()
for (const event of $content) {
for (const k of getIdAndAddress(event)) {
latestActivityByKey.set(k, Math.max(latestActivityByKey.get(k) || 0, event.created_at))
}
}
for (const event of $comments) {
for (const k of tagValues(tagSpec(["E", "A"]), event.tags)) {
latestActivityByKey.set(k, Math.max(latestActivityByKey.get(k) || 0, event.created_at))
}
}
for (const [address, timestamp] of latestActivityByKey.entries()) {
const event = app.get().repository.getEvent(address)
if (event) {
activity.push({type: "content", event, timestamp, count: 1})
}
}
return sortBy(
a => -a.timestamp,
uniqBy(a => a.event.id, activity),
)
})
}

View file

@ -16,7 +16,6 @@ const staticTitles = new Map<string, string>([
["/spaces/create", "Create a Space"],
["/spaces/[relay]", "Space"],
["/spaces/[relay]/chat", "Space Chat"],
["/spaces/[relay]/recent", "Recent Activity"],
["/spaces/[relay]/threads", "Threads"],
["/spaces/[relay]/classifieds", "Classifieds"],
["/spaces/[relay]/articles", "Articles"],

View file

@ -1,67 +0,0 @@
<script lang="ts">
import {onDestroy, onMount} from "svelte"
import {page} from "$app/stores"
import History from "@assets/icons/history.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import {createScroller} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import RecentItem from "@app/components/RecentItem.svelte"
import {makeFeedContext} from "@app/feeds"
import SpaceSearch from "@app/components/SpaceSearch.svelte"
import {decodeRelay} from "@app/relays"
import {deriveRecentActivity} from "@app/recent"
import {pushModal} from "@app/modal"
const url = decodeRelay($page.params.relay!)
const context = makeFeedContext({relays: [url]})
onDestroy(context.cleanup)
const recentActivity = deriveRecentActivity(url)
const openSearch = () => pushModal(SpaceSearch, {url})
let limit = $state(20)
let element: Element | undefined = $state()
onMount(() => {
const scroller = createScroller({
element: element!,
onScroll: () => {
limit += 10
},
})
return () => scroller.stop()
})
</script>
<SpaceBar>
{#snippet leading()}
<Icon icon={History} />
{/snippet}
{#snippet title()}
<strong>Recent Activity</strong>
{/snippet}
{#snippet action()}
<Button
class="button button-neutral button-sm button-square"
aria-label="Search"
onclick={openSearch}>
<Icon size={4} icon={Magnifier} />
</Button>
{/snippet}
</SpaceBar>
<PageContent class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4" bind:element>
{#if $recentActivity.length === 0}
<p class="flex flex-col items-center py-20 text-center">No recent activity found!</p>
{:else}
{#each $recentActivity.slice(0, limit) as item (item.event.id)}
<RecentItem {url} {item} {context} />
{/each}
{/if}
</PageContent>