diff --git a/apps/app-frontend/src/components/ui/RemoteAnnouncements.vue b/apps/app-frontend/src/components/ui/RemoteAnnouncements.vue index 4e23f3c..e357a9a 100644 --- a/apps/app-frontend/src/components/ui/RemoteAnnouncements.vue +++ b/apps/app-frontend/src/components/ui/RemoteAnnouncements.vue @@ -8,12 +8,14 @@ import { useVIntl, } from '@modrinth/ui' import { renderString } from '@modrinth/utils' +import { fetch as tauriFetch } from '@tauri-apps/plugin-http' import { openUrl } from '@tauri-apps/plugin-opener' import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue' import { announcementKey, isAnnouncementActive, + mapBroadcastAnnouncements, OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT, parseAnnouncements, REMOTE_ANNOUNCEMENTS_UPDATED_EVENT, @@ -21,6 +23,12 @@ import { safeAnnouncementUrl, } from '@/helpers/remote-announcements' +import { + requestSkinSiteDownloadToken, + skinSiteStatus, + waitForSkinSiteSession, +} from '@/composables/skin-site-session.ts' + const props = defineProps<{ ready: boolean; previewOnly?: boolean }>() const { formatMessage } = useVIntl() const { hasModal } = useModalStack() @@ -61,7 +69,7 @@ const queuedThisSession = new Set() const startupNotified = new Set() let items: RemoteAnnouncement[] = [] let pending: RemoteAnnouncement[] = [] -let cacheKey = '' +let cacheKey = 'starlight-remote-announcements-cache-v1' let cacheLoaded = false let disposed = false let interval: ReturnType | undefined @@ -202,11 +210,35 @@ function loadCache() { // Ignore malformed or expired cache payloads } } +function saveCache(next: RemoteAnnouncement[]) { + if (!cacheKey) return + try { + localStorage.setItem(cacheKey, JSON.stringify({ savedAt: Date.now(), items: next })) + } catch { + // localStorage may be unavailable (private mode / quota) + } +} async function refresh() { - // Remote fetching is disabled until Starlight has its own announcements API. - // We still load any previously cached items so the center has content to show. if (disposed) return loadCache() + if (skinSiteStatus.value !== 'signed-in') return + try { + await waitForSkinSiteSession() + const token = await requestSkinSiteDownloadToken() + const response = await tauriFetch('https://skin.starlight.cool/starlight/skin/broadcast/all', { + method: 'GET', + headers: { Authorization: `Bearer ${token}` }, + }) + if (!response.ok) return + const data = await response.json() + const parsed = mapBroadcastAnnouncements((data as { payload?: unknown } | null)?.payload) + if (parsed && !disposed) { + sync(parsed, true) + saveCache(parsed) + } + } catch { + // Remote fetch failed; previously cached items remain displayed + } } async function openLink(value: unknown) { const url = safeAnnouncementUrl(value) diff --git a/apps/app-frontend/src/helpers/remote-announcements.test.ts b/apps/app-frontend/src/helpers/remote-announcements.test.ts new file mode 100644 index 0000000..1b82f9f --- /dev/null +++ b/apps/app-frontend/src/helpers/remote-announcements.test.ts @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { mapBroadcastAnnouncements } from './remote-announcements.ts' + +test('maps a broadcast entry into a remote announcement', () => { + const result = mapBroadcastAnnouncements([ + { + id: 1, + title: '维护通知', + subtitle: '今晚停机', + content: '服务器将于今晚维护。', + updateAt: 1791640873710, + star: true, + importantLevel: 9, + source: 'SKIN', + }, + ]) + assert.equal(result?.length, 1) + const item = result?.[0] + assert.equal(item?.id, '1') + assert.equal(item?.title, '维护通知') + assert.equal(item?.summary, '今晚停机') + assert.equal(item?.priority, 'critical') + assert.equal(item?.pinned, true) + assert.equal(item?.type, 'modal') + assert.equal(item?.published_at, new Date(1791640873710).toISOString()) + assert.equal(item?.ends_at, null) +}) + +test('empty or blank subtitle becomes null summary', () => { + const result = mapBroadcastAnnouncements([ + { id: 2, title: '公告', subtitle: ' ', content: '内容', updateAt: 1000 }, + ]) + assert.equal(result?.[0]?.summary, null) +}) + +test('skips entries missing required fields', () => { + const result = mapBroadcastAnnouncements([ + { id: 3, title: '有效', content: '内容', updateAt: 1000 }, + { id: 4, title: '', content: '内容', updateAt: 1000 }, + { title: '无 id', content: '内容', updateAt: 1000 }, + { id: 5, title: '无时间', content: '内容' }, + null, + '不是对象', + ]) + assert.equal(result?.length, 1) + assert.equal(result?.[0]?.id, '3') +}) + +test('clamps importantLevel and maps priority boundaries', () => { + const result = mapBroadcastAnnouncements([ + { id: 6, title: 'a', content: 'x', updateAt: 1000, importantLevel: 0 }, + { id: 7, title: 'b', content: 'x', updateAt: 1000, importantLevel: 3 }, + { id: 8, title: 'c', content: 'x', updateAt: 1000, importantLevel: 6 }, + { id: 9, title: 'd', content: 'x', updateAt: 1000, importantLevel: 999 }, + ]) + const byId = new Map(result?.map((item) => [item.id, item.priority])) + assert.equal(byId.get('6'), 'low') + assert.equal(byId.get('7'), 'normal') + assert.equal(byId.get('8'), 'high') + assert.equal(byId.get('9'), 'critical') +}) + +test('returns null for non-array payloads', () => { + assert.equal(mapBroadcastAnnouncements(null), null) + assert.equal(mapBroadcastAnnouncements({ payload: [] }), null) + assert.equal(mapBroadcastAnnouncements('nope'), null) +}) + +test('pinned announcements sort first', () => { + const result = mapBroadcastAnnouncements([ + { id: 10, title: '普通', content: 'x', updateAt: 2000, importantLevel: 9 }, + { id: 11, title: '置顶', content: 'x', updateAt: 1000, star: true }, + ]) + assert.equal(result?.[0]?.id, '11') + assert.equal(result?.[1]?.id, '10') +}) diff --git a/apps/app-frontend/src/helpers/remote-announcements.ts b/apps/app-frontend/src/helpers/remote-announcements.ts index 92f7bfe..ddfd85b 100644 --- a/apps/app-frontend/src/helpers/remote-announcements.ts +++ b/apps/app-frontend/src/helpers/remote-announcements.ts @@ -5,6 +5,7 @@ export type RemoteAnnouncement = { content: string type: 'modal' | 'notification' priority: 'low' | 'normal' | 'high' | 'critical' + pinned?: boolean starts_at: string ends_at: string | null published_at: string @@ -32,18 +33,28 @@ export function parseAnnouncements(value: unknown): RemoteAnnouncement[] | null if (!entry || typeof entry !== 'object') continue const item = entry as Record if ( - typeof item.id !== 'string' || item.id.length > 100 || ids.has(item.id) || - typeof item.title !== 'string' || !item.title.trim() || item.title.length > 120 || - typeof item.content !== 'string' || item.content.length > 20000 || + typeof item.id !== 'string' || + item.id.length > 100 || + ids.has(item.id) || + typeof item.title !== 'string' || + !item.title.trim() || + item.title.length > 120 || + typeof item.content !== 'string' || + item.content.length > 20000 || (item.summary != null && (typeof item.summary !== 'string' || item.summary.length > 300)) || !['modal', 'notification'].includes(String(item.type)) || !['low', 'normal', 'high', 'critical'].includes(String(item.priority)) || - typeof item.starts_at !== 'string' || !Number.isFinite(Date.parse(item.starts_at)) || - typeof item.published_at !== 'string' || !Number.isFinite(Date.parse(item.published_at)) || - (item.ends_at != null && (typeof item.ends_at !== 'string' || !Number.isFinite(Date.parse(item.ends_at)))) || - (item.action_label != null && (typeof item.action_label !== 'string' || item.action_label.length > 80)) || + typeof item.starts_at !== 'string' || + !Number.isFinite(Date.parse(item.starts_at)) || + typeof item.published_at !== 'string' || + !Number.isFinite(Date.parse(item.published_at)) || + (item.ends_at != null && + (typeof item.ends_at !== 'string' || !Number.isFinite(Date.parse(item.ends_at)))) || + (item.action_label != null && + (typeof item.action_label !== 'string' || item.action_label.length > 80)) || (item.action_url != null && !safeAnnouncementUrl(item.action_url)) - ) continue + ) + continue ids.add(item.id) items.push({ ...item, @@ -54,7 +65,12 @@ export function parseAnnouncements(value: unknown): RemoteAnnouncement[] | null } as RemoteAnnouncement) } const rank = { low: 0, normal: 1, high: 2, critical: 3 } - return items.sort((first, second) => rank[second.priority] - rank[first.priority] || Date.parse(second.published_at) - Date.parse(first.published_at)) + return items.sort( + (first, second) => + Number(second.pinned ?? false) - Number(first.pinned ?? false) || + rank[second.priority] - rank[first.priority] || + Date.parse(second.published_at) - Date.parse(first.published_at), + ) } export function isAnnouncementActive(item: RemoteAnnouncement, now = Date.now()) { @@ -67,3 +83,73 @@ export function announcementKey(item: RemoteAnnouncement) { export const REMOTE_ANNOUNCEMENTS_UPDATED_EVENT = 'starlight:remote-announcements-updated' export const OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT = 'starlight:open-remote-announcement-center' +type BroadcastAnnouncementEntry = { + title?: unknown + subtitle?: unknown + content?: unknown + updateAt?: unknown + star?: unknown + importantLevel?: unknown + id?: unknown +} + +function broadcastPriority(level: number): RemoteAnnouncement['priority'] { + if (level >= 9) return 'critical' + if (level >= 6) return 'high' + if (level >= 3) return 'normal' + return 'low' +} + +export function mapBroadcastAnnouncements(payload: unknown): RemoteAnnouncement[] | null { + if (!Array.isArray(payload) || payload.length > 200) return null + const items: RemoteAnnouncement[] = [] + const seen = new Set() + for (const entry of payload) { + if (!entry || typeof entry !== 'object') continue + const item = entry as BroadcastAnnouncementEntry + if ( + typeof item.id !== 'number' || + !Number.isFinite(item.id) || + typeof item.title !== 'string' || + !item.title.trim() || + item.title.length > 120 || + typeof item.content !== 'string' || + item.content.length > 20000 || + typeof item.updateAt !== 'number' || + !Number.isFinite(item.updateAt) || + (item.subtitle != null && typeof item.subtitle !== 'string') + ) + continue + const id = String(item.id) + if (seen.has(id)) continue + seen.add(id) + const level = + typeof item.importantLevel === 'number' && Number.isFinite(item.importantLevel) + ? Math.max(0, Math.min(10, Math.floor(item.importantLevel))) + : 0 + const starred = item.star === true + const published = new Date(item.updateAt).toISOString() + const summary = typeof item.subtitle === 'string' && item.subtitle.trim() ? item.subtitle : null + items.push({ + id, + title: item.title, + summary, + content: item.content, + type: starred || level >= 8 ? 'modal' : 'notification', + priority: broadcastPriority(level), + pinned: starred, + starts_at: published, + ends_at: null, + published_at: published, + action_url: null, + action_label: null, + }) + } + const rank = { low: 0, normal: 1, high: 2, critical: 3 } + return items.sort( + (first, second) => + Number(second.pinned ?? false) - Number(first.pinned ?? false) || + rank[second.priority] - rank[first.priority] || + Date.parse(second.published_at) - Date.parse(first.published_at), + ) +}