Remove the space recent activity view in favor of the home inbox
This commit is contained in:
parent
0d1e60bf1d
commit
9027634567
10 changed files with 6 additions and 387 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Reference in a new issue