Skip to content
Merged
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "notedeck",
"description": "Misskey IDE — integrated deck environment for browsing, posting, searching, and connecting",
"private": true,
"version": "1.1.3",
"version": "1.1.4",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "notedeck"
version = "1.1.3"
version = "1.1.4"
description = "Misskey IDE — integrated deck environment for browsing, posting, searching, and connecting"
edition = "2021"
[dependencies]
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"license": {
"name": "MIT"
},
"version": "1.1.3"
"version": "1.1.4"
},
"paths": {
"/api": {
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
"productName": "NoteDeck",
"version": "1.1.3",
"version": "1.1.4",
"identifier": "com.notedeck.desktop",
"build": {
"frontendDist": "../dist",
Expand Down
15 changes: 8 additions & 7 deletions src/components/deck/DeckNotificationColumn.vue
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ import { ACHIEVEMENT_LABELS } from '@/utils/achievementLabels'
import { AppError, AUTH_ERROR_MESSAGE } from '@/utils/errors'
import { formatTime } from '@/utils/formatTime'
import { proxyUrl } from '@/utils/imageProxy'
import { getStorageJson, STORAGE_KEYS, setStorageJson } from '@/utils/storage'
import {
loadNotificationCache,
saveNotificationCache,
} from '@/utils/notificationCache'
import { commands, unwrap } from '@/utils/tauriInvoke'
import { char2twemojiUrl } from '@/utils/twemoji'
import type { ColumnTabDef } from './ColumnTabs.vue'
Expand Down Expand Up @@ -278,11 +281,11 @@ function flushCache() {
clearTimeout(saveCacheTimer)
saveCacheTimer = null
}
setStorageJson(getCacheKey(), notifications.value)
saveNotificationCache(cacheAccountKey(), notifications.value)
}

function loadCache(): NormalizedNotification[] {
return getStorageJson<NormalizedNotification[]>(getCacheKey(), [])
return loadNotificationCache(cacheAccountKey())
}

const NOTIFICATION_FILTERS = [
Expand Down Expand Up @@ -521,10 +524,8 @@ function notificationLabel(type: string): string {
return NOTIFICATION_LABELS[baseType(type)] || type
}

function getCacheKey() {
return STORAGE_KEYS.notificationCache(
props.column.accountId ?? 'cross-account',
)
function cacheAccountKey() {
return props.column.accountId ?? 'cross-account'
}

// When account loses token (logout with keep-data), switch to cache display
Expand Down
68 changes: 68 additions & 0 deletions src/utils/notificationCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { NormalizedNotification } from '@/adapters/types'
import {
getStorageJson,
removeStorage,
STORAGE_KEYS,
setStorageJson,
} from './storage'

// 形式変更時にバンプすると旧 entry は破棄され、通常フェッチで再構築される (#407)
const NOTIFICATION_CACHE_VERSION = 1

interface CacheEnvelope {
_v: number
items: NormalizedNotification[]
}

function isValidNotification(v: unknown): v is NormalizedNotification {
if (typeof v !== 'object' || v === null) return false
const n = v as Record<string, unknown>
if (
typeof n.id !== 'string' ||
typeof n._accountId !== 'string' ||
typeof n._serverHost !== 'string' ||
typeof n.createdAt !== 'string' ||
typeof n.type !== 'string'
) {
return false
}
// grouped 通知の表示パスは配列前提で .filter する (visibleReactions 等)
if (n.reactions !== undefined && !Array.isArray(n.reactions)) return false
if (n.users !== undefined && !Array.isArray(n.users)) return false
return true
}

/**
* 通知キャッシュを読み込む。バージョン不一致・形式不整合は全破棄して
* 空配列を返し、通常フェッチに委ねる (#407)。
*/
export function loadNotificationCache(
accountKey: string,
): NormalizedNotification[] {
const key = STORAGE_KEYS.notificationCache(accountKey)
const raw = getStorageJson<unknown>(key, null)
if (raw === null) return []
const envelope = raw as Partial<CacheEnvelope>
if (
typeof raw === 'object' &&
!Array.isArray(raw) &&
envelope._v === NOTIFICATION_CACHE_VERSION &&
Array.isArray(envelope.items) &&
envelope.items.every(isValidNotification)
) {
return envelope.items
}
removeStorage(key)
return []
}

/** 通知キャッシュをバージョン付き envelope で保存する。 */
export function saveNotificationCache(
accountKey: string,
items: NormalizedNotification[],
): void {
setStorageJson(STORAGE_KEYS.notificationCache(accountKey), {
_v: NOTIFICATION_CACHE_VERSION,
items,
} satisfies CacheEnvelope)
}
115 changes: 115 additions & 0 deletions tests/utils/notificationCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from 'vitest'
import type { NormalizedNotification } from '@/adapters/types'
import {
loadNotificationCache,
saveNotificationCache,
} from '@/utils/notificationCache'
import { STORAGE_KEYS } from '@/utils/storage'

const ACCOUNT_KEY = 'test-account'
const KEY = STORAGE_KEYS.notificationCache(ACCOUNT_KEY)

function makeNotification(
overrides: Partial<NormalizedNotification> = {},
): NormalizedNotification {
return {
id: 'n1',
_accountId: 'test-account',
_serverHost: 'misskey.example.com',
createdAt: '2026-06-11T00:00:00.000Z',
type: 'reaction',
...overrides,
}
}

describe('notificationCache', () => {
beforeEach(() => {
localStorage.clear()
})

it('save した通知一覧を load すると同じ配列が返る (optional フィールドも保持)', () => {
const items = [
makeNotification(),
makeNotification({
id: 'n2',
type: 'reaction:grouped',
reactions: [],
reaction: '👍',
}),
]
saveNotificationCache(ACCOUNT_KEY, items)
expect(loadNotificationCache(ACCOUNT_KEY)).toEqual(items)
})

it('旧形式の plain array は破棄され、キーも削除される', () => {
localStorage.setItem(KEY, JSON.stringify([makeNotification()]))
expect(loadNotificationCache(ACCOUNT_KEY)).toEqual([])
expect(localStorage.getItem(KEY)).toBeNull()
})

it('version が不一致の envelope は破棄される', () => {
localStorage.setItem(
KEY,
JSON.stringify({ _v: 999, items: [makeNotification()] }),
)
expect(loadNotificationCache(ACCOUNT_KEY)).toEqual([])
expect(localStorage.getItem(KEY)).toBeNull()
})

it('必須フィールド欠落 item が 1 件でも混入していると全破棄される', () => {
localStorage.setItem(
KEY,
JSON.stringify({ _v: 1, items: [makeNotification(), { id: 'x' }] }),
)
expect(loadNotificationCache(ACCOUNT_KEY)).toEqual([])
expect(localStorage.getItem(KEY)).toBeNull()
})

it('必須フィールドが string 以外の item が混入していると全破棄される', () => {
localStorage.setItem(
KEY,
JSON.stringify({
_v: 1,
items: [makeNotification({ createdAt: 123 as unknown as string })],
}),
)
expect(loadNotificationCache(ACCOUNT_KEY)).toEqual([])
})

it('reactions が配列でない item が混入していると全破棄される', () => {
localStorage.setItem(
KEY,
JSON.stringify({
_v: 1,
items: [makeNotification({ reactions: {} as unknown as [] })],
}),
)
expect(loadNotificationCache(ACCOUNT_KEY)).toEqual([])
})

it('users が配列でない item が混入していると全破棄される', () => {
localStorage.setItem(
KEY,
JSON.stringify({
_v: 1,
items: [makeNotification({ users: 'broken' as unknown as [] })],
}),
)
expect(loadNotificationCache(ACCOUNT_KEY)).toEqual([])
})

it('JSON として壊れたデータは空配列にフォールバックする', () => {
localStorage.setItem(KEY, '{broken')
expect(loadNotificationCache(ACCOUNT_KEY)).toEqual([])
})

it('キーが存在しないとき load は空配列を返す', () => {
expect(loadNotificationCache(ACCOUNT_KEY)).toEqual([])
})

it('item が null のとき全破棄される', () => {
localStorage.setItem(KEY, JSON.stringify({ _v: 1, items: [null] }))
expect(loadNotificationCache(ACCOUNT_KEY)).toEqual([])
})
})
Loading