diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index 6c6fb44e..e9848180 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -1551,11 +1551,26 @@ never staring at a blank screen. Acceptance: - On a build with a configured platform space, `/home` opens that space. -- With none configured, it shows a welcome screen offering "Add a space" and - "Start a conversation". +- With none configured, it shows the dashboard, whose empty inbox offers "Add a + space" and "Start a conversation". - Those options navigate to the spaces directory and the chat view respectively. - On a phone, the bottom bar carries a link home from wherever she is. +### US-116 — Read the home dashboard + +As alice, I want the home page to tell me what happened while I was away, so +that I don't have to walk every space to find out. + +Acceptance: + +- The inbox lists each room and conversation with unread activity, naming the + room and space and showing the latest message, newest first. +- A conversation with unread activity carries an unread dot, and "Mark all read" + clears every dot on the page. +- Selecting a conversation opens it, and it stays in the inbox once read. +- Relay health checks are listed alongside the inbox, with the recommendation + each one applies. + ### US-106 — Share text into the app As alice, I want to hand text to Flotilla and choose where it lands, so that I diff --git a/e2e/specs/notifications.spec.ts b/e2e/specs/notifications.spec.ts index 5bff4ca8..2f745f9c 100644 --- a/e2e/specs/notifications.spec.ts +++ b/e2e/specs/notifications.spec.ts @@ -327,7 +327,7 @@ test("US-105 land on the home page", async ({seed, as}) => { await expect(platform).toHaveURL(pattern(spacePath(space.url))) - // With none configured, /home is a welcome screen with two ways out + // With none configured, /home is the dashboard, whose empty inbox offers two ways out const page = await as(users.alice, "/home") const addSpace = page.getByRole("link", {name: "Add a space"}) @@ -358,6 +358,39 @@ test("US-105 land on the home page", async ({seed, as}) => { await expect(phone).toHaveURL(/\/home$/) }) +test("US-116 read the home dashboard", async ({seed, as}) => { + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + space.join(user.bob, "general") + space.message(user.bob, "general", "the server is on fire", at(1, HOUR)) + }) + + const space = scenario.space("space") + const page = await as(users.alice, "/home") + + // Each conversation is one link, headed by its room and carrying the latest message underneath + const conversation = page.getByRole("link").filter({hasText: "the server is on fire"}) + + await expect(conversation).toBeVisible() + await expect(conversation).toContainText("General") + await expect(unreadDot(conversation)).toBeVisible() + + // Relay health checks had no mount point at all before the dashboard + await expect(page.getByText("Health checks")).toBeVisible() + + await page.getByRole("button", {name: "Mark all read"}).click() + + await expect(unreadDot(conversation)).toHaveCount(0) + + // A conversation stays in the inbox once it's read - it's a list of where things are, not a queue + await conversation.click() + + await expect(page).toHaveURL(pattern(roomPath(space.url, "general"))) +}) + test("US-106 share text into the app", async ({seed, as}) => { const shared = "the offsite is moving to the 14th" diff --git a/src/app/components/RelaySettingsHealthCheck.svelte b/src/app/components/HealthCheckItem.svelte similarity index 100% rename from src/app/components/RelaySettingsHealthCheck.svelte rename to src/app/components/HealthCheckItem.svelte diff --git a/src/app/components/HomeHealthChecks.svelte b/src/app/components/HomeHealthChecks.svelte new file mode 100644 index 00000000..51096bf1 --- /dev/null +++ b/src/app/components/HomeHealthChecks.svelte @@ -0,0 +1,46 @@ + + +
+
+ + + Health checks + + {#if $pending.length > 0} + {$pending.length} open + {/if} +
+ {#if $pending.length === 0} +

+ + Your connection to the network looks healthy. +

+ {:else} + {#each $pending as healthCheck (healthCheck.title)} + + {/each} + {#if $pending.length > 1} + + {/if} + {/if} +
diff --git a/src/app/components/HomeHosting.svelte b/src/app/components/HomeHosting.svelte new file mode 100644 index 00000000..d5883290 --- /dev/null +++ b/src/app/components/HomeHosting.svelte @@ -0,0 +1,60 @@ + + +{#if loaded} +
+
+ + + Hosting + + {#if relays.length > 0} + Manage + {/if} +
+ {#each relays as relay (relay.id)} + + {/each} +

+ {#if relays.length > 0} + Spin up another hosted space — rooms, calendar and moderation included. + {:else} + Don't wait for an invite. Flotilla Hosting sets up a space with rooms, calendar and + moderation in about a minute. + {/if} +

+ +
+{/if} diff --git a/src/app/components/HomeInbox.svelte b/src/app/components/HomeInbox.svelte new file mode 100644 index 00000000..e69cc710 --- /dev/null +++ b/src/app/components/HomeInbox.svelte @@ -0,0 +1,71 @@ + + +
+
+ + + Inbox + + {#if hasUnread} + + {/if} +
+ {#if conversations.length === 0} +
+

Nothing in your inbox yet

+

+ Rooms and spaces you belong to report their activity here — messages, threads, classifieds, + events and polls. +

+
+ + + Add a space + + +
+
+ {:else} +
+ {#each conversations as conversation (conversation.path)} + + {/each} +
+ {/if} + {#each $inboxSpaceContent as { url, countsByKind } (url)} + + + {displayContent(countsByKind)} + + {/each} +
diff --git a/src/app/components/HomeInboxItem.svelte b/src/app/components/HomeInboxItem.svelte new file mode 100644 index 00000000..00d211e9 --- /dev/null +++ b/src/app/components/HomeInboxItem.svelte @@ -0,0 +1,73 @@ + + + +
+ {#if url} + + {#if h} + + {:else} + Chat + {/if} + + + + + {:else} + + + {#if others.length === 0} + Note to self + {:else} + + {#if others.length > 1} + and {others.length - 1} {others.length > 2 ? "others" : "other"} + {/if} + {/if} + + direct message + {/if} + + {formatTimestamp(event.created_at)} + + +
+
+ + + {#if event.pubkey === $user.pubkey} + You: + {:else} + : + {/if} + +
+ +
+
+ diff --git a/src/app/components/HomeNetwork.svelte b/src/app/components/HomeNetwork.svelte new file mode 100644 index 00000000..6d021a81 --- /dev/null +++ b/src/app/components/HomeNetwork.svelte @@ -0,0 +1,90 @@ + + +
+ + + Network + + {#if events.length === 0} + {#if loading} +
+ Looking for notes from people you follow… +
+ {:else} +
+

Follow a few people to fill this out

+

+ Notes from the people you follow collect here. Spaces are a good place to find some. +

+ Browse spaces +
+ {/if} + {:else} +
+ {#each events.slice(0, limit) as event (event.id)} + + {/each} +
+ + {/if} +
diff --git a/src/app/components/RelaySettingsHealthChecks.svelte b/src/app/components/RelaySettingsHealthChecks.svelte deleted file mode 100644 index d389f06f..00000000 --- a/src/app/components/RelaySettingsHealthChecks.svelte +++ /dev/null @@ -1,45 +0,0 @@ - - -
-
- - - Health Check - - - - {$pending.length} Issue{$pending.length === 1 ? "" : "s"} Detected - -
-

- {PLATFORM_NAME} actively checks your connection to the network in the background to discover relays - that are offline, that you don't have access to, or are otherwise causing trouble. -

- {#each $pending as healthCheck (healthCheck.title)} - - {/each} - {#if $pending.length > 0} - - {/if} -
diff --git a/src/app/content.ts b/src/app/content.ts index e5f84a81..3a2755ca 100644 --- a/src/app/content.ts +++ b/src/app/content.ts @@ -81,6 +81,22 @@ if (ENABLE_ZAPS) { export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED, POLL, PINBOARD, LONG_FORM] +const CONTENT_NOUNS = new Map([ + [ZAP_GOAL, ["goal", "goals"]], + [EVENT_TIME, ["event", "events"]], + [THREAD, ["thread", "threads"]], + [CLASSIFIED, ["classified", "classifieds"]], + [POLL, ["poll", "polls"]], + [PINBOARD, ["pinboard", "pinboards"]], + [LONG_FORM, ["article", "articles"]], +]) + +export const displayContentCount = (kind: number, count: number) => { + const [singular, plural] = CONTENT_NOUNS.get(kind) ?? ["item", "items"] + + return `${count} ${count === 1 ? singular : plural}` +} + export const DM_KINDS = [DIRECT_MESSAGE, DIRECT_MESSAGE_FILE] export const displayReaction = (content: string) => { diff --git a/src/app/inbox.ts b/src/app/inbox.ts new file mode 100644 index 00000000..003a00ac --- /dev/null +++ b/src/app/inbox.ts @@ -0,0 +1,46 @@ +import {derived} from "svelte/store" +import {sortBy} from "@welshman/lib" +import type {Activity} from "@app/notifications" +import {allNotifications, latestActivityByPath} from "@app/notifications" + +export type InboxConversation = Activity & {unread: boolean} + +// Every room, space chat and direct message the user can see, newest first, whether or not it +// has anything unread - an inbox that hid what has been read would be empty for anyone caught up. +export const inboxConversations = derived( + [latestActivityByPath, allNotifications], + ([$latestActivityByPath, $allNotifications]) => + sortBy( + conversation => -conversation.event.created_at, + [...$latestActivityByPath.values()] + .filter(activity => !activity.contentKind) + .map(activity => ({...activity, unread: $allNotifications.has(activity.path)})), + ), +) + +export type SpaceContent = { + url: string + timestamp: number + countsByKind: Map +} + +// What a space has waiting that isn't a message: threads, events, classifieds and the rest, +// counted only where they're unread, since a total would be the same number every day. +export const inboxSpaceContent = derived( + [latestActivityByPath, allNotifications], + ([$latestActivityByPath, $allNotifications]) => { + const byUrl = new Map() + + for (const {path, url, contentKind, event} of $latestActivityByPath.values()) { + if (url && contentKind && $allNotifications.has(path)) { + const content = byUrl.get(url) ?? {url, timestamp: 0, countsByKind: new Map()} + + content.timestamp = Math.max(content.timestamp, event.created_at) + content.countsByKind.set(contentKind, (content.countsByKind.get(contentKind) ?? 0) + 1) + byUrl.set(url, content) + } + } + + return sortBy(content => -content.timestamp, [...byUrl.values()]) + }, +) diff --git a/src/app/notifications.ts b/src/app/notifications.ts index 534e1b9b..f1bb0956 100644 --- a/src/app/notifications.ts +++ b/src/app/notifications.ts @@ -207,9 +207,22 @@ const getContentTarget = (event: TrustedEvent) => { } } +// Where an activity happened, alongside the event itself. The map is built from room lists, chats +// and content targets, all of which know what they are - a consumer handed only a path would have +// to parse it back apart. `contentKind` is what an activity is filed under when it belongs to a +// content item rather than to a conversation, and a comment files under its subject's kind. +export type Activity = { + path: string + event: TrustedEvent + contentKind?: number + url?: string + h?: string + pubkeys?: string[] +} + // Assumes `events` is sorted descending, so the first event seen per content item wins. -const latestEventByContentPath = (url: string, events: TrustedEvent[]) => { - const byPath = new Map() +const latestContentActivity = (url: string, events: TrustedEvent[]) => { + const byPath = new Map() for (const event of events) { const target = getContentTarget(event) @@ -219,7 +232,7 @@ const latestEventByContentPath = (url: string, events: TrustedEvent[]) => { const path = makeContentPath(url, target.kind, target.idOrAddress) if (path && !byPath.has(path)) { - byPath.set(path, event) + byPath.set(path, {path, url, contentKind: target.kind, event}) } } @@ -245,11 +258,13 @@ export const latestActivityByPath = derived( ), ), ([$app, $chatsById, $relays, $roomLists, eventsByIdByUrl, $settings]) => { - const activity = new Map() + const activity = new Map() for (const {pubkeys, messages} of $chatsById.values()) { if (messages[0]) { - activity.set(makeChatPath(pubkeys), messages[0]) + const path = makeChatPath(pubkeys) + + activity.set(path, {path, pubkeys, event: messages[0]}) } } @@ -260,22 +275,26 @@ export const latestActivityByPath = derived( const events = sortEventsDesc((eventsByIdByUrl.get(url) || new Map()).values()) if ($relays.get(url)?.hasNip(29)) { - for (const [h, [latestEvent]] of groupBy(e => tagValue(tagSpec("h"), e.tags), events)) { + for (const [h, [event]] of groupBy(e => tagValue(tagSpec("h"), e.tags), events)) { // A muted room is left out entirely, so it can't light up its own badge or the space's if (h && !getIsMuted($settings, url, h)) { - activity.set(makeRoomPath(url, h), latestEvent) + const path = makeRoomPath(url, h) + + activity.set(path, {path, url, h, event}) } } } else { - const latestEvent = first(events) + const event = first(events) - if (latestEvent) { - activity.set(makeSpaceChatPath(url), latestEvent) + if (event) { + const path = makeSpaceChatPath(url) + + activity.set(path, {path, url, event}) } } - for (const [path, latestEvent] of latestEventByContentPath(url, events)) { - activity.set(path, latestEvent) + for (const [path, contentActivity] of latestContentActivity(url, events)) { + activity.set(path, contentActivity) } } @@ -306,12 +325,12 @@ export const allNotifications = derived( const paths = new Set() - for (const [path, latestEvent] of $latestActivityByPath) { - if (hasNotification(path, latestEvent)) { + for (const [path, {event}] of $latestActivityByPath) { + if (hasNotification(path, event)) { paths.add(path) for (const branchPath of remove(path, getPaths(path.split("?")[0]))) { - if (hasNotification(branchPath, latestEvent)) { + if (hasNotification(branchPath, event)) { paths.add(branchPath) } } @@ -334,7 +353,7 @@ export const notifications = derived( ), ) -const countActivity = (activity: Map, paths: Set) => +const countActivity = (activity: Map, paths: Set) => [...activity.keys()].filter(path => paths.has(path)).length export const notificationCount = derived( diff --git a/src/routes/home/+page.svelte b/src/routes/home/+page.svelte index 8f6dc2fa..5c371a3e 100644 --- a/src/routes/home/+page.svelte +++ b/src/routes/home/+page.svelte @@ -1,58 +1,43 @@ -
-
- -

Welcome to

-

{PLATFORM_NAME}

-
- - - {#snippet icon()} - - {/snippet} - {#snippet title()} -
Add a space
- {/snippet} - {#snippet info()} -
Use an invite link, or create your own space.
- {/snippet} -
- - -
-
-
-
+ + +
+ + Home +
+
+ +
+ + +
+
+ + {#if HOSTING_ENABLED} + + {/if} +
+
+