Replace the home welcome screen with a dashboard

This commit is contained in:
Coracle-Bot 2026-09-08 22:47:51 +00:00 committed by hodlbod
parent 2c06a107f2
commit d3ceaac1b9
13 changed files with 519 additions and 110 deletions

View file

@ -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

View file

@ -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"

View 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>

View 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}

View 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>

View 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>

View 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>

View file

@ -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>

View file

@ -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
View 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()])
},
)

View file

@ -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(

View file

@ -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>