Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions src/components/messages/NotificationList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ const { CategoryID } = defineProps<{
CategoryID: number
}>()

const notificationCacheKey = `notifications:${CategoryID}:${locale.value}`
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include user identity in notification cache key

The new cache key only uses CategoryID and locale, so cached notifications are shared across accounts on the same browser profile. On a logout/login switch, restoreNotificationsFromCache() will hydrate another user's messages before any API response arrives, which is a privacy leak and can also leave the list offset (skip) inconsistent for the new account. Add a stable per-user identifier to the key (or clear this store on auth change) to keep notification data isolated.

Useful? React with 👍 / 👎.


function fillInTemplate(data: string | null, message: Message) {
const re = (data ?? '')
.replace(
Expand Down Expand Up @@ -209,15 +211,27 @@ const handleLoad = async (noTemplates = true) => {
}
})

items.value = [...items.value, ...defaultItems]
const merged = [...items.value, ...defaultItems]
const deduplicated = Array.from(new Map(merged.map((item) => [item.ID, item])).values())
items.value = deduplicated
await storageManager.setObjToIDB(notificationCacheKey, items.value)
loading.value = false
skip.value += 20
} catch (error) {
showMessage('error', String(error), { duration: 5000 })
}
}

handleLoad(false)
async function restoreNotificationsFromCache() {
const cached = await storageManager.getObjFromIDB<NotificationMessage[]>(notificationCacheKey)
if (cached.status !== 'success' || cached.value == null || cached.value.length === 0) return
items.value = cached.value
skip.value = cached.value.length
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reset offset when refreshing after restoring cached items

Setting skip to cached.value.length causes offset pagination to miss newly arrived notifications. If new messages were inserted at the top since the cache was written, the next /Messages/GetMessages call (which uses Skip: skip.value) skips over those unseen entries, so users can miss fresh notifications indefinitely. After restoring cache, refresh should fetch from the head (or use a cursor/anchor) instead of reusing list length as an offset.

Useful? React with 👍 / 👎.

}

restoreNotificationsFromCache().finally(() => {
handleLoad(false)
})
</script>

<style scoped>
Expand Down
58 changes: 58 additions & 0 deletions src/services/storage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,52 @@ type localStorages =
| 'userIDAndAvatarIDMap'
| 'userAuthInfo'
| 'cookieConsent'
| 'notificationsCache'

interface StorageResult<T> {
status: StorageStatus
value: T | null
}

const IDB_DB_NAME = 'plweb2-storage-db'
const IDB_STORE_NAME = 'kv'

function openIDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(IDB_DB_NAME, 1)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(IDB_STORE_NAME)) {
db.createObjectStore(IDB_STORE_NAME)
}
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}

async function idbGet<T>(key: string): Promise<T | undefined> {
const db = await openIDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(IDB_STORE_NAME, 'readonly')
const store = tx.objectStore(IDB_STORE_NAME)
const req = store.get(key)
req.onsuccess = () => resolve(req.result as T | undefined)
req.onerror = () => reject(req.error)
})
}

async function idbSet<T>(key: string, value: T): Promise<void> {
const db = await openIDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(IDB_STORE_NAME, 'readwrite')
const store = tx.objectStore(IDB_STORE_NAME)
store.put(value, key)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
}

function now() {
return Date.now()
}
Expand Down Expand Up @@ -72,6 +112,24 @@ const storageManager = {
clear() {
localStorage.clear()
},
async getObjFromIDB<T>(key: string, maxAgeMs?: number): Promise<StorageResult<T>> {
try {
const data = await idbGet<{ value: T; time: number; maxAgeMs?: number }>(key)
if (!data) return { status: 'empty', value: null }
const ageLimit = maxAgeMs ?? data.maxAgeMs
if (ageLimit && data.time && now() - data.time > ageLimit) {
return { status: 'expired', value: null }
}
return { status: 'success', value: data.value }
} catch (e) {
console.error(e)
return { status: 'empty', value: null }
}
},
async setObjToIDB<T>(key: string, value: T, maxAgeMs?: number): Promise<void> {
const data = { value, time: now(), maxAgeMs }
await idbSet(key, data)
},
}

export default storageManager