mirror of
https://github.com/block/buzz.git
synced 2026-11-04 08:21:48 +00:00
feat(desktop): customize home experience
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
7
desktop/src-tauri/Cargo.lock
generated
7
desktop/src-tauri/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
111
desktop/src-tauri/src/commands/home_background.rs
Normal file
111
desktop/src-tauri/src/commands/home_background.rs
Normal 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,
|
||||
}))
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -30,7 +30,11 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": null,
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["$APPDATA/home-backgrounds/**"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
117
desktop/src/features/home/ui/HomeBackgroundLayer.tsx
Normal file
117
desktop/src/features/home/ui/HomeBackgroundLayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}}
|
||||
|
||||
250
desktop/src/features/home/ui/HomeSearchPanel.tsx
Normal file
250
desktop/src/features/home/ui/HomeSearchPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}...`;
|
||||
|
||||
144
desktop/src/features/home/useHomeBackgroundSettings.ts
Normal file
144
desktop/src/features/home/useHomeBackgroundSettings.ts
Normal 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;
|
||||
273
desktop/src/features/settings/ui/HomeBackgroundSettingsCard.tsx
Normal file
273
desktop/src/features/settings/ui/HomeBackgroundSettingsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user