mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-05-01 01:05:50 +02:00
Compare commits
14 Commits
2025.12.0-
...
copilot/bu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23358a5fe9 | ||
|
|
fbc4da1c48 | ||
|
|
28e196e978 | ||
|
|
2cffd9f0fb | ||
|
|
988f5ab69f | ||
|
|
3afe7c5348 | ||
|
|
73cc30f50f | ||
|
|
da3b3af984 | ||
|
|
3273ca7512 | ||
|
|
b67bfe0763 | ||
|
|
63d2870755 | ||
|
|
61f9c148f0 | ||
|
|
8927a9e98a | ||
|
|
dc77d59f87 |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
packages/backend/src/misc/should-hide-note-by-time.ts
Normal file
29
packages/backend/src/misc/should-hide-note-by-time.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
136
packages/backend/test/unit/misc/should-hide-note-by-time.ts
Normal file
136
packages/backend/test/unit/misc/should-hide-note-by-time.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -70,6 +70,8 @@
|
|||||||
importAppScript();
|
importAppScript();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('lang', lang);
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
async function addStyle(styleText) {
|
async function addStyle(styleText) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
6
packages/sw/src/const.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const FETCH_TIMEOUT_MS = 10000;
|
||||||
@@ -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());
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
Reference in New Issue
Block a user