Replace the home welcome screen with a dashboard
This commit is contained in:
parent
2c06a107f2
commit
d3ceaac1b9
13 changed files with 519 additions and 110 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
46
src/app/components/HomeHealthChecks.svelte
Normal file
46
src/app/components/HomeHealthChecks.svelte
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<script lang="ts">
|
||||
import Stars from "@assets/icons/stars.svg?dataurl"
|
||||
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
||||
import Stethoscope from "@assets/icons/stethoscope.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Badge from "@lib/components/Badge.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import HealthCheckItem from "@app/components/HealthCheckItem.svelte"
|
||||
import {healthChecks} from "@app/healthChecks"
|
||||
|
||||
const pending = $healthChecks.pending.$
|
||||
|
||||
const applyAll = () => {
|
||||
for (const healthCheck of $pending) {
|
||||
$healthChecks.apply(healthCheck)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<strong class="flex items-center gap-2 text-lg">
|
||||
<Icon icon={Stethoscope} />
|
||||
Health checks
|
||||
</strong>
|
||||
{#if $pending.length > 0}
|
||||
<Badge variant="warning">{$pending.length} open</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{#if $pending.length === 0}
|
||||
<p class="flex items-center gap-2 text-sm opacity-75">
|
||||
<Icon icon={CheckCircle} size={4} />
|
||||
Your connection to the network looks healthy.
|
||||
</p>
|
||||
{:else}
|
||||
{#each $pending as healthCheck (healthCheck.title)}
|
||||
<HealthCheckItem {healthCheck} />
|
||||
{/each}
|
||||
{#if $pending.length > 1}
|
||||
<Button class="button button-primary button-sm" onclick={applyAll}>
|
||||
<Icon icon={Stars} size={4} />
|
||||
Apply all recommendations
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
60
src/app/components/HomeHosting.svelte
Normal file
60
src/app/components/HomeHosting.svelte
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import Add from "@assets/icons/add.svg?dataurl"
|
||||
import Server from "@assets/icons/server.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import RelayListItem from "@app/components/hosting/RelayListItem.svelte"
|
||||
import RelayCreate from "@app/components/hosting/RelayCreate.svelte"
|
||||
import {user} from "@app/core"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {HostingError, listTenantRelays, type HostedRelay} from "@app/hosting"
|
||||
|
||||
let relays = $state<HostedRelay[]>([])
|
||||
let loaded = $state(false)
|
||||
|
||||
const openCreate = () => pushModal(RelayCreate)
|
||||
|
||||
// A pubkey with no tenant yet answers with a HostingError, which is the ordinary case for
|
||||
// anyone who has never hosted a space. The panel stays out of the way rather than reporting it.
|
||||
onMount(async () => {
|
||||
try {
|
||||
relays = await listTenantRelays($user.pubkey)
|
||||
loaded = true
|
||||
} catch (e) {
|
||||
if (!(e instanceof HostingError)) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if loaded}
|
||||
<div class="card flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<strong class="flex items-center gap-2 text-lg">
|
||||
<Icon icon={Server} />
|
||||
Hosting
|
||||
</strong>
|
||||
{#if relays.length > 0}
|
||||
<Link href="/settings/hosting" class="button button-neutral button-xs">Manage</Link>
|
||||
{/if}
|
||||
</div>
|
||||
{#each relays as relay (relay.id)}
|
||||
<RelayListItem {relay} />
|
||||
{/each}
|
||||
<p class="text-sm opacity-75">
|
||||
{#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}
|
||||
</p>
|
||||
<Button class="button button-primary button-sm" onclick={openCreate}>
|
||||
<Icon icon={Add} size={4} />
|
||||
{relays.length > 0 ? "Add another space" : "Start a space"}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
71
src/app/components/HomeInbox.svelte
Normal file
71
src/app/components/HomeInbox.svelte
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<script lang="ts">
|
||||
import Inbox from "@assets/icons/inbox.svg?dataurl"
|
||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import HomeInboxItem from "@app/components/HomeInboxItem.svelte"
|
||||
import RelayName from "@app/components/RelayName.svelte"
|
||||
import {displayContentCount} from "@app/content"
|
||||
import {inboxConversations, inboxSpaceContent} from "@app/inbox"
|
||||
import {navigate} from "@app/modal"
|
||||
import {setChecked} from "@app/notifications"
|
||||
import {makeSpacePath} from "@app/routes"
|
||||
|
||||
const conversations = $derived($inboxConversations.slice(0, 8))
|
||||
|
||||
const hasUnread = $derived(
|
||||
$inboxConversations.some(conversation => conversation.unread) || $inboxSpaceContent.length > 0,
|
||||
)
|
||||
|
||||
const displayContent = (countsByKind: Map<number, number>) =>
|
||||
[...countsByKind].map(([kind, count]) => displayContentCount(kind, count)).join(" · ")
|
||||
|
||||
const markAllRead = () => setChecked("*")
|
||||
|
||||
const startChat = () => navigate("/chat")
|
||||
</script>
|
||||
|
||||
<div class="card flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<strong class="flex items-center gap-2 text-lg">
|
||||
<Icon icon={Inbox} />
|
||||
Inbox
|
||||
</strong>
|
||||
{#if hasUnread}
|
||||
<Button class="button button-neutral button-xs" onclick={markAllRead}>Mark all read</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if conversations.length === 0}
|
||||
<div class="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<p class="font-medium">Nothing in your inbox yet</p>
|
||||
<p class="max-w-md text-sm opacity-75">
|
||||
Rooms and spaces you belong to report their activity here — messages, threads, classifieds,
|
||||
events and polls.
|
||||
</p>
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
<Link href="/spaces" class="button button-primary button-sm">
|
||||
<Icon icon={AddCircle} size={4} />
|
||||
Add a space
|
||||
</Link>
|
||||
<Button class="button button-neutral button-sm" onclick={startChat}>
|
||||
<Icon icon={ChatRound} size={4} />
|
||||
Start a conversation
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each conversations as conversation (conversation.path)}
|
||||
<HomeInboxItem {conversation} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#each $inboxSpaceContent as { url, countsByKind } (url)}
|
||||
<Link href={makeSpacePath(url)} class="flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||
<strong><RelayName {url} /></strong>
|
||||
<span class="opacity-75">{displayContent(countsByKind)}</span>
|
||||
</Link>
|
||||
{/each}
|
||||
</div>
|
||||
73
src/app/components/HomeInboxItem.svelte
Normal file
73
src/app/components/HomeInboxItem.svelte
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<script lang="ts">
|
||||
import {formatTimestamp, remove, uniq} from "@welshman/lib"
|
||||
import ChatRoundDots from "@assets/icons/chat-round-dots.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import ProfileName from "@app/components/ProfileName.svelte"
|
||||
import RelayName from "@app/components/RelayName.svelte"
|
||||
import RoomName from "@app/components/RoomName.svelte"
|
||||
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
||||
import UnreadDot from "@app/components/UnreadDot.svelte"
|
||||
import {user} from "@app/core"
|
||||
import type {InboxConversation} from "@app/inbox"
|
||||
|
||||
type Props = {
|
||||
conversation: InboxConversation
|
||||
}
|
||||
|
||||
const {conversation}: Props = $props()
|
||||
|
||||
const path = $derived(conversation.path)
|
||||
const url = $derived(conversation.url)
|
||||
const h = $derived(conversation.h)
|
||||
const event = $derived(conversation.event)
|
||||
const others = $derived(uniq(remove($user.pubkey, conversation.pubkeys ?? [])))
|
||||
</script>
|
||||
|
||||
<Link href={path} class="card card-sm card-interactive flex flex-col gap-2">
|
||||
<div class="flex min-w-0 items-center gap-2 text-sm">
|
||||
{#if url}
|
||||
<strong class="truncate">
|
||||
{#if h}
|
||||
<RoomName {url} {h} />
|
||||
{:else}
|
||||
Chat
|
||||
{/if}
|
||||
</strong>
|
||||
<span class="truncate opacity-50">
|
||||
<RelayName {url} />
|
||||
</span>
|
||||
{:else}
|
||||
<Icon icon={ChatRoundDots} size={4} class="shrink-0 opacity-50" />
|
||||
<strong class="truncate">
|
||||
{#if others.length === 0}
|
||||
Note to self
|
||||
{:else}
|
||||
<ProfileName pubkey={others[0]} />
|
||||
{#if others.length > 1}
|
||||
and {others.length - 1} {others.length > 2 ? "others" : "other"}
|
||||
{/if}
|
||||
{/if}
|
||||
</strong>
|
||||
<span class="truncate opacity-50">direct message</span>
|
||||
{/if}
|
||||
<span class="ml-auto flex shrink-0 items-center gap-2 text-xs opacity-50">
|
||||
{formatTimestamp(event.created_at)}
|
||||
<UnreadDot {path} />
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<ProfileCircle pubkey={event.pubkey} size={5} />
|
||||
<span class="shrink-0 text-xs opacity-50">
|
||||
{#if event.pubkey === $user.pubkey}
|
||||
You:
|
||||
{:else}
|
||||
<ProfileName pubkey={event.pubkey} {url} />:
|
||||
{/if}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<NoteContentMinimal {event} {url} singleLine />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
90
src/app/components/HomeNetwork.svelte
Normal file
90
src/app/components/HomeNetwork.svelte
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<script lang="ts">
|
||||
import {onDestroy, onMount} from "svelte"
|
||||
import {sortBy, uniqBy} from "@welshman/lib"
|
||||
import {NOTE} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
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 Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import NoteItem from "@app/components/NoteItem.svelte"
|
||||
import {app, relayLists, user} from "@app/core"
|
||||
import {makeFeedContext} from "@app/feeds"
|
||||
|
||||
const PAGE_SIZE = 5
|
||||
|
||||
const context = makeFeedContext({relays: $relayLists.readUrls($user.pubkey).get()})
|
||||
|
||||
onDestroy(context.cleanup)
|
||||
|
||||
// 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.
|
||||
const controller = $app.use(Feeds).makeFeedController({
|
||||
useWindowing: true,
|
||||
feed: makeIntersectionFeed(makeScopeFeed(Scope.Follows), feedFromFilter({kinds: [NOTE]})),
|
||||
onEvent: (event: TrustedEvent) => {
|
||||
if (getReplyTags(event.tags).replies.length === 0) {
|
||||
events = uniqBy(
|
||||
event => event.id,
|
||||
sortBy(event => -event.created_at, [...events, event]),
|
||||
)
|
||||
context.add(event)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
let events = $state<TrustedEvent[]>([])
|
||||
let limit = $state(PAGE_SIZE)
|
||||
let loading = $state(true)
|
||||
|
||||
const load = () => {
|
||||
loading = true
|
||||
controller
|
||||
.load(PAGE_SIZE * 4)
|
||||
.catch(e => console.error(e))
|
||||
.finally(() => (loading = false))
|
||||
}
|
||||
|
||||
const showMore = () => {
|
||||
limit += PAGE_SIZE
|
||||
|
||||
if (events.length < limit + PAGE_SIZE) {
|
||||
load()
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load)
|
||||
</script>
|
||||
|
||||
<div class="card flex flex-col gap-3">
|
||||
<strong class="flex items-center gap-2 text-lg">
|
||||
<Icon icon={Planet} />
|
||||
Network
|
||||
</strong>
|
||||
{#if events.length === 0}
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-8">
|
||||
<Spinner {loading}>Looking for notes from people you follow…</Spinner>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<p class="font-medium">Follow a few people to fill this out</p>
|
||||
<p class="max-w-md text-sm opacity-75">
|
||||
Notes from the people you follow collect here. Spaces are a good place to find some.
|
||||
</p>
|
||||
<Link href="/spaces" class="button button-neutral button-sm">Browse spaces</Link>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each events.slice(0, limit) as event (event.id)}
|
||||
<NoteItem {event} {context} />
|
||||
{/each}
|
||||
</div>
|
||||
<Button class="button button-neutral button-sm" onclick={showMore}>Show more</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
<script lang="ts">
|
||||
import Stars from "@assets/icons/stars.svg?dataurl"
|
||||
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
||||
import DangerTriangle from "@assets/icons/danger-triangle.svg?dataurl"
|
||||
import Stethoscope from "@assets/icons/stethoscope.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import RelaySettingsHealthCheck from "@app/components/RelaySettingsHealthCheck.svelte"
|
||||
import {PLATFORM_NAME} from "@app/env"
|
||||
import {healthChecks} from "@app/healthChecks"
|
||||
|
||||
const pending = $healthChecks.pending.$
|
||||
|
||||
const applyAll = () => {
|
||||
for (const healthCheck of $pending) {
|
||||
$healthChecks.apply(healthCheck)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card flex flex-col gap-4 shadow-md">
|
||||
<div class="flex justify-between items-center">
|
||||
<strong class="flex items-center gap-3 text-lg">
|
||||
<Icon icon={Stethoscope} />
|
||||
Health Check
|
||||
</strong>
|
||||
<span class="flex items-center gap-2 text-sm">
|
||||
<Icon icon={$pending.length === 0 ? CheckCircle : DangerTriangle} />
|
||||
{$pending.length} Issue{$pending.length === 1 ? "" : "s"} Detected
|
||||
</span>
|
||||
</div>
|
||||
<p>
|
||||
{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.
|
||||
</p>
|
||||
{#each $pending as healthCheck (healthCheck.title)}
|
||||
<RelaySettingsHealthCheck {healthCheck} />
|
||||
{/each}
|
||||
{#if $pending.length > 0}
|
||||
<Button class="button button-primary" onclick={applyAll}>
|
||||
<Icon icon={Stars} />
|
||||
Apply All Recommendations
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
46
src/app/inbox.ts
Normal file
46
src/app/inbox.ts
Normal file
|
|
@ -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<number, number>
|
||||
}
|
||||
|
||||
// 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<string, SpaceContent>()
|
||||
|
||||
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()])
|
||||
},
|
||||
)
|
||||
|
|
@ -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<string, TrustedEvent>()
|
||||
const latestContentActivity = (url: string, events: TrustedEvent[]) => {
|
||||
const byPath = new Map<string, Activity>()
|
||||
|
||||
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<string, TrustedEvent>()
|
||||
const activity = new Map<string, Activity>()
|
||||
|
||||
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<string>()
|
||||
|
||||
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<string, TrustedEvent>, paths: Set<string>) =>
|
||||
const countActivity = (activity: Map<string, Activity>, paths: Set<string>) =>
|
||||
[...activity.keys()].filter(path => paths.has(path)).length
|
||||
|
||||
export const notificationCount = derived(
|
||||
|
|
|
|||
|
|
@ -1,58 +1,43 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {goto} from "$app/navigation"
|
||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
||||
import Home from "@assets/icons/home.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Page from "@lib/components/Page.svelte"
|
||||
import PageBar from "@lib/components/PageBar.svelte"
|
||||
import PageContent from "@lib/components/PageContent.svelte"
|
||||
import CardButton from "@lib/components/CardButton.svelte"
|
||||
import HomeInbox from "@app/components/HomeInbox.svelte"
|
||||
import HomeNetwork from "@app/components/HomeNetwork.svelte"
|
||||
import HomeHealthChecks from "@app/components/HomeHealthChecks.svelte"
|
||||
import HomeHosting from "@app/components/HomeHosting.svelte"
|
||||
import {goToSpace} from "@app/routes"
|
||||
import {PLATFORM_NAME, PLATFORM_RELAYS} from "@app/env"
|
||||
import {HOSTING_ENABLED} from "@app/hosting"
|
||||
import {PLATFORM_RELAYS} from "@app/env"
|
||||
|
||||
const openChat = () => goto("/chat")
|
||||
|
||||
onMount(async () => {
|
||||
// A single-space build has no dashboard to show - everything on it is scoped to the one space.
|
||||
onMount(() => {
|
||||
if (PLATFORM_RELAYS.length > 0) {
|
||||
goToSpace(PLATFORM_RELAYS[0], {replaceState: true})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="m-auto min-h-screen overflow-auto pb-8 h-full flex items-center">
|
||||
<div class="p-4">
|
||||
<PageContent class="flex flex-col gap-4">
|
||||
<h1 class="text-center text-5xl">Welcome to</h1>
|
||||
<h1 class="mb-4 text-center text-5xl font-bold uppercase">{PLATFORM_NAME}</h1>
|
||||
<div class="flex flex-col gap-3">
|
||||
<Link href="/spaces">
|
||||
<CardButton>
|
||||
{#snippet icon()}
|
||||
<Icon icon={AddCircle} size={7} />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<div>Add a space</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<div>Use an invite link, or create your own space.</div>
|
||||
{/snippet}
|
||||
</CardButton>
|
||||
</Link>
|
||||
<Button onclick={openChat}>
|
||||
<CardButton>
|
||||
{#snippet icon()}
|
||||
<Icon icon={ChatRound} size={7} />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<div>Start a conversation</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<div>Use nostr's encrypted group chats to stay in touch.</div>
|
||||
{/snippet}
|
||||
</CardButton>
|
||||
</Button>
|
||||
</div>
|
||||
</PageContent>
|
||||
</div>
|
||||
</div>
|
||||
<Page>
|
||||
<PageBar>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon={Home} size={6} />
|
||||
<strong>Home</strong>
|
||||
</div>
|
||||
</PageBar>
|
||||
<PageContent class="flex flex-col gap-4 p-2 sm:p-4 lg:flex-row lg:items-start">
|
||||
<div class="flex min-w-0 flex-col gap-4 lg:flex-[2]">
|
||||
<HomeInbox />
|
||||
<HomeNetwork />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col gap-4 lg:flex-1">
|
||||
<HomeHealthChecks />
|
||||
{#if HOSTING_ENABLED}
|
||||
<HomeHosting />
|
||||
{/if}
|
||||
</div>
|
||||
</PageContent>
|
||||
</Page>
|
||||
|
|
|
|||
Loading…
Reference in a new issue