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:
|
Acceptance:
|
||||||
|
|
||||||
- On a build with a configured platform space, `/home` opens that space.
|
- 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
|
- With none configured, it shows the dashboard, whose empty inbox offers "Add a
|
||||||
"Start a conversation".
|
space" and "Start a conversation".
|
||||||
- Those options navigate to the spaces directory and the chat view respectively.
|
- 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.
|
- 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
|
### US-106 — Share text into the app
|
||||||
|
|
||||||
As alice, I want to hand text to Flotilla and choose where it lands, so that I
|
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)))
|
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 page = await as(users.alice, "/home")
|
||||||
|
|
||||||
const addSpace = page.getByRole("link", {name: "Add a space"})
|
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$/)
|
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}) => {
|
test("US-106 share text into the app", async ({seed, as}) => {
|
||||||
const shared = "the offsite is moving to the 14th"
|
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]
|
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 DM_KINDS = [DIRECT_MESSAGE, DIRECT_MESSAGE_FILE]
|
||||||
|
|
||||||
export const displayReaction = (content: string) => {
|
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.
|
// Assumes `events` is sorted descending, so the first event seen per content item wins.
|
||||||
const latestEventByContentPath = (url: string, events: TrustedEvent[]) => {
|
const latestContentActivity = (url: string, events: TrustedEvent[]) => {
|
||||||
const byPath = new Map<string, TrustedEvent>()
|
const byPath = new Map<string, Activity>()
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
const target = getContentTarget(event)
|
const target = getContentTarget(event)
|
||||||
|
|
@ -219,7 +232,7 @@ const latestEventByContentPath = (url: string, events: TrustedEvent[]) => {
|
||||||
const path = makeContentPath(url, target.kind, target.idOrAddress)
|
const path = makeContentPath(url, target.kind, target.idOrAddress)
|
||||||
|
|
||||||
if (path && !byPath.has(path)) {
|
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]) => {
|
([$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()) {
|
for (const {pubkeys, messages} of $chatsById.values()) {
|
||||||
if (messages[0]) {
|
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())
|
const events = sortEventsDesc((eventsByIdByUrl.get(url) || new Map()).values())
|
||||||
|
|
||||||
if ($relays.get(url)?.hasNip(29)) {
|
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
|
// 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)) {
|
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 {
|
} else {
|
||||||
const latestEvent = first(events)
|
const event = first(events)
|
||||||
|
|
||||||
if (latestEvent) {
|
if (event) {
|
||||||
activity.set(makeSpaceChatPath(url), latestEvent)
|
const path = makeSpaceChatPath(url)
|
||||||
|
|
||||||
|
activity.set(path, {path, url, event})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [path, latestEvent] of latestEventByContentPath(url, events)) {
|
for (const [path, contentActivity] of latestContentActivity(url, events)) {
|
||||||
activity.set(path, latestEvent)
|
activity.set(path, contentActivity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -306,12 +325,12 @@ export const allNotifications = derived(
|
||||||
|
|
||||||
const paths = new Set<string>()
|
const paths = new Set<string>()
|
||||||
|
|
||||||
for (const [path, latestEvent] of $latestActivityByPath) {
|
for (const [path, {event}] of $latestActivityByPath) {
|
||||||
if (hasNotification(path, latestEvent)) {
|
if (hasNotification(path, event)) {
|
||||||
paths.add(path)
|
paths.add(path)
|
||||||
|
|
||||||
for (const branchPath of remove(path, getPaths(path.split("?")[0]))) {
|
for (const branchPath of remove(path, getPaths(path.split("?")[0]))) {
|
||||||
if (hasNotification(branchPath, latestEvent)) {
|
if (hasNotification(branchPath, event)) {
|
||||||
paths.add(branchPath)
|
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
|
[...activity.keys()].filter(path => paths.has(path)).length
|
||||||
|
|
||||||
export const notificationCount = derived(
|
export const notificationCount = derived(
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,43 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {goto} from "$app/navigation"
|
import Home from "@assets/icons/home.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 Icon from "@lib/components/Icon.svelte"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Page from "@lib/components/Page.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import PageBar from "@lib/components/PageBar.svelte"
|
||||||
import PageContent from "@lib/components/PageContent.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 {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")
|
// A single-space build has no dashboard to show - everything on it is scoped to the one space.
|
||||||
|
onMount(() => {
|
||||||
onMount(async () => {
|
|
||||||
if (PLATFORM_RELAYS.length > 0) {
|
if (PLATFORM_RELAYS.length > 0) {
|
||||||
goToSpace(PLATFORM_RELAYS[0], {replaceState: true})
|
goToSpace(PLATFORM_RELAYS[0], {replaceState: true})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="m-auto min-h-screen overflow-auto pb-8 h-full flex items-center">
|
<Page>
|
||||||
<div class="p-4">
|
<PageBar>
|
||||||
<PageContent class="flex flex-col gap-4">
|
<div class="flex items-center gap-2">
|
||||||
<h1 class="text-center text-5xl">Welcome to</h1>
|
<Icon icon={Home} size={6} />
|
||||||
<h1 class="mb-4 text-center text-5xl font-bold uppercase">{PLATFORM_NAME}</h1>
|
<strong>Home</strong>
|
||||||
<div class="flex flex-col gap-3">
|
</div>
|
||||||
<Link href="/spaces">
|
</PageBar>
|
||||||
<CardButton>
|
<PageContent class="flex flex-col gap-4 p-2 sm:p-4 lg:flex-row lg:items-start">
|
||||||
{#snippet icon()}
|
<div class="flex min-w-0 flex-col gap-4 lg:flex-[2]">
|
||||||
<Icon icon={AddCircle} size={7} />
|
<HomeInbox />
|
||||||
{/snippet}
|
<HomeNetwork />
|
||||||
{#snippet title()}
|
</div>
|
||||||
<div>Add a space</div>
|
<div class="flex min-w-0 flex-col gap-4 lg:flex-1">
|
||||||
{/snippet}
|
<HomeHealthChecks />
|
||||||
{#snippet info()}
|
{#if HOSTING_ENABLED}
|
||||||
<div>Use an invite link, or create your own space.</div>
|
<HomeHosting />
|
||||||
{/snippet}
|
{/if}
|
||||||
</CardButton>
|
</div>
|
||||||
</Link>
|
</PageContent>
|
||||||
<Button onclick={openChat}>
|
</Page>
|
||||||
<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>
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue