1 parent
c54acd2e36
commit
cadb7bbdcd
3 files changed
+208
-12
No files matched your search
@@ -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<string>()
|
||||
const startupNotified = new Set<string>()
|
||||
let items: RemoteAnnouncement[] = []
|
||||
let pending: RemoteAnnouncement[] = []
|
||||
let cacheKey = ''
|
||||
let cacheKey = 'starlight-remote-announcements-cache-v1'
|
||||
let cacheLoaded = false
|
||||
let disposed = false
|
||||
let interval: ReturnType<typeof setInterval> | 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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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<string, unknown>
|
||||
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<string>()
|
||||
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),
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user