diff --git a/CHANGELOG.md b/CHANGELOG.md index a1e9a12b9c..f7470ca15f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - データベースの肥大化を防止することが可能です - 既存のサーバーで当機能を有効化した場合は、処理量が多くなるため、一時的にストレージ使用量が増加する可能性があります。 - 増加量を抑えるには、最大処理継続時間をデフォルトより短くしてください。 + - データベースサイズへの効果が見られない場合はautovacuumが有効になっているか確認してください - サーバーの初期設定が完了するまでは連合がオンにならないようになりました - 日本語における公開範囲名称の「ダイレクト」が「指名」に改称されました - 実際の動作に即した名称になり、馴染みのない人でも理解しやすくなりました @@ -22,8 +23,8 @@ - Enhance: ユーザー検索をロールポリシーで制限できるように ### Client -- Feat: AiScriptが1.0に更新されました - - プラグインは1.0に対応したものが必要です +- Feat: AiScriptが1.1.0に更新されました + - プラグインは1.xに対応したものが必要です - Playはそのまま動作しますが、新規に作られるプリセットは1.0になります - 以前のバージョンから無効化されていた note_view_interruptor が有効になりました - Feat: セーフモード @@ -33,12 +34,14 @@ - URLに`?safemode=true`を付ける - PWAのショートカットで Safemode を選択して起動する - Feat: ページのタブバーを下部に表示できるように +- Feat: (実験的)iOSでの触覚フィードバックを有効にできるように - Enhance: 「自動でもっと見る」オプションが有効になり、安定性が向上しました - Enhance: コントロールパネルを検索できるように - Enhance: トルコ語 (tr-TR) に対応 - Enhance: 不必要な翻訳データを読み込まなくなり、パフォーマンスが向上しました - Enhance: 画像エフェクトのパラメータ名の多言語対応 - Enhance: 依存ソフトウェアの更新 +- Enhance: ノートを非表示にする相対期間を1ヶ月単位で自由に指定できるように - Enhance: プロフィールへのリンクをユーザーポップアップのアバターとバナーに追加 - Enhance: ユーザーのノート、フォロー、フォロワーページへのリンクをユーザーポップアップに追加 - Fix: 投稿フォームでファイルのアップロードが中止または失敗した際のハンドリングを修正 diff --git a/locales/index.d.ts b/locales/index.d.ts index 028db4043f..79aa665c06 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -4234,6 +4234,10 @@ export interface Locale extends ILocale { * プリセットから選択 */ "selectFromPresets": string; + /** + * カスタム + */ + "custom": string; /** * 実績 */ @@ -8836,6 +8840,10 @@ export interface Locale extends ILocale { * 日 */ "day": string; + /** + * ヶ月 + */ + "month": string; }; "_2fa": { /** diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 7aa88f399d..aa500c4f38 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1054,6 +1054,7 @@ permissionDeniedError: "操作が拒否されました" permissionDeniedErrorDescription: "このアカウントにはこの操作を行うための権限がありません。" preset: "プリセット" selectFromPresets: "プリセットから選択" +custom: "カスタム" achievements: "実績" gotInvalidResponseError: "サーバーの応答が無効です" gotInvalidResponseErrorDescription: "サーバーがダウンまたはメンテナンスしている可能性があります。しばらくしてから再度お試しください。" @@ -2321,6 +2322,7 @@ _time: minute: "分" hour: "時間" day: "日" + month: "ヶ月" _2fa: alreadyRegistered: "既に設定は完了しています。" diff --git a/package.json b/package.json index 75496c324a..7c9a8f597a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "2025.8.0-alpha.11", + "version": "2025.8.0-beta.0", "codename": "nasubi", "repository": { "type": "git", diff --git a/packages/backend/migration/1755168347001-PageCountInNote.js b/packages/backend/migration/1755168347001-PageCountInNote.js new file mode 100644 index 0000000000..9f1894ab2f --- /dev/null +++ b/packages/backend/migration/1755168347001-PageCountInNote.js @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class PageCountInNote1755168347001 { + name = 'PageCountInNote1755168347001' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" ADD "pageCount" smallint NOT NULL DEFAULT '0'`); + + // Update existing notes + // block_list CTE collects all page blocks on the pages including child blocks in the section blocks. + // The clipped_notes CTE counts how many distinct pages each note block is referenced in. + // Finally, we update the note table with the count of pages for each referenced note. + await queryRunner.query(` + WITH RECURSIVE block_list AS ( + ( + SELECT + page.id as page_id, + block as block + FROM page + CROSS JOIN LATERAL jsonb_array_elements(page.content) block + WHERE block->>'type' = 'note' OR block->>'type' = 'section' + ) + UNION ALL + ( + SELECT + block_list.page_id, + child_block AS block + FROM LATERAL ( + SELECT page_id, block + FROM block_list + WHERE block_list.block->>'type' = 'section' + ) block_list + CROSS JOIN LATERAL jsonb_array_elements(block_list.block->'children') child_block + WHERE child_block->>'type' = 'note' OR child_block->>'type' = 'section' + ) + ), + clipped_notes AS ( + SELECT + (block->>'note') AS note_id, + COUNT(distinct block_list.page_id) AS count + FROM block_list + WHERE block_list.block->>'type' = 'note' + GROUP BY block->>'note' + ) + UPDATE note + SET "pageCount" = clipped_notes.count + FROM clipped_notes + WHERE note.id = clipped_notes.note_id; + `); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "pageCount"`); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 0c0c5d3a39..a30bff0fe4 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -78,6 +78,7 @@ import { ChannelFollowingService } from './ChannelFollowingService.js'; import { ChatService } from './ChatService.js'; import { RegistryApiService } from './RegistryApiService.js'; import { ReversiService } from './ReversiService.js'; +import { PageService } from './PageService.js'; import { ChartLoggerService } from './chart/ChartLoggerService.js'; import FederationChart from './chart/charts/federation.js'; @@ -227,6 +228,7 @@ const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', const $ChatService: Provider = { provide: 'ChatService', useExisting: ChatService }; const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService }; const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService }; +const $PageService: Provider = { provide: 'PageService', useExisting: PageService }; const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService }; const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart }; @@ -379,6 +381,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ChatService, RegistryApiService, ReversiService, + PageService, ChartLoggerService, FederationChart, @@ -527,6 +530,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ChatService, $RegistryApiService, $ReversiService, + $PageService, $ChartLoggerService, $FederationChart, @@ -676,6 +680,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ChatService, RegistryApiService, ReversiService, + PageService, FederationChart, NotesChart, @@ -822,6 +827,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ChatService, $RegistryApiService, $ReversiService, + $PageService, $FederationChart, $NotesChart, diff --git a/packages/backend/src/core/PageService.ts b/packages/backend/src/core/PageService.ts new file mode 100644 index 0000000000..7f0e5c7ccc --- /dev/null +++ b/packages/backend/src/core/PageService.ts @@ -0,0 +1,223 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource, In, Not } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { + type NotesRepository, + MiPage, + type PagesRepository, + MiDriveFile, + type UsersRepository, + MiNote, +} from '@/models/_.js'; +import { bindThis } from '@/decorators.js'; +import { RoleService } from '@/core/RoleService.js'; +import { IdService } from '@/core/IdService.js'; +import type { MiUser } from '@/models/User.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; + +export interface PageBody { + title: string; + name: string; + summary: string | null; + content: Array>; + variables: Array>; + script: string; + eyeCatchingImage?: MiDriveFile | null; + font: string; + alignCenter: boolean; + hideTitleWhenPinned: boolean; +} + +@Injectable() +export class PageService { + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.pagesRepository) + private pagesRepository: PagesRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private roleService: RoleService, + private moderationLogService: ModerationLogService, + private idService: IdService, + ) { + } + + @bindThis + public async create( + me: MiUser, + body: PageBody, + ): Promise { + await this.pagesRepository.findBy({ + userId: me.id, + name: body.name, + }).then(result => { + if (result.length > 0) { + throw new IdentifiableError('1a79e38e-3d83-4423-845b-a9d83ff93b61'); + } + }); + + const page = await this.pagesRepository.insertOne(new MiPage({ + id: this.idService.gen(), + updatedAt: new Date(), + title: body.title, + name: body.name, + summary: body.summary, + content: body.content, + variables: body.variables, + script: body.script, + eyeCatchingImageId: body.eyeCatchingImage ? body.eyeCatchingImage.id : null, + userId: me.id, + visibility: 'public', + alignCenter: body.alignCenter, + hideTitleWhenPinned: body.hideTitleWhenPinned, + font: body.font, + })); + + const referencedNotes = this.collectReferencedNotes(page.content); + if (referencedNotes.length > 0) { + await this.notesRepository.increment({ id: In(referencedNotes) }, 'pageCount', 1); + } + + return page; + } + + @bindThis + public async update( + me: MiUser, + pageId: MiPage['id'], + body: Partial, + ): Promise { + await this.db.transaction(async (transaction) => { + const page = await transaction.findOne(MiPage, { + where: { + id: pageId, + }, + lock: { mode: 'for_no_key_update' }, + }); + + if (page == null) { + throw new IdentifiableError('66aefd3c-fdb2-4a71-85ae-cc18bea85d3f'); + } + if (page.userId !== me.id) { + throw new IdentifiableError('d0017699-8256-46f1-aed4-bc03bed73616'); + } + + if (body.name != null) { + await transaction.findBy(MiPage, { + id: Not(pageId), + userId: me.id, + name: body.name, + }).then(result => { + if (result.length > 0) { + throw new IdentifiableError('d05bfe24-24b6-4ea2-a3ec-87cc9bf4daa4'); + } + }); + } + + await transaction.update(MiPage, page.id, { + updatedAt: new Date(), + title: body.title, + name: body.name, + summary: body.summary === undefined ? page.summary : body.summary, + content: body.content, + variables: body.variables, + script: body.script, + alignCenter: body.alignCenter, + hideTitleWhenPinned: body.hideTitleWhenPinned, + font: body.font, + eyeCatchingImageId: body.eyeCatchingImage === undefined ? undefined : (body.eyeCatchingImage?.id ?? null), + }); + + console.log("page.content", page.content); + + if (body.content != null) { + const beforeReferencedNotes = this.collectReferencedNotes(page.content); + const afterReferencedNotes = this.collectReferencedNotes(body.content); + + const removedNotes = beforeReferencedNotes.filter(noteId => !afterReferencedNotes.includes(noteId)); + const addedNotes = afterReferencedNotes.filter(noteId => !beforeReferencedNotes.includes(noteId)); + + if (removedNotes.length > 0) { + await transaction.decrement(MiNote, { id: In(removedNotes) }, 'pageCount', 1); + } + if (addedNotes.length > 0) { + await transaction.increment(MiNote, { id: In(addedNotes) }, 'pageCount', 1); + } + } + }); + } + + @bindThis + public async delete(me: MiUser, pageId: MiPage['id']): Promise { + await this.db.transaction(async (transaction) => { + const page = await transaction.findOne(MiPage, { + where: { + id: pageId, + }, + lock: { mode: 'pessimistic_write' }, // same lock level as DELETE + }); + + if (page == null) { + throw new IdentifiableError('66aefd3c-fdb2-4a71-85ae-cc18bea85d3f'); + } + + if (!await this.roleService.isModerator(me) && page.userId !== me.id) { + throw new IdentifiableError('d0017699-8256-46f1-aed4-bc03bed73616'); + } + + await transaction.delete(MiPage, page.id); + + if (page.userId !== me.id) { + const user = await this.usersRepository.findOneByOrFail({ id: page.userId }); + this.moderationLogService.log(me, 'deletePage', { + pageId: page.id, + pageUserId: page.userId, + pageUserUsername: user.username, + page, + }); + } + + const referencedNotes = this.collectReferencedNotes(page.content); + if (referencedNotes.length > 0) { + await transaction.decrement(MiNote, { id: In(referencedNotes) }, 'pageCount', 1); + } + }); + } + + collectReferencedNotes(content: MiPage['content']): string[] { + const referencingNotes = new Set(); + const recursiveCollect = (content: unknown[]) => { + for (const contentElement of content) { + if (typeof contentElement === 'object' + && contentElement !== null + && 'type' in contentElement) { + if (contentElement.type === 'note' + && 'note' in contentElement + && typeof contentElement.note === 'string') { + referencingNotes.add(contentElement.note); + } + if (contentElement.type === 'section' + && 'children' in contentElement + && Array.isArray(contentElement.children)) { + recursiveCollect(contentElement.children); + } + } + } + }; + recursiveCollect(content); + return [...referencingNotes]; + } +} diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index 9cf985b688..907b5ea6be 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -85,6 +85,7 @@ function generateDummyNote(override?: Partial): MiNote { renoteCount: 10, repliesCount: 5, clippedCount: 0, + pageCount: 0, reactions: {}, visibility: 'public', uri: null, diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index ff46615729..26d5c1d535 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -114,6 +114,13 @@ export class MiNote { }) public clippedCount: number; + // The number of note page blocks referencing this note. + // This column is used by Remote Note Cleaning and manually updated rather than automatically with triggers. + @Column('smallint', { + default: 0, + }) + public pageCount: number; + @Column('jsonb', { default: {}, }) diff --git a/packages/backend/src/queue/processors/CleanRemoteNotesProcessorService.ts b/packages/backend/src/queue/processors/CleanRemoteNotesProcessorService.ts index 77a9dc5557..36c34c753c 100644 --- a/packages/backend/src/queue/processors/CleanRemoteNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanRemoteNotesProcessorService.ts @@ -82,9 +82,11 @@ export class CleanRemoteNotesProcessorService { const removalCriteria = [ 'note."id" < :newestLimit', 'note."clippedCount" = 0', + 'note."pageCount" = 0', 'note."userHost" IS NOT NULL', 'NOT EXISTS (SELECT 1 FROM user_note_pining WHERE "noteId" = note."id")', 'NOT EXISTS (SELECT 1 FROM note_favorite WHERE "noteId" = note."id")', + 'NOT EXISTS (SELECT 1 FROM note_reaction INNER JOIN "user" ON note_reaction."userId" = "user".id WHERE note_reaction."noteId" = note."id" AND "user"."host" IS NULL)', ].join(' AND '); const minId = (await this.notesRepository.createQueryBuilder('note') diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts index 14a53e0c42..b643c2a6d0 100644 --- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts @@ -6,7 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { MoreThan } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { DriveFilesRepository, NotesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { DriveFilesRepository, NotesRepository, PagesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; @@ -14,6 +14,7 @@ import type { MiNote } from '@/models/Note.js'; import { EmailService } from '@/core/EmailService.js'; import { bindThis } from '@/decorators.js'; import { SearchService } from '@/core/SearchService.js'; +import { PageService } from '@/core/PageService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbUserDeleteJobData } from '../types.js'; @@ -35,7 +36,11 @@ export class DeleteAccountProcessorService { @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, + @Inject(DI.pagesRepository) + private pagesRepository: PagesRepository, + private driveService: DriveService, + private pageService: PageService, private emailService: EmailService, private queueLoggerService: QueueLoggerService, private searchService: SearchService, @@ -112,6 +117,28 @@ export class DeleteAccountProcessorService { this.logger.succ('All of files deleted'); } + { + // delete pages. Necessary for decrementing pageCount of notes. + while (true) { + const pages = await this.pagesRepository.find({ + where: { + userId: user.id, + }, + take: 100, + order: { + id: 1, + }, + }); + + if (pages.length === 0) { + break; + } + for (const page of pages) { + await this.pageService.delete(user, page.id); + } + } + } + { // Send email notification const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); if (profile.email && profile.emailVerified) { diff --git a/packages/backend/src/server/api/endpoints/pages/create.ts b/packages/backend/src/server/api/endpoints/pages/create.ts index 6de5fe3d44..96bc2a953a 100644 --- a/packages/backend/src/server/api/endpoints/pages/create.ts +++ b/packages/backend/src/server/api/endpoints/pages/create.ts @@ -5,12 +5,13 @@ import ms from 'ms'; import { Inject, Injectable } from '@nestjs/common'; -import type { DriveFilesRepository, PagesRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; -import { MiPage, pageNameSchema } from '@/models/Page.js'; +import type { DriveFilesRepository, MiDriveFile, PagesRepository } from '@/models/_.js'; +import { pageNameSchema } from '@/models/Page.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { PageEntityService } from '@/core/entities/PageEntityService.js'; import { DI } from '@/di-symbols.js'; +import { PageService } from '@/core/PageService.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -77,11 +78,11 @@ export default class extends Endpoint { // eslint- @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, + private pageService: PageService, private pageEntityService: PageEntityService, - private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { - let eyeCatchingImage = null; + let eyeCatchingImage: MiDriveFile | null = null; if (ps.eyeCatchingImageId != null) { eyeCatchingImage = await this.driveFilesRepository.findOneBy({ id: ps.eyeCatchingImageId, @@ -102,24 +103,20 @@ export default class extends Endpoint { // eslint- } }); - const page = await this.pagesRepository.insertOne(new MiPage({ - id: this.idService.gen(), - updatedAt: new Date(), - title: ps.title, - name: ps.name, - summary: ps.summary, - content: ps.content, - variables: ps.variables, - script: ps.script, - eyeCatchingImageId: eyeCatchingImage ? eyeCatchingImage.id : null, - userId: me.id, - visibility: 'public', - alignCenter: ps.alignCenter, - hideTitleWhenPinned: ps.hideTitleWhenPinned, - font: ps.font, - })); + try { + const page = await this.pageService.create(me, { + ...ps, + eyeCatchingImage, + summary: ps.summary ?? null, + }); - return await this.pageEntityService.pack(page); + return await this.pageEntityService.pack(page); + } catch (err) { + if (err instanceof IdentifiableError && err.id === '1a79e38e-3d83-4423-845b-a9d83ff93b61') { + throw new ApiError(meta.errors.nameAlreadyExists); + } + throw err; + } }); } } diff --git a/packages/backend/src/server/api/endpoints/pages/delete.ts b/packages/backend/src/server/api/endpoints/pages/delete.ts index f2bc946788..a33868552d 100644 --- a/packages/backend/src/server/api/endpoints/pages/delete.ts +++ b/packages/backend/src/server/api/endpoints/pages/delete.ts @@ -4,12 +4,14 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { PagesRepository, UsersRepository } from '@/models/_.js'; +import type { MiDriveFile, PagesRepository, UsersRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { PageService } from '@/core/PageService.js'; export const meta = { tags: ['pages'], @@ -44,36 +46,17 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.pagesRepository) - private pagesRepository: PagesRepository, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private moderationLogService: ModerationLogService, - private roleService: RoleService, + private pageService: PageService, ) { super(meta, paramDef, async (ps, me) => { - const page = await this.pagesRepository.findOneBy({ id: ps.pageId }); - - if (page == null) { - throw new ApiError(meta.errors.noSuchPage); - } - - if (!await this.roleService.isModerator(me) && page.userId !== me.id) { - throw new ApiError(meta.errors.accessDenied); - } - - await this.pagesRepository.delete(page.id); - - if (page.userId !== me.id) { - const user = await this.usersRepository.findOneByOrFail({ id: page.userId }); - this.moderationLogService.log(me, 'deletePage', { - pageId: page.id, - pageUserId: page.userId, - pageUserUsername: user.username, - page, - }); + try { + await this.pageService.delete(me, ps.pageId); + } catch (err) { + if (err instanceof IdentifiableError) { + if (err.id === '66aefd3c-fdb2-4a71-85ae-cc18bea85d3f') throw new ApiError(meta.errors.noSuchPage); + if (err.id === 'd0017699-8256-46f1-aed4-bc03bed73616') throw new ApiError(meta.errors.accessDenied); + } + throw err; } }); } diff --git a/packages/backend/src/server/api/endpoints/pages/update.ts b/packages/backend/src/server/api/endpoints/pages/update.ts index a6aeb6002e..6fa5c1d75c 100644 --- a/packages/backend/src/server/api/endpoints/pages/update.ts +++ b/packages/backend/src/server/api/endpoints/pages/update.ts @@ -4,13 +4,14 @@ */ import ms from 'ms'; -import { Not } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { PagesRepository, DriveFilesRepository } from '@/models/_.js'; +import type { DriveFilesRepository, MiDriveFile } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; import { pageNameSchema } from '@/models/Page.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { PageService } from '@/core/PageService.js'; export const meta = { tags: ['pages'], @@ -75,57 +76,37 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.pagesRepository) - private pagesRepository: PagesRepository, - @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, + + private pageService: PageService, ) { super(meta, paramDef, async (ps, me) => { - const page = await this.pagesRepository.findOneBy({ id: ps.pageId }); - if (page == null) { - throw new ApiError(meta.errors.noSuchPage); - } - if (page.userId !== me.id) { - throw new ApiError(meta.errors.accessDenied); - } + try { + let eyeCatchingImage: MiDriveFile | null | undefined | string = ps.eyeCatchingImageId; + if (eyeCatchingImage != null) { + eyeCatchingImage = await this.driveFilesRepository.findOneBy({ + id: eyeCatchingImage, + userId: me.id, + }); - if (ps.eyeCatchingImageId != null) { - const eyeCatchingImage = await this.driveFilesRepository.findOneBy({ - id: ps.eyeCatchingImageId, - userId: me.id, - }); - - if (eyeCatchingImage == null) { - throw new ApiError(meta.errors.noSuchFile); - } - } - - if (ps.name != null) { - await this.pagesRepository.findBy({ - id: Not(ps.pageId), - userId: me.id, - name: ps.name, - }).then(result => { - if (result.length > 0) { - throw new ApiError(meta.errors.nameAlreadyExists); + if (eyeCatchingImage == null) { + throw new ApiError(meta.errors.noSuchFile); } - }); - } + } - await this.pagesRepository.update(page.id, { - updatedAt: new Date(), - title: ps.title, - name: ps.name, - summary: ps.summary === undefined ? page.summary : ps.summary, - content: ps.content, - variables: ps.variables, - script: ps.script, - alignCenter: ps.alignCenter, - hideTitleWhenPinned: ps.hideTitleWhenPinned, - font: ps.font, - eyeCatchingImageId: ps.eyeCatchingImageId, - }); + await this.pageService.update(me, ps.pageId, { + ...ps, + eyeCatchingImage, + }); + } catch (err) { + if (err instanceof IdentifiableError) { + if (err.id === '66aefd3c-fdb2-4a71-85ae-cc18bea85d3f') throw new ApiError(meta.errors.noSuchPage); + if (err.id === 'd0017699-8256-46f1-aed4-bc03bed73616') throw new ApiError(meta.errors.accessDenied); + if (err.id === 'd05bfe24-24b6-4ea2-a3ec-87cc9bf4daa4') throw new ApiError(meta.errors.nameAlreadyExists); + } + throw err; + } }); } } diff --git a/packages/backend/test/unit/NoteCreateService.ts b/packages/backend/test/unit/NoteCreateService.ts index f2d4c8ffbb..23f409420e 100644 --- a/packages/backend/test/unit/NoteCreateService.ts +++ b/packages/backend/test/unit/NoteCreateService.ts @@ -40,6 +40,7 @@ describe('NoteCreateService', () => { renoteCount: 0, repliesCount: 0, clippedCount: 0, + pageCount: 0, reactions: {}, visibility: 'public', uri: null, diff --git a/packages/backend/test/unit/misc/is-renote.ts b/packages/backend/test/unit/misc/is-renote.ts index 0b713e8bf6..74d17abcb6 100644 --- a/packages/backend/test/unit/misc/is-renote.ts +++ b/packages/backend/test/unit/misc/is-renote.ts @@ -23,6 +23,7 @@ const base: MiNote = { renoteCount: 0, repliesCount: 0, clippedCount: 0, + pageCount: 0, reactions: {}, visibility: 'public', uri: null, diff --git a/packages/backend/test/unit/queue/processors/CleanRemoteNotesProcessorService.ts b/packages/backend/test/unit/queue/processors/CleanRemoteNotesProcessorService.ts index 597d6b90cd..631e160afc 100644 --- a/packages/backend/test/unit/queue/processors/CleanRemoteNotesProcessorService.ts +++ b/packages/backend/test/unit/queue/processors/CleanRemoteNotesProcessorService.ts @@ -281,6 +281,24 @@ describe('CleanRemoteNotesProcessorService', () => { expect(remainingNote).not.toBeNull(); }); + // ページ + test('should not delete note that is embedded in a page', async () => { + const job = createMockJob(); + + // Create old remote note that is embedded in a page + const clippedNote = await createNote({ + pageCount: 1, // Embedded in a page + }, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000); + + const result = await service.process(job as any); + + expect(result.deletedCount).toBe(0); + expect(result.skipped).toBe(false); + + const remainingNote = await notesRepository.findOneBy({ id: clippedNote.id }); + expect(remainingNote).not.toBeNull(); + }); + // 古いreply, renoteが含まれている時の挙動 test('should handle reply/renote relationships correctly', async () => { const job = createMockJob(); diff --git a/packages/frontend/package.json b/packages/frontend/package.json index eaefe92612..fe2f47ad1f 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -25,7 +25,7 @@ "@rollup/plugin-replace": "6.0.2", "@rollup/pluginutils": "5.2.0", "@sentry/vue": "10.0.0", - "@syuilo/aiscript": "1.0.0", + "@syuilo/aiscript": "1.1.0", "@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0", "@twemoji/parser": "16.0.0", "@vitejs/plugin-vue": "6.0.1", @@ -52,6 +52,7 @@ "icons-subsetter": "workspace:*", "idb-keyval": "6.2.2", "insert-text-at-cursor": "0.3.0", + "ios-haptics": "0.1.0", "is-file-animated": "1.0.2", "json5": "2.2.3", "magic-string": "0.30.17", diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index 0248d75f75..df05bcc94c 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -141,6 +141,7 @@ import { $i } from '@/i.js'; import { checkReactionPermissions } from '@/utility/check-reaction-permissions.js'; import { prefer } from '@/preferences.js'; import { useRouter } from '@/router.js'; +import { haptic } from '@/utility/haptic.js'; const router = useRouter(); @@ -431,6 +432,8 @@ function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef, const key = getKey(emoji); emit('chosen', key); + haptic(); + // 最近使った絵文字更新 if (!pinned.value?.includes(key)) { let recents = store.s.recentlyUsedEmojis; diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue index b65f610986..c7361a19c6 100644 --- a/packages/frontend/src/components/MkFollowButton.vue +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -46,6 +46,7 @@ import { claimAchievement } from '@/utility/achievements.js'; import { pleaseLogin } from '@/utility/please-login.js'; import { $i } from '@/i.js'; import { prefer } from '@/preferences.js'; +import { haptic } from '@/utility/haptic.js'; const props = withDefaults(defineProps<{ user: Misskey.entities.UserDetailed, @@ -84,6 +85,8 @@ async function onClick() { wait.value = true; + haptic(); + try { if (isFollowing.value) { const { canceled } = await os.confirm({ diff --git a/packages/frontend/src/components/MkPullToRefresh.vue b/packages/frontend/src/components/MkPullToRefresh.vue index c792ff3488..89aca5d29b 100644 --- a/packages/frontend/src/components/MkPullToRefresh.vue +++ b/packages/frontend/src/components/MkPullToRefresh.vue @@ -27,6 +27,7 @@ import { onMounted, onUnmounted, ref, useTemplateRef } from 'vue'; import { getScrollContainer } from '@@/js/scroll.js'; import { i18n } from '@/i18n.js'; import { isHorizontalSwipeSwiping } from '@/utility/touch.js'; +import { haptic } from '@/utility/haptic.js'; const SCROLL_STOP = 10; const MAX_PULL_DISTANCE = Infinity; @@ -203,6 +204,8 @@ function moving(event: MouseEvent | TouchEvent) { pullDistance.value = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE); isPulledEnough.value = pullDistance.value >= FIRE_THRESHOLD; + + if (isPulledEnough.value) haptic(); } /** diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index 7d76dffa5a..e02d0ec21d 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -38,6 +38,7 @@ import { prefer } from '@/preferences.js'; import { DI } from '@/di.js'; import { noteEvents } from '@/composables/use-note-capture.js'; import { mute as muteEmoji, unmute as unmuteEmoji, checkMuted as isEmojiMuted } from '@/utility/emoji-mute.js'; +import { haptic } from '@/utility/haptic.js'; const props = defineProps<{ noteId: Misskey.entities.Note['id']; @@ -80,6 +81,7 @@ async function toggleReaction() { if (oldReaction !== props.reaction) { sound.playMisskeySfx('reaction'); + haptic(); } if (mock) { @@ -118,6 +120,7 @@ async function toggleReaction() { } sound.playMisskeySfx('reaction'); + haptic(); if (mock) { emit('reactionToggled', props.reaction, (props.count + 1)); diff --git a/packages/frontend/src/components/MkSwitch.vue b/packages/frontend/src/components/MkSwitch.vue index 92359b773a..9a2bea3616 100644 --- a/packages/frontend/src/components/MkSwitch.vue +++ b/packages/frontend/src/components/MkSwitch.vue @@ -30,6 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { toRefs } from 'vue'; import type { Ref } from 'vue'; import XButton from '@/components/MkSwitch.button.vue'; +import { haptic } from '@/utility/haptic.js'; const props = defineProps<{ modelValue: boolean | Ref; @@ -48,6 +49,8 @@ const toggle = () => { if (props.disabled) return; emit('update:modelValue', !checked.value); emit('change', !checked.value); + + haptic(); }; diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue index 17258cc11b..a481972174 100644 --- a/packages/frontend/src/pages/about-misskey.vue +++ b/packages/frontend/src/pages/about-misskey.vue @@ -292,6 +292,9 @@ const patronsWithIcon = [{ }, { name: 'NigN', icon: 'https://assets.misskey-hub.net/patrons/1ccaef8e73ec4a50b59ff7cd688ceb84.jpg', +}, { + name: 'しゃどかの', + icon: 'https://assets.misskey-hub.net/patrons/5bec3c6b402942619e03f7a2ae76d69e.jpg', }]; const patrons = [ diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue index bf4911f648..d2c2621a35 100644 --- a/packages/frontend/src/pages/flash/flash-edit.vue +++ b/packages/frontend/src/pages/flash/flash-edit.vue @@ -88,7 +88,7 @@ let choices = [ ] // シードが「PlayID+ユーザーID+今日の日付」である乱数生成器を用意 -let random = Math:gen_rng(\`{THIS_ID}{USER_ID}{Date:year()}{Date:month()}{Date:day()}\`, { algorithm: 'rc4_legacy' }) +let random = Math:gen_rng(\`{THIS_ID}{USER_ID}{Date:year()}{Date:month()}{Date:day()}\`) // ランダムに選択肢を選ぶ let chosen = choices[random(0, (choices.len - 1))] diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue index 30ab2ce11e..730cce183a 100644 --- a/packages/frontend/src/pages/settings/other.vue +++ b/packages/frontend/src/pages/settings/other.vue @@ -99,6 +99,9 @@ SPDX-License-Identifier: AGPL-3.0-only + + + @@ -173,6 +176,7 @@ const skipNoteRender = prefer.model('skipNoteRender'); const devMode = prefer.model('devMode'); const stackingRouterView = prefer.model('experimental.stackingRouterView'); const enableFolderPageView = prefer.model('experimental.enableFolderPageView'); +const enableHapticFeedback = prefer.model('experimental.enableHapticFeedback'); watch(skipNoteRender, () => { suggestReload(); diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue index 3977359c54..ab012841dc 100644 --- a/packages/frontend/src/pages/settings/privacy.vue +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -125,16 +125,20 @@ SPDX-License-Identifier: AGPL-3.0-only - - - - - - - - + + + + + + + {{ i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime }} - - - - - - - - + + + + + + + { } }); +const makeNotesFollowersOnlyBefore_presets = [ + { label: i18n.ts.oneHour, value: -3600 }, + { label: i18n.ts.oneDay, value: -86400 }, + { label: i18n.ts.threeDays, value: -259200 }, + { label: i18n.ts.oneWeek, value: -604800 }, + { label: i18n.ts.oneMonth, value: -2592000 }, + { label: i18n.ts.threeMonths, value: -7776000 }, + { label: i18n.ts.oneYear, value: -31104000 }, +]; + +const makeNotesFollowersOnlyBefore_isCustomMode = ref( + makeNotesFollowersOnlyBefore.value != null && + makeNotesFollowersOnlyBefore.value < 0 && + !makeNotesFollowersOnlyBefore_presets.some((preset) => preset.value === makeNotesFollowersOnlyBefore.value) +); + +const makeNotesFollowersOnlyBefore_selection = computed({ + get: () => makeNotesFollowersOnlyBefore_isCustomMode.value ? 'custom' : makeNotesFollowersOnlyBefore.value, + set(value) { + makeNotesFollowersOnlyBefore_isCustomMode.value = value === 'custom'; + if (value !== 'custom') makeNotesFollowersOnlyBefore.value = value; + } +}); + +const makeNotesFollowersOnlyBefore_customMonths = computed({ + get: () => makeNotesFollowersOnlyBefore.value ? Math.abs(makeNotesFollowersOnlyBefore.value) / (30 * 24 * 60 * 60) : null, + set(value) { + if (value != null && value > 0) makeNotesFollowersOnlyBefore.value = -Math.abs(Math.floor(Number(value))) * 30 * 24 * 60 * 60; + } +}); + const makeNotesHiddenBefore_type = computed(() => { if (makeNotesHiddenBefore.value == null) { return null; @@ -251,6 +290,37 @@ const makeNotesHiddenBefore_type = computed(() => { } }); +const makeNotesHiddenBefore_presets = [ + { label: i18n.ts.oneHour, value: -3600 }, + { label: i18n.ts.oneDay, value: -86400 }, + { label: i18n.ts.threeDays, value: -259200 }, + { label: i18n.ts.oneWeek, value: -604800 }, + { label: i18n.ts.oneMonth, value: -2592000 }, + { label: i18n.ts.threeMonths, value: -7776000 }, + { label: i18n.ts.oneYear, value: -31104000 }, +]; + +const makeNotesHiddenBefore_isCustomMode = ref( + makeNotesHiddenBefore.value != null && + makeNotesHiddenBefore.value < 0 && + !makeNotesHiddenBefore_presets.some((preset) => preset.value === makeNotesHiddenBefore.value) +); + +const makeNotesHiddenBefore_selection = computed({ + get: () => makeNotesHiddenBefore_isCustomMode.value ? 'custom' : makeNotesHiddenBefore.value, + set(value) { + makeNotesHiddenBefore_isCustomMode.value = value === 'custom'; + if (value !== 'custom') makeNotesHiddenBefore.value = value; + } +}); + +const makeNotesHiddenBefore_customMonths = computed({ + get: () => makeNotesHiddenBefore.value ? Math.abs(makeNotesHiddenBefore.value) / (30 * 24 * 60 * 60) : null, + set(value) { + if (value != null && value > 0) makeNotesHiddenBefore.value = -Math.abs(Math.floor(Number(value))) * 30 * 24 * 60 * 60; + } +}); + watch([makeNotesFollowersOnlyBefore, makeNotesHiddenBefore], () => { save(); }); diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts index f6370c8c78..7b045687d6 100644 --- a/packages/frontend/src/preferences/def.ts +++ b/packages/frontend/src/preferences/def.ts @@ -498,4 +498,7 @@ export const PREF_DEF = definePreferences({ 'experimental.enableFolderPageView': { default: false, }, + 'experimental.enableHapticFeedback': { + default: false, + }, }); diff --git a/packages/frontend/src/utility/haptic.ts b/packages/frontend/src/utility/haptic.ts new file mode 100644 index 0000000000..6f4706d202 --- /dev/null +++ b/packages/frontend/src/utility/haptic.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { haptic as _haptic } from 'ios-haptics'; +import { prefer } from '@/preferences.js'; + +export function haptic() { + if (prefer.s['experimental.enableHapticFeedback']) { + _haptic(); + } +} diff --git a/packages/frontend/test/aiscript/ui.test.ts b/packages/frontend/test/aiscript/ui.test.ts index c9df070bec..dc4041b135 100644 --- a/packages/frontend/test/aiscript/ui.test.ts +++ b/packages/frontend/test/aiscript/ui.test.ts @@ -320,7 +320,7 @@ describe('AiScript UI API', () => { const { root, get, outputs } = await exe(` let text_input = Ui:C:textInput({ onInput: print - "default": 'a' + default: 'a' label: 'b' caption: 'c' }, 'id') @@ -361,7 +361,7 @@ describe('AiScript UI API', () => { const { root, get, outputs } = await exe(` let textarea = Ui:C:textarea({ onInput: print - "default": 'a' + default: 'a' label: 'b' caption: 'c' }, 'id') @@ -402,7 +402,7 @@ describe('AiScript UI API', () => { const { root, get, outputs } = await exe(` let number_input = Ui:C:numberInput({ onInput: print - "default": 1 + default: 1 label: 'a' caption: 'b' }, 'id') @@ -564,7 +564,7 @@ describe('AiScript UI API', () => { const { root, get, outputs } = await exe(` let switch = Ui:C:switch({ onChange: print - "default": false + default: false label: 'a' caption: 'b' }, 'id') @@ -609,7 +609,7 @@ describe('AiScript UI API', () => { { text: 'B', value: 'b' } ] onChange: print - "default": 'a' + default: 'a' label: 'c' caption: 'd' }, 'id') diff --git a/packages/misskey-js/package.json b/packages/misskey-js/package.json index 3da0e5d6e0..b74afa9115 100644 --- a/packages/misskey-js/package.json +++ b/packages/misskey-js/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "misskey-js", - "version": "2025.8.0-alpha.11", + "version": "2025.8.0-beta.0", "description": "Misskey SDK for JavaScript", "license": "MIT", "main": "./built/index.js", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9bd004206..c54d7aa264 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -731,8 +731,8 @@ importers: specifier: 10.0.0 version: 10.0.0(vue@3.5.18(typescript@5.9.2)) '@syuilo/aiscript': - specifier: 1.0.0 - version: 1.0.0 + specifier: 1.1.0 + version: 1.1.0 '@syuilo/aiscript-0-19-0': specifier: npm:@syuilo/aiscript@^0.19.0 version: '@syuilo/aiscript@0.19.0' @@ -811,6 +811,9 @@ importers: insert-text-at-cursor: specifier: 0.3.0 version: 0.3.0 + ios-haptics: + specifier: 0.1.0 + version: 0.1.0 is-file-animated: specifier: 1.0.2 version: 1.0.2 @@ -4193,8 +4196,8 @@ packages: '@syuilo/aiscript@0.19.0': resolution: {integrity: sha512-ZWG4s1m6RrFjE7NeIMaxFz769YO1jW5ReTrOROrEO4IHheOrjxxJ/Ffe2TUNqX9/XxDloMwfWplKhfSzx8LGMA==} - '@syuilo/aiscript@1.0.0': - resolution: {integrity: sha512-m+Dxx0g2pDI198OCj/OJgiJnE4ajlbOFAMyh84FmbY1S8ss/MHytxY82dCnMZj5WVt7VE7a1rtW7biuRRfuyaA==} + '@syuilo/aiscript@1.1.0': + resolution: {integrity: sha512-3S6+tWC6f8WD8nnCgXSkqzPlEL9iKH9cfjERrk3OEuVQy+qP3wSLRszjy9FEHeYtvg90erUCGhTuYUy4XCNnmg==} '@szmarczak/http-timer@5.0.1': resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} @@ -7179,6 +7182,9 @@ packages: resolution: {integrity: sha512-NUcA93i1lukyXU+riqEyPtSEkyFq8tX90uL659J+qpCZ3rEdViB/APC58oAhIh3+bJln2hzdlZbBZsGNrlsR8g==} engines: {node: '>=12.22.0'} + ios-haptics@0.1.0: + resolution: {integrity: sha512-Fk0RApBYJeZNZ9pW3Wx3WcunhdLlpEnVNy/BOn85tx39eZDOHLGhXEb7medoIURGBUjXatOZf5Ozy0+OG466YA==} + ip-address@9.0.5: resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==} engines: {node: '>= 12'} @@ -14450,7 +14456,7 @@ snapshots: stringz: 2.1.0 uuid: 9.0.1 - '@syuilo/aiscript@1.0.0': + '@syuilo/aiscript@1.1.0': dependencies: seedrandom: 3.0.5 stringz: 2.1.0 @@ -18221,6 +18227,8 @@ snapshots: transitivePeerDependencies: - supports-color + ios-haptics@0.1.0: {} + ip-address@9.0.5: dependencies: jsbn: 1.1.0