feat(desktop): customize home experience

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Thomas Petersen
2026-05-12 19:59:12 -04:00
parent 6b4f9f05d4
commit 1df46a0063
18 changed files with 1382 additions and 250 deletions

View File

@@ -2280,6 +2280,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -5818,6 +5824,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"http-range",
"jni",
"libc",
"log",

View File

@@ -26,7 +26,7 @@ windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
[dependencies]
atomic-write-file = "0.3"
dirs = "6"
tauri = { version = "2", features = [] }
tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-deep-link = "2"
tauri-plugin-opener = "2"
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }

View File

@@ -0,0 +1,111 @@
use serde::Serialize;
use tauri::Manager;
use tauri_plugin_dialog::DialogExt;
use uuid::Uuid;
const SCRIPT_MAX_BYTES: u64 = 256 * 1024;
#[derive(Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PickHomeBackgroundResult {
Asset {
path: String,
file_name: Option<String>,
},
Script {
content: String,
file_name: Option<String>,
},
}
fn dialog_filter(kind: &str) -> Result<(&'static str, &'static [&'static str]), String> {
match kind {
"image" => Ok((
"Images",
&["jpg", "jpeg", "png", "gif", "webp", "avif", "svg"],
)),
"video" => Ok(("Videos", &["mp4", "mov", "m4v", "webm", "mkv"])),
"script" => Ok(("Animations", &["html", "htm", "js", "css", "txt"])),
_ => Err("unsupported background file type".to_string()),
}
}
#[tauri::command]
pub async fn pick_home_background_file(
app: tauri::AppHandle,
kind: String,
) -> Result<Option<PickHomeBackgroundResult>, String> {
let (filter_name, filter_exts) = dialog_filter(&kind)?;
let (tx, rx) = tokio::sync::oneshot::channel();
app.dialog()
.file()
.add_filter(filter_name, filter_exts)
.pick_file(move |path| {
let _ = tx.send(path);
});
let selected = rx.await.map_err(|_| "dialog cancelled".to_string())?;
let file_path = match selected {
Some(path) => path,
None => return Ok(None),
};
let path = file_path.as_path().ok_or("invalid path")?.to_path_buf();
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.map(ToOwned::to_owned);
if kind == "script" {
let metadata =
std::fs::metadata(&path).map_err(|error| format!("failed to inspect file: {error}"))?;
if metadata.len() > SCRIPT_MAX_BYTES {
return Err("script file is too large; choose a file under 256 KB".to_string());
}
let content = std::fs::read_to_string(&path)
.map_err(|error| format!("failed to read file: {error}"))?;
return Ok(Some(PickHomeBackgroundResult::Script {
content,
file_name,
}));
}
let background_dir = app
.path()
.app_data_dir()
.map_err(|error| format!("failed to resolve app data directory: {error}"))?
.join("home-backgrounds");
std::fs::create_dir_all(&background_dir)
.map_err(|error| format!("failed to prepare background directory: {error}"))?;
let extension = path
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| {
extension
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.take(8)
.collect::<String>()
.to_ascii_lowercase()
})
.filter(|extension| !extension.is_empty())
.unwrap_or_else(|| {
if kind == "video" {
"mp4".to_string()
} else {
"png".to_string()
}
});
let persisted_path =
background_dir.join(format!("home-background-{}.{}", Uuid::new_v4(), extension));
std::fs::copy(&path, &persisted_path)
.map_err(|error| format!("failed to copy background file: {error}"))?;
Ok(Some(PickHomeBackgroundResult::Asset {
path: persisted_path.to_string_lossy().into_owned(),
file_name,
}))
}

View File

@@ -7,6 +7,7 @@ mod channel_templates;
mod channels;
mod dms;
mod export_util;
mod home_background;
mod identity;
mod media;
mod media_download;
@@ -29,6 +30,7 @@ pub use canvas::*;
pub use channel_templates::*;
pub use channels::*;
pub use dms::*;
pub use home_background::*;
pub use identity::*;
pub use media::*;
pub use media_download::*;

View File

@@ -491,6 +491,7 @@ pub fn run() {
add_reaction,
remove_reaction,
get_event,
pick_home_background_file,
upload_media,
pick_and_upload_media,
upload_media_bytes,

View File

@@ -30,7 +30,11 @@
}
],
"security": {
"csp": null
"csp": null,
"assetProtocol": {
"enable": true,
"scope": ["$APPDATA/home-backgrounds/**"]
}
}
},
"plugins": {

View File

@@ -60,6 +60,7 @@ function HomeRouteComponent() {
return (
<HomeScreen
availableChannelIds={availableChannelIds}
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
onOpenFeedItem={(item) => {
if (!item.channelId) {
@@ -76,6 +77,9 @@ function HomeRouteComponent() {
onOpenPulse={() => {
void goPulse();
}}
onOpenSearchResult={(hit) => {
void openSearchHit(hit);
}}
/>
);
}

View File

@@ -84,35 +84,37 @@ export function ChatHeader({
return (
<header
className={cn(
"relative z-30 flex min-h-11 min-w-0 shrink-0 cursor-default select-none items-center gap-2.5 bg-background/70 py-1.5 pl-4 pr-2 shadow-[0_4px_24px_rgba(0,0,0,0.06)] backdrop-blur-xl transition-[margin,padding] duration-200 ease-linear supports-[backdrop-filter]:bg-background/55 dark:shadow-[0_4px_24px_rgba(0,0,0,0.25)] sm:pl-6 sm:pr-3",
"relative z-30 flex min-h-11 min-w-0 shrink-0 cursor-default select-none items-center gap-2.5 py-1.5 pl-4 pr-2 transition-[margin,padding] duration-200 ease-linear sm:pl-6 sm:pr-3",
mode === "home"
? "bg-transparent"
: "bg-background/70 shadow-[0_4px_24px_rgba(0,0,0,0.06)] backdrop-blur-xl supports-[backdrop-filter]:bg-background/55 dark:shadow-[0_4px_24px_rgba(0,0,0,0.25)]",
overlaysContent && "-mb-11",
reserveGlobalControls && "md:pl-40",
)}
data-testid="chat-header"
data-tauri-drag-region
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-1">
<ChannelIcon
channelType={channelType}
mode={mode}
visibility={visibility}
/>
<h1
className="min-w-0 truncate text-sm font-semibold leading-5 tracking-tight"
data-testid="chat-title"
title={trimmedDescription || undefined}
>
{title}
</h1>
{statusBadge ? (
<div className="flex shrink-0 flex-wrap items-center gap-1">
{statusBadge}
</div>
) : null}
</div>
<div className="pointer-events-none absolute left-1/2 top-1/2 flex max-w-[min(60vw,36rem)] -translate-x-1/2 -translate-y-1/2 items-center justify-center gap-1 text-center">
<ChannelIcon
channelType={channelType}
mode={mode}
visibility={visibility}
/>
<h1
className="min-w-0 truncate text-sm font-semibold leading-5 tracking-tight"
data-testid="chat-title"
title={trimmedDescription || undefined}
>
{title}
</h1>
{statusBadge ? (
<div className="flex shrink-0 flex-wrap items-center gap-1">
{statusBadge}
</div>
) : null}
</div>
<div className="min-w-0 flex-1" />
{actions ? <div className="shrink-0">{actions}</div> : null}
</header>
);

View File

@@ -151,7 +151,7 @@ export function FeedSection({
</div>
{items.length === 0 ? (
<div className="rounded-md border border-dashed border-border/60 px-4 py-5 text-center">
<div className="rounded-md border border-dashed border-border/60 bg-background/45 px-4 py-5 text-center shadow-sm backdrop-blur-xl supports-[backdrop-filter]:bg-background/35">
<p className="text-sm font-medium text-muted-foreground">
{emptyTitle}
</p>
@@ -160,7 +160,7 @@ export function FeedSection({
</p>
</div>
) : (
<div className="divide-y divide-border/60 rounded-md border border-border/60">
<div className="divide-y divide-border/60 rounded-md border border-border/60 bg-background/45 shadow-sm backdrop-blur-xl supports-[backdrop-filter]:bg-background/35">
{items.map((item) => {
const channelId = item.channelId;
const canOpenChannel =

View File

@@ -0,0 +1,117 @@
import * as React from "react";
import type { HomeBackgroundSettings } from "@/features/home/useHomeBackgroundSettings";
import { cn } from "@/shared/lib/cn";
type HomeBackgroundLayerProps = {
className?: string;
settings: HomeBackgroundSettings;
};
function buildAnimationSrcDoc(code: string) {
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; media-src data: blob:; font-src data:;" />
<style>
html, body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: transparent;
}
*, *::before, *::after {
box-sizing: border-box;
}
</style>
</head>
<body>
${code}
</body>
</html>`;
}
function hasRenderableBackground(settings: HomeBackgroundSettings) {
if (settings.mode === "none") {
return false;
}
if (settings.mode === "animation") {
return settings.animationCode.trim().length > 0;
}
return settings.sourceUrl.trim().length > 0;
}
export function HomeBackgroundLayer({
className,
settings,
}: HomeBackgroundLayerProps) {
const mediaSource =
settings.mode === "image" || settings.mode === "video"
? settings.sourceUrl.trim()
: "";
const [failedMediaSource, setFailedMediaSource] = React.useState<
string | null
>(null);
if (!hasRenderableBackground(settings)) {
return null;
}
if (mediaSource && failedMediaSource === mediaSource) {
return null;
}
return (
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 overflow-hidden",
className,
)}
>
{settings.mode === "image" ? (
<img
alt=""
className="h-full w-full object-cover"
onError={() => setFailedMediaSource(mediaSource)}
src={settings.sourceUrl}
style={{ opacity: settings.opacity }}
/>
) : null}
{settings.mode === "video" ? (
<video
autoPlay
className="h-full w-full object-cover"
loop
muted
onError={() => setFailedMediaSource(mediaSource)}
playsInline
src={settings.sourceUrl}
style={{ opacity: settings.opacity }}
/>
) : null}
{settings.mode === "animation" ? (
<iframe
className="h-full w-full border-0"
sandbox="allow-scripts"
srcDoc={buildAnimationSrcDoc(settings.animationCode)}
style={{ opacity: settings.opacity }}
title="Home background animation"
/>
) : null}
<div
className="absolute inset-0 bg-background"
style={{ opacity: settings.scrimOpacity }}
/>
</div>
);
}

View File

@@ -1,20 +1,24 @@
import type { FeedItem } from "@/shared/api/types";
import type { Channel, FeedItem, SearchHit } from "@/shared/api/types";
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import { useHomeFeedQuery } from "@/features/home/hooks";
import { HomeView } from "@/features/home/ui/HomeView";
type HomeScreenProps = {
channels: Channel[];
availableChannelIds: ReadonlySet<string>;
currentPubkey?: string;
onOpenFeedItem: (item: FeedItem) => void;
onOpenPulse: () => void;
onOpenSearchResult: (hit: SearchHit) => void;
};
export function HomeScreen({
channels,
availableChannelIds,
currentPubkey,
onOpenFeedItem,
onOpenPulse,
onOpenSearchResult,
}: HomeScreenProps) {
const homeFeedQuery = useHomeFeedQuery();
@@ -30,6 +34,7 @@ export function HomeScreen({
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<HomeView
availableChannelIds={availableChannelIds}
channels={channels}
currentPubkey={currentPubkey}
errorMessage={
homeFeedQuery.error instanceof Error
@@ -40,6 +45,7 @@ export function HomeScreen({
isLoading={homeFeedQuery.isLoading}
onOpenFeedItem={onOpenFeedItem}
onOpenPulse={onOpenPulse}
onOpenSearchResult={onOpenSearchResult}
onRefresh={() => {
void homeFeedQuery.refetch();
}}

View File

@@ -0,0 +1,250 @@
import * as React from "react";
import { ArrowRight, FileText, Hash, Search } from "lucide-react";
import {
resolveUserLabel,
resolveUserSecondaryLabel,
} from "@/features/profile/lib/identity";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { useSearchMessagesQuery } from "@/features/search/hooks";
import type { Channel, SearchHit } from "@/shared/api/types";
import { Badge } from "@/shared/ui/badge";
import { Input } from "@/shared/ui/input";
import { Skeleton } from "@/shared/ui/skeleton";
import { UserAvatar } from "@/shared/ui/UserAvatar";
const MIN_QUERY_LENGTH = 2;
function describeSearchHit(hit: SearchHit) {
switch (hit.kind) {
case 1:
return "Note";
case 45001:
return "Forum post";
case 45003:
return "Forum reply";
case 43001:
return "Agent job";
case 43003:
return "Agent update";
case 46010:
return "Approval request";
default:
return "Message";
}
}
function truncateContent(content: string) {
const trimmed = content.trim();
if (trimmed.length === 0) {
return "No message body.";
}
return trimmed.length <= 180 ? trimmed : `${trimmed.slice(0, 177)}...`;
}
function formatRelativeTime(unixSeconds: number) {
const diff = Math.floor(Date.now() / 1_000) - unixSeconds;
if (diff < 60) return "just now";
if (diff < 60 * 60) return `${Math.floor(diff / 60)}m ago`;
if (diff < 60 * 60 * 24) return `${Math.floor(diff / (60 * 60))}h ago`;
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(new Date(unixSeconds * 1_000));
}
function EmptyPanel({
title,
description,
}: {
title: string;
description: string;
}) {
return (
<div className="mx-auto flex w-full max-w-3xl flex-col items-center justify-center px-4 py-16 text-center sm:px-6">
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<p className="mt-1 max-w-md text-xs text-muted-foreground/70">
{description}
</p>
</div>
);
}
type HomeSearchPanelProps = {
channels: Channel[];
currentPubkey?: string;
onOpenSearchResult: (hit: SearchHit) => void;
};
export function HomeSearchPanel({
channels,
currentPubkey,
onOpenSearchResult,
}: HomeSearchPanelProps) {
const [searchQuery, setSearchQuery] = React.useState("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = React.useState("");
const channelLookup = React.useMemo(
() => new Map(channels.map((channel) => [channel.id, channel])),
[channels],
);
React.useEffect(() => {
const trimmed = searchQuery.trim();
if (trimmed.length < MIN_QUERY_LENGTH) {
setDebouncedSearchQuery("");
return;
}
const timeout = window.setTimeout(
() => setDebouncedSearchQuery(trimmed),
300,
);
return () => window.clearTimeout(timeout);
}, [searchQuery]);
const searchMessagesQuery = useSearchMessagesQuery(debouncedSearchQuery, {
enabled: true,
limit: 10,
});
const searchResults = searchMessagesQuery.data?.hits ?? [];
const searchProfilesQuery = useUsersBatchQuery(
searchResults.map((hit) => hit.pubkey),
{ enabled: searchResults.length > 0 },
);
const searchProfiles = searchProfilesQuery.data?.profiles;
return (
<div
className={`mx-auto flex min-h-full w-full max-w-3xl flex-col px-4 py-8 sm:px-6 ${
debouncedSearchQuery.length < MIN_QUERY_LENGTH
? "justify-center"
: "justify-start"
}`}
>
<div className="flex items-center gap-3 rounded-full border border-border/50 bg-background/70 px-5 py-4 shadow-[0_4px_24px_rgba(0,0,0,0.08)] backdrop-blur-xl supports-[backdrop-filter]:bg-background/55 dark:shadow-[0_4px_24px_rgba(0,0,0,0.35)]">
<Search className="h-5 w-5 text-muted-foreground" />
<Input
autoFocus
className="h-auto border-0 bg-transparent px-0 py-0 text-base shadow-none focus-visible:ring-0"
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search messages, approvals, and forum posts"
value={searchQuery}
/>
</div>
{debouncedSearchQuery.length < MIN_QUERY_LENGTH ? (
<p className="mt-3 text-center text-xs text-muted-foreground">
Type at least two characters to search accessible conversations.
</p>
) : searchMessagesQuery.isLoading ? (
<div className="mt-5 space-y-3">
{["first", "second", "third"].map((row) => (
<div
className="rounded-2xl border border-border/80 bg-card/60 p-4"
key={row}
>
<Skeleton className="h-4 w-32" />
<Skeleton className="mt-3 h-4 w-full" />
<Skeleton className="mt-2 h-4 w-3/4" />
</div>
))}
</div>
) : searchMessagesQuery.error instanceof Error ? (
<EmptyPanel
description={searchMessagesQuery.error.message}
title="Search unavailable"
/>
) : searchResults.length === 0 ? (
<EmptyPanel
description="Try a different keyword, channel name, or phrase from the message body."
title="No matches found"
/>
) : (
<div className="mt-5 space-y-2" data-testid="home-search-results">
<div className="flex items-center justify-between px-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
<span>
{searchMessagesQuery.data?.found ?? searchResults.length} results
</span>
<span>Select to open</span>
</div>
{searchResults.map((hit) => {
const channel = hit.channelId
? channelLookup.get(hit.channelId)
: undefined;
const authorLabel = resolveUserLabel({
pubkey: hit.pubkey,
currentPubkey,
profiles: searchProfiles,
preferResolvedSelfLabel: true,
});
const authorSecondaryLabel = resolveUserSecondaryLabel({
pubkey: hit.pubkey,
profiles: searchProfiles,
});
return (
<button
className="w-full rounded-2xl border border-border/80 bg-card/60 px-4 py-4 text-left shadow-sm outline-none transition-colors hover:border-primary/20 hover:bg-accent"
data-testid={`home-search-result-${hit.eventId}`}
key={hit.eventId}
onClick={() => onOpenSearchResult(hit)}
type="button"
>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-secondary text-secondary-foreground">
{channel?.channelType === "forum" ? (
<FileText className="h-4 w-4" />
) : (
<Hash className="h-4 w-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold tracking-tight">
{hit.channelName}
</p>
<Badge variant="secondary">
{describeSearchHit(hit)}
</Badge>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<UserAvatar
avatarUrl={
searchProfiles?.[hit.pubkey.toLowerCase()]
?.avatarUrl ?? null
}
displayName={authorLabel}
size="xs"
/>
{authorLabel}
</span>
<p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
{formatRelativeTime(hit.createdAt)}
</p>
</div>
{authorSecondaryLabel ? (
<p className="mt-1 text-xs text-muted-foreground">
{authorSecondaryLabel}
</p>
) : null}
<p className="mt-2 text-sm leading-6 text-foreground">
{truncateContent(hit.content)}
</p>
</div>
<ArrowRight className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
</div>
</button>
);
})}
</div>
)}
</div>
);
}

View File

@@ -1,14 +1,30 @@
import * as React from "react";
import { Activity, AtSign, Bot, CircleAlert, RefreshCcw } from "lucide-react";
import {
Activity,
AtSign,
Bot,
CircleAlert,
RefreshCcw,
Search,
} from "lucide-react";
import { useRelayAgentsQuery } from "@/features/agents/hooks";
import { useHomeBackgroundSettings } from "@/features/home/useHomeBackgroundSettings";
import { useFeedItemState } from "@/features/home/useFeedItemState";
import { HomeBackgroundLayer } from "@/features/home/ui/HomeBackgroundLayer";
import { HomeSearchPanel } from "@/features/home/ui/HomeSearchPanel";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { useContactListQuery, useTimelineQuery } from "@/features/pulse/hooks";
import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup";
import type { FeedItem, HomeFeedResponse } from "@/shared/api/types";
import type {
Channel,
FeedItem,
HomeFeedResponse,
SearchHit,
} from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Skeleton } from "@/shared/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
const FeedSection = React.lazy(async () => {
const module = await import("./FeedSection");
@@ -20,16 +36,14 @@ const RecentNotesSection = React.lazy(async () => {
return { default: module.RecentNotesSection };
});
type FeedFilter =
| "all"
| "mention"
| "needs_action"
| "activity"
| "agent_activity";
type HomeTab = "search" | "inbox" | "feed";
const tabTriggerClassName =
"h-8 rounded-full px-4 text-xs font-medium shadow-none transition-colors data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:shadow-sm";
function HomeLoadingState() {
return (
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 pb-3 pt-14 sm:px-6">
<div className="px-4 py-5 sm:px-6">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4">
<div className="grid gap-4">
{["mentions", "actions"].map((section) => (
@@ -48,22 +62,53 @@ function HomeLoadingState() {
);
}
const FILTER_OPTIONS: { value: FeedFilter; label: string }[] = [
{ value: "all", label: "All" },
{ value: "mention", label: "Mentions" },
{ value: "needs_action", label: "Needs Action" },
{ value: "activity", label: "Activity" },
{ value: "agent_activity", label: "Agent Updates" },
];
function FeedLoadingState() {
return (
<div className="mx-auto w-full max-w-3xl space-y-3 px-4 py-5 sm:px-6">
{["first", "second", "third"].map((row) => (
<div
className="flex items-start gap-2.5 rounded-md border border-border/60 px-3 py-2.5"
key={row}
>
<Skeleton className="h-8 w-8 shrink-0 rounded-full" />
<div className="min-w-0 flex-1 space-y-2">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
))}
</div>
);
}
function EmptyPanel({
title,
description,
}: {
title: string;
description: string;
}) {
return (
<div className="mx-auto flex w-full max-w-3xl flex-col items-center justify-center px-4 py-16 text-center sm:px-6">
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<p className="mt-1 max-w-md text-xs text-muted-foreground/70">
{description}
</p>
</div>
);
}
type HomeViewProps = {
feed?: HomeFeedResponse;
isLoading?: boolean;
errorMessage?: string;
channels: Channel[];
currentPubkey?: string;
availableChannelIds: ReadonlySet<string>;
onOpenFeedItem: (item: FeedItem) => void;
onOpenPulse: () => void;
onOpenSearchResult: (hit: SearchHit) => void;
onRefresh: () => void;
};
@@ -71,37 +116,37 @@ export function HomeView({
feed,
isLoading = false,
errorMessage,
channels,
currentPubkey,
availableChannelIds,
onOpenFeedItem,
onOpenPulse,
onOpenSearchResult,
onRefresh,
}: HomeViewProps) {
const [filter, setFilter] = React.useState<FeedFilter>("all");
const [activeTab, setActiveTab] = React.useState<HomeTab>("search");
const { settings: homeBackgroundSettings } = useHomeBackgroundSettings();
const { doneSet, markDone, undoDone } = useFeedItemState(currentPubkey);
// Defer Pulse widget queries until the shell is interactive
// Defer Pulse feed queries until the shell is interactive and the tab is open.
const startupReady = useDeferredStartup();
const deferredPubkey = startupReady ? currentPubkey : undefined;
const shouldLoadFollowingFeed = startupReady && activeTab === "feed";
const deferredPubkey = shouldLoadFollowingFeed ? currentPubkey : undefined;
// Recent notes for the Pulse widget
const contactListQuery = useContactListQuery(deferredPubkey);
const contactPubkeys = React.useMemo(
() => (contactListQuery.data?.contacts ?? []).map((c) => c.pubkey),
[contactListQuery.data],
);
const notesPubkeys = React.useMemo(
() =>
deferredPubkey
? [...new Set([deferredPubkey, ...contactPubkeys])]
: contactPubkeys,
[deferredPubkey, contactPubkeys],
() => [...new Set(contactPubkeys)],
[contactPubkeys],
);
const notesTimelineQuery = useTimelineQuery(
notesPubkeys,
notesPubkeys.length > 0,
shouldLoadFollowingFeed && notesPubkeys.length > 0,
);
const recentNotes = notesTimelineQuery.data?.notes?.slice(0, 5) ?? [];
const recentNotes = notesTimelineQuery.data?.notes ?? [];
const noteAuthorPubkeys = React.useMemo(
() => [...new Set(recentNotes.map((n) => n.pubkey))],
[recentNotes],
@@ -110,7 +155,9 @@ export function HomeView({
enabled: noteAuthorPubkeys.length > 0,
});
const noteProfiles = noteProfilesQuery.data?.profiles ?? {};
const relayAgentsQuery = useRelayAgentsQuery({ enabled: startupReady });
const relayAgentsQuery = useRelayAgentsQuery({
enabled: shouldLoadFollowingFeed,
});
const agentPubkeySet = React.useMemo(
() => new Set((relayAgentsQuery.data ?? []).map((a) => a.pubkey)),
[relayAgentsQuery.data],
@@ -132,137 +179,246 @@ export function HomeView({
);
const feedProfiles = feedProfilesQuery.data?.profiles;
if (isLoading && !feed) {
return <HomeLoadingState />;
}
function renderInboxPanel() {
if (isLoading && !feed) {
return <HomeLoadingState />;
}
if (!feed) {
return (
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 pb-3 pt-14 sm:px-6">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-4 py-5">
<p className="text-base font-semibold tracking-tight">
Home feed unavailable
</p>
<p className="mt-2 text-sm text-muted-foreground">
{errorMessage ?? "The relay did not return a feed response."}
</p>
<Button className="mt-5" onClick={onRefresh} type="button">
<RefreshCcw className="h-4 w-4" />
Try again
</Button>
if (!feed) {
return (
<div className="px-4 py-5 sm:px-6">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-4 py-5">
<p className="text-base font-semibold tracking-tight">
Home feed unavailable
</p>
<p className="mt-2 text-sm text-muted-foreground">
{errorMessage ?? "The relay did not return a feed response."}
</p>
<Button className="mt-5" onClick={onRefresh} type="button">
<RefreshCcw className="h-4 w-4" />
Try again
</Button>
</div>
</div>
</div>
);
}
const priorityCount =
feed.feed.needsAction.length + feed.feed.mentions.length;
return (
<div className="px-4 py-5 sm:px-6">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4">
<React.Suspense fallback={null}>
<div className="grid gap-6">
<section className="rounded-2xl border border-primary/15 bg-background/35 p-4 shadow-[0_4px_24px_rgba(0,0,0,0.08)] backdrop-blur-xl supports-[backdrop-filter]:bg-background/25 dark:shadow-[0_4px_24px_rgba(0,0,0,0.35)]">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<CircleAlert className="h-4 w-4 text-primary" />
<h2 className="text-sm font-semibold tracking-tight">
Priority inbox
</h2>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Mentions and items waiting for you are grouped here first.
</p>
</div>
<span className="rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary">
{priorityCount} open
</span>
</div>
<div className="grid gap-5">
<FeedSection
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
profiles={feedProfiles}
doneSet={doneSet}
emptyDescription="Approval requests and reminders will appear here."
emptyTitle="Nothing needs action"
icon={CircleAlert}
items={feed.feed.needsAction}
onMarkDone={markDone}
onOpenItem={onOpenFeedItem}
onUndoDone={undoDone}
showDoneAction={true}
title="Needs Action"
/>
<FeedSection
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
profiles={feedProfiles}
doneSet={doneSet}
emptyDescription="When someone mentions you, it will land here."
emptyTitle="No mentions right now"
icon={AtSign}
items={feed.feed.mentions}
onMarkDone={markDone}
onOpenItem={onOpenFeedItem}
onUndoDone={undoDone}
showDoneAction={false}
title="Mentions"
/>
</div>
</section>
<section className="rounded-2xl border border-border/50 bg-background/30 p-4 shadow-[0_4px_24px_rgba(0,0,0,0.08)] backdrop-blur-xl supports-[backdrop-filter]:bg-background/20 dark:shadow-[0_4px_24px_rgba(0,0,0,0.35)]">
<div className="mb-4">
<h2 className="text-sm font-semibold tracking-tight">
Everything else
</h2>
<p className="mt-1 text-xs text-muted-foreground">
Channel activity and agent updates that are useful, but not
urgent.
</p>
</div>
<div className="grid gap-5">
<FeedSection
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
profiles={feedProfiles}
doneSet={doneSet}
emptyDescription="Recent channel messages and forum posts will show up here."
emptyTitle="No channel activity yet"
icon={Activity}
items={feed.feed.activity ?? []}
onMarkDone={markDone}
onOpenItem={onOpenFeedItem}
onUndoDone={undoDone}
showDoneAction={false}
title="Channel Activity"
/>
<FeedSection
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
profiles={feedProfiles}
doneSet={doneSet}
emptyDescription="Agent job requests, progress, and results will appear here."
emptyTitle="No agent updates yet"
icon={Bot}
items={feed.feed.agentActivity ?? []}
onMarkDone={markDone}
onOpenItem={onOpenFeedItem}
onUndoDone={undoDone}
showDoneAction={false}
title="Agent Updates"
/>
</div>
</section>
</div>
</React.Suspense>
</div>
</div>
);
}
const showMentions = filter === "all" || filter === "mention";
const showNeedsAction = filter === "all" || filter === "needs_action";
const showActivity = filter === "all" || filter === "activity";
const showAgentActivity = filter === "all" || filter === "agent_activity";
return (
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 pb-3 pt-14 sm:px-6">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4">
<div className="flex items-center gap-1.5">
{FILTER_OPTIONS.map((option) => (
<Button
key={option.value}
onClick={() => setFilter(option.value)}
size="sm"
type="button"
variant={filter === option.value ? "default" : "ghost"}
className="h-7 px-2.5 text-xs"
>
{option.label}
</Button>
))}
</div>
function renderFollowingFeedPanel() {
if (!currentPubkey) {
return (
<EmptyPanel
description="Choose a workspace identity before loading followed people and agents."
title="Feed unavailable"
/>
);
}
{recentNotes.length > 0 ? (
<React.Suspense fallback={null}>
if (!startupReady) {
return <FeedLoadingState />;
}
if (contactListQuery.isLoading || notesTimelineQuery.isLoading) {
return <FeedLoadingState />;
}
if (contactPubkeys.length === 0) {
return (
<EmptyPanel
description="Follow people and agents in Pulse to build this feed."
title="Nothing in your feed yet"
/>
);
}
if (recentNotes.length === 0) {
return (
<EmptyPanel
description="Followed people and agents have not posted notes yet."
title="No feed updates yet"
/>
);
}
return (
<div className="px-4 py-5 sm:px-6">
<div className="mx-auto w-full max-w-3xl">
<React.Suspense fallback={<FeedLoadingState />}>
<RecentNotesSection
agentPubkeys={agentPubkeySet}
notes={recentNotes}
onOpenPulse={onOpenPulse}
profiles={noteProfiles}
title="Following Feed"
/>
</React.Suspense>
) : null}
<React.Suspense fallback={null}>
<div className="grid gap-5">
{showMentions ? (
<FeedSection
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
profiles={feedProfiles}
doneSet={doneSet}
emptyDescription="When someone mentions you, it will land here."
emptyTitle="No mentions right now"
icon={AtSign}
items={feed.feed.mentions}
onMarkDone={markDone}
onOpenItem={onOpenFeedItem}
onUndoDone={undoDone}
showDoneAction={false}
title="Mentions"
/>
) : null}
{showNeedsAction ? (
<FeedSection
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
profiles={feedProfiles}
doneSet={doneSet}
emptyDescription="Approval requests and reminders will appear here."
emptyTitle="Nothing needs action"
icon={CircleAlert}
items={feed.feed.needsAction}
onMarkDone={markDone}
onOpenItem={onOpenFeedItem}
onUndoDone={undoDone}
showDoneAction={true}
title="Needs Action"
/>
) : null}
{showActivity ? (
<FeedSection
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
profiles={feedProfiles}
doneSet={doneSet}
emptyDescription="Recent channel messages and forum posts will show up here."
emptyTitle="No channel activity yet"
icon={Activity}
items={feed.feed.activity ?? []}
onMarkDone={markDone}
onOpenItem={onOpenFeedItem}
onUndoDone={undoDone}
showDoneAction={false}
title="Channel Activity"
/>
) : null}
{showAgentActivity ? (
<FeedSection
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
profiles={feedProfiles}
doneSet={doneSet}
emptyDescription="Agent job requests, progress, and results will appear here."
emptyTitle="No agent updates yet"
icon={Bot}
items={feed.feed.agentActivity ?? []}
onMarkDone={markDone}
onOpenItem={onOpenFeedItem}
onUndoDone={undoDone}
showDoneAction={false}
title="Agent Updates"
/>
) : null}
</div>
</React.Suspense>
</div>
</div>
</div>
);
}
return (
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as HomeTab)}
className="relative isolate flex min-h-0 flex-1 flex-col overflow-hidden"
>
<HomeBackgroundLayer className="z-0" settings={homeBackgroundSettings} />
<div className="relative z-10 flex justify-center px-4 pt-14 sm:px-6">
<TabsList className="h-10 gap-1 rounded-full border border-border/60 bg-card/80 p-1 shadow-sm backdrop-blur">
<TabsTrigger
aria-label="Search"
className="h-8 w-10 rounded-full px-0 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground"
value="search"
>
<Search className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger className={tabTriggerClassName} value="inbox">
Inbox
</TabsTrigger>
<TabsTrigger className={tabTriggerClassName} value="feed">
Feed
</TabsTrigger>
</TabsList>
</div>
<TabsContent
className="relative z-10 mt-0 min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain"
value="search"
>
<HomeSearchPanel
channels={channels}
currentPubkey={currentPubkey}
onOpenSearchResult={onOpenSearchResult}
/>
</TabsContent>
<TabsContent
className="relative z-10 mt-0 min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain"
value="inbox"
>
{renderInboxPanel()}
</TabsContent>
<TabsContent
className="relative z-10 mt-0 min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain"
value="feed"
>
{renderFollowingFeedPanel()}
</TabsContent>
</Tabs>
);
}

View File

@@ -9,6 +9,7 @@ type RecentNotesSectionProps = {
notes: UserNote[];
profiles: Record<string, UserProfileSummary>;
agentPubkeys: ReadonlySet<string>;
title?: string;
onOpenPulse: () => void;
};
@@ -26,6 +27,7 @@ export function RecentNotesSection({
notes,
profiles,
agentPubkeys,
title = "Recent Notes",
onOpenPulse,
}: RecentNotesSectionProps) {
if (notes.length === 0) return null;
@@ -35,9 +37,7 @@ export function RecentNotesSection({
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold text-foreground">
Recent Notes
</h3>
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
</div>
<button
className="text-xs text-primary hover:underline"
@@ -48,8 +48,8 @@ export function RecentNotesSection({
</button>
</div>
<div className="space-y-0 overflow-hidden rounded-md border border-border/60">
{notes.slice(0, 5).map((note) => {
<div className="space-y-0 overflow-hidden rounded-md border border-border/60 bg-background/45 shadow-sm backdrop-blur-xl supports-[backdrop-filter]:bg-background/35">
{notes.map((note) => {
const profile = profiles[note.pubkey.toLowerCase()];
const displayName =
profile?.displayName ?? `${note.pubkey.slice(0, 8)}...`;

View File

@@ -0,0 +1,144 @@
import * as React from "react";
export type HomeBackgroundMode = "none" | "image" | "video" | "animation";
export type HomeBackgroundSettings = {
mode: HomeBackgroundMode;
sourceUrl: string;
animationCode: string;
opacity: number;
scrimOpacity: number;
};
const STORAGE_KEY = "sprout:home-background:v1";
const SETTINGS_EVENT = "sprout:home-background-settings-changed";
let cachedRawValue: string | null | undefined;
let cachedSettings: HomeBackgroundSettings | undefined;
export const DEFAULT_HOME_BACKGROUND_SETTINGS: HomeBackgroundSettings = {
mode: "none",
sourceUrl: "",
animationCode: "",
opacity: 0.45,
scrimOpacity: 0.55,
};
function clampUnit(value: unknown, fallback: number) {
return typeof value === "number" && Number.isFinite(value)
? Math.min(1, Math.max(0, value))
: fallback;
}
function isHomeBackgroundMode(value: unknown): value is HomeBackgroundMode {
return (
value === "none" ||
value === "image" ||
value === "video" ||
value === "animation"
);
}
function parseHomeBackgroundSettings(
value: unknown,
): HomeBackgroundSettings | null {
if (typeof value !== "object" || value === null) {
return null;
}
const record = value as Record<string, unknown>;
const mode = isHomeBackgroundMode(record.mode) ? record.mode : "none";
return {
mode,
sourceUrl:
typeof record.sourceUrl === "string" ? record.sourceUrl.trim() : "",
animationCode:
typeof record.animationCode === "string" ? record.animationCode : "",
opacity: clampUnit(
record.opacity,
DEFAULT_HOME_BACKGROUND_SETTINGS.opacity,
),
scrimOpacity: clampUnit(
record.scrimOpacity,
DEFAULT_HOME_BACKGROUND_SETTINGS.scrimOpacity,
),
};
}
export function readHomeBackgroundSettings(): HomeBackgroundSettings {
if (typeof window === "undefined") {
return DEFAULT_HOME_BACKGROUND_SETTINGS;
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw === cachedRawValue && cachedSettings) {
return cachedSettings;
}
cachedRawValue = raw;
if (!raw) {
cachedSettings = DEFAULT_HOME_BACKGROUND_SETTINGS;
return cachedSettings;
}
cachedSettings =
parseHomeBackgroundSettings(JSON.parse(raw)) ??
DEFAULT_HOME_BACKGROUND_SETTINGS;
return cachedSettings;
} catch {
cachedSettings = DEFAULT_HOME_BACKGROUND_SETTINGS;
return cachedSettings;
}
}
function writeHomeBackgroundSettings(settings: HomeBackgroundSettings) {
const raw = JSON.stringify(settings);
cachedRawValue = raw;
cachedSettings = settings;
window.localStorage.setItem(STORAGE_KEY, raw);
window.dispatchEvent(new Event(SETTINGS_EVENT));
}
function subscribeHomeBackgroundSettings(onStoreChange: () => void) {
window.addEventListener(SETTINGS_EVENT, onStoreChange);
window.addEventListener("storage", onStoreChange);
return () => {
window.removeEventListener(SETTINGS_EVENT, onStoreChange);
window.removeEventListener("storage", onStoreChange);
};
}
export function useHomeBackgroundSettings() {
const settings = React.useSyncExternalStore(
subscribeHomeBackgroundSettings,
readHomeBackgroundSettings,
() => DEFAULT_HOME_BACKGROUND_SETTINGS,
);
const setSettings = React.useCallback(
(
next:
| HomeBackgroundSettings
| ((current: HomeBackgroundSettings) => HomeBackgroundSettings),
) => {
const current = readHomeBackgroundSettings();
const resolved = typeof next === "function" ? next(current) : next;
writeHomeBackgroundSettings(
parseHomeBackgroundSettings(resolved) ??
DEFAULT_HOME_BACKGROUND_SETTINGS,
);
},
[],
);
const resetSettings = React.useCallback(() => {
writeHomeBackgroundSettings(DEFAULT_HOME_BACKGROUND_SETTINGS);
}, []);
return { settings, setSettings, resetSettings };
}
export default useHomeBackgroundSettings;

View File

@@ -0,0 +1,273 @@
import { Image } from "lucide-react";
import { useState } from "react";
import {
type HomeBackgroundMode,
useHomeBackgroundSettings,
} from "@/features/home/useHomeBackgroundSettings";
import { HomeBackgroundLayer } from "@/features/home/ui/HomeBackgroundLayer";
import { pickHomeBackgroundFile } from "@/shared/api/tauri";
import { Textarea } from "@/shared/ui/textarea";
const backgroundModeOptions: { value: HomeBackgroundMode; label: string }[] = [
{ value: "none", label: "None" },
{ value: "image", label: "Image URL" },
{ value: "video", label: "Video URL" },
{ value: "animation", label: "Sandboxed animation" },
];
export function HomeBackgroundSettingsCard() {
const {
settings: homeBackgroundSettings,
setSettings: setHomeBackgroundSettings,
resetSettings: resetHomeBackgroundSettings,
} = useHomeBackgroundSettings();
const [backgroundUploadError, setBackgroundUploadError] = useState<
string | null
>(null);
const [pickingBackgroundKind, setPickingBackgroundKind] = useState<
"image" | "video" | "script" | null
>(null);
async function handlePickHomeBackgroundFile(
kind: "image" | "video" | "script",
) {
setBackgroundUploadError(null);
setPickingBackgroundKind(kind);
try {
const file = await pickHomeBackgroundFile(kind);
if (!file) return;
if (file.kind === "script") {
setHomeBackgroundSettings((current) => ({
...current,
animationCode: file.content,
mode: "animation",
}));
return;
}
const mode = kind === "video" ? "video" : "image";
setHomeBackgroundSettings((current) => ({
...current,
mode,
sourceUrl: file.sourceUrl,
}));
} catch (error) {
setBackgroundUploadError(
error instanceof Error ? error.message : "Failed to choose file.",
);
} finally {
setPickingBackgroundKind(null);
}
}
return (
<div className="rounded-xl border border-border/70 bg-background/70 p-4">
<div className="mb-4 flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Image className="h-4 w-4" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold tracking-tight">
Home background
</h3>
<p className="mt-1 text-sm text-muted-foreground">
Add a full-screen visual background to Search, Inbox, and Feed.
Custom animations run in a sandboxed, visual-only iframe.
</p>
</div>
</div>
<div className="grid gap-4">
<label className="grid gap-1.5 text-sm">
<span className="font-medium">Background type</span>
<select
className="h-9 rounded-md border border-input bg-background px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
onChange={(event) => {
const mode = event.target.value as HomeBackgroundMode;
setHomeBackgroundSettings((current) => ({ ...current, mode }));
}}
value={homeBackgroundSettings.mode}
>
{backgroundModeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
{homeBackgroundSettings.mode === "image" ||
homeBackgroundSettings.mode === "video" ? (
<div className="grid gap-1.5 text-sm">
<div className="flex items-center justify-between gap-3">
<label className="font-medium" htmlFor="home-background-url">
{homeBackgroundSettings.mode === "image"
? "Image URL"
: "Video URL"}
</label>
<button
className="inline-flex h-8 items-center rounded-md border border-border/70 px-3 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-60"
disabled={pickingBackgroundKind !== null}
onClick={() => {
void handlePickHomeBackgroundFile(
homeBackgroundSettings.mode === "image" ? "image" : "video",
);
}}
type="button"
>
{pickingBackgroundKind === homeBackgroundSettings.mode
? "Choosing..."
: `Choose ${homeBackgroundSettings.mode}`}
</button>
</div>
<input
id="home-background-url"
className="h-9 rounded-md border border-input bg-background px-3 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
onChange={(event) => {
setHomeBackgroundSettings((current) => ({
...current,
sourceUrl: event.target.value,
}));
}}
placeholder={
homeBackgroundSettings.mode === "image"
? "https://example.com/background.jpg"
: "https://example.com/background.mp4"
}
type="url"
value={homeBackgroundSettings.sourceUrl}
/>
</div>
) : null}
{homeBackgroundSettings.mode === "animation" ? (
<div className="grid gap-1.5 text-sm">
<div className="flex items-center justify-between gap-3">
<label
className="font-medium"
htmlFor="home-background-animation"
>
HTML, CSS, and JavaScript
</label>
<button
className="inline-flex h-8 items-center rounded-md border border-border/70 px-3 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-60"
disabled={pickingBackgroundKind !== null}
onClick={() => {
void handlePickHomeBackgroundFile("script");
}}
type="button"
>
{pickingBackgroundKind === "script"
? "Choosing..."
: "Choose script"}
</button>
</div>
<Textarea
id="home-background-animation"
className="min-h-40 font-mono text-xs"
onChange={(event) => {
setHomeBackgroundSettings((current) => ({
...current,
animationCode: event.target.value,
}));
}}
placeholder="<canvas id='bg'></canvas><script>/* visual-only animation */</script>"
spellCheck={false}
value={homeBackgroundSettings.animationCode}
/>
<span className="text-xs text-muted-foreground">
Scripts are sandboxed away from Sprout and external network
requests are blocked by the iframe policy.
</span>
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<label className="grid gap-1.5 text-sm">
<span className="font-medium">
Background opacity{" "}
{Math.round(homeBackgroundSettings.opacity * 100)}%
</span>
<input
max="1"
min="0"
onChange={(event) => {
setHomeBackgroundSettings((current) => ({
...current,
opacity: Number(event.target.value),
}));
}}
step="0.05"
type="range"
value={homeBackgroundSettings.opacity}
/>
</label>
<label className="grid gap-1.5 text-sm">
<span className="font-medium">
Readability overlay{" "}
{Math.round(homeBackgroundSettings.scrimOpacity * 100)}%
</span>
<input
max="1"
min="0"
onChange={(event) => {
setHomeBackgroundSettings((current) => ({
...current,
scrimOpacity: Number(event.target.value),
}));
}}
step="0.05"
type="range"
value={homeBackgroundSettings.scrimOpacity}
/>
</label>
</div>
<div className="relative h-32 overflow-hidden rounded-xl border border-border/70 bg-muted/30">
<HomeBackgroundLayer settings={homeBackgroundSettings} />
<div className="relative z-10 flex h-full items-center justify-center p-4">
<div className="rounded-2xl border border-border/70 bg-background/75 px-4 py-3 text-center shadow-sm backdrop-blur">
<p className="text-sm font-medium">Home preview</p>
<p className="mt-1 text-xs text-muted-foreground">
This background appears behind all Home tabs.
</p>
</div>
</div>
</div>
<div className="flex justify-end">
<button
className="inline-flex h-8 items-center rounded-md border border-border/70 px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
onClick={resetHomeBackgroundSettings}
type="button"
>
Reset background
</button>
</div>
{backgroundUploadError ? (
<p className="text-xs text-destructive">{backgroundUploadError}</p>
) : null}
{homeBackgroundSettings.mode !== "none" &&
homeBackgroundSettings.mode !== "animation" &&
homeBackgroundSettings.sourceUrl.trim().length === 0 ? (
<p className="text-xs text-destructive">
Add a URL before this background can render.
</p>
) : null}
{homeBackgroundSettings.mode === "animation" &&
homeBackgroundSettings.animationCode.trim().length === 0 ? (
<p className="text-xs text-destructive">
Add animation markup before this background can render.
</p>
) : null}
</div>
</div>
);
}

View File

@@ -26,6 +26,7 @@ import { ACCENT_COLORS, useTheme } from "@/shared/theme/ThemeProvider";
import { SYNTAX_THEMES, isLightTheme } from "@/shared/theme/theme-loader";
import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard";
import { DoctorSettingsPanel } from "./DoctorSettingsPanel";
import { HomeBackgroundSettingsCard } from "./HomeBackgroundSettingsCard";
import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard";
import { MobilePairingCard } from "./MobilePairingCard";
import { NotificationSettingsCard } from "./NotificationSettingsCard";
@@ -145,91 +146,95 @@ function ThemeSettingsCard() {
}, [search]);
return (
<section className="min-w-0" data-testid="settings-theme">
<div className="mb-3 min-w-0">
<h2 className="text-sm font-semibold tracking-tight">Appearance</h2>
<p className="text-sm text-muted-foreground">
Choose a theme for Sprout. Light and dark mode is auto-detected.
</p>
</div>
<div className="relative mb-3">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
className="w-full rounded-lg border border-border/70 bg-background/70 py-2 pl-9 pr-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onChange={(e) => setSearch(e.target.value)}
placeholder="Search themes..."
type="text"
value={search}
/>
</div>
<div className="max-h-72 overflow-y-auto rounded-lg border border-border/70 bg-background/70">
{filtered.length === 0 ? (
<p className="px-3 py-4 text-center text-sm text-muted-foreground">
No themes match your search.
<section className="min-w-0 space-y-6" data-testid="settings-theme">
<div>
<div className="mb-3 min-w-0">
<h2 className="text-sm font-semibold tracking-tight">Appearance</h2>
<p className="text-sm text-muted-foreground">
Choose a theme for Sprout. Light and dark mode is auto-detected.
</p>
) : (
filtered.map((name) => {
const isActive = themeName === name;
const light = isLightTheme(name);
</div>
return (
<div className="relative mb-3">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
className="w-full rounded-lg border border-border/70 bg-background/70 py-2 pl-9 pr-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onChange={(e) => setSearch(e.target.value)}
placeholder="Search themes..."
type="text"
value={search}
/>
</div>
<div className="max-h-72 overflow-y-auto rounded-lg border border-border/70 bg-background/70">
{filtered.length === 0 ? (
<p className="px-3 py-4 text-center text-sm text-muted-foreground">
No themes match your search.
</p>
) : (
filtered.map((name) => {
const isActive = themeName === name;
const light = isLightTheme(name);
return (
<button
aria-pressed={isActive}
className={cn(
"flex w-full items-center gap-3 px-3 py-2 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
isActive
? "bg-primary/10 text-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
data-testid={`theme-option-${name}`}
key={name}
onClick={() => setTheme(name)}
ref={isActive ? activeRef : undefined}
type="button"
>
{light ? (
<Sun className="h-4 w-4 shrink-0" />
) : (
<Moon className="h-4 w-4 shrink-0" />
)}
<span className="flex-1 truncate">
{formatThemeLabel(name)}
</span>
{isActive && (
<Check className="h-4 w-4 shrink-0 text-primary" />
)}
</button>
);
})
)}
</div>
<div className="mt-4">
<h3 className="mb-2 text-sm font-medium">Accent Color</h3>
<div className="flex gap-2">
{ACCENT_COLORS.map((color) => (
<button
aria-pressed={isActive}
className={cn(
"flex w-full items-center gap-3 px-3 py-2 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
isActive
? "bg-primary/10 text-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
"flex h-7 w-7 items-center justify-center rounded-full transition-transform hover:scale-110",
accentColor === color.value &&
"ring-2 ring-ring ring-offset-2 ring-offset-background",
)}
data-testid={`theme-option-${name}`}
key={name}
onClick={() => setTheme(name)}
ref={isActive ? activeRef : undefined}
data-testid={`accent-color-${color.name.toLowerCase()}`}
key={color.value}
onClick={() => setAccentColor(color.value)}
style={{ backgroundColor: color.value }}
title={color.name}
type="button"
>
{light ? (
<Sun className="h-4 w-4 shrink-0" />
) : (
<Moon className="h-4 w-4 shrink-0" />
)}
<span className="flex-1 truncate">
{formatThemeLabel(name)}
</span>
{isActive && (
<Check className="h-4 w-4 shrink-0 text-primary" />
{accentColor === color.value && (
<Check className="h-3.5 w-3.5 text-white" />
)}
</button>
);
})
)}
</div>
<div className="mt-4">
<h3 className="mb-2 text-sm font-medium">Accent Color</h3>
<div className="flex gap-2">
{ACCENT_COLORS.map((color) => (
<button
className={cn(
"flex h-7 w-7 items-center justify-center rounded-full transition-transform hover:scale-110",
accentColor === color.value &&
"ring-2 ring-ring ring-offset-2 ring-offset-background",
)}
data-testid={`accent-color-${color.name.toLowerCase()}`}
key={color.value}
onClick={() => setAccentColor(color.value)}
style={{ backgroundColor: color.value }}
title={color.name}
type="button"
>
{accentColor === color.value && (
<Check className="h-3.5 w-3.5 text-white" />
)}
</button>
))}
))}
</div>
</div>
</div>
<HomeBackgroundSettingsCard />
</section>
);
}

View File

@@ -1,4 +1,4 @@
import { invoke as tauriInvoke } from "@tauri-apps/api/core";
import { convertFileSrc, invoke as tauriInvoke } from "@tauri-apps/api/core";
import type {
AddChannelMembersInput,
@@ -79,6 +79,30 @@ type RawSearchUsersResponse = {
users: RawUserSearchResult[];
};
type RawHomeBackgroundFile =
| {
kind: "asset";
path: string;
file_name: string | null;
}
| {
kind: "script";
content: string;
file_name: string | null;
};
export type HomeBackgroundFile =
| {
kind: "asset";
sourceUrl: string;
fileName: string | null;
}
| {
kind: "script";
content: string;
fileName: string | null;
};
type RawPresenceLookup = Record<string, PresenceStatus>;
type RawSetPresenceResult = {
@@ -318,6 +342,32 @@ export async function invokeTauri<T>(
}
}
export async function pickHomeBackgroundFile(
kind: "image" | "video" | "script",
): Promise<HomeBackgroundFile | null> {
const result = await invokeTauri<RawHomeBackgroundFile | null>(
"pick_home_background_file",
{ kind },
);
if (!result) {
return null;
}
if (result.kind === "script") {
return {
kind: "script",
content: result.content,
fileName: result.file_name,
};
}
return {
kind: "asset",
sourceUrl: convertFileSrc(result.path),
fileName: result.file_name,
};
}
function fromRawChannel(channel: RawChannel): Channel {
return {
id: channel.id,