diff --git a/desktop/src/features/custom-emoji/ui/CustomEmojiSettingsCard.tsx b/desktop/src/features/custom-emoji/ui/CustomEmojiSettingsCard.tsx index 80f44e143..2e14edf7e 100644 --- a/desktop/src/features/custom-emoji/ui/CustomEmojiSettingsCard.tsx +++ b/desktop/src/features/custom-emoji/ui/CustomEmojiSettingsCard.tsx @@ -16,6 +16,7 @@ import { pickAndUploadMedia } from "@/shared/api/tauri"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; +import { SettingsOptionGroup } from "@/features/settings/ui/SettingsOptionGroup"; /** * Custom emoji management (NIP-30, kind:30030). Each member owns their own set: @@ -122,198 +123,211 @@ export function CustomEmojiSettingsCard() { const othersEmoji = workspace.filter((e) => !ownShortcodes.has(e.shortcode)); return ( -
-
-

Custom Emoji

-

+

+
+

Custom Emoji

+

Add your own custom emoji for everyone on this relay to use. Type{" "} :name: in messages and reactions.

-
{ - event.preventDefault(); - if (canSubmit) void handleAdd(); - }} - > -
-
-

1. Upload an image

-

- Square images work best. GIF, PNG, JPEG, and WebP files are - supported. -

-
-
-
- {pendingUpload ? ( - Selected custom emoji preview - ) : ( - - )} +
+ { + event.preventDefault(); + if (canSubmit) void handleAdd(); + }} + > + +
+
+

Upload an image

+

+ Square images work best. GIF, PNG, JPEG, and WebP files are + supported. +

+
+
+
+ {pendingUpload ? ( + Selected custom emoji preview + ) : ( + + )} +
+
+

+ {pendingUpload?.filename ?? "No image selected"} +

+ +
+
+
+ +
+
+

Give it a name

+

+ This is what you’ll type to add this emoji to messages and + reactions. +

+
+
+
+ + : + + setName(event.target.value)} + /> + + : + +
+ {nameInvalid ? ( +

+ Use only letters, numbers, hyphen, or underscore. +

+ ) : pendingUpload === null ? ( +

+ Choose an image first; Sprout will suggest a name from the + filename. +

+ ) : ownDuplicate ? ( +

+ You already have :{normalized}: — saving will replace its + image. +

+ ) : null} +
-
-

- {pendingUpload?.filename ?? "No image selected"} -

+ +
+
-
-
+ + -
-
-

2. Give it a name

-

- This is what you’ll type to add this emoji to messages and - reactions. -

-
-
- - : - - setName(event.target.value)} - /> - - : - -
- {nameInvalid ? ( -

- Use only letters, numbers, hyphen, or underscore. -

- ) : pendingUpload === null ? ( -

- Choose an image first; Sprout will suggest a name from the - filename. -

- ) : ownDuplicate ? ( -

- You already have :{normalized}: — saving will replace its image. -

- ) : null} -
- -
- - +
+

+ My emoji{own.length > 0 ? ` (${own.length})` : ""} +

+ {ownLoading ? ( + +
+ Loading… +
+
+ ) : own.length === 0 ? ( + +
+ You haven't added any emoji yet. Add one above. +
+
+ ) : ( + + {own.map((e) => ( +
+ {`:${e.shortcode}:`} + + :{e.shortcode}: + + +
+ ))} +
+ )}
- -
-

- My emoji{own.length > 0 ? ` (${own.length})` : ""} -

- {ownLoading ? ( -

Loading…

- ) : own.length === 0 ? ( -

- You haven't added any emoji yet. Add one above. -

- ) : ( -
    - {own.map((e) => ( -
  • - {`:${e.shortcode}:`} - - :{e.shortcode}: - - -
  • - ))} -
- )} + {`:${e.shortcode}:`} + + :{e.shortcode}: + +
+ ))} + +
+ ) : null}
- - {!workspaceLoading && othersEmoji.length > 0 ? ( -
-

- Workspace emoji ({othersEmoji.length}) -

-

- Added by other members. You can use these, but only their owner can - remove them. -

-
    - {othersEmoji.map((e) => ( -
  • - {`:${e.shortcode}:`} - - :{e.shortcode}: - -
  • - ))} -
-
- ) : null}
); } diff --git a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx index 4e2b7c881..b5060a853 100644 --- a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx +++ b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx @@ -11,6 +11,10 @@ import { meshInstalledModels, } from "@/shared/api/tauriMesh"; import type { MeshModelOption, MeshNodeStatus } from "@/shared/api/tauriMesh"; +import { + SettingsOptionGroup, + SettingsOptionRow, +} from "@/features/settings/ui/SettingsOptionGroup"; import { classifyModelRef, modelRefHintLabel } from "../classifyModelRef"; import { useMeshNodeStatus } from "../hooks/useMeshNodeStatus"; @@ -95,6 +99,7 @@ export function MeshComputeSettingsCard() { }, [status?.state, status?.modelId, modelInput]); const isOn = status?.state === "running" || status?.state === "starting"; + const controlsDisabled = isOn || actionInFlight; const refClass = classifyModelRef(modelInput); const refHint = modelRefHintLabel(refClass); const canStart = @@ -130,28 +135,27 @@ export function MeshComputeSettingsCard() { return (
-
-

Share compute

-

+

+

Share compute

+

Share this machine with your relay. When on, other members can run their agents here.

{error ? ( -

+

Couldn't load mesh status: {error}

) : null} {actionError ? ( -

+

{actionError}

) : null} -
- {/* ── Master toggle + status row ─────────────────────────────── */} -
+ +
+
- {/* ── Model field ──────────────────────────────────────────── */} -
- +
+
+ )} + {installedModels.length > 0 ? ( +
+

+ Already installed on this machine: +

+
    + {installedModels.map((m) => ( +
  • + +
  • + ))} +
+
+ ) : null} +
+
- {/* ── Advanced ─────────────────────────────────────────────── */}
setAdvancedOpen((e.target as HTMLDetailsElement).open) } open={advancedOpen} > - + Advanced -
-
+ - {/* ── Architectural-trust footer ───────────────────────────── */} -

- Sprout will not publish your machine to public Nostr relays, - auto-discover other networks, or share your endpoint outside this - relay's members. Only members of this relay can dial in. -

-
+

