From de54f93a733fccc688da3d377fb6f4d73408210b Mon Sep 17 00:00:00 2001 From: toorux Date: Sat, 10 Oct 2026 00:39:22 +0800 Subject: [PATCH] Redesign spatial notifications and add streaming to the quick panel --- tests/notifications_fixture.tsx | 9 ++--- tests/quick_panel_navigation_fixture.tsx | 14 ++++++-- ui/locales/en-US.json | 4 ++- ui/locales/zh-CN.json | 4 ++- ui/src/cast-quick.tsx | 21 ++++++++++++ ui/src/main.tsx | 8 +++-- ui/src/notification-surfaces.css | 42 ++++++++++++++++++++++++ ui/src/notifications.tsx | 7 ++-- 8 files changed, 95 insertions(+), 14 deletions(-) create mode 100644 ui/src/cast-quick.tsx create mode 100644 ui/src/notification-surfaces.css diff --git a/tests/notifications_fixture.tsx b/tests/notifications_fixture.tsx index d2868c3..4ac1b6b 100644 --- a/tests/notifications_fixture.tsx +++ b/tests/notifications_fixture.tsx @@ -4,6 +4,7 @@ import {createRoot} from 'react-dom/client'; import {NotificationCenter,type NotificationEntry} from '../ui/src/notifications'; import {configureLanguage} from '../ui/src/i18n'; import '../ui/src/style.css'; +import '../ui/src/notification-surfaces.css'; const root=createRoot(document.getElementById('root')!); const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms)); const plugin={manifest:{id:'capture.clip',name:'Clip capture',description:'Capture your session',version:'1.0.0',author:'Framely',ui:{windows:{}}},enabled:true,favorite:false,order:0}; @@ -58,19 +59,19 @@ async function until(check:()=>boolean){for(let i=0;i<60;i++){if(check())return; if(!entries[0].inInbox)throw Error('floating close removed saved message'); render(true);await wait();button('移除通知').click();await until(()=>!!document.querySelector('.inbox-empty')); entries=[entry('expiry',{expiresAt:Date.now()+350})];render(false);await wait(700); - if(document.querySelector('.toast')||!entries[0].inInbox)throw Error('automatic close removed inbox copy'); + if(document.querySelector('.toasts')||!entries[0].inInbox)throw Error('automatic close left an empty popup or removed inbox copy'); entries=[entry('persistent',{expiresAt:null,notification:{id:'persistent',title:'Persistent status',body:'Stays visible until dismissed',durationMs:0}})];render(false);await wait(); if(!document.querySelector('.toast')||document.querySelector('[role=timer]'))throw Error('persistent notification has a countdown'); entries=[entry('long',{notification:{id:'long',title:'A recording with a long title is ready to review',body:'Recording details. '.repeat(80),actions:[{id:'open',label:'Open recording'},{id:'copy',label:'Copy path'},{id:'continue',label:'Continue recording'}]}})];render(false);await wait(); const compact=document.querySelector('.toast') as HTMLElement,message=compact.querySelector('.notification-message') as HTMLElement; - if(compact.getBoundingClientRect().height>185||message.scrollHeight<=message.clientHeight)throw Error('long notification expanded instead of scrolling inside compact popup'); + if(compact.getBoundingClientRect().height>233||message.scrollHeight<=message.clientHeight)throw Error('long notification expanded instead of scrolling inside compact popup'); for(const action of compact.querySelectorAll('.toast-actions button')){const bounds=action.getBoundingClientRect();if(bounds.height<44||bounds.bottom>compact.getBoundingClientRect().bottom)throw Error('compact notification action is clipped or too small');} entries=[entry('saved',{notification:{id:'saved',title:'Recording saved',body:'Your recording is ready. Open it whenever you are ready.',actions:[{id:'open',label:'Open recording'}]}}),entry('update',{plugin:'',pluginName:'Framely',update:{version:'0.4.3-preview.1'},notification:{id:'update',title:'Update',body:'New release',actions:[{id:'ignore',label:'Ignore'},{id:'open',label:'Open updates'}]}})]; configureLanguage('en-US',[]);render(true);await wait(180); if(!document.body.textContent!.includes('Inbox')||!document.body.textContent!.includes('Open updates'))throw Error('English translation missing'); console.log('FRAMELY_PREVIEW_INSTALL_INBOX_EN'); render(false);await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_TOAST_EN'); - if([...document.querySelectorAll('.toast')].some(card=>card.getBoundingClientRect().height>185))throw Error('floating notification exceeds compact height'); + if([...document.querySelectorAll('.toast')].some(card=>card.getBoundingClientRect().height>233))throw Error('floating notification exceeds compact height'); if([...document.querySelectorAll('.toast-actions button')].some(b=>b.scrollWidth>b.clientWidth))throw Error('notification action overflowed'); // Mount the real quick menu and assert the navigation actually reaches the inbox. root.unmount();await import('../ui/src/main'); @@ -80,7 +81,7 @@ async function until(check:()=>boolean){for(let i=0;i<60;i++){if(check())return; if(document.querySelectorAll('.plugin-row').length!==1)throw Error('quick installed list includes disabled plugins'); const notifications=[...document.querySelectorAll('nav button')].find(b=>b.textContent?.startsWith('通知')) as HTMLButtonElement; if(!notifications)throw Error('quick inbox tab missing');notifications.click();await wait(180); - if(document.querySelectorAll('.inbox-message').length!==2||document.querySelectorAll('nav button').length!==4)throw Error('quick inbox navigation broken'); + if(document.querySelectorAll('.inbox-message').length!==2||document.querySelectorAll('nav button').length!==5)throw Error('quick inbox navigation broken'); if(!document.querySelector('[role=switch][aria-label="允许通知"]')||!document.querySelector('[aria-label="通知设置"]'))throw Error('quick notification controls missing'); console.log('FRAMELY_PREVIEW_INSTALL_QUICK_INBOX'); button('打开更新').click();await until(()=>!document.querySelector('.inbox-message')?.textContent?.includes('可更新')); diff --git a/tests/quick_panel_navigation_fixture.tsx b/tests/quick_panel_navigation_fixture.tsx index 0be3554..9394cbd 100644 --- a/tests/quick_panel_navigation_fixture.tsx +++ b/tests/quick_panel_navigation_fixture.tsx @@ -2,13 +2,18 @@ const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms)); const until=async(f:()=>any)=>{for(let i=0;i<150;i++){if(f())return;await wait(30);}throw Error('Timed out '+document.body.innerText.slice(0,350));}; history.replaceState(null,'','/?fixture=1#plugin:test.quick'); -let polls=0,opens=0; +let polls=0,opens=0,streaming=false; +const casting={source:'screen',codec:'h265',output:'eye',eye:'steamVR',width:1920,height:1200,fps:60,bitrateMbps:8,horizontalFov:null,centerX:.5,centerY:.5,systemAudio:true,microphone:false,airplay:false,dlna:false,receiverName:'Framely'}; window.fetch=async(_url,init)=>{ const {method}=JSON.parse(String(init?.body));let result:any=true; - if(method==='status'){polls++;result={version:'0.4.3-preview.2',agreement:{version:1,accepted:true},database:{language:'en-US',plugins:{'test.quick':{manifest:{id:'test.quick',name:'Quick fixture',version:'1',author:'Test',description:'',ui:{quickPage:'page.js',windows:{}}},enabled:true,favorite:true,order:0}},sources:[],safeMode:false},running:[],notifications:[],inbox:[]};} + if(method==='status'){polls++;result={version:'0.4.3-preview.2',agreement:{version:1,accepted:true},database:{casting,language:'en-US',plugins:{'test.quick':{manifest:{id:'test.quick',name:'Quick fixture',version:'1',author:'Test',description:'',ui:{quickPage:'page.js',windows:{}}},enabled:true,favorite:true,order:0}},sources:[],safeMode:false},running:[],notifications:[],inbox:[]};} else if(method==='language.list')result={packs:[],invalidFiles:[]}; else if(method==='ui.events')result={cursor:0,events:[]}; else if(method==='plugin.open')opens++; + else if(method==='cast.status')result={running:streaming}; + else if(method==='cast.start')streaming=true; + else if(method==='cast.stop')streaming=false; + else if(method==='cast.settings.save'){const params=JSON.parse(String(init?.body)).params;if(params.width!==1920||params.codec!=='h265')throw Error('Receiver switch reset stream settings');Object.assign(casting,params);} return new Response(JSON.stringify({result})); }; const back=()=>document.querySelector('.page-title button')!; @@ -26,6 +31,11 @@ const back=()=>document.querySelector('.page-title button')!; window.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape'}));await until(()=>!!document.querySelector('.workspace')); const third=polls;await until(()=>polls>=third+2); if(back()||location.hash||opens!==3)throw Error('Escape reopened plugin'); + const streamTab=[...document.querySelectorAll('nav button')].find(b=>b.textContent==='Streaming');if(!streamTab)throw Error('Streaming tab missing');streamTab.click();await until(()=>!!document.querySelector('.cast-quick')); + const start=[...document.querySelectorAll('.cast-quick button')].find(b=>b.textContent==='Start streaming')!;await until(()=>!start.disabled);start.click();await until(()=>streaming&&document.querySelector('.cast-quick-stream button')?.textContent==='Stop streaming'); + const receiver=document.querySelector('button[role=switch][aria-label=AirPlay]')!;await until(()=>!receiver.disabled);receiver.click();await until(()=>casting.airplay&&receiver.getAttribute('aria-checked')==='true'); + document.querySelector('.cast-quick-stream button')!.click();await until(()=>!streaming); + if(document.documentElement.scrollWidth>innerWidth)throw Error('Quick panel overflows viewport'); console.log('FRAMELY_BRIDGE_PASS'); }catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);} }; diff --git a/ui/locales/en-US.json b/ui/locales/en-US.json index e773d25..5b34516 100644 --- a/ui/locales/en-US.json +++ b/ui/locales/en-US.json @@ -1227,6 +1227,8 @@ "通用兼容": "Broad compatibility", "更高压缩效率": "Better compression", "使用头显硬件编码。浏览器不支持所选编码时使用兼容画面。": "Uses headset hardware encoding. Browsers that cannot decode the selected codec receive a compatible stream.", - "SteamVR 使用软件编码,H.265 会增加处理负载。": "SteamVR uses software encoding. H.265 increases processing load." + "SteamVR 使用软件编码,H.265 会增加处理负载。": "SteamVR uses software encoding. H.265 increases processing load.", + "串流": "Streaming", + "投屏设置": "Casting settings" } } diff --git a/ui/locales/zh-CN.json b/ui/locales/zh-CN.json index ebe24f9..8a9fc86 100644 --- a/ui/locales/zh-CN.json +++ b/ui/locales/zh-CN.json @@ -1227,6 +1227,8 @@ "通用兼容": "通用兼容", "更高压缩效率": "更高压缩效率", "使用头显硬件编码。浏览器不支持所选编码时使用兼容画面。": "使用头显硬件编码。浏览器不支持所选编码时使用兼容画面。", - "SteamVR 使用软件编码,H.265 会增加处理负载。": "SteamVR 使用软件编码,H.265 会增加处理负载。" + "SteamVR 使用软件编码,H.265 会增加处理负载。": "SteamVR 使用软件编码,H.265 会增加处理负载。", + "串流": "串流", + "投屏设置": "投屏设置" } } diff --git a/ui/src/cast-quick.tsx b/ui/src/cast-quick.tsx new file mode 100644 index 0000000..c8bd45b --- /dev/null +++ b/ui/src/cast-quick.tsx @@ -0,0 +1,21 @@ +import React,{useEffect,useState} from 'react'; +import {IconCast,IconPlayerPlay,IconPlayerStop,IconArrowUpRight,IconDeviceVisionPro} from '@tabler/icons-react'; +import {api} from './api'; +import {t} from './i18n'; +import {Switch} from './switch'; +import type {CastSettings} from './casting'; + +type Runtime={running:boolean;error?:string;receiverError?:string;watchAddresses?:string[]}; +export function CastQuick({config,refresh}:{config:CastSettings;refresh:()=>Promise}){ + const [runtime,setRuntime]=useState(null),[busy,setBusy]=useState(false),[error,setError]=useState(''); + useEffect(()=>{let alive=true;const poll=()=>void api('cast.status').then(v=>{if(alive)setRuntime(v);}).catch(e=>{if(alive)setError(e.message);});poll();const timer=setInterval(poll,1000);return()=>{alive=false;clearInterval(timer);};},[]); + async function run(task:()=>Promise){if(busy)return;setBusy(true);setError('');try{await task();setRuntime(await api('cast.status'));await refresh();}catch(e){setError(e instanceof Error?e.message:String(e));}finally{setBusy(false);}} + const running=!!runtime?.running; + return
+

{t('串流')}

+

{running?t('正在串流'):t('头显画面')}

{runtime?`${config.width} × ${config.height} / ${config.fps} FPS`:t('正在连接')}

+ {running&&runtime?.watchAddresses?.[0]&&
{t('网页观看')}{runtime.watchAddresses[0]}
} +

{t('接收投屏')}

{(['airplay','dlna'] as const).map(protocol=>)}
+ {(error||runtime?.error||runtime?.receiverError)&&

{error||runtime?.error||runtime?.receiverError}

} +
; +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index e7da9c8..a367ba2 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -1,3 +1,4 @@ +import {CastQuick} from './cast-quick'; import {CastPlayer} from './cast-player'; import {Casting,type CastSettings,type CastPage} from './casting'; import {CastViewer} from './cast-viewer'; @@ -12,7 +13,7 @@ import {PluginFrame,pluginLanguagePreference,setPluginLanguagePreference} from ' import {Launcher,LauncherSettings,type LauncherConfig} from './launcher'; import {NotificationSettings,PopupPermission,useReadInbox,defaultNotificationConfig,type NotificationConfig} from './notification-settings'; import {NotificationCenter,type NotificationEntry} from './notifications'; -import {IconBell,IconLayoutGrid} from '@tabler/icons-react'; +import {IconBell,IconLayoutGrid,IconCast} from '@tabler/icons-react'; import {t} from './i18n'; import {uploadFile} from './upload'; import {LanguageSettings} from './language'; @@ -26,7 +27,7 @@ import {InstallReview} from './install-review'; import {MemoryLimit} from './memory-limit'; import {installScrollbars} from '../../sdk/src/scrollbars'; installScrollbars(); -import React,{useEffect,useRef,useState} from 'react';import{createRoot}from'react-dom/client';import{uploadChunk,api,viewKey,exportDiagnosticLogs}from'./api';import {Icon,Brand} from './icons';import {Store,Sources,PluginImage,PluginLinks,PluginAuthor,installedIcon,runJob,Transfer,Job,Presentation,compareVersions} from './store';import {SystemUpdates} from './updates';import './style.css';import './mobile-manager.css';if(location.pathname==='/launcher')document.documentElement.classList.add('launcher-view');if(location.pathname==='/notifications')document.documentElement.classList.add('notification-view'); +import React,{useEffect,useRef,useState} from 'react';import{createRoot}from'react-dom/client';import{uploadChunk,api,viewKey,exportDiagnosticLogs}from'./api';import {Icon,Brand} from './icons';import {Store,Sources,PluginImage,PluginLinks,PluginAuthor,installedIcon,runJob,Transfer,Job,Presentation,compareVersions} from './store';import {SystemUpdates} from './updates';import './style.css';import './mobile-manager.css';import './notification-surfaces.css';if(location.pathname==='/launcher')document.documentElement.classList.add('launcher-view');if(location.pathname==='/notifications')document.documentElement.classList.add('notification-view'); export type Manifest=Presentation&{id:string;name:string;version:string;author:string;description:string;backend?:{runAs:string;autostart:boolean;memoryLimitMiB?:number};lifecycle?:{runAs?:string};dependencies?:Record;optionalDependencies?:Record;conflicts?:Record;exclusiveResources?:string[];engines?:{framely?:string};ui:{quickPage?:string;windows:Record;launch?:Record;launcherActions?:{id:string;label:string;target:{type:string;entry?:string;window?:string}}[]}}; export type Plugin={manifest:Manifest;enabled:boolean;favorite:boolean;order:number;source?:string;error?:string}; type Source={id:string;name:string;url:string;enabled:boolean;allowHttp:boolean}; @@ -97,7 +98,7 @@ function App(){ if(parts[1]==='notifications')return ; if(windowPlugin){const p=status.database.plugins[windowPlugin];return
{p?.enabled&&!status.database.safeMode?:

{t("插件已停用或正在启动。")}

}{error&&

{error}

}
;} return
{managerMode?<>{managerSection==='plugins'&&}{mobile&&key==='catalog'?void loadCatalog():(setPage(null),setTab(key),history.replaceState(null,'',location.pathname+location.search+'#'+key))}/> }:<>}
{error&&

{error}

}{status.database.safeMode&&

{t("安全模式:第三方插件暂停运行。")}

} - {selected?<>
{selected.manifest.name}{managerMode&&}
{selected.enabled&&!status.database.safeMode&&selected.manifest.ui.quickPage?:

{t("插件已停用、处于安全模式或没有快捷页面。")}

}:
+ {selected?<>
{selected.manifest.name}{managerMode&&}
{selected.enabled&&!status.database.safeMode&&selected.manifest.ui.quickPage?:

{t("插件已停用、处于安全模式或没有快捷页面。")}

}:
{(tab==='favorites'||tab==='installed')&&<> {managerMode&&

{t("已安装")}

{plugins.length}{t("个插件,")}{plugins.filter(p=>p.enabled).length}{t("个已启用")}

setQuery(e.target.value)}/>
} {managerMode&&
{t("插件")}{t("状态")}{t("版本")}{t("操作")}
}
{listedPlugins.map(p=>
@@ -107,6 +108,7 @@ function App(){
)}
{listedPlugins.length===0&&

{query?t("未找到插件"):tab==='favorites'?t("没有收藏的插件"):onlyDisabledInstalled?t("没有已启用的插件"):t("还没有安装插件")}

{query?t("换一个关键词再试。"):tab==='favorites'?t("点击插件旁的星标,将它加入常用。"):onlyDisabledInstalled?t("打开插件管理以启用插件。"):t("打开插件管理以安装插件。")}

} } + {tab==='casting'&&!managerMode&&status.database.casting&&} {tab==='notifications'&&!managerMode&&<>}/>{readError&&

{readError}

}} {managerMode&&(filesVisited||tab==='files')&&} {tab==='terminal'&&managerMode&&} diff --git a/ui/src/notification-surfaces.css b/ui/src/notification-surfaces.css new file mode 100644 index 0000000..79de623 --- /dev/null +++ b/ui/src/notification-surfaces.css @@ -0,0 +1,42 @@ +/* Spatial controls retain Framely's dark material and single blue accent. + Panels use 16px corners; compact actions use pill corners. */ +.notification-view #root,.notification-view .toasts{background:transparent} +.notification-view .toasts:empty{display:none} +.toasts{padding:12px;scrollbar-width:none} +.toasts .notification-card{padding:16px 18px;max-height:232px;margin:0 0 10px;border-radius:16px;border:1px solid #ffffff20;background:rgba(25,27,30,.97);box-shadow:inset 0 1px 0 #ffffff0c;gap:0} +.toasts .notification-heading{min-height:28px;gap:10px;margin-bottom:12px} +.toasts .notification-source-icon{width:24px;height:24px;color:var(--blue)} +.toasts .notification-origin{gap:12px;align-items:center} +.toasts .notification-origin b{font-size:15px;font-weight:550;color:var(--muted)} +.toasts .notification-countdown{font-size:12px;color:var(--muted);margin-left:auto} +.toasts .notification-dismiss{border:0;background:transparent;color:var(--muted);width:36px;min-height:36px;height:36px;border-radius:999px;padding:8px} +.toasts .notification-dismiss:hover{background:var(--control-hover);color:var(--text)} +.toasts .notification-message{max-height:88px;overflow:auto;scrollbar-width:thin} +.toasts .notification-message h2{font-size:22px;line-height:1.3;letter-spacing:-.35px;margin:0 0 6px} +.toasts .notification-message p{font-size:17px;line-height:1.45;color:#c7cad0} +.toasts .notification-card .toast-actions{gap:10px;margin-top:16px;justify-content:flex-end} +.toasts .notification-card .toast-actions button{min-width:76px;min-height:44px;padding:10px 18px;border-radius:999px;border:1px solid #ffffff1a;background:#ffffff09;font-size:15px;font-weight:550} +.toasts .notification-card .toast-actions .notification-primary{background:var(--blue);border-color:var(--blue);color:#152331} +.toasts .notification-card .toast-actions .notification-primary:hover{background:#b5d7f2} +.notification-inbox .notification-card{border-radius:16px;padding:22px;border-color:#ffffff18;background:#ffffff05} +.notification-inbox .notification-card .toast-actions button{border-radius:999px} +.notification-inbox .notification-card .toast-actions .notification-primary{background:var(--blue);color:#152331;border-color:var(--blue)} +.cast-quick{padding:4px 0 20px} +.cast-quick>header{display:flex;align-items:center;gap:12px;margin-bottom:24px;color:var(--blue)} +.cast-quick>header h1{font-size:28px;color:var(--text);flex:1} +.cast-quick>header button{width:44px;min-height:44px;padding:10px;background:transparent;border:0;border-radius:999px} +.cast-quick-stream{display:flex;flex-direction:column;align-items:center;padding:24px 16px 28px;border-radius:16px;background:#ffffff06;border:1px solid #ffffff14} +.cast-quick-stream>svg{color:var(--blue);margin-bottom:18px} +.cast-quick-stream h2{font-size:24px;margin-bottom:6px} +.cast-quick-stream p{font-size:16px;color:var(--muted);font-variant-numeric:tabular-nums;margin:0 0 22px} +.cast-quick-stream button{min-width:180px;border-radius:999px} +.cast-quick-stream button.primary{background:var(--blue);color:#152331;border-color:var(--blue)} +.cast-quick-address{display:grid;gap:8px;padding:18px 0;color:var(--muted);font-size:16px} +.cast-quick-address code{font:15px/1.5 ui-monospace,monospace;color:var(--text);overflow-wrap:anywhere;user-select:all} +.cast-quick-receive{margin-top:28px} +.cast-quick-receive h2{font-size:17px;font-weight:500;color:var(--muted);margin-bottom:8px} +.cast-quick-receive label{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:0;padding:14px 0} +.cast-quick-receive .ui-switch{flex:none} +.quick nav button>span{font-size:16px} +@media(max-width:480px){.toasts .notification-card{padding:14px}.toasts .notification-heading{gap:8px}.toasts .notification-origin{flex-direction:row;gap:6px}.toasts .notification-countdown{font-size:11px}.toasts .notification-card .toast-actions button{flex:1;padding:10px 12px}.notification-inbox .notification-card{padding:18px}} +@media(prefers-reduced-motion:no-preference){.notification-card button,.cast-quick button{transition:background .15s,transform .15s}.notification-card button:active:not(:disabled),.cast-quick button:active:not(:disabled){transform:scale(.98)}} diff --git a/ui/src/notifications.tsx b/ui/src/notifications.tsx index 9f43b96..b247d7a 100644 --- a/ui/src/notifications.tsx +++ b/ui/src/notifications.tsx @@ -1,5 +1,5 @@ import React,{useEffect,useState} from 'react'; -import {IconBell,IconInbox,IconX,IconTrash,IconArrowUpRight} from '@tabler/icons-react'; +import {IconBell,IconInbox,IconX,IconTrash,IconArrowUpRight,IconCast} from '@tabler/icons-react'; import {api} from './api'; import {t,currentLanguage} from './i18n'; import {Brand} from './icons'; @@ -23,7 +23,7 @@ function NotificationCard({entry,inbox,now,refresh,available}:{entry:Notificatio } return
- {entry.update?:} + {entry.notification.id.startsWith('cast.')?:entry.update?:}
{entry.pluginName}{inbox?:remaining!==null&&{t('{0} 秒后关闭',{0:remaining})}}
@@ -31,7 +31,7 @@ function NotificationCard({entry,inbox,now,refresh,available}:{entry:Notificatio {!available&&

{t('启用此插件后可使用通知操作。')}

} {!!entry.notification.actions?.length&&
{entry.notification.actions.map(a=>{ const label=entry.update?(a.id==='ignore'?t('忽略'):t('打开更新')):a.label; - return ; + return ; })}
} {error&&

{error}

}
; @@ -40,6 +40,7 @@ export function NotificationCenter({items,inbox=false,refresh,plugins={},safeMod const[now,setNow]=useState(Date.now()); useEffect(()=>{if(inbox)return;const timer=setInterval(()=>setNow(Date.now()),250);return()=>clearInterval(timer);},[inbox]); const visible=items.filter(n=>inbox?n.inInbox:n.toast&&(n.expiresAt===null||n.expiresAt>now)).sort((a,b)=>b.createdAt-a.createdAt); + if(!inbox&&!visible.length)return null; return
{inbox&&

{t('通知')}

{t('收件箱')}{visible.length}

} {inbox&&controls}