1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-05-01 01:05:50 +02:00

Compare commits

...

14 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
23358a5fe9 Revert incorrect cssCodeSplit change - it still creates separate CSS files
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2025-12-11 10:27:35 +00:00
copilot-swe-agent[bot]
fbc4da1c48 Change cssCodeSplit from true to false to bundle CSS into JS
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2025-12-11 09:25:58 +00:00
copilot-swe-agent[bot]
28e196e978 Initial plan 2025-12-11 09:22:59 +00:00
まっちゃてぃー。
2cffd9f0fb fix(sw): オフライン時のfetch timeout処理を実装 (#16952)
* fix(sw): implement fetch timeout handling for navigation and offline content

* fix(sw): increase fetch timeout

* fix(sw): improve fetch timeout handling for i18n content

* fix(sw): 結局、fetchを通るかCacheがhitするはずなので、i18nのところはいらない

* fix(sw): 400番台のエラーを無条件でオフラインページにしていたのを修正

* 間違えた

* i18nもtimeoutが必要

* import sortingを修正

* import sortingを修正

* Fix: Frontend のsharedにはアクセスできないじゃん...

* SPDX

* Update CHANGELOG.md

* Update packages/sw/src/scripts/lang.ts

Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>

* Update packages/sw/src/sw.ts

Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>

* Update CHANGELOG.md

Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>

---------

Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
2025-12-10 17:26:30 +09:00
syuilo
988f5ab69f fix(backend): ジョブキューでSentryが有効にならない問題を修正 2025-12-08 15:44:37 +09:00
かっこかり
3afe7c5348 Update CHANGELOG.md [ci skip] 2025-12-08 10:20:07 +09:00
かっこかり
73cc30f50f fix(frontend): ロード時の言語判定結果が保存されない問題を修正 (#16956)
* fix(frontend): ロード時の言語判定結果が保存されない問題を修正

* Update Changelog
2025-12-08 10:17:13 +09:00
github-actions[bot]
da3b3af984 [skip ci] Update CHANGELOG.md (prepend template) 2025-12-06 12:23:00 +00:00
github-actions[bot]
3273ca7512 Release: 2025.12.0 2025-12-06 12:22:55 +00:00
syuilo
b67bfe0763 Update CHANGELOG.md
Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
2025-12-06 21:03:06 +09:00
かっこかり
63d2870755 fix(backend): fix tests (#16947)
* fix(backend): shouldHideNoteByTimeのロジックの誤りを修正

* fix tests
2025-12-06 19:32:13 +09:00
syuilo
61f9c148f0 🎨 2025-12-06 18:46:13 +09:00
syuilo
8927a9e98a Update CHANGELOG.md 2025-12-06 18:27:57 +09:00
おさむのひと
dc77d59f87 Merge commit from fork 2025-12-06 18:25:20 +09:00
16 changed files with 357 additions and 93 deletions

View File

@@ -1,8 +1,21 @@
## 2025.12.0 ## Unreleased
### General ### General
- -
### Client
- Fix: 特定の条件下でMisskeyが起動せず空白のページが表示されることがある問題を軽減
- Fix: 初回読み込み時などに、言語設定で不整合が発生することがある問題を修正
### Server
- Fix: ジョブキューでSentryが有効にならない問題を修正
## 2025.12.0
### Note
- configの`trustProxy`のデフォルト値を`false`に変更しました。アップデート前に現在のconfigをご確認の上、必要に応じて値を変更してください。
### Client ### Client
- Fix: stacking router viewで連続して戻る操作を行うと何も表示されなくなる問題を修正 - Fix: stacking router viewで連続して戻る操作を行うと何も表示されなくなる問題を修正
@@ -10,7 +23,7 @@
- Enhance: メモリ使用量を削減しました - Enhance: メモリ使用量を削減しました
- Enhance: ActivityPubアクティビティを送信する際のパフォーマンス向上 - Enhance: ActivityPubアクティビティを送信する際のパフォーマンス向上
- Enhance: 依存関係の更新 - Enhance: 依存関係の更新
- Fix: セキュリティに関する修正
## 2025.11.1 ## 2025.11.1

View File

@@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2025.12.0-beta.0", "version": "2025.12.0",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@@ -15,6 +15,7 @@ import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepos
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { DebounceLoader } from '@/misc/loader.js'; import { DebounceLoader } from '@/misc/loader.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
import type { OnModuleInit } from '@nestjs/common'; import type { OnModuleInit } from '@nestjs/common';
import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { CustomEmojiService } from '../CustomEmojiService.js';
@@ -116,12 +117,7 @@ export class NoteEntityService implements OnModuleInit {
private treatVisibility(packedNote: Packed<'Note'>): Packed<'Note'>['visibility'] { private treatVisibility(packedNote: Packed<'Note'>): Packed<'Note'>['visibility'] {
if (packedNote.visibility === 'public' || packedNote.visibility === 'home') { if (packedNote.visibility === 'public' || packedNote.visibility === 'home') {
const followersOnlyBefore = packedNote.user.makeNotesFollowersOnlyBefore; const followersOnlyBefore = packedNote.user.makeNotesFollowersOnlyBefore;
if ((followersOnlyBefore != null) if (shouldHideNoteByTime(followersOnlyBefore, packedNote.createdAt)) {
&& (
(followersOnlyBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (followersOnlyBefore * 1000)))
|| (followersOnlyBefore > 0 && (new Date(packedNote.createdAt).getTime() < followersOnlyBefore * 1000))
)
) {
packedNote.visibility = 'followers'; packedNote.visibility = 'followers';
} }
} }
@@ -141,12 +137,7 @@ export class NoteEntityService implements OnModuleInit {
if (!hide) { if (!hide) {
const hiddenBefore = packedNote.user.makeNotesHiddenBefore; const hiddenBefore = packedNote.user.makeNotesHiddenBefore;
if ((hiddenBefore != null) if (shouldHideNoteByTime(hiddenBefore, packedNote.createdAt)) {
&& (
(hiddenBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (hiddenBefore * 1000)))
|| (hiddenBefore > 0 && (new Date(packedNote.createdAt).getTime() < hiddenBefore * 1000))
)
) {
hide = true; hide = true;
} }
} }

View File

@@ -0,0 +1,29 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* ノートが指定された時間条件に基づいて非表示対象かどうかを判定する
* @param hiddenBefore 非表示条件(負の値: 作成からの経過秒数、正の値: UNIXタイムスタンプ秒、null: 判定しない)
* @param createdAt ートの作成日時ISO 8601形式の文字列 または Date オブジェクト)
* @returns 非表示にすべき場合は true
*/
export function shouldHideNoteByTime(hiddenBefore: number | null | undefined, createdAt: string | Date): boolean {
if (hiddenBefore == null) {
return false;
}
const createdAtTime = typeof createdAt === 'string' ? new Date(createdAt).getTime() : createdAt.getTime();
if (hiddenBefore <= 0) {
// 負の値: 作成からの経過時間(秒)で判定
const elapsedSeconds = (Date.now() - createdAtTime) / 1000;
const hideAfterSeconds = Math.abs(hiddenBefore);
return elapsedSeconds >= hideAfterSeconds;
} else {
// 正の値: 絶対的なタイムスタンプ(秒)で判定
const createdAtSeconds = createdAtTime / 1000;
return createdAtSeconds <= hiddenBefore;
}
}

View File

@@ -157,7 +157,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
} }
let Sentry: typeof import('@sentry/node') | undefined; let Sentry: typeof import('@sentry/node') | undefined;
if (Sentry != null) { if (this.config.sentryForBackend) {
import('@sentry/node').then((mod) => { import('@sentry/node').then((mod) => {
Sentry = mod; Sentry = mod;
}); });

View File

@@ -5,21 +5,20 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import { Writable } from 'node:stream'; import { Writable } from 'node:stream';
import { Inject, Injectable, StreamableFile } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { MoreThan } from 'typeorm';
import { format as dateFormat } from 'date-fns'; import { format as dateFormat } from 'date-fns';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { ClipNotesRepository, ClipsRepository, MiClip, MiClipNote, MiUser, NotesRepository, PollsRepository, UsersRepository } from '@/models/_.js'; import type { ClipNotesRepository, ClipsRepository, MiClip, MiClipNote, MiUser, PollsRepository, UsersRepository } from '@/models/_.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js'; import { DriveService } from '@/core/DriveService.js';
import { createTemp } from '@/misc/create-temp.js'; import { createTemp } from '@/misc/create-temp.js';
import type { MiPoll } from '@/models/Poll.js'; import type { MiPoll } from '@/models/Poll.js';
import type { MiNote } from '@/models/Note.js'; import type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import { Packed } from '@/misc/json-schema.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { NotificationService } from '@/core/NotificationService.js'; import { NotificationService } from '@/core/NotificationService.js';
import { QueryService } from '@/core/QueryService.js';
import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq'; import type * as Bull from 'bullmq';
import type { DbJobDataWithUser } from '../types.js'; import type { DbJobDataWithUser } from '../types.js';
@@ -43,6 +42,7 @@ export class ExportClipsProcessorService {
private driveService: DriveService, private driveService: DriveService,
private queueLoggerService: QueueLoggerService, private queueLoggerService: QueueLoggerService,
private queryService: QueryService,
private idService: IdService, private idService: IdService,
private notificationService: NotificationService, private notificationService: NotificationService,
) { ) {
@@ -100,16 +100,16 @@ export class ExportClipsProcessorService {
}); });
while (true) { while (true) {
const clips = await this.clipsRepository.find({ const query = this.clipsRepository.createQueryBuilder('clip')
where: { .where('clip.userId = :userId', { userId: user.id })
userId: user.id, .orderBy('clip.id', 'ASC')
...(cursor ? { id: MoreThan(cursor) } : {}), .take(100);
},
take: 100, if (cursor) {
order: { query.andWhere('clip.id > :cursor', { cursor });
id: 1, }
},
}); const clips = await query.getMany();
if (clips.length === 0) { if (clips.length === 0) {
job.updateProgress(100); job.updateProgress(100);
@@ -124,7 +124,7 @@ export class ExportClipsProcessorService {
const isFirst = exportedClipsCount === 0; const isFirst = exportedClipsCount === 0;
await writer.write(isFirst ? content : ',\n' + content); await writer.write(isFirst ? content : ',\n' + content);
await this.processClipNotes(writer, clip.id); await this.processClipNotes(writer, clip.id, user.id);
await writer.write(']}'); await writer.write(']}');
exportedClipsCount++; exportedClipsCount++;
@@ -134,22 +134,25 @@ export class ExportClipsProcessorService {
} }
} }
async processClipNotes(writer: WritableStreamDefaultWriter, clipId: string): Promise<void> { async processClipNotes(writer: WritableStreamDefaultWriter, clipId: string, userId: string): Promise<void> {
let exportedClipNotesCount = 0; let exportedClipNotesCount = 0;
let cursor: MiClipNote['id'] | null = null; let cursor: MiClipNote['id'] | null = null;
while (true) { while (true) {
const clipNotes = await this.clipNotesRepository.find({ const query = this.clipNotesRepository.createQueryBuilder('clipNote')
where: { .leftJoinAndSelect('clipNote.note', 'note')
clipId, .leftJoinAndSelect('note.user', 'user')
...(cursor ? { id: MoreThan(cursor) } : {}), .where('clipNote.clipId = :clipId', { clipId })
}, .orderBy('clipNote.id', 'ASC')
take: 100, .take(100);
order: {
id: 1, if (cursor) {
}, query.andWhere('clipNote.id > :cursor', { cursor });
relations: ['note', 'note.user'], }
}) as (MiClipNote & { note: MiNote & { user: MiUser } })[];
this.queryService.generateVisibilityQuery(query, { id: userId });
const clipNotes = await query.getMany() as (MiClipNote & { note: MiNote & { user: MiUser } })[];
if (clipNotes.length === 0) { if (clipNotes.length === 0) {
break; break;
@@ -158,6 +161,11 @@ export class ExportClipsProcessorService {
cursor = clipNotes.at(-1)?.id ?? null; cursor = clipNotes.at(-1)?.id ?? null;
for (const clipNote of clipNotes) { for (const clipNote of clipNotes) {
const noteCreatedAt = this.idService.parse(clipNote.note.id).date;
if (shouldHideNoteByTime(clipNote.note.user.makeNotesHiddenBefore, noteCreatedAt)) {
continue;
}
let poll: MiPoll | undefined; let poll: MiPoll | undefined;
if (clipNote.note.hasPoll) { if (clipNote.note.hasPoll) {
poll = await this.pollsRepository.findOneByOrFail({ noteId: clipNote.note.id }); poll = await this.pollsRepository.findOneByOrFail({ noteId: clipNote.note.id });

View File

@@ -5,7 +5,6 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { MoreThan } from 'typeorm';
import { format as dateFormat } from 'date-fns'; import { format as dateFormat } from 'date-fns';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { MiNoteFavorite, NoteFavoritesRepository, PollsRepository, MiUser, UsersRepository } from '@/models/_.js'; import type { MiNoteFavorite, NoteFavoritesRepository, PollsRepository, MiUser, UsersRepository } from '@/models/_.js';
@@ -17,6 +16,8 @@ import type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { NotificationService } from '@/core/NotificationService.js'; import { NotificationService } from '@/core/NotificationService.js';
import { QueryService } from '@/core/QueryService.js';
import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq'; import type * as Bull from 'bullmq';
import type { DbJobDataWithUser } from '../types.js'; import type { DbJobDataWithUser } from '../types.js';
@@ -37,6 +38,7 @@ export class ExportFavoritesProcessorService {
private driveService: DriveService, private driveService: DriveService,
private queueLoggerService: QueueLoggerService, private queueLoggerService: QueueLoggerService,
private queryService: QueryService,
private idService: IdService, private idService: IdService,
private notificationService: NotificationService, private notificationService: NotificationService,
) { ) {
@@ -83,17 +85,20 @@ export class ExportFavoritesProcessorService {
}); });
while (true) { while (true) {
const favorites = await this.noteFavoritesRepository.find({ const query = this.noteFavoritesRepository.createQueryBuilder('favorite')
where: { .leftJoinAndSelect('favorite.note', 'note')
userId: user.id, .leftJoinAndSelect('note.user', 'user')
...(cursor ? { id: MoreThan(cursor) } : {}), .where('favorite.userId = :userId', { userId: user.id })
}, .orderBy('favorite.id', 'ASC')
take: 100, .take(100);
order: {
id: 1, if (cursor) {
}, query.andWhere('favorite.id > :cursor', { cursor });
relations: ['note', 'note.user'], }
}) as (MiNoteFavorite & { note: MiNote & { user: MiUser } })[];
this.queryService.generateVisibilityQuery(query, { id: user.id });
const favorites = await query.getMany() as (MiNoteFavorite & { note: MiNote & { user: MiUser } })[];
if (favorites.length === 0) { if (favorites.length === 0) {
job.updateProgress(100); job.updateProgress(100);
@@ -103,6 +108,11 @@ export class ExportFavoritesProcessorService {
cursor = favorites.at(-1)?.id ?? null; cursor = favorites.at(-1)?.id ?? null;
for (const favorite of favorites) { for (const favorite of favorites) {
const noteCreatedAt = this.idService.parse(favorite.note.id).date;
if (shouldHideNoteByTime(favorite.note.user.makeNotesHiddenBefore, noteCreatedAt)) {
continue;
}
let poll: MiPoll | undefined; let poll: MiPoll | undefined;
if (favorite.note.hasPoll) { if (favorite.note.hasPoll) {
poll = await this.pollsRepository.findOneByOrFail({ noteId: favorite.note.id }); poll = await this.pollsRepository.findOneByOrFail({ noteId: favorite.note.id });

View File

@@ -168,7 +168,36 @@ describe('export-clips', () => {
assert.strictEqual(exported[1].clipNotes[0].note.text, 'baz2'); assert.strictEqual(exported[1].clipNotes[0].note.text, 'baz2');
}); });
test('Clipping other user\'s note', async () => { test('Clipping other user\'s note (followers only notes are excluded when not following)', async () => {
const res = await api('clips/create', {
name: 'kawaii',
description: 'kawaii',
}, alice);
assert.strictEqual(res.status, 200);
const clip = res.body;
const note = await post(bob, {
text: 'baz',
visibility: 'followers',
});
const res2 = await api('clips/add-note', {
clipId: clip.id,
noteId: note.id,
}, alice);
assert.strictEqual(res2.status, 204);
const res3 = await api('i/export-clips', {}, alice);
assert.strictEqual(res3.status, 204);
const exported = await pollFirstDriveFile();
assert.strictEqual(exported[0].clipNotes.length, 0);
});
test('Clipping other user\'s note (followers only notes are included when following)', async () => {
// Alice follows Bob
await api('following/create', { userId: bob.id }, alice);
const res = await api('clips/create', { const res = await api('clips/create', {
name: 'kawaii', name: 'kawaii',
description: 'kawaii', description: 'kawaii',

View File

@@ -0,0 +1,136 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test, beforeEach, afterEach } from '@jest/globals';
import * as lolex from '@sinonjs/fake-timers';
import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js';
describe('misc:should-hide-note-by-time', () => {
let clock: lolex.InstalledClock;
const epoch = Date.UTC(2000, 0, 1, 0, 0, 0);
beforeEach(() => {
clock = lolex.install({
// https://github.com/sinonjs/sinon/issues/2620
toFake: Object.keys(lolex.timers).filter((key) => !['nextTick', 'queueMicrotask'].includes(key)) as lolex.FakeMethod[],
now: new Date(epoch),
shouldClearNativeTimers: true,
});
});
afterEach(() => {
clock.uninstall();
});
describe('hiddenBefore が null または undefined の場合', () => {
test('hiddenBefore が null のときは false を返す(非表示機能が有効でない)', () => {
const createdAt = new Date(epoch - 86400000); // 1 day ago
expect(shouldHideNoteByTime(null, createdAt)).toBe(false);
});
test('hiddenBefore が undefined のときは false を返す(非表示機能が有効でない)', () => {
const createdAt = new Date(epoch - 86400000); // 1 day ago
expect(shouldHideNoteByTime(undefined, createdAt)).toBe(false);
});
});
describe('相対時間モード (hiddenBefore <= 0)', () => {
test('閾値内に作成されたノートは false を返す(作成からの経過時間がまだ短い→表示)', () => {
const hiddenBefore = -86400; // 1 day in seconds
const createdAt = new Date(epoch - 3600000); // 1 hour ago
expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(false);
});
test('閾値を超えて作成されたノートは true を返す(指定期間以上経過している→非表示)', () => {
const hiddenBefore = -86400; // 1 day in seconds
const createdAt = new Date(epoch - 172800000); // 2 days ago
expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(true);
});
test('ちょうど閾値で作成されたノートは true を返す(閾値に達したら非表示)', () => {
const hiddenBefore = -86400; // 1 day in seconds
const createdAt = new Date(epoch - 86400000); // exactly 1 day ago
expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(true);
});
test('異なる相対時間値で判定できる1時間設定と3時間設定の異なる結果', () => {
const createdAt = new Date(epoch - 7200000); // 2 hours ago
expect(shouldHideNoteByTime(-3600, createdAt)).toBe(true); // 1時間経過→非表示
expect(shouldHideNoteByTime(-10800, createdAt)).toBe(false); // 3時間未経過→表示
});
test('ISO 8601 形式の文字列の createdAt に対応できる(文字列でも正しく判定)', () => {
const createdAtString = new Date(epoch - 86400000).toISOString();
const hiddenBefore = -86400; // 1 day in seconds
expect(shouldHideNoteByTime(hiddenBefore, createdAtString)).toBe(true);
});
test('hiddenBefore が 0 の場合に対応できる0秒以上経過で非表示→ほぼ全て非表示', () => {
const hiddenBefore = 0;
const createdAt = new Date(epoch - 1); // 1ms ago
expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(true);
});
});
describe('絶対時間モード (hiddenBefore > 0)', () => {
test('閾値タイムスタンプより後に作成されたノートは false を返す(指定日時より後→表示)', () => {
const thresholdSeconds = Math.floor(epoch / 1000);
const createdAt = new Date(epoch + 3600000); // 1 hour from epoch
expect(shouldHideNoteByTime(thresholdSeconds, createdAt)).toBe(false);
});
test('閾値タイムスタンプより前に作成されたノートは true を返す(指定日時より前→非表示)', () => {
const thresholdSeconds = Math.floor(epoch / 1000);
const createdAt = new Date(epoch - 3600000); // 1 hour ago
expect(shouldHideNoteByTime(thresholdSeconds, createdAt)).toBe(true);
});
test('ちょうど閾値タイムスタンプで作成されたノートは true を返す(指定日時に達したら非表示)', () => {
const thresholdSeconds = Math.floor(epoch / 1000);
const createdAt = new Date(epoch); // exactly epoch
expect(shouldHideNoteByTime(thresholdSeconds, createdAt)).toBe(true);
});
test('ISO 8601 形式の文字列の createdAt に対応できる(文字列でも正しく判定)', () => {
const thresholdSeconds = Math.floor(epoch / 1000);
const createdAtString = new Date(epoch - 3600000).toISOString();
expect(shouldHideNoteByTime(thresholdSeconds, createdAtString)).toBe(true);
});
test('異なる閾値タイムスタンプで判定できる2021年設定と現在より1時間前設定の異なる結果', () => {
const thresholdSeconds = Math.floor((epoch - 86400000) / 1000); // 1 day ago
const createdAtBefore = new Date(epoch - 172800000); // 2 days ago
const createdAtAfter = new Date(epoch - 3600000); // 1 hour ago
expect(shouldHideNoteByTime(thresholdSeconds, createdAtBefore)).toBe(true); // 閾値より前→非表示
expect(shouldHideNoteByTime(thresholdSeconds, createdAtAfter)).toBe(false); // 閾値より後→表示
});
});
describe('エッジケース', () => {
test('相対時間モードで非常に古いノートに対応できる(非常に古い→閾値超→非表示)', () => {
const hiddenBefore = -1; // hide notes older than 1 second
const createdAt = new Date(epoch - 1000000); // very old
expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(true);
});
test('相対時間モードで非常に新しいノートに対応できる(非常に新しい→閾値未満→表示)', () => {
const hiddenBefore = -86400; // 1 day
const createdAt = new Date(epoch - 1); // 1ms ago
expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(false);
});
test('大きなタイムスタンプ値に対応できる(未来の日時を指定→現在のノートは全て非表示)', () => {
const thresholdSeconds = Math.floor(epoch / 1000) + 86400; // 1 day from epoch
const createdAt = new Date(epoch); // created epoch
expect(shouldHideNoteByTime(thresholdSeconds, createdAt)).toBe(true);
});
test('小さな相対時間値に対応できる1秒設定で2秒前→非表示', () => {
const hiddenBefore = -1; // 1 second
const createdAt = new Date(epoch - 2000); // 2 seconds ago
expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(true);
});
});
});

View File

@@ -70,6 +70,8 @@
importAppScript(); importAppScript();
}); });
} }
localStorage.setItem('lang', lang);
//#endregion //#endregion
async function addStyle(styleText) { async function addStyle(styleText) {

View File

@@ -42,6 +42,8 @@
console.error('invalid lang value detected!!!', typeof lang, lang); console.error('invalid lang value detected!!!', typeof lang, lang);
lang = 'en-US'; lang = 'en-US';
} }
localStorage.setItem('lang', lang);
//#endregion //#endregion
//#region Script //#region Script

View File

@@ -233,16 +233,18 @@ function showMenu(ev: MouseEvent) {
.hide { .hide {
display: block; display: block;
position: absolute; position: absolute;
border-radius: 6px; background-color: rgba(0, 0, 0, 0.3);
background-color: var(--MI_THEME-fg); -webkit-backdrop-filter: var(--MI-blur, blur(15px));
color: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); backdrop-filter: var(--MI-blur, blur(15px));
border-radius: 0 0 0 9px;
color: #fff;
font-size: 12px; font-size: 12px;
opacity: .5; opacity: .5;
padding: 5px 8px; padding: 5px 8px;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
top: 12px; top: 0;
right: 12px; right: 0;
} }
.hiddenTextWrapper { .hiddenTextWrapper {
@@ -272,17 +274,17 @@ html[data-color-scheme=light] .visible {
.menu { .menu {
display: block; display: block;
position: absolute; position: absolute;
border-radius: 999px;
background-color: rgba(0, 0, 0, 0.3); background-color: rgba(0, 0, 0, 0.3);
-webkit-backdrop-filter: var(--MI-blur, blur(15px)); -webkit-backdrop-filter: var(--MI-blur, blur(15px));
backdrop-filter: var(--MI-blur, blur(15px)); backdrop-filter: var(--MI-blur, blur(15px));
border-radius: 9px 0 0 0;
color: #fff; color: #fff;
font-size: 0.8em; font-size: 0.8em;
width: 28px; width: 28px;
height: 28px; height: 28px;
text-align: center; text-align: center;
bottom: 10px; bottom: 0;
right: 10px; right: 0;
} }
.imageContainer { .imageContainer {

View File

@@ -1,7 +1,7 @@
{ {
"type": "module", "type": "module",
"name": "misskey-js", "name": "misskey-js",
"version": "2025.12.0-beta.0", "version": "2025.12.0",
"description": "Misskey SDK for JavaScript", "description": "Misskey SDK for JavaScript",
"license": "MIT", "license": "MIT",
"main": "./built/index.js", "main": "./built/index.js",

6
packages/sw/src/const.ts Normal file
View File

@@ -0,0 +1,6 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export const FETCH_TIMEOUT_MS = 10000;

View File

@@ -8,6 +8,7 @@
*/ */
import { get, set } from 'idb-keyval'; import { get, set } from 'idb-keyval';
import { I18n } from '@@/js/i18n.js'; import { I18n } from '@@/js/i18n.js';
import { FETCH_TIMEOUT_MS } from '@/const.js';
import type { Locale } from 'i18n'; import type { Locale } from 'i18n';
class SwLang { class SwLang {
@@ -37,11 +38,21 @@ class SwLang {
// _DEV_がtrueの場合は常に最新化 // _DEV_がtrueの場合は常に最新化
if (!localeRes || _DEV_) { if (!localeRes || _DEV_) {
localeRes = await fetch(localeUrl); const controller = new AbortController();
const timeout = globalThis.setTimeout(() => {
controller.abort('locale-fetch-timeout');
}, FETCH_TIMEOUT_MS);
try {
localeRes = await fetch(localeUrl, { signal: controller.signal });
const clone = localeRes.clone(); const clone = localeRes.clone();
if (!clone.clone().ok) throw new Error('locale fetching error'); if (!clone.clone().ok) throw new Error('locale fetching error');
caches.open(this.cacheName).then(cache => cache.put(localeUrl, clone)); caches.open(this.cacheName).then(cache => cache.put(localeUrl, clone));
} finally {
globalThis.clearTimeout(timeout);
}
} }
return new I18n<Locale>(await localeRes.json()); return new I18n<Locale>(await localeRes.json());

View File

@@ -5,6 +5,7 @@
import { get } from 'idb-keyval'; import { get } from 'idb-keyval';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { FETCH_TIMEOUT_MS } from '@/const.js';
import type { PushNotificationDataMap } from '@/types.js'; import type { PushNotificationDataMap } from '@/types.js';
import type { I18n } from '@@/js/i18n.js'; import type { I18n } from '@@/js/i18n.js';
import type { Locale } from 'i18n'; import type { Locale } from 'i18n';
@@ -12,6 +13,52 @@ import { createEmptyNotification, createNotification } from '@/scripts/create-no
import { swLang } from '@/scripts/lang.js'; import { swLang } from '@/scripts/lang.js';
import * as swos from '@/scripts/operations.js'; import * as swos from '@/scripts/operations.js';
async function respondToNavigation(request: Request): Promise<Response> {
const controller = new AbortController();
const timeout = globalThis.setTimeout(() => {
controller.abort('navigation-timeout');
}, FETCH_TIMEOUT_MS);
try {
const response = await fetch(request, { signal: controller.signal });
if (response?.status && response.status < 500) return response;
if (response?.type === 'opaqueredirect') return response;
} catch (error) {
if (_DEV_) {
console.warn('navigation fetch failed; showing offline page', error);
}
} finally {
globalThis.clearTimeout(timeout);
}
// Only show offline page when network request actually fails
const html = await offlineContentHTML();
return new Response(html, {
status: 200,
headers: {
'content-type': 'text/html',
},
});
}
async function offlineContentHTML() {
let i18n: Partial<I18n<Locale>>;
try {
i18n = await (swLang.i18n ?? await swLang.fetchLocale()) as Partial<I18n<Locale>>;
} catch {
i18n = {};
}
const messages = {
title: i18n.ts?._offlineScreen.title ?? 'Offline - Could not connect to server',
header: i18n.ts?._offlineScreen.header ?? 'Could not connect to server',
reload: i18n.ts?.reload ?? 'Reload',
};
return `<!DOCTYPE html><html lang="ja"><head><meta charset="UTF-8"><meta content="width=device-width,initial-scale=1"name="viewport"><title>${messages.title}</title><style>body{background-color:#0c1210;color:#dee7e4;font-family:Hiragino Maru Gothic Pro,BIZ UDGothic,Roboto,HelveticaNeue,Arial,sans-serif;line-height:1.35;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;padding:24px;box-sizing:border-box}.icon{max-width:120px;width:100%;height:auto;margin-bottom:20px;}.message{text-align:center;font-size:20px;font-weight:700;margin-bottom:20px}.version{text-align:center;font-size:90%;margin-bottom:20px}button{padding:7px 14px;min-width:100px;font-weight:700;font-family:Hiragino Maru Gothic Pro,BIZ UDGothic,Roboto,HelveticaNeue,Arial,sans-serif;line-height:1.35;border-radius:99rem;background-color:#b4e900;color:#192320;border:none;cursor:pointer;-webkit-tap-highlight-color:transparent}button:hover{background-color:#c6ff03}</style></head><body><svg class="icon"fill="none"height="24"stroke="currentColor"stroke-linecap="round"stroke-linejoin="round"stroke-width="2"viewBox="0 0 24 24"width="24"xmlns="http://www.w3.org/2000/svg"><path d="M0 0h24v24H0z"fill="none"stroke="none"/><path d="M9.58 5.548c.24 -.11 .492 -.207 .752 -.286c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.913 0 3.464 1.56 3.464 3.486c0 .957 -.383 1.824 -1.003 2.454m-2.997 1.033h-11.343c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.13 -.582 .37 -1.128 .7 -1.62"/><path d="M3 3l18 18"/></svg><div class="message">${messages.header}</div><div class="version">v${_VERSION_}</div><button onclick="reloadPage()">${messages.reload}</button><script>function reloadPage(){location.reload(!0)}</script></body></html>`;
}
globalThis.addEventListener('install', () => { globalThis.addEventListener('install', () => {
// ev.waitUntil(globalThis.skipWaiting()); // ev.waitUntil(globalThis.skipWaiting());
}); });
@@ -28,17 +75,6 @@ globalThis.addEventListener('activate', ev => {
); );
}); });
async function offlineContentHTML() {
const i18n = await (swLang.i18n ?? swLang.fetchLocale()) as Partial<I18n<Locale>>;
const messages = {
title: i18n.ts?._offlineScreen.title ?? 'Offline - Could not connect to server',
header: i18n.ts?._offlineScreen.header ?? 'Could not connect to server',
reload: i18n.ts?.reload ?? 'Reload',
};
return `<!DOCTYPE html><html lang="ja"><head><meta charset="UTF-8"><meta content="width=device-width,initial-scale=1"name="viewport"><title>${messages.title}</title><style>body{background-color:#0c1210;color:#dee7e4;font-family:Hiragino Maru Gothic Pro,BIZ UDGothic,Roboto,HelveticaNeue,Arial,sans-serif;line-height:1.35;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;padding:24px;box-sizing:border-box}.icon{max-width:120px;width:100%;height:auto;margin-bottom:20px;}.message{text-align:center;font-size:20px;font-weight:700;margin-bottom:20px}.version{text-align:center;font-size:90%;margin-bottom:20px}button{padding:7px 14px;min-width:100px;font-weight:700;font-family:Hiragino Maru Gothic Pro,BIZ UDGothic,Roboto,HelveticaNeue,Arial,sans-serif;line-height:1.35;border-radius:99rem;background-color:#b4e900;color:#192320;border:none;cursor:pointer;-webkit-tap-highlight-color:transparent}button:hover{background-color:#c6ff03}</style></head><body><svg class="icon"fill="none"height="24"stroke="currentColor"stroke-linecap="round"stroke-linejoin="round"stroke-width="2"viewBox="0 0 24 24"width="24"xmlns="http://www.w3.org/2000/svg"><path d="M0 0h24v24H0z"fill="none"stroke="none"/><path d="M9.58 5.548c.24 -.11 .492 -.207 .752 -.286c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.913 0 3.464 1.56 3.464 3.486c0 .957 -.383 1.824 -1.003 2.454m-2.997 1.033h-11.343c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.13 -.582 .37 -1.128 .7 -1.62"/><path d="M3 3l18 18"/></svg><div class="message">${messages.header}</div><div class="version">v${_VERSION_}</div><button onclick="reloadPage()">${messages.reload}</button><script>function reloadPage(){location.reload(!0)}</script></body></html>`;
}
globalThis.addEventListener('fetch', ev => { globalThis.addEventListener('fetch', ev => {
let isHTMLRequest = false; let isHTMLRequest = false;
if (ev.request.headers.get('sec-fetch-dest') === 'document') { if (ev.request.headers.get('sec-fetch-dest') === 'document') {
@@ -50,18 +86,7 @@ globalThis.addEventListener('fetch', ev => {
} }
if (!isHTMLRequest) return; if (!isHTMLRequest) return;
ev.respondWith( ev.respondWith(respondToNavigation(ev.request));
fetch(ev.request)
.catch(async () => {
const html = await offlineContentHTML();
return new Response(html, {
status: 200,
headers: {
'content-type': 'text/html',
},
});
}),
);
}); });
globalThis.addEventListener('push', ev => { globalThis.addEventListener('push', ev => {