+ Sprout will not publish your machine to public Nostr relays, + auto-discover other networks, or share your endpoint outside this + relay's members. Only members of this relay can dial in. +

); } diff --git a/desktop/src/features/profile/ui/ProfileAvatarEditor.tsx b/desktop/src/features/profile/ui/ProfileAvatarEditor.tsx new file mode 100644 index 000000000..a3ec6a568 --- /dev/null +++ b/desktop/src/features/profile/ui/ProfileAvatarEditor.tsx @@ -0,0 +1,854 @@ +import emojiData from "@emoji-mart/data"; +import Picker from "@emoji-mart/react"; +import { Link2, Loader2, UploadCloud } from "lucide-react"; +import * as React from "react"; + +import { useAvatarUpload } from "@/features/profile/useAvatarUpload"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs"; +import { useEmojiBurst } from "@/shared/ui/EmojiBurstProvider"; +import { + AVATAR_COLORS, + AVATAR_COLOR_SWATCHES, + CUSTOM_AVATAR_COLOR_SWATCH, + CUSTOM_COLOR_GRID_COLUMNS, + CUSTOM_COLOR_GRID_HORIZONTAL_INSET, + CUSTOM_COLOR_GRID_ROWS, + CUSTOM_COLOR_GRID_VERTICAL_INSET, + CUSTOM_HUE_SCRUBBER_INSET, + DEFAULT_CUSTOM_HUE, + DEFAULT_CUSTOM_SATURATION, + DEFAULT_CUSTOM_VALUE, + DEFAULT_EMOJI_AVATAR_COLOR, + EMOJI_MART_CATEGORIES, + type AvatarColorSwatch, + clampPercent, + contrastColorForBackground, + dataTransferHasImage, + emojiAvatarDataUrl, + gridInsetPosition, + hexToHsv, + hsvToHex, + hueScrubberPosition, + normalizeHue, + parseEmojiAvatarDataUrl, + snapToGrid, + useEmojiMartStyles, + useEmojiMartThemeVars, + visibleUrlDraft, +} from "./ProfileAvatarEditor.utils"; + +export { parseEmojiAvatarDataUrl } from "./ProfileAvatarEditor.utils"; + +type AvatarMode = "image" | "emoji"; + +type ProfileAvatarEditorProps = { + avatarUrl: string; + previewName: string; + onUrlChange: (url: string) => void; + onEmojiAvatarChange?: () => void; + onUploadedAvatarChange?: (url: string | null) => void; + onUploadingChange?: (isUploading: boolean) => void; + onDone?: () => void; + donePending?: boolean; + hiddenAvatarUrl?: string | null; + disabled?: boolean; + testIdPrefix?: string; +}; + +type EmojiMartEmoji = { + native?: string; +}; + +export function ProfileAvatarEditor({ + avatarUrl, + donePending = false, + hiddenAvatarUrl, + onEmojiAvatarChange, + onUploadedAvatarChange, + onUrlChange, + onDone, + onUploadingChange, + disabled, + testIdPrefix = "profile-avatar", +}: ProfileAvatarEditorProps) { + const { burstEmoji } = useEmojiBurst(); + const initialEmojiAvatar = React.useMemo( + () => parseEmojiAvatarDataUrl(avatarUrl), + [avatarUrl], + ); + const [mode, setMode] = React.useState("image"); + const [isDragging, setIsDragging] = React.useState(false); + const [urlDraft, setUrlDraft] = React.useState(() => + visibleUrlDraft(avatarUrl, hiddenAvatarUrl), + ); + const [selectedEmoji, setSelectedEmoji] = React.useState( + () => initialEmojiAvatar?.emoji ?? null, + ); + const [selectedColor, setSelectedColor] = React.useState( + () => initialEmojiAvatar?.color ?? DEFAULT_EMOJI_AVATAR_COLOR, + ); + const [customHue, setCustomHue] = React.useState(DEFAULT_CUSTOM_HUE); + const [customSaturation, setCustomSaturation] = React.useState( + DEFAULT_CUSTOM_SATURATION, + ); + const [customValue, setCustomValue] = React.useState(DEFAULT_CUSTOM_VALUE); + const [isCustomColorPickerOpen, setIsCustomColorPickerOpen] = + React.useState(false); + const dragDepthRef = React.useRef(0); + const emojiPickerContainerRef = React.useRef(null); + const hueDragUserSelectRef = React.useRef(null); + const isUrlInputFocusedRef = React.useRef(false); + const hasUserEditedUrlDraftRef = React.useRef(false); + const emojiMartThemeVars = useEmojiMartThemeVars(); + const customColorDraft = React.useMemo( + () => hsvToHex(customHue, customSaturation, customValue), + [customHue, customSaturation, customValue], + ); + const shouldShowColorControls = mode === "emoji" && selectedEmoji !== null; + const isCustomColorPickerVisible = + isCustomColorPickerOpen && shouldShowColorControls; + const handleUploadSuccess = React.useCallback( + (uploadedUrl: string) => { + setUrlDraft(""); + onUploadedAvatarChange?.(uploadedUrl); + onUrlChange(uploadedUrl); + setMode("image"); + }, + [onUploadedAvatarChange, onUrlChange], + ); + const { + clearError: clearUploadError, + errorMessage: uploadErrorMessage, + handleFileChange, + inputRef: browseInputRef, + isUploading, + openPicker, + uploadFile, + } = useAvatarUpload({ onUploadSuccess: handleUploadSuccess }); + const isInputDisabled = disabled || isUploading; + + useEmojiMartStyles(emojiPickerContainerRef, mode === "emoji"); + + React.useEffect(() => { + onUploadingChange?.(isUploading); + }, [isUploading, onUploadingChange]); + + React.useLayoutEffect(() => { + if (isUrlInputFocusedRef.current || hasUserEditedUrlDraftRef.current) { + return; + } + setUrlDraft(visibleUrlDraft(avatarUrl, hiddenAvatarUrl)); + }, [avatarUrl, hiddenAvatarUrl]); + + React.useEffect(() => { + const emojiAvatar = parseEmojiAvatarDataUrl(avatarUrl); + if (emojiAvatar) { + setSelectedEmoji(emojiAvatar.emoji); + setSelectedColor(emojiAvatar.color); + return; + } + + setSelectedEmoji(null); + setSelectedColor(DEFAULT_EMOJI_AVATAR_COLOR); + setIsCustomColorPickerOpen(false); + }, [avatarUrl]); + + React.useEffect(() => { + if (!shouldShowColorControls) { + setIsCustomColorPickerOpen(false); + } + }, [shouldShowColorControls]); + + React.useEffect(() => { + if (!isCustomColorPickerOpen || !selectedEmoji) { + return; + } + + onUploadedAvatarChange?.(null); + onUrlChange(emojiAvatarDataUrl(selectedEmoji, customColorDraft)); + }, [ + customColorDraft, + isCustomColorPickerOpen, + onUploadedAvatarChange, + onUrlChange, + selectedEmoji, + ]); + + const unlockHueDragSelection = React.useCallback(() => { + if (hueDragUserSelectRef.current === null) { + return; + } + + document.body.style.userSelect = hueDragUserSelectRef.current; + hueDragUserSelectRef.current = null; + }, []); + + const lockHueDragSelection = React.useCallback(() => { + if (hueDragUserSelectRef.current !== null) { + return; + } + + hueDragUserSelectRef.current = document.body.style.userSelect; + document.body.style.userSelect = "none"; + }, []); + + const handleFiles = React.useCallback( + (files: FileList | null) => { + const file = files?.[0]; + if (!file || isInputDisabled) { + return; + } + + void uploadFile(file); + setMode("image"); + }, + [isInputDisabled, uploadFile], + ); + + const applyUrl = React.useCallback(() => { + const nextUrl = urlDraft.trim(); + if (nextUrl.length === 0 || isInputDisabled) { + hasUserEditedUrlDraftRef.current = false; + return; + } + + clearUploadError(); + onUploadedAvatarChange?.(null); + onUrlChange(nextUrl); + hasUserEditedUrlDraftRef.current = false; + setMode("image"); + }, [ + clearUploadError, + isInputDisabled, + onUploadedAvatarChange, + onUrlChange, + urlDraft, + ]); + + const applyEmojiAvatar = React.useCallback( + (emoji: string, color = selectedColor) => { + onUploadedAvatarChange?.(null); + onUrlChange(emojiAvatarDataUrl(emoji, color)); + onEmojiAvatarChange?.(); + }, + [onEmojiAvatarChange, onUploadedAvatarChange, onUrlChange, selectedColor], + ); + + const openCustomColorPicker = React.useCallback(() => { + const nextColor = hexToHsv(selectedColor); + setCustomHue(normalizeHue(nextColor.hue)); + setCustomSaturation(nextColor.saturation); + setCustomValue(nextColor.value); + setIsCustomColorPickerOpen(true); + }, [selectedColor]); + + const updateCustomColorFromPointer = React.useCallback( + (event: React.PointerEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + const width = Math.max( + rect.width - CUSTOM_COLOR_GRID_HORIZONTAL_INSET * 2, + 1, + ); + const height = Math.max( + rect.height - CUSTOM_COLOR_GRID_VERTICAL_INSET * 2, + 1, + ); + const rawSaturation = clampPercent( + ((event.clientX - rect.left - CUSTOM_COLOR_GRID_HORIZONTAL_INSET) / + width) * + 100, + ); + const rawValue = clampPercent( + (1 - + (event.clientY - rect.top - CUSTOM_COLOR_GRID_VERTICAL_INSET) / + height) * + 100, + ); + const nextSaturation = Math.round( + snapToGrid(rawSaturation, CUSTOM_COLOR_GRID_COLUMNS), + ); + const nextValue = Math.round( + snapToGrid(rawValue, CUSTOM_COLOR_GRID_ROWS), + ); + + setCustomSaturation(nextSaturation); + setCustomValue(nextValue); + }, + [], + ); + + const updateCustomHueFromPointer = React.useCallback( + (event: React.PointerEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + const trackWidth = Math.max( + rect.width - CUSTOM_HUE_SCRUBBER_INSET * 2, + 1, + ); + const nextPercent = clampPercent( + ((event.clientX - rect.left - CUSTOM_HUE_SCRUBBER_INSET) / trackWidth) * + 100, + ); + setCustomHue(Math.round((nextPercent / 100) * 360)); + }, + [], + ); + + const adjustCustomHue = React.useCallback((delta: number) => { + setCustomHue((current) => normalizeHue(current + delta)); + }, []); + + const commitCustomColor = React.useCallback(() => { + setSelectedColor(customColorDraft); + if (selectedEmoji) { + applyEmojiAvatar(selectedEmoji, customColorDraft); + } + setIsCustomColorPickerOpen(false); + }, [applyEmojiAvatar, customColorDraft, selectedEmoji]); + + const handleColorSelect = React.useCallback( + (swatch: AvatarColorSwatch) => { + if (disabled) { + return; + } + + if (swatch === CUSTOM_AVATAR_COLOR_SWATCH) { + openCustomColorPicker(); + return; + } + + setSelectedColor(swatch); + if (selectedEmoji) { + applyEmojiAvatar(selectedEmoji, swatch); + } + }, + [applyEmojiAvatar, disabled, openCustomColorPicker, selectedEmoji], + ); + + const resetDragState = React.useCallback(() => { + dragDepthRef.current = 0; + setIsDragging(false); + }, []); + + React.useEffect(() => { + if (!isDragging) { + return; + } + + const handleWindowDragEnd = () => resetDragState(); + const handleWindowDrop = () => resetDragState(); + const handleWindowDragLeave = (event: DragEvent) => { + if (event.clientX <= 0 || event.clientY <= 0) { + resetDragState(); + return; + } + + if ( + event.clientX >= window.innerWidth || + event.clientY >= window.innerHeight + ) { + resetDragState(); + } + }; + + window.addEventListener("dragend", handleWindowDragEnd); + window.addEventListener("drop", handleWindowDrop); + window.addEventListener("dragleave", handleWindowDragLeave); + + return () => { + window.removeEventListener("dragend", handleWindowDragEnd); + window.removeEventListener("drop", handleWindowDrop); + window.removeEventListener("dragleave", handleWindowDragLeave); + }; + }, [isDragging, resetDragState]); + + const isImageDropActive = mode === "image" && isDragging; + + return ( +
{ + if (!dataTransferHasImage(event.dataTransfer)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + if (isInputDisabled) { + return; + } + dragDepthRef.current += 1; + setMode("image"); + setIsDragging(true); + }} + onDragLeave={(event) => { + if (!isDragging && !dataTransferHasImage(event.dataTransfer)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) { + setIsDragging(false); + } + }} + onDragOver={(event) => { + if (!dataTransferHasImage(event.dataTransfer)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + if (isInputDisabled) { + return; + } + event.dataTransfer.dropEffect = "copy"; + setMode("image"); + setIsDragging(true); + }} + onDrop={(event) => { + if (!dataTransferHasImage(event.dataTransfer)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + resetDragState(); + if (isInputDisabled) { + return; + } + void handleFiles(event.dataTransfer.files); + }} + > + Avatar image picker +
+
+ { + if (isInputDisabled) { + return; + } + setMode(nextMode as AvatarMode); + }} + value={mode} + > + +
+ ); +} diff --git a/desktop/src/features/profile/ui/ProfileAvatarEditor.utils.ts b/desktop/src/features/profile/ui/ProfileAvatarEditor.utils.ts new file mode 100644 index 000000000..4aa341e83 --- /dev/null +++ b/desktop/src/features/profile/ui/ProfileAvatarEditor.utils.ts @@ -0,0 +1,513 @@ +import * as React from "react"; + +export type EmojiAvatarDescriptor = { + color: string; + emoji: string; +}; + +export const AVATAR_COLORS = [ + "#FFFFFF", + "#FFF4CC", + "#FFE75C", + "#FFB84D", + "#FF8652", + "#F6534F", + "#FF6B9A", + "#FB60C4", + "#D66BFF", + "#B141FF", + "#7C5CFF", + "#476CFF", + "#3399FF", + "#63C6F2", + "#41EBC1", + "#2ED3A2", + "#73EF75", + "#9FE870", + "#C7D36F", + "#CCCCCC", + "#8A8F98", + "#4B5563", + "#000000", +]; +export const CUSTOM_AVATAR_COLOR_SWATCH = "custom"; +export const AVATAR_COLOR_SWATCHES = [ + ...AVATAR_COLORS, + CUSTOM_AVATAR_COLOR_SWATCH, +] as const; +export type AvatarColorSwatch = (typeof AVATAR_COLOR_SWATCHES)[number]; + +export const DEFAULT_EMOJI_AVATAR_COLOR = "#FFFFFF"; +export const DEFAULT_CUSTOM_HUE = 210; +export const DEFAULT_CUSTOM_SATURATION = 76; +export const DEFAULT_CUSTOM_VALUE = 92; +export const CUSTOM_COLOR_GRID_COLUMNS = 15; +export const CUSTOM_COLOR_GRID_ROWS = 8; +export const CUSTOM_COLOR_GRID_HORIZONTAL_INSET = 24; +export const CUSTOM_COLOR_GRID_VERTICAL_INSET = 24; +export const CUSTOM_HUE_SCRUBBER_INSET = 20; +export const EMOJI_MART_CATEGORIES = [ + "people", + "nature", + "foods", + "activity", + "places", + "objects", + "symbols", + "flags", +]; + +const EMOJI_AVATAR_DATA_URL_PREFIX = "data:image/svg+xml,"; +const EMOJI_AVATAR_FONT_SIZE = 258; + +const EMOJI_MART_SHADOW_CSS = ` + :host { + display: block; + height: 100%; + max-height: 100%; + min-height: 0; + overflow: hidden; + width: 100%; + } + + #root { + --padding: 16px; + --sidebar-width: 0px; + display: flex; + flex-direction: column; + height: 100%; + max-height: 100%; + min-height: 0; + overflow: hidden; + width: 100% !important; + } + + .scroll { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding-left: var(--padding); + padding-right: var(--padding); + padding-top: 28px; + width: 100%; + } + + .scroll > div { + width: 100% !important; + } + + .category { + width: 100%; + } + + .scroll::-webkit-scrollbar { + width: 0; + height: 0; + } + + .category .sticky { + display: none; + } + + .category button .background { + background-color: rgba(255, 255, 255, 0.1); + } + + .row { + justify-content: space-between; + } + + #nav { + align-items: center; + display: flex; + flex: 0 0 auto; + justify-content: space-between; + padding: 8px 24px 16px; + } + + #nav .bar { + display: none; + } + + #nav > .relative { + justify-content: space-between; + width: 100%; + } + + #nav button { + align-items: center; + border-radius: 999px; + color: rgba(var(--em-rgb-color), 0.58); + display: flex; + flex: 0 0 40px; + height: 40px; + justify-content: center; + transition: + background-color var(--duration) var(--easing), + color var(--duration) var(--easing), + transform var(--duration) var(--easing); + width: 40px; + } + + #nav button:hover, + #nav button[aria-selected] { + color: rgb(var(--em-rgb-color)); + } + + #nav button:hover { + background-color: rgba(var(--em-rgb-color), 0.1); + } + + #nav button[aria-selected] { + background-color: rgba(var(--em-rgb-color), 0.14); + } + + #nav svg, + #nav img { + height: 24px; + width: 24px; + } +`; + +function escapeSvgText(text: string) { + return text + .replace(/&/gu, "&") + .replace(//gu, ">"); +} + +function unescapeSvgText(text: string) { + return text + .replace(/>/gu, ">") + .replace(/</gu, "<") + .replace(/&/gu, "&"); +} + +export function emojiAvatarDataUrl(emoji: string, color: string) { + const svg = `${escapeSvgText(emoji)}`; + return `${EMOJI_AVATAR_DATA_URL_PREFIX}${encodeURIComponent(svg)}`; +} + +export function parseEmojiAvatarDataUrl( + avatarUrl: string, +): EmojiAvatarDescriptor | null { + if (!avatarUrl.startsWith(EMOJI_AVATAR_DATA_URL_PREFIX)) { + return null; + } + + try { + const svg = decodeURIComponent( + avatarUrl.slice(EMOJI_AVATAR_DATA_URL_PREFIX.length), + ); + const color = svg.match(/]*\sfill="([^"]+)"/u)?.[1]; + const emoji = svg.match(/]*>(.*?)<\/text>/u)?.[1]; + + if (!color || !emoji) { + return null; + } + + return { color, emoji: unescapeSvgText(emoji) }; + } catch { + return null; + } +} + +export function hsvToHex(hue: number, saturation: number, value: number) { + const normalizedHue = ((hue % 360) + 360) % 360; + const chroma = (value / 100) * (saturation / 100); + const huePrime = normalizedHue / 60; + const secondary = chroma * (1 - Math.abs((huePrime % 2) - 1)); + const match = value / 100 - chroma; + let red = 0; + let green = 0; + let blue = 0; + + if (huePrime >= 0 && huePrime < 1) { + red = chroma; + green = secondary; + } else if (huePrime < 2) { + red = secondary; + green = chroma; + } else if (huePrime < 3) { + green = chroma; + blue = secondary; + } else if (huePrime < 4) { + green = secondary; + blue = chroma; + } else if (huePrime < 5) { + red = secondary; + blue = chroma; + } else { + red = chroma; + blue = secondary; + } + + return [red, green, blue] + .map((channel) => + Math.round((channel + match) * 255) + .toString(16) + .padStart(2, "0"), + ) + .join("") + .toUpperCase() + .padStart(6, "0") + .replace(/^/, "#"); +} + +export function hexToHsv(hexColor: string) { + const match = hexColor.match(/^#?([0-9a-f]{6})$/i); + if (!match) { + return { + hue: DEFAULT_CUSTOM_HUE, + saturation: DEFAULT_CUSTOM_SATURATION, + value: DEFAULT_CUSTOM_VALUE, + }; + } + + const value = match[1]; + const red = Number.parseInt(value.slice(0, 2), 16) / 255; + const green = Number.parseInt(value.slice(2, 4), 16) / 255; + const blue = Number.parseInt(value.slice(4, 6), 16) / 255; + const max = Math.max(red, green, blue); + const min = Math.min(red, green, blue); + const delta = max - min; + let hue = 0; + + if (delta !== 0) { + if (max === red) { + hue = 60 * (((green - blue) / delta) % 6); + } else if (max === green) { + hue = 60 * ((blue - red) / delta + 2); + } else { + hue = 60 * ((red - green) / delta + 4); + } + } + + return { + hue: Math.round((hue + 360) % 360), + saturation: max === 0 ? 0 : Math.round((delta / max) * 100), + value: Math.round(max * 100), + }; +} + +export function clampPercent(value: number) { + return Math.max(0, Math.min(100, value)); +} + +export function snapToGrid(value: number, gridCount: number) { + if (gridCount <= 1) { + return clampPercent(value); + } + + const step = 100 / (gridCount - 1); + return Math.round(value / step) * step; +} + +export function gridInsetPosition(value: number, inset: number) { + return `calc(${inset}px + (${value} * (100% - ${inset * 2}px) / 100))`; +} + +export function hueScrubberPosition(value: number) { + return `calc(${CUSTOM_HUE_SCRUBBER_INSET}px + (${value} * (100% - ${ + CUSTOM_HUE_SCRUBBER_INSET * 2 + }px) / 100))`; +} + +export function normalizeHue(hue: number) { + return ((hue % 360) + 360) % 360; +} + +function hslToRgbString(hslValue: string) { + const [hue, saturation, lightness] = hslValue + .trim() + .split(/\s+/) + .map((part) => Number.parseFloat(part.replace("%", ""))); + + if ( + !Number.isFinite(hue) || + !Number.isFinite(saturation) || + !Number.isFinite(lightness) + ) { + return null; + } + + const normalizedHue = ((hue % 360) + 360) % 360; + const saturationRatio = saturation / 100; + const lightnessRatio = lightness / 100; + const chroma = (1 - Math.abs(2 * lightnessRatio - 1)) * saturationRatio; + const huePrime = normalizedHue / 60; + const secondary = chroma * (1 - Math.abs((huePrime % 2) - 1)); + const match = lightnessRatio - chroma / 2; + let red = 0; + let green = 0; + let blue = 0; + + if (huePrime >= 0 && huePrime < 1) { + red = chroma; + green = secondary; + } else if (huePrime < 2) { + red = secondary; + green = chroma; + } else if (huePrime < 3) { + green = chroma; + blue = secondary; + } else if (huePrime < 4) { + green = secondary; + blue = chroma; + } else if (huePrime < 5) { + red = secondary; + blue = chroma; + } else { + red = chroma; + blue = secondary; + } + + return [red, green, blue] + .map((channel) => Math.round((channel + match) * 255)) + .join(", "); +} + +function hexToRgb(hexColor: string) { + const match = hexColor.match(/^#?([0-9a-f]{6})$/i); + if (!match) { + return null; + } + + const value = match[1]; + return { + blue: Number.parseInt(value.slice(4, 6), 16), + green: Number.parseInt(value.slice(2, 4), 16), + red: Number.parseInt(value.slice(0, 2), 16), + }; +} + +function relativeLuminance(hexColor: string) { + const rgb = hexToRgb(hexColor); + if (!rgb) { + return 0; + } + + const channels = [rgb.red, rgb.green, rgb.blue].map((channel) => { + const normalized = channel / 255; + return normalized <= 0.03928 + ? normalized / 12.92 + : ((normalized + 0.055) / 1.055) ** 2.4; + }); + + return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; +} + +function contrastRatio(colorA: string, colorB: string) { + const luminanceA = relativeLuminance(colorA); + const luminanceB = relativeLuminance(colorB); + const lighter = Math.max(luminanceA, luminanceB); + const darker = Math.min(luminanceA, luminanceB); + + return (lighter + 0.05) / (darker + 0.05); +} + +export function contrastColorForBackground(backgroundColor: string) { + return contrastRatio(backgroundColor, "#000000") >= + contrastRatio(backgroundColor, "#FFFFFF") + ? "#000000" + : "#FFFFFF"; +} + +export function dataTransferHasImage(dataTransfer: DataTransfer | null) { + if (!dataTransfer) { + return false; + } + + const items = Array.from(dataTransfer.items); + if (items.length > 0) { + return items.some( + (item) => item.kind === "file" && item.type.startsWith("image/"), + ); + } + + return Array.from(dataTransfer.files).some((file) => + file.type.startsWith("image/"), + ); +} + +export function visibleUrlDraft( + avatarUrl: string, + hiddenAvatarUrl?: string | null, +) { + if (avatarUrl.startsWith("data:") || avatarUrl === hiddenAvatarUrl) { + return ""; + } + + return avatarUrl; +} + +export function useEmojiMartStyles( + containerRef: React.RefObject, + enabled: boolean, +) { + React.useEffect(() => { + if (!enabled) { + return; + } + + let animationFrame = 0; + + const installEmojiMartStyles = () => { + const host = containerRef.current?.querySelector("em-emoji-picker"); + const shadowRoot = host?.shadowRoot; + + if (!shadowRoot) { + animationFrame = window.requestAnimationFrame(installEmojiMartStyles); + return; + } + + if (!shadowRoot.querySelector("#sprout-emoji-mart-style")) { + const style = document.createElement("style"); + style.id = "sprout-emoji-mart-style"; + style.textContent = EMOJI_MART_SHADOW_CSS; + shadowRoot.appendChild(style); + } + }; + + animationFrame = window.requestAnimationFrame(installEmojiMartStyles); + + return () => { + window.cancelAnimationFrame(animationFrame); + }; + }, [containerRef, enabled]); +} + +export function useEmojiMartThemeVars() { + const [themeVars, setThemeVars] = React.useState({}); + + React.useEffect(() => { + const updateThemeVars = () => { + const styles = window.getComputedStyle(document.documentElement); + const muted = hslToRgbString(styles.getPropertyValue("--muted")); + const foreground = hslToRgbString( + styles.getPropertyValue("--foreground"), + ); + const background = hslToRgbString( + styles.getPropertyValue("--background"), + ); + + setThemeVars({ + "--sprout-emoji-picker-rgb-background": muted ?? "54, 58, 79", + "--sprout-emoji-picker-rgb-color": foreground ?? "245, 247, 255", + "--sprout-emoji-picker-rgb-input": background ?? "47, 51, 68", + } as React.CSSProperties); + }; + + updateThemeVars(); + + const observer = new MutationObserver(updateThemeVars); + observer.observe(document.documentElement, { + attributeFilter: ["class", "style"], + attributes: true, + }); + + return () => observer.disconnect(); + }, []); + + return themeVars; +} diff --git a/desktop/src/features/profile/useAvatarUpload.ts b/desktop/src/features/profile/useAvatarUpload.ts index d3fc8066c..3dba241fe 100644 --- a/desktop/src/features/profile/useAvatarUpload.ts +++ b/desktop/src/features/profile/useAvatarUpload.ts @@ -19,6 +19,7 @@ type UseAvatarUploadReturn = { errorMessage: string | null; clearError: () => void; openPicker: () => void; + uploadFile: (file: File) => Promise; handleFileChange: (event: React.ChangeEvent) => void; }; @@ -37,15 +38,8 @@ export function useAvatarUpload({ inputRef.current?.click(); }, []); - const handleFileChange = React.useCallback( - async (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - event.target.value = ""; - - if (!file) { - return; - } - + const uploadFile = React.useCallback( + async (file: File) => { if (!AVATAR_IMAGE_TYPES.includes(file.type)) { setErrorMessage("Choose a PNG, JPG, GIF, or WebP image."); return; @@ -79,12 +73,27 @@ export function useAvatarUpload({ [onUploadSuccess], ); + const handleFileChange = React.useCallback( + (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + + if (!file) { + return; + } + + void uploadFile(file); + }, + [uploadFile], + ); + return { inputRef, isUploading, errorMessage, clearError, openPicker, + uploadFile, handleFileChange, }; } diff --git a/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx b/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx index 6fb44d383..bc1c60c36 100644 --- a/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx +++ b/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx @@ -351,148 +351,151 @@ export function RelayMembersSettingsCard({ } return ( -
-
-

Relay Access

-

+

+
+

Relay Access

+

Manage who can connect to this relay. Owners can invite admins or members; admins can invite members.

-
- -
- setPubkeyInput(event.target.value)} - placeholder="npub1… or 64-char hex pubkey" - value={pubkeyInput} - /> -
- - {canGrantAdmin ? ( - - - - - - Invite as - - - setRole(value as AssignableRelayRole) - } - value={role} - > - {ROLE_OPTIONS.map((option) => ( - -
- - {option.label} - - - {option.description} - -
-
- ))} -
-
-
- ) : null} +
+ + +
+ setPubkeyInput(event.target.value)} + placeholder="npub1… or 64-char hex pubkey" + value={pubkeyInput} + /> +
+ + {canGrantAdmin ? ( + + + + + + Invite as + + + setRole(value as AssignableRelayRole) + } + value={role} + > + {ROLE_OPTIONS.map((option) => ( + +
+ + {option.label} + + + {option.description} + +
+
+ ))} +
+
+
+ ) : null} +
-
- {pubkeyInput.trim().length > 0 && !isValidHexPubkey(normalizedInput) ? ( -

- Enter a valid npub or 64-character hex pubkey. + {pubkeyInput.trim().length > 0 && + !isValidHexPubkey(normalizedInput) ? ( +

+ Enter a valid npub or 64-character hex pubkey. +

+ ) : null} + + + {membersQuery.error instanceof Error ? ( +

+ {membersQuery.error.message}

) : null} - - {membersQuery.error instanceof Error ? ( -

- {membersQuery.error.message} -

- ) : null} +
+
+

+ Members + {members.length > 0 ? ( + + ({members.length}) + + ) : null} +

+
-
-
-

- Members - {members.length > 0 ? ( - - ({members.length}) - - ) : null} -

-
+
+ + setSearch(event.target.value)} + placeholder="Search members by name, npub, or role…" + type="text" + value={search} + /> +
-
- - setSearch(event.target.value)} - placeholder="Search members by name, npub, or role…" - type="text" - value={search} - /> + {membersQuery.isLoading ? ( +

+ Loading relay members… +

+ ) : members.length === 0 ? ( +

+ No relay members yet. Invite someone above to get started. +

+ ) : filteredMembers.length === 0 ? ( +

+ No members match your search. +

+ ) : ( +
+ {filteredMembers.map((member) => ( + + ))} +
+ )}
- - {membersQuery.isLoading ? ( -

- Loading relay members… -

- ) : members.length === 0 ? ( -

- No relay members yet. Invite someone above to get started. -

- ) : filteredMembers.length === 0 ? ( -

- No members match your search. -

- ) : ( -
- {filteredMembers.map((member) => ( - - ))} -
- )}
); diff --git a/desktop/src/features/settings/UpdateChecker.tsx b/desktop/src/features/settings/UpdateChecker.tsx index 9a35fbace..c03971bbb 100644 --- a/desktop/src/features/settings/UpdateChecker.tsx +++ b/desktop/src/features/settings/UpdateChecker.tsx @@ -1,79 +1,125 @@ import { useUpdaterContext } from "./hooks/UpdaterProvider"; import { Button } from "@/shared/ui/button"; +import { + SettingsOptionGroup, + SettingsOptionRow, +} from "./ui/SettingsOptionGroup"; export function UpdateChecker() { const { status, checkForUpdate, relaunch } = useUpdaterContext(); return (
-
-

+
+

Software Updates

-

+

Keep Sprout up to date with the latest features and fixes.

- {status.state === "idle" && ( -
-

- Check if a new version is available. -

- -
- )} + + {status.state === "idle" && ( + +
+

Update status

+

+ Check if a new version is available. +

+
+ +
+ )} - {status.state === "checking" && ( -

Checking for updates...

- )} + {status.state === "checking" && ( + +
+

Update status

+

+ Checking for updates... +

+
+
+ )} - {status.state === "up-to-date" && ( -
-

- You're on the latest version. -

- -
- )} + {status.state === "up-to-date" && ( + +
+

Update status

+

+ You're on the latest version. +

+
+ +
+ )} - {status.state === "available" && ( -

Preparing update...

- )} + {status.state === "available" && ( + +
+

Update status

+

+ Preparing update... +

+
+
+ )} - {status.state === "downloading" && ( -

Downloading update...

- )} + {status.state === "downloading" && ( + +
+

Update status

+

+ Downloading update... +

+
+
+ )} - {status.state === "installing" && ( -

Installing update...

- )} + {status.state === "installing" && ( + +
+

Update status

+

+ Installing update... +

+
+
+ )} - {status.state === "ready" && ( -
-

- Update installed. Restart to apply. -

- -
- )} + {status.state === "ready" && ( + +
+

Update status

+

+ Update installed. Restart to apply. +

+
+ +
+ )} - {status.state === "error" && ( -
-

- Update failed: {status.message} -

- -
- )} + {status.state === "error" && ( + +
+

Update status

+

+ Update failed: {status.message} +

+
+ +
+ )} +

); } diff --git a/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx b/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx index 14bfbf7ca..0181e1928 100644 --- a/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx @@ -103,12 +103,12 @@ export function ChannelTemplatesSettingsCard() { return (
-
+
-

+

Channel Templates

-

+

Save reusable channel configurations and apply them when creating new channels.

@@ -222,7 +222,7 @@ function TemplateRow({ ) : null}
{template.description ? ( -

+

{template.description}

) : null} @@ -609,7 +609,7 @@ function TemplateTeamSelector({
Teams
-

+

Select teams to include in this template.

@@ -688,7 +688,7 @@ function RuntimeAssignments({
Runtimes
-

+

Choose which runtime to use for each agent.

diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index 1b8b351fa..a6d3044cd 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -6,7 +6,6 @@ import { Download, ExternalLink, RefreshCw, - Stethoscope, } from "lucide-react"; import { openUrl } from "@tauri-apps/plugin-opener"; @@ -18,6 +17,7 @@ import { describeResolvedCommand } from "@/features/agents/ui/agentUi"; import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; +import { SettingsOptionGroup } from "./SettingsOptionGroup"; function StatusIcon({ availability, @@ -91,13 +91,13 @@ function RuntimeRow({ return (
@@ -107,9 +107,7 @@ function RuntimeRow({
-

- {runtime.label} -

+

{runtime.label}

{runtime.command ? ( {runtime.command} @@ -121,7 +119,7 @@ function RuntimeRow({ runtime.command && runtime.binaryPath ? ( <> -

+

Available via{" "} {describeResolvedCommand(runtime.command, runtime.binaryPath)}.

@@ -158,14 +156,14 @@ function RuntimeRow({ ) : runtime.availability === "adapter_missing" ? ( <> -

+

CLI detected at{" "} {runtime.underlyingCliPath ?? "unknown path"} {" "} but ACP adapter not found.

-

+

{runtime.installHint}

) : runtime.availability === "cli_missing" ? ( <> -

+

ACP adapter found at{" "} {runtime.binaryPath ?? "unknown path"} {" "} but the {runtime.label} CLI is not installed.

-

+

{runtime.installHint}

) : ( <> -

Not installed

-

+

+ Not installed +

+

{runtime.installHint}

+

Installed successfully!

) : null} {installError ? ( -

+

{installError}

) : null} @@ -268,14 +268,11 @@ export function DoctorSettingsPanel() { } return ( -
-
+
+
-
- -

Doctor

-
-

+

Doctor

+

Verify the ACP runtime commands available to the desktop app.

@@ -298,47 +295,46 @@ export function DoctorSettingsPanel() {
-
-
-

- Agent CLIs and ACP runtimes -

-

- Installation status of supported agent CLIs and their ACP runtimes. -

- -
- {runtimesQuery.isLoading ? ( -

- Looking for ACP runtimes... -

- ) : runtimes.length > 0 ? ( - runtimes.map((runtime) => ( - handleInstall(runtime.id)} - runtime={runtime} - /> - )) - ) : ( -
- No known ACP runtimes found. -
- )} +
+ +
+

Agent CLIs and ACP runtimes

+

+ Installation status of supported agent CLIs and their ACP + runtimes. +

+ {runtimesQuery.isLoading ? ( +
+ Looking for ACP runtimes... +
+ ) : runtimes.length > 0 ? ( + runtimes.map((runtime) => ( + handleInstall(runtime.id)} + runtime={runtime} + /> + )) + ) : ( +
+ No known ACP runtimes found. +
+ )} + {runtimesQuery.error instanceof Error ? ( -

+

{runtimesQuery.error.message}

) : null} -
+
); diff --git a/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx b/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx index a2449ce39..2e84f32a0 100644 --- a/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx +++ b/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx @@ -1,10 +1,9 @@ -import { Keyboard } from "lucide-react"; - import { getShortcutsByCategory, getPlatformKeys, type KeyboardShortcut, } from "@/shared/lib/keyboard-shortcuts"; +import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; function KeyCombo({ shortcut }: { shortcut: KeyboardShortcut }) { const keys = getPlatformKeys(shortcut); @@ -33,14 +32,11 @@ export function KeyboardShortcutsCard() { return (
-
-
- -

- Keyboard Shortcuts -

-
-

+

+

+ Keyboard Shortcuts +

+

All available keyboard shortcuts. Shortcuts are read-only.

@@ -51,22 +47,24 @@ export function KeyboardShortcutsCard() {

{category}

-
- {shortcuts.map((shortcut, i) => ( -
+ {shortcuts.map((shortcut) => ( +
- {shortcut.label} + + {shortcut.label} + {shortcut.description}
-
+ ))} -
+
))}
diff --git a/desktop/src/features/settings/ui/MobilePairingCard.tsx b/desktop/src/features/settings/ui/MobilePairingCard.tsx index 5a7517728..0571855bb 100644 --- a/desktop/src/features/settings/ui/MobilePairingCard.tsx +++ b/desktop/src/features/settings/ui/MobilePairingCard.tsx @@ -26,6 +26,7 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; +import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; type PairingStep = | "generating" @@ -319,33 +320,35 @@ export function MobilePairingCard({ const [dialogOpen, setDialogOpen] = useState(false); return ( -
-
-

Mobile

-

+

+
+

Mobile

+

Connect the Sprout mobile app to this relay by scanning a QR code. The connection is secured with end-to-end encryption and a verification code.

-
- -
-

Pair Mobile Device

-

- Securely transfer your identity via NIP-AB protocol -

-
- -
+ + + +
+

Pair Mobile Device

+

+ Securely transfer your identity via NIP-AB protocol +

+
+ +
+
{currentPubkey && ( diff --git a/desktop/src/features/settings/ui/NotificationSettingsCard.tsx b/desktop/src/features/settings/ui/NotificationSettingsCard.tsx index e465c980a..f7d29b468 100644 --- a/desktop/src/features/settings/ui/NotificationSettingsCard.tsx +++ b/desktop/src/features/settings/ui/NotificationSettingsCard.tsx @@ -3,6 +3,7 @@ import type { NotificationSettings, } from "@/features/notifications/hooks"; import { Switch } from "@/shared/ui/switch"; +import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; export function NotificationSettingsCard({ isUpdatingDesktopNotifications, @@ -31,9 +32,9 @@ export function NotificationSettingsCard({ return (
-
-

Notifications

-

+

+

Notifications

+

Desktop alerts are on by default. Fine-tune what gets through below.

@@ -48,8 +49,8 @@ export function NotificationSettingsCard({ : "Off"} -
-
+ +
+
-
+
-

+

Play a sound when a desktop notification fires.

@@ -100,14 +101,14 @@ export function NotificationSettingsCard({ onSetSoundEnabled(checked); }} /> -
+ -
+
-

+

Show a Home badge for mentions and needs-action items in the sidebar.

@@ -120,14 +121,14 @@ export function NotificationSettingsCard({ onSetHomeBadgeEnabled(checked); }} /> -
+
-
+
-

+

Alert when someone tags your pubkey in a channel you can access.

@@ -139,9 +140,9 @@ export function NotificationSettingsCard({ onSetMentionNotificationsEnabled(checked); }} /> -
+ -
+
-

+

Alert for reminders and workflow approvals that are waiting on you.

@@ -162,8 +163,8 @@ export function NotificationSettingsCard({ onSetNeedsActionNotificationsEnabled(checked); }} /> -
-
+ + {permissionBlocked && (

diff --git a/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx b/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx index f7a94a5bc..c502efd53 100644 --- a/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx +++ b/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx @@ -1,5 +1,6 @@ import { usePreventSleepContext } from "@/features/agents/usePreventSleep"; import { Switch } from "@/shared/ui/switch"; +import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; export function PreventSleepSettingsCard() { const { enabled, setEnabled, hasRunningAgents, expired, clearExpired } = @@ -7,15 +8,15 @@ export function PreventSleepSettingsCard() { return (

-
-

Agents

-

+

+

Agents

+

Settings that affect how local managed agents run on this machine.

-
-
+ +
-

+

Prevents your computer from sleeping while local agents are running. Automatically releases when all agents stop or after 4 hours. @@ -40,8 +41,8 @@ export function PreventSleepSettingsCard() { setEnabled(checked); }} /> -

-
+ + {enabled && !hasRunningAgents && (

diff --git a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx index fe5c32354..4122742c1 100644 --- a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx @@ -1,4 +1,4 @@ -import { AtSign, Check, Copy, UserRound } from "lucide-react"; +import { Check, ChevronDown, Copy, Pencil } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; @@ -6,10 +6,13 @@ import { useProfileQuery, useUpdateProfileMutation, } from "@/features/profile/hooks"; -import { AvatarUpload } from "@/features/profile/ui/AvatarUpload"; -import { Button } from "@/shared/ui/button"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { + ProfileAvatarEditor, + parseEmojiAvatarDataUrl, +} from "@/features/profile/ui/ProfileAvatarEditor"; +import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; -import { Separator } from "@/shared/ui/separator"; import { Textarea } from "@/shared/ui/textarea"; type ProfileSettingsCardProps = { @@ -17,28 +20,9 @@ type ProfileSettingsCardProps = { fallbackDisplayName?: string; }; -function Section({ - title, - description, - children, -}: React.PropsWithChildren<{ - title: string; - description?: string; -}>) { - return ( -

-
-

{title}

- {description ? ( -

{description}

- ) : null} -
- {children} -
- ); -} +const AVATAR_EDITOR_TRANSITION_MS = 150; -function ReadOnlyField({ +function IdentityRow({ label, value, testId, @@ -49,16 +33,22 @@ function ReadOnlyField({ testId: string; copyValue?: string; }) { - const boxClassName = - "flex min-w-0 items-center gap-2 rounded-xl border border-border/80 bg-muted/25 px-3 py-2 text-sm text-muted-foreground"; - return ( -
-

{label}

+
+
+

{label}

+

+ {value} +

+
{copyValue ? ( - ) : ( -
- {value} -
- )} + ) : null}
); } +function EditProfileMetadataButton({ + label, + testId, + onClick, + disabled, + isEditing, +}: { + label: string; + testId: string; + onClick: () => void; + disabled: boolean; + isEditing: boolean; +}) { + const Icon = isEditing ? Check : Pencil; + const actionLabel = isEditing ? "Done" : "Edit"; + const accessibleLabel = isEditing ? `Done editing ${label}` : `Edit ${label}`; + + return ( + + ); +} + export function ProfileSettingsCard({ currentPubkey, fallbackDisplayName, @@ -95,38 +117,125 @@ export function ProfileSettingsCard({ const [displayNameDraft, setDisplayNameDraft] = React.useState(""); const [avatarUrlDraft, setAvatarUrlDraft] = React.useState(""); const [aboutDraft, setAboutDraft] = React.useState(""); + const [uploadedAvatarUrlDraft, setUploadedAvatarUrlDraft] = React.useState< + string | null + >(null); + const [isAvatarEditorOpen, setIsAvatarEditorOpen] = React.useState(false); + const [isUploadingAvatar, setIsUploadingAvatar] = React.useState(false); + const [isEditingProfileMetadata, setIsEditingProfileMetadata] = + React.useState(false); + const [shouldRenderAvatarEditor, setShouldRenderAvatarEditor] = + React.useState(false); + const [avatarSquishKey, setAvatarSquishKey] = React.useState(0); + const displayNameInputRef = React.useRef(null); + const aboutTextareaRef = React.useRef(null); + const isEditingProfileMetadataRef = React.useRef(false); + const avatarEditorOpenFrameRef = React.useRef(null); + const avatarEditClipId = React.useId().replace(/:/g, ""); + isEditingProfileMetadataRef.current = isEditingProfileMetadata; + + React.useEffect(() => { + if (!isEditingProfileMetadataRef.current) { + setDisplayNameDraft(currentDisplayName); + } + }, [currentDisplayName]); + + React.useEffect(() => { + if (!isAvatarEditorOpen) { + setAvatarUrlDraft(currentAvatarUrl); + } + }, [currentAvatarUrl, isAvatarEditorOpen]); + + React.useEffect(() => { + if (!isEditingProfileMetadataRef.current) { + setAboutDraft(currentAbout); + } + }, [currentAbout]); + + React.useEffect(() => { + if ( + uploadedAvatarUrlDraft && + currentAvatarUrl && + uploadedAvatarUrlDraft !== currentAvatarUrl && + avatarUrlDraft !== uploadedAvatarUrlDraft + ) { + setUploadedAvatarUrlDraft(null); + } + }, [avatarUrlDraft, currentAvatarUrl, uploadedAvatarUrlDraft]); + + React.useEffect(() => { + if (isEditingProfileMetadata) { + displayNameInputRef.current?.focus(); + } + }, [isEditingProfileMetadata]); + + React.useEffect(() => { + if (isAvatarEditorOpen || !shouldRenderAvatarEditor) { + return; + } + + const timeoutId = window.setTimeout(() => { + setShouldRenderAvatarEditor(false); + }, AVATAR_EDITOR_TRANSITION_MS); + + return () => window.clearTimeout(timeoutId); + }, [isAvatarEditorOpen, shouldRenderAvatarEditor]); React.useEffect(() => { - setDisplayNameDraft(currentDisplayName); - setAvatarUrlDraft(currentAvatarUrl); - setAboutDraft(currentAbout); - }, [currentAbout, currentAvatarUrl, currentDisplayName]); + return () => { + if (avatarEditorOpenFrameRef.current !== null) { + window.cancelAnimationFrame(avatarEditorOpenFrameRef.current); + } + }; + }, []); const nextDisplayName = displayNameDraft.trim(); const nextAvatarUrl = avatarUrlDraft.trim(); const nextAbout = aboutDraft.trim(); - const updatePayload: { - displayName?: string; - avatarUrl?: string; - about?: string; - } = {}; - - if (nextDisplayName.length > 0 && nextDisplayName !== currentDisplayName) { - updatePayload.displayName = nextDisplayName; - } - if (nextAvatarUrl.length > 0 && nextAvatarUrl !== currentAvatarUrl) { - updatePayload.avatarUrl = nextAvatarUrl; - } - if (nextAbout.length > 0 && nextAbout !== currentAbout) { - updatePayload.about = nextAbout; - } + const updatePayload = React.useMemo(() => { + const payload: { + displayName?: string; + avatarUrl?: string; + about?: string; + } = {}; + + if (nextDisplayName.length > 0 && nextDisplayName !== currentDisplayName) { + payload.displayName = nextDisplayName; + } + if (nextAvatarUrl.length > 0 && nextAvatarUrl !== currentAvatarUrl) { + payload.avatarUrl = nextAvatarUrl; + } + if (nextAbout !== currentAbout) { + payload.about = nextAbout; + } + + return payload; + }, [ + currentAbout, + currentAvatarUrl, + currentDisplayName, + nextAbout, + nextAvatarUrl, + nextDisplayName, + ]); + const hasPendingDisplayNameClearRequest = + currentDisplayName.length > 0 && nextDisplayName.length === 0; + const hasPendingAvatarClearRequest = + currentAvatarUrl.length > 0 && nextAvatarUrl.length === 0; const hasPendingClearRequest = - (currentDisplayName.length > 0 && nextDisplayName.length === 0) || - (currentAvatarUrl.length > 0 && nextAvatarUrl.length === 0) || - (currentAbout.length > 0 && nextAbout.length === 0); + hasPendingDisplayNameClearRequest || hasPendingAvatarClearRequest; + const hasProfileChanges = Object.keys(updatePayload).length > 0; const canSave = - Object.keys(updatePayload).length > 0 && !updateProfileMutation.isPending; + hasProfileChanges && !updateProfileMutation.isPending && !isUploadingAvatar; + const shouldShowSaveArea = hasPendingClearRequest; + const readOnlyContentMotionClassName = cn( + "min-w-0 w-full origin-top overflow-hidden transition-[opacity,scale] duration-150 ease-out", + shouldRenderAvatarEditor ? "absolute inset-x-0 top-0" : "relative", + isAvatarEditorOpen + ? "pointer-events-none scale-[0.94] opacity-0" + : "scale-100 opacity-100", + ); const resolvedName = nextDisplayName || @@ -135,132 +244,407 @@ export function ProfileSettingsCard({ "Your profile"; const resolvedPubkey = profile?.pubkey ?? currentPubkey ?? "Unavailable"; const nip05Handle = profile?.nip05Handle ?? "Not set"; + const emojiAvatarPreview = React.useMemo( + () => parseEmojiAvatarDataUrl(avatarUrlDraft), + [avatarUrlDraft], + ); + const avatarEditShellClassName = cn( + "absolute right-0 bottom-0 z-10 flex h-[54px] w-[54px] items-center justify-center rounded-full bg-background opacity-100 transition-[opacity,scale,transform] duration-150 ease-out", + isAvatarEditorOpen + ? "pointer-events-none scale-[0.94] opacity-0" + : "scale-100 opacity-100", + ); + const avatarEditButtonClassName = cn( + "flex h-11 w-11 items-center justify-center rounded-full bg-sidebar-active text-sidebar-active-foreground shadow-lg transition-[background-color,opacity,scale,transform] duration-150 ease-out hover:scale-[1.04] hover:bg-sidebar-active focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + ); + const avatarClipStyle = React.useMemo( + () => + !isAvatarEditorOpen + ? { + clipPath: `url(#${avatarEditClipId})`, + transform: "translateZ(0)", + } + : undefined, + [avatarEditClipId, isAvatarEditorOpen], + ); + + const openAvatarEditor = React.useCallback(() => { + setShouldRenderAvatarEditor(true); + + if (avatarEditorOpenFrameRef.current !== null) { + window.cancelAnimationFrame(avatarEditorOpenFrameRef.current); + } + + avatarEditorOpenFrameRef.current = window.requestAnimationFrame(() => { + avatarEditorOpenFrameRef.current = null; + setIsAvatarEditorOpen(true); + }); + }, []); + + const saveProfile = React.useCallback(async () => { + if (!canSave) { + return false; + } + + await updateProfileMutation.mutateAsync(updatePayload); + setIsEditingProfileMetadata(false); + setDisplayNameDraft(updatePayload.displayName ?? currentDisplayName); + setAvatarUrlDraft(updatePayload.avatarUrl ?? currentAvatarUrl); + setAboutDraft(updatePayload.about ?? currentAbout); + toast.success("Profile saved"); + return true; + }, [ + canSave, + currentAbout, + currentAvatarUrl, + currentDisplayName, + updatePayload, + updateProfileMutation, + ]); + + const handleProfileMetadataEdit = React.useCallback(() => { + if (!isEditingProfileMetadata) { + setIsEditingProfileMetadata(true); + return; + } + + if (!hasProfileChanges) { + if (hasPendingDisplayNameClearRequest) { + setDisplayNameDraft(currentDisplayName); + } + if (hasPendingAvatarClearRequest) { + setAvatarUrlDraft(currentAvatarUrl); + } + setIsEditingProfileMetadata(false); + return; + } + + void saveProfile(); + }, [ + currentAvatarUrl, + currentDisplayName, + hasPendingAvatarClearRequest, + hasPendingDisplayNameClearRequest, + hasProfileChanges, + isEditingProfileMetadata, + saveProfile, + ]); + + const handleAvatarEditorDone = React.useCallback(() => { + if (!hasProfileChanges) { + if (hasPendingAvatarClearRequest) { + setAvatarUrlDraft(currentAvatarUrl); + } + setIsAvatarEditorOpen(false); + return; + } + + void saveProfile().then((didSave) => { + if (didSave) { + setIsAvatarEditorOpen(false); + } + }); + }, [ + currentAvatarUrl, + hasPendingAvatarClearRequest, + hasProfileChanges, + saveProfile, + ]); + + const animateEmojiAvatarChange = React.useCallback(() => { + setAvatarSquishKey((key) => key + 1); + }, []); return (
-
-

Profile

- - {profileQuery.error instanceof Error ? ( -

- {profileQuery.error.message} +

+
+

Profile

+

+ Update how your name, avatar, and bio appear across Sprout.

- ) : null} +
- {updateProfileMutation.error instanceof Error ? ( -

- {updateProfileMutation.error.message} -

- ) : null} +
+ {profileQuery.error instanceof Error ? ( +

+ {profileQuery.error.message} +

+ ) : null} - {updateProfileMutation.isSuccess ? ( -
- - Profile saved. -
- ) : null} - -
{ - event.preventDefault(); - if (!canSave) { - return; - } - - void updateProfileMutation.mutateAsync(updatePayload); - }} - > -
-