diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index 36fb9e91687..ffa41c5af4e 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query' import { useEffect, useState } from 'react' import { LanguageSwitcher } from '@/components/language-switcher' +import { Button } from '@/components/ui/button' import { SegmentedControl } from '@/components/ui/segmented-control' import type { DesktopMarketplaceSearchItem } from '@/global' import { useI18n } from '@/i18n' @@ -10,6 +11,7 @@ import { triggerHaptic } from '@/lib/haptics' import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons' import { selectableCardClass } from '@/lib/selectable-card' import { cn } from '@/lib/utils' +import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent' import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile' import { $toolViewMode, setToolViewMode } from '@/store/tool-view' import { $translucency, setTranslucency } from '@/store/translucency' @@ -215,6 +217,8 @@ export function AppearanceSettings() { const { t, isSavingLocale } = useI18n() const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme() const toolViewMode = useStore($toolViewMode) + const embedMode = useStore($embedMode) + const embedAllowed = useStore($embedAllowed) const translucency = useStore($translucency) const profiles = useStore($profiles) const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile)) @@ -266,6 +270,12 @@ export function AppearanceSettings() { { id: 'technical', label: a.technical } ] as const + const embedOptions = [ + { id: 'ask', label: a.embedsAsk }, + { id: 'always', label: a.embedsAlways }, + { id: 'off', label: a.embedsOff } + ] as const satisfies readonly { id: EmbedMode; label: string }[] + return (
@@ -425,6 +435,35 @@ export function AppearanceSettings() { description={a.toolViewDesc} title={a.toolViewTitle} /> + + + { + triggerHaptic('selection') + setEmbedMode(id) + }} + options={embedOptions} + value={embedMode} + /> + {embedAllowed.length > 0 && ( + + )} +
+ } + description={a.embedsDesc} + title={a.embedsTitle} + /> diff --git a/apps/desktop/src/components/assistant-ui/embeds/embed-consent.tsx b/apps/desktop/src/components/assistant-ui/embeds/embed-consent.tsx new file mode 100644 index 00000000000..c2fa1bff21a --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/embeds/embed-consent.tsx @@ -0,0 +1,55 @@ +'use client' + +import { type CSSProperties, useState } from 'react' + +import { SplitButton } from '@/components/ui/split-button' +import { Play } from '@/lib/icons' +import { allowProvider } from '@/store/embed-consent' + +import type { EmbedDescriptor } from './providers/types' + +// Privacy placeholder shown before an embed reaches out to a third party. Sized +// to the embed's footprint (no layout shift). The split control mirrors the +// commit button: primary "Load" (this embed) with a caret for "Always allow +// " (persisted). Global off lives in Appearance settings. +export function EmbedFacade({ descriptor, onLoad }: { descriptor: EmbedDescriptor; onLoad: () => void }) { + const [choice, setChoice] = useState('once') + + const style: CSSProperties = descriptor.aspectRatio + ? { aspectRatio: descriptor.aspectRatio } + : { height: descriptor.height ?? 320 } + + const actions = [ + { id: 'once', label: `Load ${descriptor.label}` }, + { id: 'always', label: `Always allow ${descriptor.label}` } + ] + + return ( + + (id === 'always' ? allowProvider(descriptor.provider) : onLoad())} + onValueChange={setChoice} + primaryIcon={} + value={choice} + /> + {hostOf(descriptor)} + + ) +} + +function hostOf(descriptor: EmbedDescriptor): string { + // x.com posts often arrive as twitter.com links — show the current brand. + if (descriptor.provider === 'twitter') { + return 'x.com' + } + + try { + return new URL(descriptor.sourceUrl).hostname.replace(/^www\./, '') + } catch { + return descriptor.label + } +} diff --git a/apps/desktop/src/components/assistant-ui/embeds/url-embed.tsx b/apps/desktop/src/components/assistant-ui/embeds/url-embed.tsx index d4e4239ab31..1560c3f11f3 100644 --- a/apps/desktop/src/components/assistant-ui/embeds/url-embed.tsx +++ b/apps/desktop/src/components/assistant-ui/embeds/url-embed.tsx @@ -1,7 +1,12 @@ 'use client' -import { type CSSProperties, lazy, Suspense } from 'react' +import { useStore } from '@nanostores/react' +import { type CSSProperties, lazy, Suspense, useState } from 'react' +import { PrettyLink } from '@/lib/external-link' +import { $embedAllowed, $embedMode } from '@/store/embed-consent' + +import { EmbedFacade } from './embed-consent' import { EMBED_MAX_H } from './embed-size' import { EmbedFail } from './fail' import type { EmbedDescriptor } from './providers/types' @@ -39,11 +44,22 @@ function LazyRenderer({ descriptor }: { descriptor: EmbedDescriptor }) { } export function UrlEmbed({ descriptor }: { descriptor: EmbedDescriptor }) { + const mode = useStore($embedMode) + const allowed = useStore($embedAllowed) + const [loaded, setLoaded] = useState(false) + + // Privacy gate: don't reach out to the provider until consented. `off` keeps + // it a plain link; otherwise the placeholder shows until "Load" (this embed) + // or "Always allow" / global `always` permits the fetch. + if (mode === 'off') { + return + } + + const consented = mode === 'always' || loaded || allowed.includes(descriptor.provider) const aspect = descriptor.aspectRatio // Ratio embeds cap WIDTH off the ratio so height tops out at the cap while - // scaling. Non-ratio embeds own their own height (scale-to-fit / measured / - // fixed) — no maxHeight here or it would clip what the renderer just sized. + // scaling. Non-ratio embeds own their own height (measured / fixed). const style: CSSProperties = { containIntrinsicSize: `auto ${intrinsicHeight(descriptor)}px`, contentVisibility: 'auto', @@ -55,9 +71,13 @@ export function UrlEmbed({ descriptor }: { descriptor: EmbedDescriptor }) { return ( } resetKey={descriptor.id}> - - - + {consented ? ( + + + + ) : ( + setLoaded(true)} /> + )} ) diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index a5083d80252..be4074ba882 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -376,6 +376,13 @@ export const en: Translations = { toolViewDesc: 'Product hides raw tool payloads; Technical shows full input/output.', translucencyTitle: 'Window Translucency', translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.', + embedsTitle: 'Inline Embeds', + embedsDesc: + 'Rich previews load from third-party sites (YouTube, X, …). Ask shows a placeholder until you allow each one; Always loads them automatically; Off keeps plain links.', + embedsAsk: 'Ask', + embedsAlways: 'Always', + embedsOff: 'Off', + embedsReset: (count: number) => `Reset ${count} allowed ${count === 1 ? 'service' : 'services'}`, product: 'Product', productDesc: 'Human-friendly tool activity with concise summaries.', technical: 'Technical', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 103d05db90f..765ddfceafa 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -286,6 +286,13 @@ export const ja = defineLocale({ toolViewDesc: 'プロダクト表示は生のツールペイロードを隠し、テクニカル表示は入出力をすべて表示します。', translucencyTitle: 'ウィンドウの透過', translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。', + embedsTitle: 'インライン埋め込み', + embedsDesc: + 'リッチプレビューは第三者サイト(YouTube、X など)から読み込まれます。確認は許可するまでプレースホルダーを表示し、常には自動で読み込み、オフはリンクのままにします。', + embedsAsk: '確認', + embedsAlways: '常に', + embedsOff: 'オフ', + embedsReset: (count: number) => `許可した${count}件のサービスをリセット`, product: 'プロダクト', productDesc: '読みやすいツール活動と簡潔な要約を表示します。', technical: 'テクニカル', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index adcf67df78a..c7915536e35 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -301,6 +301,12 @@ export interface Translations { toolViewDesc: string translucencyTitle: string translucencyDesc: string + embedsTitle: string + embedsDesc: string + embedsAsk: string + embedsAlways: string + embedsOff: string + embedsReset: (count: number) => string product: string productDesc: string technical: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 00ecdb5884e..1f16056a79a 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -278,6 +278,12 @@ export const zhHant = defineLocale({ toolViewDesc: '產品模式會隱藏原始工具 payload;技術模式會顯示完整輸入/輸出。', translucencyTitle: '視窗透明', translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。', + embedsTitle: '內嵌預覽', + embedsDesc: '豐富預覽會從第三方網站(YouTube、X 等)載入。詢問會在你允許前顯示佔位符;一律會自動載入;關閉則保留純連結。', + embedsAsk: '詢問', + embedsAlways: '一律', + embedsOff: '關閉', + embedsReset: (count: number) => `重設 ${count} 個已允許的服務`, product: '產品', productDesc: '易讀的工具活動與精簡摘要。', technical: '技術', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 328efb90f7d..6e16d0fb005 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -369,6 +369,12 @@ export const zh: Translations = { toolViewDesc: '产品模式隐藏原始工具数据;技术模式显示完整输入/输出。', translucencyTitle: '窗口透明', translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。', + embedsTitle: '内嵌预览', + embedsDesc: '富预览会从第三方网站(YouTube、X 等)加载。询问会在你允许前显示占位符;总是会自动加载;关闭则保留纯链接。', + embedsAsk: '询问', + embedsAlways: '总是', + embedsOff: '关闭', + embedsReset: (count: number) => `重置 ${count} 个已允许的服务`, product: '产品', productDesc: '易读的工具活动与简洁摘要。', technical: '技术', diff --git a/apps/desktop/src/store/embed-consent.ts b/apps/desktop/src/store/embed-consent.ts new file mode 100644 index 00000000000..1ff0f420908 --- /dev/null +++ b/apps/desktop/src/store/embed-consent.ts @@ -0,0 +1,37 @@ +import { type Codec, Codecs, persistentAtom } from '@/lib/persisted' + +// Privacy gate for inline embeds. Loading an embed reaches out to a third party +// (IP, referrer, cookies), so by default we render a placeholder until the user +// consents — per embed ("Load once") or per service ("Always allow YouTube"). +// Mirrors the tool-approval model, but purely client-side (the renderer is what +// makes the request) so it never touches the gateway/config.yaml. +export type EmbedMode = 'always' | 'ask' | 'off' + +const MODE_KEY = 'hermes.desktop.embed-mode' +const ALLOWED_KEY = 'hermes.desktop.embed-allowed' + +const modeCodec: Codec = { + decode: raw => (raw === 'always' || raw === 'off' ? raw : 'ask'), + encode: value => value +} + +/** Global default: ask (placeholder), always (auto-load), off (plain link). */ +export const $embedMode = persistentAtom(MODE_KEY, 'ask', modeCodec) +/** Providers granted a standing "always allow" (e.g. `youtube`, `twitter`). */ +export const $embedAllowed = persistentAtom(ALLOWED_KEY, [], Codecs.stringArray) + +export function allowProvider(provider: string) { + const current = $embedAllowed.get() + + if (!current.includes(provider)) { + $embedAllowed.set([...current, provider]) + } +} + +export function setEmbedMode(mode: EmbedMode) { + $embedMode.set(mode) +} + +export function clearEmbedAllowed() { + $embedAllowed.set([]) +}