diff options
| author | Hazelnoot <acomputerdog@gmail.com> | 2025-04-13 13:04:57 -0400 |
|---|---|---|
| committer | Hazelnoot <acomputerdog@gmail.com> | 2025-04-13 13:07:09 -0400 |
| commit | 71326962853fff5ff52cb11de50e135e373e0733 (patch) | |
| tree | 49eb91f7ba03df788527906ba5a2e720a791c0c5 /packages | |
| parent | remove docs for regenerate-search-index, as it's now a vite plugin (diff) | |
| parent | Release: 2025.4.0 (diff) | |
| download | sharkey-71326962853fff5ff52cb11de50e135e373e0733.tar.gz sharkey-71326962853fff5ff52cb11de50e135e373e0733.tar.bz2 sharkey-71326962853fff5ff52cb11de50e135e373e0733.zip | |
Merge tag '2025.4.0' into merge/2025-03-24
# Conflicts:
# .github/workflows/storybook.yml
# locales/index.d.ts
# package.json
# packages/backend/src/models/json-schema/role.ts
# packages/frontend/src/components/MkPageWindow.vue
# packages/frontend/src/pages/admin/roles.editor.vue
# packages/frontend/src/pages/admin/roles.vue
# packages/frontend/src/pages/settings/preferences.vue
# packages/frontend/src/pages/settings/privacy.vue
# packages/frontend/src/pages/timeline.vue
# packages/frontend/src/pref-migrate.ts
# packages/frontend/src/ui/_common_/common.vue
# packages/frontend/src/ui/deck.vue
# packages/frontend/src/ui/universal.vue
# packages/misskey-js/src/autogen/types.ts
Diffstat (limited to 'packages')
86 files changed, 1161 insertions, 1862 deletions
diff --git a/packages/backend/migration/1744075766000-excludeNotesInSensitiveChannel.js b/packages/backend/migration/1744075766000-excludeNotesInSensitiveChannel.js new file mode 100644 index 0000000000..1e8faafbc4 --- /dev/null +++ b/packages/backend/migration/1744075766000-excludeNotesInSensitiveChannel.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class ExcludeNotesInSensitiveChannel1744075766000 { + name = 'ExcludeNotesInSensitiveChannel1744075766000' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "antenna" RENAME COLUMN "hideNotesInSensitiveChannel" TO "excludeNotesInSensitiveChannel"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "antenna" RENAME COLUMN "excludeNotesInSensitiveChannel" TO "hideNotesInSensitiveChannel"`); + } +} diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index bc7b3d2417..13e3dcdbd8 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -114,7 +114,7 @@ export class AntennaService implements OnApplicationShutdown { if (note.visibility === 'specified') return false; if (note.visibility === 'followers') return false; - if (antenna.hideNotesInSensitiveChannel && note.channel?.isSensitive) return false; + if (antenna.excludeNotesInSensitiveChannel && note.channel?.isSensitive) return false; if (antenna.excludeBots && noteUser.isBot) return false; diff --git a/packages/backend/src/core/ChatService.ts b/packages/backend/src/core/ChatService.ts index 3984cefc80..b0e8cfb61c 100644 --- a/packages/backend/src/core/ChatService.ts +++ b/packages/backend/src/core/ChatService.ts @@ -95,6 +95,40 @@ export class ChatService { } @bindThis + public async getChatAvailability(userId: MiUser['id']): Promise<{ read: boolean; write: boolean; }> { + const policies = await this.roleService.getUserPolicies(userId); + + switch (policies.chatAvailability) { + case 'available': + return { + read: true, + write: true, + }; + case 'readonly': + return { + read: true, + write: false, + }; + case 'unavailable': + return { + read: false, + write: false, + }; + default: + throw new Error('invalid chat availability (unreachable)'); + } + } + + /** getChatAvailabilityの糖衣。主にAPI呼び出し時に走らせて、権限的に問題ない場合はそのまま続行する */ + @bindThis + public async checkChatAvailability(userId: MiUser['id'], permission: 'read' | 'write') { + const policy = await this.getChatAvailability(userId); + if (policy[permission] === false) { + throw new Error('ROLE_PERMISSION_DENIED'); + } + } + + @bindThis public async createMessageToUser(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toUser: MiUser, params: { text?: string | null; file?: MiDriveFile | null; @@ -140,7 +174,7 @@ export class ChatService { } } - if (!(await this.roleService.getUserPolicies(toUser.id)).canChat) { + if (!(await this.getChatAvailability(toUser.id)).write) { throw new Error('recipient is cannot chat (policy)'); } diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index d4b5b07b38..8b98680f4c 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -66,7 +66,7 @@ export type RolePolicies = { canImportFollowing: boolean; canImportMuting: boolean; canImportUserLists: boolean; - canChat: boolean; + chatAvailability: 'available' | 'readonly' | 'unavailable'; }; export const DEFAULT_POLICIES: RolePolicies = { @@ -104,7 +104,7 @@ export const DEFAULT_POLICIES: RolePolicies = { canImportFollowing: true, canImportMuting: true, canImportUserLists: true, - canChat: true, + chatAvailability: 'available', }; @Injectable() @@ -376,6 +376,12 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { return aggregate(policies.map(policy => policy.useDefault ? basePolicies[name] : policy.value)); } + function aggregateChatAvailability(vs: RolePolicies['chatAvailability'][]) { + if (vs.some(v => v === 'available')) return 'available'; + if (vs.some(v => v === 'readonly')) return 'readonly'; + return 'unavailable'; + } + return { gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)), btlAvailable: calc('btlAvailable', vs => vs.some(v => v === true)), @@ -411,7 +417,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { canImportFollowing: calc('canImportFollowing', vs => vs.some(v => v === true)), canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)), canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)), - canChat: calc('canChat', vs => vs.some(v => v === true)), + chatAvailability: calc('chatAvailability', aggregateChatAvailability), }; } diff --git a/packages/backend/src/core/entities/AntennaEntityService.ts b/packages/backend/src/core/entities/AntennaEntityService.ts index e81c1e8db4..1f8c8ae3e8 100644 --- a/packages/backend/src/core/entities/AntennaEntityService.ts +++ b/packages/backend/src/core/entities/AntennaEntityService.ts @@ -41,7 +41,7 @@ export class AntennaEntityService { excludeBots: antenna.excludeBots, withReplies: antenna.withReplies, withFile: antenna.withFile, - hideNotesInSensitiveChannel: antenna.hideNotesInSensitiveChannel, + excludeNotesInSensitiveChannel: antenna.excludeNotesInSensitiveChannel, isActive: antenna.isActive, hasUnreadNote: false, // TODO notify: false, // 後方互換性のため diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index a46e2e2983..d11042d73b 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -665,7 +665,7 @@ export class UserEntityService implements OnModuleInit { followersVisibility: profile!.followersVisibility, followingVisibility: profile!.followingVisibility, chatScope: user.chatScope, - canChat: this.roleService.getUserPolicies(user.id).then(r => r.canChat), + canChat: this.roleService.getUserPolicies(user.id).then(r => r.chatAvailability === 'available'), roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({ id: role.id, name: role.name, diff --git a/packages/backend/src/models/Antenna.ts b/packages/backend/src/models/Antenna.ts index 0d92c5cade..17ec0c0f79 100644 --- a/packages/backend/src/models/Antenna.ts +++ b/packages/backend/src/models/Antenna.ts @@ -104,5 +104,5 @@ export class MiAntenna { @Column('boolean', { default: false, }) - public hideNotesInSensitiveChannel: boolean; + public excludeNotesInSensitiveChannel: boolean; } diff --git a/packages/backend/src/models/json-schema/antenna.ts b/packages/backend/src/models/json-schema/antenna.ts index 2bdaca80d0..eca7563066 100644 --- a/packages/backend/src/models/json-schema/antenna.ts +++ b/packages/backend/src/models/json-schema/antenna.ts @@ -100,7 +100,7 @@ export const packedAntennaSchema = { optional: false, nullable: false, default: false, }, - hideNotesInSensitiveChannel: { + excludeNotesInSensitiveChannel: { type: 'boolean', optional: false, nullable: false, default: false, diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 285c10c4bd..9e95684f67 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -300,9 +300,10 @@ export const packedRolePoliciesSchema = { type: 'integer', optional: false, nullable: false, }, - canChat: { - type: 'boolean', + chatAvailability: { + type: 'string', optional: false, nullable: false, + enum: ['available', 'readonly', 'unavailable'], }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts index a5c1bd7835..55d686e390 100644 --- a/packages/backend/src/server/api/endpoints/antennas/create.ts +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -79,7 +79,7 @@ export const paramDef = { excludeBots: { type: 'boolean' }, withReplies: { type: 'boolean' }, withFile: { type: 'boolean' }, - hideNotesInSensitiveChannel: { type: 'boolean' }, + excludeNotesInSensitiveChannel: { type: 'boolean' }, }, required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile'], } as const; @@ -140,7 +140,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- excludeBots: ps.excludeBots, withReplies: ps.withReplies, withFile: ps.withFile, - hideNotesInSensitiveChannel: ps.hideNotesInSensitiveChannel, + excludeNotesInSensitiveChannel: ps.excludeNotesInSensitiveChannel, }); this.globalEventService.publishInternalEvent('antennaCreated', antenna); diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts index a8e0ae39c5..3f2513bf75 100644 --- a/packages/backend/src/server/api/endpoints/antennas/update.ts +++ b/packages/backend/src/server/api/endpoints/antennas/update.ts @@ -78,7 +78,7 @@ export const paramDef = { excludeBots: { type: 'boolean' }, withReplies: { type: 'boolean' }, withFile: { type: 'boolean' }, - hideNotesInSensitiveChannel: { type: 'boolean' }, + excludeNotesInSensitiveChannel: { type: 'boolean' }, }, required: ['antennaId'], } as const; @@ -136,7 +136,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- excludeBots: ps.excludeBots, withReplies: ps.withReplies, withFile: ps.withFile, - hideNotesInSensitiveChannel: ps.hideNotesInSensitiveChannel, + excludeNotesInSensitiveChannel: ps.excludeNotesInSensitiveChannel, isActive: true, lastUsedAt: new Date(), }); diff --git a/packages/backend/src/server/api/endpoints/chat/history.ts b/packages/backend/src/server/api/endpoints/chat/history.ts index 7553a751e0..fdd9055106 100644 --- a/packages/backend/src/server/api/endpoints/chat/history.ts +++ b/packages/backend/src/server/api/endpoints/chat/history.ts @@ -46,6 +46,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + const history = ps.room ? await this.chatService.roomHistory(me.id, ps.limit) : await this.chatService.userHistory(me.id, ps.limit); const packedMessages = await this.chatEntityService.packMessagesDetailed(history, me); diff --git a/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts b/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts index a988dc60b9..ad2b82e219 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts @@ -16,7 +16,6 @@ export const meta = { tags: ['chat'], requireCredential: true, - requiredRolePolicy: 'canChat', prohibitMoved: true, @@ -74,6 +73,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + const room = await this.chatService.findRoomById(ps.toRoomId); if (room == null) { throw new ApiError(meta.errors.noSuchRoom); diff --git a/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts b/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts index bbaab8a6c3..fa34a7d558 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts @@ -16,7 +16,6 @@ export const meta = { tags: ['chat'], requireCredential: true, - requiredRolePolicy: 'canChat', prohibitMoved: true, @@ -86,6 +85,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + let file = null; if (ps.fileId != null) { file = await this.driveFilesRepository.findOneBy({ diff --git a/packages/backend/src/server/api/endpoints/chat/messages/delete.ts b/packages/backend/src/server/api/endpoints/chat/messages/delete.ts index 25fc774d4f..63b75fb6a7 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/delete.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/delete.ts @@ -13,7 +13,6 @@ export const meta = { tags: ['chat'], requireCredential: true, - requiredRolePolicy: 'canChat', kind: 'write:chat', @@ -43,6 +42,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + const message = await this.chatService.findMyMessageById(me.id, ps.messageId); if (message == null) { throw new ApiError(meta.errors.noSuchMessage); diff --git a/packages/backend/src/server/api/endpoints/chat/messages/react.ts b/packages/backend/src/server/api/endpoints/chat/messages/react.ts index 0145e380be..5f61e7e992 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/react.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/react.ts @@ -13,7 +13,6 @@ export const meta = { tags: ['chat'], requireCredential: true, - requiredRolePolicy: 'canChat', kind: 'write:chat', @@ -44,6 +43,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + await this.chatService.react(ps.messageId, me.id, ps.reaction); }); } diff --git a/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts b/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts index b6d3356196..c0e344b889 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts @@ -54,6 +54,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + const room = await this.chatService.findRoomById(ps.roomId); if (room == null) { throw new ApiError(meta.errors.noSuchRoom); diff --git a/packages/backend/src/server/api/endpoints/chat/messages/search.ts b/packages/backend/src/server/api/endpoints/chat/messages/search.ts index 4c989e5ca9..682597f76d 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/search.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/search.ts @@ -54,6 +54,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + if (ps.roomId != null) { const room = await this.chatService.findRoomById(ps.roomId); if (room == null) { diff --git a/packages/backend/src/server/api/endpoints/chat/messages/show.ts b/packages/backend/src/server/api/endpoints/chat/messages/show.ts index 371f7a7071..9a2bbb8742 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/show.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/show.ts @@ -50,6 +50,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private chatEntityService: ChatEntityService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + const message = await this.chatService.findMessageById(ps.messageId); if (message == null) { throw new ApiError(meta.errors.noSuchMessage); diff --git a/packages/backend/src/server/api/endpoints/chat/messages/unreact.ts b/packages/backend/src/server/api/endpoints/chat/messages/unreact.ts index b97bad8a9c..6784bb6ecf 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/unreact.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/unreact.ts @@ -13,7 +13,6 @@ export const meta = { tags: ['chat'], requireCredential: true, - requiredRolePolicy: 'canChat', kind: 'write:chat', @@ -44,6 +43,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + await this.chatService.unreact(ps.messageId, me.id, ps.reaction); }); } diff --git a/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts b/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts index a35f121bb1..a057e2e088 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts @@ -56,6 +56,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private getterService: GetterService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + const other = await this.getterService.getUser(ps.userId).catch(err => { if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); throw err; diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/create.ts b/packages/backend/src/server/api/endpoints/chat/rooms/create.ts index fa4cc8ceb4..68a53f0886 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/create.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/create.ts @@ -15,7 +15,6 @@ export const meta = { tags: ['chat'], requireCredential: true, - requiredRolePolicy: 'canChat', prohibitMoved: true, @@ -52,6 +51,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private chatEntityService: ChatEntityService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + const room = await this.chatService.createRoom(me, { name: ps.name, description: ps.description ?? '', diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts b/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts index 1d77a06dd8..82a8e1f30d 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts @@ -42,6 +42,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + const room = await this.chatService.findRoomById(ps.roomId); if (room == null) { throw new ApiError(meta.errors.noSuchRoom); diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts index 5da4a1a772..b1f049f2b9 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts @@ -15,7 +15,6 @@ export const meta = { tags: ['chat'], requireCredential: true, - requiredRolePolicy: 'canChat', prohibitMoved: true, @@ -57,6 +56,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private chatEntityService: ChatEntityService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + const room = await this.chatService.findMyRoomById(me.id, ps.roomId); if (room == null) { throw new ApiError(meta.errors.noSuchRoom); diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts index 8c017f7d01..b8a228089b 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts @@ -42,6 +42,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + await this.chatService.ignoreRoomInvitation(me.id, ps.roomId); }); } diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts index 07337480fc..8a02d1c704 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts @@ -47,6 +47,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + const invitations = await this.chatService.getReceivedRoomInvitationsWithPagination(me.id, ps.limit, ps.sinceId, ps.untilId); return this.chatEntityService.packRoomInvitations(invitations, me); }); diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/outbox.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/outbox.ts index 12d496e94b..0702ba086c 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/outbox.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/outbox.ts @@ -55,6 +55,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private chatEntityService: ChatEntityService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + const room = await this.chatService.findMyRoomById(me.id, ps.roomId); if (room == null) { throw new ApiError(meta.errors.noSuchRoom); diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/join.ts b/packages/backend/src/server/api/endpoints/chat/rooms/join.ts index dbd4d1ea5a..d561f9e03f 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/join.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/join.ts @@ -42,6 +42,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + await this.chatService.joinToRoom(me.id, ps.roomId); }); } diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts b/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts index c4c6253236..ba9242c762 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts @@ -47,6 +47,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private chatEntityService: ChatEntityService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + const memberships = await this.chatService.getMyMemberships(me.id, ps.limit, ps.sinceId, ps.untilId); return this.chatEntityService.packRoomMemberships(memberships, me, { diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts b/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts index 724ad61f7e..a3ad0c2d6f 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts @@ -42,6 +42,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + await this.chatService.leaveRoom(me.id, ps.roomId); }); } diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/members.ts b/packages/backend/src/server/api/endpoints/chat/rooms/members.ts index 407bfe74f1..f5ffa21d32 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/members.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/members.ts @@ -54,6 +54,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private chatEntityService: ChatEntityService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + const room = await this.chatService.findRoomById(ps.roomId); if (room == null) { throw new ApiError(meta.errors.noSuchRoom); diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/mute.ts b/packages/backend/src/server/api/endpoints/chat/rooms/mute.ts index 5208b8a253..11cbe7b8b9 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/mute.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/mute.ts @@ -43,6 +43,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + await this.chatService.muteRoom(me.id, ps.roomId, ps.mute); }); } diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts b/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts index 6516120bca..accf7e1bee 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts @@ -47,6 +47,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + const rooms = await this.chatService.getOwnedRoomsWithPagination(me.id, ps.limit, ps.sinceId, ps.untilId); return this.chatEntityService.packRooms(rooms, me); }); diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/show.ts b/packages/backend/src/server/api/endpoints/chat/rooms/show.ts index 547618ee7d..50da210d81 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/show.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/show.ts @@ -47,6 +47,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private chatEntityService: ChatEntityService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + const room = await this.chatService.findRoomById(ps.roomId); if (room == null) { throw new ApiError(meta.errors.noSuchRoom); diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/update.ts b/packages/backend/src/server/api/endpoints/chat/rooms/update.ts index 6f2a9c10b5..0cd62cb040 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/update.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/update.ts @@ -49,6 +49,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private chatEntityService: ChatEntityService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + const room = await this.chatService.findMyRoomById(me.id, ps.roomId); if (room == null) { throw new ApiError(meta.errors.noSuchRoom); diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index c90e99d930..c899ad9490 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -54,6 +54,8 @@ class GlobalTimelineChannel extends Channel { if (note.visibility !== 'public') return; if (note.channelId != null) return; if (note.user.requireSigninToViewContents && this.user == null) return; + if (note.renote && note.renote.user.requireSigninToViewContents && this.user == null) return; + if (note.reply && note.reply.user.requireSigninToViewContents && this.user == null) return; if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 630bcce8cf..82b128eae0 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -57,6 +57,8 @@ class LocalTimelineChannel extends Channel { if (note.visibility !== 'public') return; if (note.channelId != null) return; if (note.user.requireSigninToViewContents && this.user == null) return; + if (note.renote && note.renote.user.requireSigninToViewContents && this.user == null) return; + if (note.reply && note.reply.user.requireSigninToViewContents && this.user == null) return; // 関係ない返信は除外 if (note.reply && this.user && !this.following[note.userId]?.withReplies && !this.withReplies) { diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts index fd3a6706f0..cbb18fe4ed 100644 --- a/packages/backend/test/e2e/antennas.ts +++ b/packages/backend/test/e2e/antennas.ts @@ -146,7 +146,7 @@ describe('アンテナ', () => { caseSensitive: false, createdAt: new Date(response.createdAt).toISOString(), excludeKeywords: [['']], - hideNotesInSensitiveChannel: false, + excludeNotesInSensitiveChannel: false, hasUnreadNote: false, isActive: true, keywords: [['keyword']], @@ -218,8 +218,8 @@ describe('アンテナ', () => { { parameters: () => ({ withReplies: true }) }, { parameters: () => ({ withFile: false }) }, { parameters: () => ({ withFile: true }) }, - { parameters: () => ({ hideNotesInSensitiveChannel: false }) }, - { parameters: () => ({ hideNotesInSensitiveChannel: true }) }, + { parameters: () => ({ excludeNotesInSensitiveChannel: false }) }, + { parameters: () => ({ excludeNotesInSensitiveChannel: true }) }, ]; test.each(antennaParamPattern)('を作成できること($#)', async ({ parameters }) => { const response = await successfulApiCall({ @@ -633,7 +633,7 @@ describe('アンテナ', () => { const keyword = 'キーワード'; const antenna = await successfulApiCall({ endpoint: 'antennas/create', - parameters: { ...defaultParam, keywords: [[keyword]], hideNotesInSensitiveChannel: true }, + parameters: { ...defaultParam, keywords: [[keyword]], excludeNotesInSensitiveChannel: true }, user: alice, }); const nonSensitiveChannel = await successfulApiCall({ diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts index 8341fed41b..22e4e36292 100644 --- a/packages/frontend-shared/js/const.ts +++ b/packages/frontend-shared/js/const.ts @@ -174,7 +174,7 @@ export const ROLE_POLICIES = [ 'canImportFollowing', 'canImportMuting', 'canImportUserLists', - 'canChat', + 'chatAvailability', ] as const; export const DEFAULT_SERVER_ERROR_IMAGE_URL = '/client-assets/status/error.jpg'; diff --git a/packages/frontend/lib/vite-plugin-create-search-index.ts b/packages/frontend/lib/vite-plugin-create-search-index.ts index 99af81fb70..97f4e589a3 100644 --- a/packages/frontend/lib/vite-plugin-create-search-index.ts +++ b/packages/frontend/lib/vite-plugin-create-search-index.ts @@ -3,13 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +/// <reference lib="esnext" /> + import { parse as vueSfcParse } from 'vue/compiler-sfc'; import { createLogger, EnvironmentModuleGraph, - normalizePath, type LogErrorOptions, type LogOptions, + normalizePath, type Plugin, type PluginOption } from 'vite'; @@ -20,29 +22,25 @@ import MagicString, { SourceMap } from 'magic-string'; import path from 'node:path' import { hash, toBase62 } from '../vite.config'; import { minimatch } from 'minimatch'; -import type { - AttributeNode, CompoundExpressionNode, DirectiveNode, - ElementNode, - RootNode, SimpleExpressionNode, - TemplateChildNode, +import { + type AttributeNode, + type DirectiveNode, + type ElementNode, + ElementTypes, + NodeTypes, + type RootNode, + type SimpleExpressionNode, + type TemplateChildNode, } from '@vue/compiler-core'; -import { NodeTypes } from '@vue/compiler-core'; - -export type AnalysisResult<T = SearchIndexItem> = { - filePath: string; - usage: T[]; -} -export type SearchIndexItem = SearchIndexItemLink<SearchIndexItem>; -export type SearchIndexStringItem = SearchIndexItemLink<string>; -export interface SearchIndexItemLink<T> { +export interface SearchIndexItem { id: string; + parentId?: string; path?: string; label: string; - keywords: string | string[]; + keywords: string[]; icon?: string; inlining?: string[]; - children?: T[]; } export type Options = { @@ -65,7 +63,7 @@ interface MarkerRelation { let logger = { info: (msg: string, options?: LogOptions) => { }, warn: (msg: string, options?: LogOptions) => { }, - error: (msg: string, options?: LogErrorOptions) => { }, + error: (msg: string, options?: LogErrorOptions | unknown) => { }, }; let loggerInitialized = false; @@ -90,253 +88,65 @@ function initLogger(options: Options) { } } -function collectSearchItemIndexes(analysisResults: AnalysisResult<SearchIndexStringItem>[]): SearchIndexItem[] { - logger.info(`Processing ${analysisResults.length} files for output`); - - // 新しいツリー構造を構築 - const allMarkers = new Map<string, SearchIndexStringItem>(); - - // 1. すべてのマーカーを一旦フラットに収集 - for (const file of analysisResults) { - logger.info(`Processing file: ${file.filePath} with ${file.usage.length} markers`); - - for (const marker of file.usage) { - if (marker.id) { - // キーワードとchildren処理を共通化 - const processedMarker: SearchIndexStringItem = { - ...marker, - keywords: processMarkerProperty(marker.keywords, 'keywords'), - }; - - allMarkers.set(marker.id, processedMarker); - } - } - } - - logger.info(`Collected total ${allMarkers.size} unique markers`); - - // 2. 子マーカーIDの収集 - const childIds = collectChildIds(allMarkers); - logger.info(`Found ${childIds.size} child markers`); - - // 3. ルートマーカーの特定(他の誰かの子でないマーカー) - const rootMarkers = identifyRootMarkers(allMarkers, childIds); - logger.info(`Found ${rootMarkers.length} root markers`); - - // 4. 子マーカーの参照を解決 - const resolvedRootMarkers = resolveChildReferences(rootMarkers, allMarkers); - - // 5. デバッグ情報を生成 - const { totalMarkers, totalChildren } = countMarkers(resolvedRootMarkers); - logger.info(`Total markers in tree: ${totalMarkers} (${resolvedRootMarkers.length} roots + ${totalChildren} nested children)`); - - return resolvedRootMarkers; -} - -/** - * マーカーのプロパティ(keywordsやchildren)を処理する - */ -function processMarkerProperty(propValue: string | string[], propType: 'keywords' | 'children'): string | string[] { - // 文字列の配列表現を解析 - if (typeof propValue === 'string' && propValue.startsWith('[') && propValue.endsWith(']')) { - try { - // JSON5解析を試みる - return JSON5.parse(propValue.replace(/'/g, '"')); - } catch (e) { - // 解析に失敗した場合 - logger.warn(`Could not parse ${propType}: ${propValue}, using ${propType === 'children' ? 'empty array' : 'as is'}`); - return propType === 'children' ? [] : propValue; - } - } +//region AST Utility - return propValue; -} +type WalkVueNode = RootNode | TemplateChildNode | SimpleExpressionNode; /** - * 全マーカーから子IDを収集する + * Walks the Vue AST. + * @param nodes + * @param context The context value passed to callback. you can update context for children by returning value in callback + * @param callback Returns false if you don't want to walk inner tree */ -function collectChildIds(allMarkers: Map<string, SearchIndexStringItem>): Set<string> { - const childIds = new Set<string>(); - - allMarkers.forEach((marker, id) => { - // 通常のchildren処理 - const children = marker.children; - if (Array.isArray(children)) { - children.forEach(childId => { - if (typeof childId === 'string') { - if (!allMarkers.has(childId)) { - logger.warn(`Warning: Child marker ID ${childId} referenced but not found`); - } else { - childIds.add(childId); - } - } - }); - } - - // inlining処理を追加 - if (marker.inlining) { - let inliningIds: string[] = []; - - // 文字列の場合は配列に変換 - if (typeof marker.inlining === 'string') { - try { - const inliningStr = (marker.inlining as string).trim(); - if (inliningStr.startsWith('[') && inliningStr.endsWith(']')) { - inliningIds = JSON5.parse(inliningStr.replace(/'/g, '"')); - logger.info(`Parsed inlining string to array: ${inliningStr} -> ${JSON.stringify(inliningIds)}`); - } else { - inliningIds = [inliningStr]; - } - } catch (e) { - logger.error(`Failed to parse inlining string: ${marker.inlining}`, e); - } - } - // 既に配列の場合 - else if (Array.isArray(marker.inlining)) { - inliningIds = marker.inlining; - } - - // inliningで指定されたIDを子セットに追加 - for (const inlineId of inliningIds) { - if (typeof inlineId === 'string') { - if (!allMarkers.has(inlineId)) { - logger.warn(`Warning: Inlining marker ID ${inlineId} referenced but not found`); - } else { - // inliningで参照されているマーカーも子として扱う - childIds.add(inlineId); - logger.info(`Added inlined marker ${inlineId} as child in collectChildIds`); - } - } - } +function walkVueElements<C extends {} | null>(nodes: WalkVueNode[], context: C, callback: (node: ElementNode, context: C) => C | undefined | void | false): void { + for (const node of nodes) { + let currentContext = context; + if (node.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error("Unexpected COMPOUND_EXPRESSION"); + if (node.type === NodeTypes.ELEMENT) { + const result = callback(node, context); + if (result === false) return; + if (result !== undefined) currentContext = result; } - }); - - return childIds; -} - -/** - * ルートマーカー(他の子でないマーカー)を特定する - */ -function identifyRootMarkers( - allMarkers: Map<string, SearchIndexStringItem>, - childIds: Set<string> -): SearchIndexStringItem[] { - const rootMarkers: SearchIndexStringItem[] = []; - - allMarkers.forEach((marker, id) => { - if (!childIds.has(id)) { - rootMarkers.push(marker); - logger.info(`Added root marker to output: ${id} with label ${marker.label}`); + if ('children' in node) { + walkVueElements(node.children, currentContext, callback); } - }); - - return rootMarkers; + } } -/** - * 子マーカーの参照をIDから実際のオブジェクトに解決する - */ -function resolveChildReferences( - rootMarkers: SearchIndexStringItem[], - allMarkers: Map<string, SearchIndexStringItem> -): SearchIndexItem[] { - function resolveChildrenForMarker(marker: SearchIndexStringItem): SearchIndexItem { - // マーカーのディープコピーを作成 - const resolvedMarker: SearchIndexItem = { ...marker, children: [] }; - // 明示的に子マーカー配列を作成 - const resolvedChildren: SearchIndexItem[] = []; - - // 通常のchildren処理 - if (Array.isArray(marker.children)) { - for (const childId of marker.children) { - if (typeof childId === 'string') { - const childMarker = allMarkers.get(childId); - if (childMarker) { - // 子マーカーの子も再帰的に解決 - const resolvedChild = resolveChildrenForMarker(childMarker); - resolvedChildren.push(resolvedChild); - logger.info(`Resolved regular child ${childId} for parent ${marker.id}`); - } - } - } - } - - // inlining属性の処理 - let inliningIds: string[] = []; - - // 文字列の場合は配列に変換。例: "['2fa']" -> ['2fa'] - if (typeof marker.inlining === 'string') { - try { - // 文字列形式の配列を実際の配列に変換 - const inliningStr = (marker.inlining as string).trim(); - if (inliningStr.startsWith('[') && inliningStr.endsWith(']')) { - inliningIds = JSON5.parse(inliningStr.replace(/'/g, '"')); - logger.info(`Converted string inlining to array: ${inliningStr} -> ${JSON.stringify(inliningIds)}`); - } else { - // 単一値の場合は配列に - inliningIds = [inliningStr]; - logger.info(`Converted single string inlining to array: ${inliningStr}`); +function findAttribute(props: Array<AttributeNode | DirectiveNode>, name: string): AttributeNode | DirectiveNode | null { + for (const prop of props) { + switch (prop.type) { + case NodeTypes.ATTRIBUTE: + if (prop.name === name) { + return prop; } - } catch (e) { - logger.error(`Failed to parse inlining string: ${marker.inlining}`, e); - } - } - // 既に配列の場合はそのまま使用 - else if (Array.isArray(marker.inlining)) { - inliningIds = marker.inlining; - } - - // インライン指定されたマーカーを子として追加 - for (const inlineId of inliningIds) { - if (typeof inlineId === 'string') { - const inlineMarker = allMarkers.get(inlineId); - if (inlineMarker) { - // インライン指定されたマーカーを再帰的に解決 - const resolvedInline = resolveChildrenForMarker(inlineMarker); - delete resolvedInline.path - resolvedChildren.push(resolvedInline); - logger.info(`Added inlined marker ${inlineId} as child to ${marker.id}`); - } else { - logger.warn(`Inlining target not found: ${inlineId} referenced by ${marker.id}`); + break; + case NodeTypes.DIRECTIVE: + if (prop.name === 'bind' && prop.arg && 'content' in prop.arg && prop.arg.content === name) { + return prop; } - } + break; } - - // 解決した子が存在する場合のみchildrenプロパティを設定 - if (resolvedChildren.length > 0) { - resolvedMarker.children = resolvedChildren; - } else { - delete resolvedMarker.children; - } - - return resolvedMarker; } - - // すべてのルートマーカーの子を解決 - return rootMarkers.map(marker => resolveChildrenForMarker(marker)); + return null; } -/** - * マーカー数を数える(デバッグ用) - */ -function countMarkers(markers: SearchIndexItem[]): { totalMarkers: number, totalChildren: number } { - let totalMarkers = markers.length; - let totalChildren = 0; - - function countNested(items: SearchIndexItem[]): void { - for (const marker of items) { - if (marker.children && Array.isArray(marker.children)) { - totalChildren += marker.children.length; - totalMarkers += marker.children.length; - countNested(marker.children as SearchIndexItem[]); - } - } +function findEndOfStartTagAttributes(node: ElementNode): number { + if (node.children.length > 0) { + // 子要素がある場合、最初の子要素の開始位置を基準にする + const nodeStart = node.loc.start.offset; + const firstChildStart = node.children[0].loc.start.offset; + const endOfStartTag = node.loc.source.lastIndexOf('>', firstChildStart - nodeStart); + if (endOfStartTag === -1) throw new Error("Bug: Failed to find end of start tag"); + return nodeStart + endOfStartTag; + } else { + // 子要素がない場合、自身の終了位置から逆算 + return node.isSelfClosing ? node.loc.end.offset - 1 : node.loc.end.offset; } - - countNested(markers); - return { totalMarkers, totalChildren }; } +//endregion + /** * TypeScriptコード生成 */ @@ -349,787 +159,325 @@ function generateJavaScriptCode(resolvedRootMarkers: SearchIndexItem[]): string * オブジェクトを特殊な形式の文字列に変換する * i18n参照を保持しつつ適切な形式に変換 */ -function customStringify(obj: unknown, depth = 0): string { - const INDENT_STR = '\t'; - - // 配列の処理 - if (Array.isArray(obj)) { - if (obj.length === 0) return '[]'; - const indent = INDENT_STR.repeat(depth); - const childIndent = INDENT_STR.repeat(depth + 1); - - // 配列要素の処理 - const items = obj.map(item => { - // オブジェクト要素 - if (typeof item === 'object' && item !== null) { - return `${childIndent}${customStringify(item, depth + 1)}`; - } - - // i18n参照を含む文字列要素 - if (typeof item === 'string' && item.includes('i18n.ts.')) { - return `${childIndent}${item}`; // クォートなしでそのまま出力 - } - - // その他の要素 - return `${childIndent}${JSON5.stringify(item)}`; - }).join(',\n'); - - return `[\n${items},\n${indent}]`; - } - - // null または非オブジェクト - if (obj === null || typeof obj !== 'object') { - return JSON5.stringify(obj); - } - - // オブジェクトの処理 - const indent = INDENT_STR.repeat(depth); - const childIndent = INDENT_STR.repeat(depth + 1); - - const entries = Object.entries(obj) - // 不要なプロパティを除去 - .filter(([key, value]) => { - if (value === undefined) return false; - if (key === 'children' && Array.isArray(value) && value.length === 0) return false; - return true; - }) - // 各プロパティを変換 - .map(([key, value]) => { - // 子要素配列の特殊処理 - if (key === 'children' && Array.isArray(value) && value.length > 0) { - return `${childIndent}${key}: ${customStringify(value, depth + 1)}`; - } - - // ラベルやその他プロパティを処理 - return `${childIndent}${key}: ${formatSpecialProperty(key, value)}`; - }); - - if (entries.length === 0) return '{}'; - return `{\n${entries.join(',\n')},\n${indent}}`; -} - -/** - * 特殊プロパティの書式設定 - */ -function formatSpecialProperty(key: string, value: unknown): string { - // 値がundefinedの場合は空文字列を返す - if (value === undefined) { - return '""'; - } - - // childrenが配列の場合は特別に処理 - if (key === 'children' && Array.isArray(value)) { - return customStringify(value); - } - - // keywordsが配列の場合、特別に処理 - if (key === 'keywords' && Array.isArray(value)) { - return `[${formatArrayForOutput(value)}]`; - } - - // 文字列値の場合の特別処理 - if (typeof value === 'string') { - // i18n.ts 参照を含む場合 - クォートなしでそのまま出力 - if (isI18nReference(value)) { - logger.info(`Preserving i18n reference in output: ${value}`); - return value; - } - - // keywords が配列リテラルの形式の場合 - if (key === 'keywords' && value.startsWith('[') && value.endsWith(']')) { - return value; - } - } - - // 上記以外は通常の JSON5 文字列として返す - return JSON5.stringify(value); -} - -/** - * 配列式の文字列表現を生成 - */ -function formatArrayForOutput(items: unknown[]): string { - return items.map(item => { - // i18n.ts. 参照の文字列はそのままJavaScript式として出力 - if (typeof item === 'string' && isI18nReference(item)) { - logger.info(`Preserving i18n reference in array: ${item}`); - return item; // クォートなしでそのまま - } - - // その他の値はJSON5形式で文字列化 - return JSON5.stringify(item); - }).join(', '); +function customStringify(obj: unknown): string { + return JSON.stringify(obj).replaceAll(/"(.*?)"/g, (all, group) => { + // propertyAccessProxy が i18n 参照を "${i18n.xxx}"のような形に変換してるので、これをそのまま`${i18n.xxx}` + // のような形にすると、実行時にi18nのプロパティにアクセスするようになる。 + // objectのkeyでは``が使えないので、${ が使われている場合にのみ``に置き換えるようにする + return group.includes('${') ? '`' + group + '`' : all; + }); } -/** - * 要素ノードからテキスト内容を抽出する - * 各抽出方法を分離して可読性を向上 - */ -function extractElementText(node: TemplateChildNode): string | null { - if (!node) return null; - if (node.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error("Unexpected COMPOUND_EXPRESSION"); - - logger.info(`Extracting text from node type=${node.type}, tag=${'tag' in node ? node.tag : 'unknown'}`); - - // 1. 直接コンテンツの抽出を試行 - const directContent = extractDirectContent(node); - if (directContent) return directContent; +// region extractElementText - // 子要素がない場合は終了 - if (!('children' in node) || !Array.isArray(node.children)) { - return null; - } - - // 2. インターポレーションノードを検索 - const interpolationContent = extractInterpolationContent(node.children); - if (interpolationContent) return interpolationContent; - - // 3. 式ノードを検索 - const expressionContent = extractExpressionContent(node.children); - if (expressionContent) return expressionContent; - - // 4. テキストノードを検索 - const textContent = extractTextContent(node.children); - if (textContent) return textContent; - - // 5. 再帰的に子ノードを探索 - return extractNestedContent(node.children); -} /** - * ノードから直接コンテンツを抽出 + * 要素のノードの中身のテキストを抽出する */ -function extractDirectContent(node: TemplateChildNode): string | null { - if (!('content' in node)) return null; - if (typeof node.content == 'object' && node.content.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error("Unexpected COMPOUND_EXPRESSION"); - - const content = typeof node.content === 'string' ? node.content.trim() - : node.content.type !== NodeTypes.INTERPOLATION ? node.content.content.trim() - : null; - - if (!content) return null; - - logger.info(`Direct node content found: ${content}`); - - // Mustache構文のチェック - const mustachePattern = /^\s*{{\s*(.*?)\s*}}\s*$/; - const mustacheMatch = content.match(mustachePattern); - - if (mustacheMatch && mustacheMatch[1] && isI18nReference(mustacheMatch[1])) { - const extractedContent = mustacheMatch[1].trim(); - logger.info(`Extracted i18n reference from mustache: ${extractedContent}`); - return extractedContent; - } - - // 直接i18n参照を含む場合 - if (isI18nReference(content)) { - logger.info(`Direct i18n reference found: ${content}`); - return content; - } - - // その他のコンテンツ - return content; +function extractElementText(node: ElementNode, id: string): string | null { + return extractElementTextChecked(node, node.tag, id); } -/** - * インターポレーションノード(Mustache)からコンテンツを抽出 - */ -function extractInterpolationContent(children: TemplateChildNode[]): string | null { - for (const child of children) { - if (child.type === NodeTypes.INTERPOLATION) { - logger.info(`Found interpolation node (Mustache): ${JSON.stringify(child.content).substring(0, 100)}...`); - - if (child.content && child.content.type === 4 && child.content.content) { - const content = child.content.content.trim(); - logger.info(`Interpolation content: ${content}`); - - if (isI18nReference(content)) { - return content; - } - } else if (child.content && typeof child.content === 'object') { - if (child.content.type == NodeTypes.COMPOUND_EXPRESSION) throw new Error("Unexpected COMPOUND_EXPRESSION"); - // オブジェクト形式のcontentを探索 - logger.info(`Complex interpolation node: ${JSON.stringify(child.content).substring(0, 100)}...`); - - if (child.content.content) { - const content = child.content.content.trim(); - - if (isI18nReference(content)) { - logger.info(`Found i18n reference in complex interpolation: ${content}`); - return content; - } - } - } - } +function extractElementTextChecked(node: ElementNode, processingNodeName: string, id: string): string | null { + const result: string[] = []; + for (const child of node.children) { + const text = extractElementText2Inner(child, processingNodeName, id); + if (text == null) return null; + result.push(text); } - - return null; + return result.join(''); } -/** - * 式ノードからコンテンツを抽出 - */ -function extractExpressionContent(children: TemplateChildNode[]): string | null { - // i18n.ts. 参照パターンを持つものを優先 - for (const child of children) { - if (child.type === NodeTypes.TEXT && child.content) { - const expr = child.content.trim(); +function extractElementText2Inner(node: TemplateChildNode, processingNodeName: string, id: string): string | null { + if (node.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error("Unexpected COMPOUND_EXPRESSION"); - if (isI18nReference(expr)) { - logger.info(`Found i18n reference in expression node: ${expr}`); - return expr; + switch (node.type) { + case NodeTypes.INTERPOLATION: { + const expr = node.content; + if (expr.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error(`Unexpected COMPOUND_EXPRESSION`); + const exprResult = evalExpression(expr.content); + if (typeof exprResult !== 'string') { + logger.error(`Result of interpolation node is not string at line ${id}:${node.loc.start.line}`); + return null; } + return exprResult; } - } - - // その他の式 - for (const child of children) { - if (child.type === NodeTypes.TEXT && child.content) { - const expr = child.content.trim(); - logger.info(`Found expression: ${expr}`); - return expr; - } - } - - return null; -} - -/** - * テキストノードからコンテンツを抽出 - */ -function extractTextContent(children: TemplateChildNode[]): string | null { - for (const child of children) { - if (child.type === NodeTypes.COMMENT && child.content) { - const text = child.content.trim(); - - if (text) { - logger.info(`Found text node: ${text}`); - - // Mustache構文のチェック - const mustachePattern = /^\s*{{\s*(.*?)\s*}}\s*$/; - const mustacheMatch = text.match(mustachePattern); - - if (mustacheMatch && mustacheMatch[1] && isI18nReference(mustacheMatch[1])) { - logger.info(`Extracted i18n ref from text mustache: ${mustacheMatch[1]}`); - return mustacheMatch[1].trim(); - } - - return text; + case NodeTypes.ELEMENT: + if (node.tagType === ElementTypes.ELEMENT) { + return extractElementTextChecked(node, processingNodeName, id); + } else { + logger.error(`Unexpected ${node.tag} extracting text of ${processingNodeName} ${id}:${node.loc.start.line}`); + return null; } - } + case NodeTypes.TEXT: + return node.content; + case NodeTypes.COMMENT: + // We skip comments + return ''; + case NodeTypes.IF: + case NodeTypes.IF_BRANCH: + case NodeTypes.FOR: + case NodeTypes.TEXT_CALL: + logger.error(`Unexpected controlflow element extracting text of ${processingNodeName} ${id}:${node.loc.start.line}`); + return null; } - - return null; } -/** - * 子ノードを再帰的に探索してコンテンツを抽出 - */ -function extractNestedContent(children: TemplateChildNode[]): string | null { - for (const child of children) { - if ('children' in child && Array.isArray(child.children) && child.children.length > 0) { - const nestedContent = extractElementText(child); - - if (nestedContent) { - logger.info(`Found nested content: ${nestedContent}`); - return nestedContent; - } - } else if (child.type === NodeTypes.ELEMENT) { - // childrenがなくても内部を調査 - const nestedContent = extractElementText(child); - - if (nestedContent) { - logger.info(`Found content in childless element: ${nestedContent}`); - return nestedContent; - } - } - } - - return null; -} +// endregion +// region extractUsageInfoFromTemplateAst /** - * SearchLabelとSearchKeywordを探して抽出する関数 + * SearchLabel/SearchKeyword/SearchIconを探して抽出する関数 */ -function extractLabelsAndKeywords(nodes: TemplateChildNode[]): { label: string | null, keywords: string[] } { - let label: string | null = null; +function extractSugarTags(nodes: TemplateChildNode[], id: string): { label: string | null, keywords: string[], icon: string | null } { + let label: string | null | undefined = undefined; + let icon: string | null | undefined = undefined; const keywords: string[] = []; logger.info(`Extracting labels and keywords from ${nodes.length} nodes`); - // 再帰的にSearchLabelとSearchKeywordを探索(ネストされたSearchMarkerは処理しない) - function findComponents(nodes: TemplateChildNode[]) { - for (const node of nodes) { - if (node.type === NodeTypes.ELEMENT) { - logger.info(`Checking element: ${node.tag}`); - - // SearchMarkerの場合は、その子要素は別スコープなのでスキップ - if (node.tag === 'SearchMarker') { - logger.info(`Found nested SearchMarker - skipping its content to maintain scope isolation`); - continue; // このSearchMarkerの中身は処理しない (スコープ分離) + walkVueElements(nodes, null, (node) => { + switch (node.tag) { + case 'SearchMarker': + return false; // SearchMarkerはスキップ + case 'SearchLabel': + if (label !== undefined) { + logger.warn(`Duplicate SearchLabel found, ignoring the second one at ${id}:${node.loc.start.line}`); + break; // 2つ目のSearchLabelは無視 } - // SearchLabelの処理 - if (node.tag === 'SearchLabel') { - logger.info(`Found SearchLabel node, structure: ${JSON.stringify(node).substring(0, 200)}...`); - - // まず完全なノード内容の抽出を試みる - const content = extractElementText(node); - if (content) { - label = content; - logger.info(`SearchLabel content extracted: ${content}`); - } else { - logger.info(`SearchLabel found but extraction failed, trying direct children inspection`); - - // バックアップ: 子直接確認 - type=5のMustacheインターポレーションを重点的に確認 - { - for (const child of node.children) { - // Mustacheインターポレーション - if (child.type === NodeTypes.INTERPOLATION && child.content) { - // content内の式を取り出す - if (child.content.type == NodeTypes.COMPOUND_EXPRESSION) throw new Error("unexpected COMPOUND_EXPRESSION"); - const expression = child.content.content || - (child.content.type === 4 ? child.content.content : null) || - JSON.stringify(child.content); - - logger.info(`Interpolation expression: ${expression}`); - if (typeof expression === 'string' && isI18nReference(expression)) { - label = expression.trim(); - logger.info(`Found i18n in interpolation: ${label}`); - break; - } - } - // 式ノード - else if (child.type === NodeTypes.TEXT && child.content && isI18nReference(child.content)) { - label = child.content.trim(); - logger.info(`Found i18n in expression: ${label}`); - break; - } - // テキストノードでもMustache構文を探す - else if (child.type === NodeTypes.COMMENT && child.content) { - const mustacheMatch = child.content.trim().match(/^\s*{{\s*(.*?)\s*}}\s*$/); - if (mustacheMatch && mustacheMatch[1] && isI18nReference(mustacheMatch[1])) { - label = mustacheMatch[1].trim(); - logger.info(`Found i18n in text mustache: ${label}`); - break; - } - } - } - } - } + label = extractElementText(node, id); + return; + case 'SearchKeyword': + const content = extractElementText(node, id); + if (content) { + keywords.push(content); + } + return; + case 'SearchIcon': + if (icon !== undefined) { + logger.warn(`Duplicate SearchIcon found, ignoring the second one at ${id}:${node.loc.start.line}`); + break; // 2つ目のSearchIconは無視 } - // SearchKeywordの処理 - else if (node.tag === 'SearchKeyword') { - logger.info(`Found SearchKeyword node`); - - // まず完全なノード内容の抽出を試みる - const content = extractElementText(node); - if (content) { - keywords.push(content); - logger.info(`SearchKeyword content extracted: ${content}`); - } else { - logger.info(`SearchKeyword found but extraction failed, trying direct children inspection`); - - // バックアップ: 子直接確認 - type=5のMustacheインターポレーションを重点的に確認 - { - for (const child of node.children) { - // Mustacheインターポレーション - if (child.type === NodeTypes.INTERPOLATION && child.content) { - // content内の式を取り出す - if (child.content.type == NodeTypes.COMPOUND_EXPRESSION) throw new Error("unexpected COMPOUND_EXPRESSION"); - const expression = child.content.content || - (child.content.type === 4 ? child.content.content : null) || - JSON.stringify(child.content); - logger.info(`Keyword interpolation: ${expression}`); - if (typeof expression === 'string' && isI18nReference(expression)) { - const keyword = expression.trim(); - keywords.push(keyword); - logger.info(`Found i18n keyword in interpolation: ${keyword}`); - break; - } - } - // 式ノード - else if (child.type === NodeTypes.TEXT && child.content && isI18nReference(child.content)) { - const keyword = child.content.trim(); - keywords.push(keyword); - logger.info(`Found i18n keyword in expression: ${keyword}`); - break; - } - // テキストノードでもMustache構文を探す - else if (child.type === NodeTypes.COMMENT && child.content) { - const mustacheMatch = child.content.trim().match(/^\s*{{\s*(.*?)\s*}}\s*$/); - if (mustacheMatch && mustacheMatch[1] && isI18nReference(mustacheMatch[1])) { - const keyword = mustacheMatch[1].trim(); - keywords.push(keyword); - logger.info(`Found i18n keyword in text mustache: ${keyword}`); - break; - } - } - } - } - } + if (node.children.length !== 1) { + logger.error(`SearchIcon must have exactly one child at ${id}:${node.loc.start.line}`); + return; } - // 子要素を再帰的に調査(ただしSearchMarkerは除外) - if (node.children && Array.isArray(node.children)) { - findComponents(node.children); + const iconNode = node.children[0]; + if (iconNode.type !== NodeTypes.ELEMENT) { + logger.error(`SearchIcon must have a child element at ${id}:${node.loc.start.line}`); + return; } - } + icon = getStringProp(findAttribute(iconNode.props, 'class'), id); + return; } - } - findComponents(nodes); + return; + }); // デバッグ情報 - logger.info(`Extraction completed: label=${label}, keywords=[${keywords.join(', ')}]`); - return { label, keywords }; + logger.info(`Extraction completed: label=${label}, keywords=[${keywords.join(', ')}, icon=${icon}]`); + return { label: label ?? null, keywords, icon: icon ?? null }; +} + +function getStringProp(attr: AttributeNode | DirectiveNode | null, id: string): string | null { + switch (attr?.type) { + case null: + case undefined: + return null; + case NodeTypes.ATTRIBUTE: + return attr.value?.content ?? null; + case NodeTypes.DIRECTIVE: + if (attr.exp == null) return null; + if (attr.exp.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error('Unexpected COMPOUND_EXPRESSION'); + const value = evalExpression(attr.exp.content ?? ''); + if (typeof value !== 'string') { + logger.error(`Expected string value, got ${typeof value} at ${id}:${attr.loc.start.line}`); + return null; + } + return value; + } } +function getStringArrayProp(attr: AttributeNode | DirectiveNode | null, id: string): string[] | null { + switch (attr?.type) { + case null: + case undefined: + return null; + case NodeTypes.ATTRIBUTE: + logger.error(`Expected directive, got attribute at ${id}:${attr.loc.start.line}`); + return null; + case NodeTypes.DIRECTIVE: + if (attr.exp == null) return null; + if (attr.exp.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error('Unexpected COMPOUND_EXPRESSION'); + const value = evalExpression(attr.exp.content ?? ''); + if (!Array.isArray(value) || !value.every(x => typeof x === 'string')) { + logger.error(`Expected string array value, got ${typeof value} at ${id}:${attr.loc.start.line}`); + return null; + } + return value; + } +} function extractUsageInfoFromTemplateAst( templateAst: RootNode | undefined, id: string, -): SearchIndexStringItem[] { - const allMarkers: SearchIndexStringItem[] = []; - const markerMap = new Map<string, SearchIndexItemLink<string>>(); - const childrenIds = new Set<string>(); - const normalizedId = id.replace(/\\/g, '/'); +): SearchIndexItem[] { + const allMarkers: SearchIndexItem[] = []; + const markerMap = new Map<string, SearchIndexItem>(); if (!templateAst) return allMarkers; - // マーカーの基本情報を収集 - function collectMarkers(node: TemplateChildNode | RootNode, parentId: string | null = null) { - if (node.type === NodeTypes.ELEMENT && node.tag === 'SearchMarker') { - // マーカーID取得 - const markerIdProp = node.props?.find(p => p.name === 'markerId'); - const markerId = markerIdProp?.type == NodeTypes.ATTRIBUTE ? markerIdProp.value?.content : null; - - // SearchMarkerにマーカーIDがない場合はエラー - if (markerId == null) { - logger.error(`Marker ID not found for node: ${JSON.stringify(node)}`); - throw new Error(`Marker ID not found in file ${id}`); - } - - // マーカー基本情報 - const markerInfo: SearchIndexStringItem = { - id: markerId, - children: [], - label: '', // デフォルト値 - keywords: [], - }; - - // 静的プロパティを取得 - if (node.props && Array.isArray(node.props)) { - for (const prop of node.props) { - if (prop.type === 6 && prop.name && prop.name !== 'markerId') { - if (prop.name === 'path') markerInfo.path = prop.value?.content || ''; - else if (prop.name === 'icon') markerInfo.icon = prop.value?.content || ''; - else if (prop.name === 'label') markerInfo.label = prop.value?.content || ''; - } - } - } - - // バインドプロパティを取得 - const bindings = extractNodeBindings(node); - if (bindings.path) markerInfo.path = bindings.path; - if (bindings.icon) markerInfo.icon = bindings.icon; - if (bindings.label) markerInfo.label = bindings.label; - if (bindings.children) markerInfo.children = processMarkerProperty(bindings.children, 'children') as string[]; - if (bindings.inlining) { - markerInfo.inlining = bindings.inlining; - logger.info(`Added inlining ${JSON.stringify(bindings.inlining)} to marker ${markerId}`); - } - if (bindings.keywords) { - if (Array.isArray(bindings.keywords)) { - markerInfo.keywords = bindings.keywords; - } else { - markerInfo.keywords = bindings.keywords || []; - } - } - - //pathがない場合はファイルパスを設定 - if (markerInfo.path == null && parentId == null) { - markerInfo.path = normalizedId.match(/.*(\/(admin|settings)\/[^\/]+)\.vue$/)?.[1]; - } - - // SearchLabelとSearchKeywordを抽出 (AST全体を探索) - if (node.children && Array.isArray(node.children)) { - logger.info(`Processing marker ${markerId} for labels and keywords`); - const extracted = extractLabelsAndKeywords(node.children); - - // SearchLabelからのラベル取得は最優先で適用 - if (extracted.label) { - markerInfo.label = extracted.label; - logger.info(`Using extracted label for ${markerId}: ${extracted.label}`); - } else if (markerInfo.label) { - logger.info(`Using existing label for ${markerId}: ${markerInfo.label}`); - } else { - markerInfo.label = 'Unnamed marker'; - logger.info(`No label found for ${markerId}, using default`); - } + walkVueElements<string | null>([templateAst], null, (node, parentId) => { + if (node.tag !== 'SearchMarker') { + return; + } - // SearchKeywordからのキーワード取得を追加 - if (extracted.keywords.length > 0) { - const existingKeywords = Array.isArray(markerInfo.keywords) ? - [...markerInfo.keywords] : - (markerInfo.keywords ? [markerInfo.keywords] : []); + // マーカーID取得 + const markerIdProp = node.props?.find(p => p.name === 'markerId'); + const markerId = markerIdProp?.type == NodeTypes.ATTRIBUTE ? markerIdProp.value?.content : null; - // i18n参照のキーワードは最優先で追加 - const combinedKeywords = [...existingKeywords]; - for (const kw of extracted.keywords) { - combinedKeywords.push(kw); - logger.info(`Added extracted keyword to ${markerId}: ${kw}`); - } + // SearchMarkerにマーカーIDがない場合はエラー + if (markerId == null) { + logger.error(`Marker ID not found for node: ${JSON.stringify(node)}`); + throw new Error(`Marker ID not found in file ${id}`); + } - markerInfo.keywords = combinedKeywords; - } - } + // マーカー基本情報 + const markerInfo: SearchIndexItem = { + id: markerId, + parentId: parentId ?? undefined, + label: '', // デフォルト値 + keywords: [], + }; - // マーカーを登録 - markerMap.set(markerId, markerInfo); - allMarkers.push(markerInfo); + // バインドプロパティを取得 + const path = getStringProp(findAttribute(node.props, 'path'), id) + const icon = getStringProp(findAttribute(node.props, 'icon'), id) + const label = getStringProp(findAttribute(node.props, 'label'), id) + const inlining = getStringArrayProp(findAttribute(node.props, 'inlining'), id) + const keywords = getStringArrayProp(findAttribute(node.props, 'keywords'), id) - // 親子関係を記録 - if (parentId) { - const parent = markerMap.get(parentId); - if (parent) { - childrenIds.add(markerId); - } - } + if (path) markerInfo.path = path; + if (icon) markerInfo.icon = icon; + if (label) markerInfo.label = label; + if (inlining) markerInfo.inlining = inlining; + if (keywords) markerInfo.keywords = keywords; - // 子ノードを処理 - for (const child of node.children) { - collectMarkers(child, markerId); - } + //pathがない場合はファイルパスを設定 + if (markerInfo.path == null && parentId == null) { + markerInfo.path = id.match(/.*(\/(admin|settings)\/[^\/]+)\.vue$/)?.[1]; + } - return markerId; + // SearchLabelとSearchKeywordを抽出 (AST全体を探索) + { + const extracted = extractSugarTags(node.children, id); + if (extracted.label && markerInfo.label) logger.warn(`Duplicate label found for ${markerId} at ${id}:${node.loc.start.line}`); + if (extracted.icon && markerInfo.icon) logger.warn(`Duplicate icon found for ${markerId} at ${id}:${node.loc.start.line}`); + markerInfo.label = extracted.label ?? markerInfo.label ?? ''; + markerInfo.keywords = [...extracted.keywords, ...markerInfo.keywords]; + markerInfo.icon = extracted.icon ?? markerInfo.icon ?? undefined; } - // SearchMarkerでない場合は再帰的に子ノードを処理 - else if ('children' in node && Array.isArray(node.children)) { - for (const child of node.children) { - if (typeof child == 'object' && child.type !== NodeTypes.SIMPLE_EXPRESSION) { - collectMarkers(child, parentId); - } - } + + if (!markerInfo.label) { + logger.warn(`No label found for ${markerId} at ${id}:${node.loc.start.line}`); } - return null; - } + // マーカーを登録 + markerMap.set(markerId, markerInfo); + allMarkers.push(markerInfo); + return markerId; + }); - // AST解析開始 - collectMarkers(templateAst); return allMarkers; } -type SpecialBindings = { - inlining: string[]; - keywords: string[] | string; -}; -type Bindings = Partial<Omit<Record<keyof SearchIndexItem, string>, keyof SpecialBindings> & SpecialBindings>; -// バインドプロパティの処理を修正する関数 -function extractNodeBindings(node: TemplateChildNode | RootNode): Bindings { - const bindings: Bindings = {}; - - if (node.type !== NodeTypes.ELEMENT) return bindings; +//endregion - // バインド式を収集 - for (const prop of node.props) { - if (prop.type === NodeTypes.DIRECTIVE && prop.name === 'bind' && prop.arg && 'content' in prop.arg) { - const propName = prop.arg.content; - if (prop.exp?.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error('unexpected COMPOUND_EXPRESSION'); - const propContent = prop.exp?.content || ''; +//region evalExpression - logger.info(`Processing bind prop ${propName}: ${propContent}`); +/** + * expr を実行します。 + * i18n はそのアクセスを保持するために propertyAccessProxy を使用しています。 + */ +function evalExpression(expr: string): unknown { + const rarResult = Function('i18n', `return ${expr}`)(i18nProxy); + // JSON.stringify を一回通すことで、 AccessProxy を文字列に変換する + // Walk してもいいんだけど横着してJSON.stringifyしてる。ビルド時にしか通らないのであんまりパフォーマンス気にする必要ないんで + return JSON.parse(JSON.stringify(rarResult)); +} - // inliningプロパティの処理を追加 - if (propName === 'inlining') { - try { - const content = propContent.trim(); +const propertyAccessProxySymbol = Symbol('propertyAccessProxySymbol'); - // 配列式の場合 - if (content.startsWith('[') && content.endsWith(']')) { - // 配列要素を解析 - const elements = parseArrayExpression(content); - if (elements.length > 0) { - bindings.inlining = elements; - logger.info(`Parsed inlining array: ${JSON5.stringify(elements)}`); - } else { - bindings.inlining = []; - } - } - // 文字列の場合は配列に変換 - else if (content) { - bindings.inlining = [content]; // 単一の値を配列に - logger.info(`Converting inlining to array: [${content}]`); - } - } catch (e) { - logger.error(`Failed to parse inlining binding: ${propContent}`, e); - } - } - // keywordsの特殊処理 - if (propName === 'keywords') { - try { - const content = propContent.trim(); +type AccessProxy = { + [propertyAccessProxySymbol]: string[], + [k: string]: AccessProxy, +} - // 配列式の場合 - if (content.startsWith('[') && content.endsWith(']')) { - // i18n参照や特殊な式を保持するため、各要素を個別に解析 - const elements = parseArrayExpression(content); - if (elements.length > 0) { - bindings.keywords = elements; - logger.info(`Parsed keywords array: ${JSON5.stringify(elements)}`); - } else { - bindings.keywords = []; - logger.info('Empty keywords array'); - } - } - // その他の式(非配列) - else if (content) { - bindings.keywords = content; // 式をそのまま保持 - logger.info(`Keeping keywords as expression: ${content}`); - } else { - bindings.keywords = []; - logger.info('No keywords provided'); - } - } catch (e) { - logger.error(`Failed to parse keywords binding: ${propContent}`, e); - // エラーが起きても何らかの値を設定 - bindings.keywords = propContent || []; - } - } - // その他のプロパティ - else if (propName === 'label') { - // ラベルの場合も式として保持 - bindings[propName] = propContent; - logger.info(`Set label from bind expression: ${propContent}`); - } - else { - bindings[propName] = propContent; - } +const propertyAccessProxyHandler: ProxyHandler<AccessProxy> = { + get(target: AccessProxy, p: string | symbol): any { + if (p in target) { + return (target as any)[p]; + } + if (p == "toJSON" || p == Symbol.toPrimitive) { + return propertyAccessProxyToJSON; + } + if (typeof p == 'string') { + return target[p] = propertyAccessProxy([...target[propertyAccessProxySymbol], p]); } + return undefined; } - - return bindings; } -// 配列式をパースする補助関数(文字列リテラル処理を改善) -function parseArrayExpression(expr: string): string[] { - try { - // 単純なケースはJSON5でパースを試みる - return JSON5.parse(expr.replace(/'/g, '"')); - } catch (e) { - // 複雑なケース(i18n.ts.xxx などの式を含む場合)は手動パース - logger.info(`Complex array expression, trying manual parsing: ${expr}`); - - // "["と"]"を取り除く - const content = expr.substring(1, expr.length - 1).trim(); - if (!content) return []; - - const result: string[] = []; - let currentItem = ''; - let depth = 0; - let inString = false; - let stringChar = ''; - - // カンマで区切る(ただし文字列内や入れ子の配列内のカンマは無視) - for (let i = 0; i < content.length; i++) { - const char = content[i]; - - if (inString) { - if (char === stringChar && content[i - 1] !== '\\') { - inString = false; - } - currentItem += char; - } else if (char === '"' || char === "'") { - inString = true; - stringChar = char; - currentItem += char; - } else if (char === '[') { - depth++; - currentItem += char; - } else if (char === ']') { - depth--; - currentItem += char; - } else if (char === ',' && depth === 0) { - // 項目の区切りを検出 - const trimmed = currentItem.trim(); - - // 純粋な文字列リテラルの場合、実際の値に変換 - if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || - (trimmed.startsWith('"') && trimmed.endsWith('"'))) { - try { - result.push(JSON5.parse(trimmed)); - } catch (err) { - result.push(trimmed); - } - } else { - // それ以外の式はそのまま(i18n.ts.xxx など) - result.push(trimmed); - } - - currentItem = ''; - } else { - currentItem += char; - } - } - - // 最後の項目を処理 - if (currentItem.trim()) { - const trimmed = currentItem.trim(); - - // 純粋な文字列リテラルの場合、実際の値に変換 - if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || - (trimmed.startsWith('"') && trimmed.endsWith('"'))) { - try { - result.push(JSON5.parse(trimmed)); - } catch (err) { - result.push(trimmed); - } - } else { - // それ以外の式はそのまま(i18n.ts.xxx など) - result.push(trimmed); - } +function propertyAccessProxyToJSON(this: AccessProxy, hint: string) { + const expression = this[propertyAccessProxySymbol].reduce((prev, current) => { + if (current.match(/^[a-z][0-9a-z]*$/i)) { + return `${prev}.${current}`; + } else { + return `${prev}['${current}']`; } - - logger.info(`Parsed complex array expression: ${expr} -> ${JSON.stringify(result)}`); - return result; - } + }); + return '$\{' + expression + '}'; } -export function collectFileMarkers(files: [id: string, code: string][]): AnalysisResult<SearchIndexStringItem> { - const allMarkers: SearchIndexStringItem[] = []; - for (const [id, code] of files) { - try { - const { descriptor, errors } = vueSfcParse(code, { - filename: id, - }); +/** + * プロパティのアクセスを保持するための Proxy オブジェクトを作成します。 + * + * この関数で生成した proxy は JSON でシリアライズするか、`${}`のように string にすると、 ${property.path} のような形になる。 + * @param path + */ +function propertyAccessProxy(path: string[]): AccessProxy { + const target: AccessProxy = { + [propertyAccessProxySymbol]: path, + }; + return new Proxy(target, propertyAccessProxyHandler); +} - if (errors.length > 0) { - logger.error(`Compile Error: ${id}, ${errors}`); - continue; // エラーが発生したファイルはスキップ - } +const i18nProxy = propertyAccessProxy(['i18n']); - const fileMarkers = extractUsageInfoFromTemplateAst(descriptor.template?.ast, id); +export function collectFileMarkers(id: string, code: string): SearchIndexItem[] { + try { + const { descriptor, errors } = vueSfcParse(code, { + filename: id, + }); - if (fileMarkers && fileMarkers.length > 0) { - allMarkers.push(...fileMarkers); // すべてのマーカーを収集 - logger.info(`Successfully extracted ${fileMarkers.length} markers from ${id}`); - } else { - logger.info(`No markers found in ${id}`); - } - } catch (error) { - logger.error(`Error analyzing file ${id}:`, error); + if (errors.length > 0) { + logger.error(`Compile Error: ${id}, ${errors}`); + return []; // エラーが発生したファイルはスキップ } + + return extractUsageInfoFromTemplateAst(descriptor.template?.ast, id); + } catch (error) { + logger.error(`Error analyzing file ${id}:`, error); } - // 収集したすべてのマーカー情報を使用 - return { - filePath: "combined-markers", // すべてのファイルのマーカーを1つのエントリとして扱う - usage: allMarkers, - }; + return []; } +// endregion + type TransformedCode = { code: string, map: SourceMap, @@ -1177,84 +525,37 @@ export class MarkerIdAssigner { }; } - type SearchMarkerElementNode = ElementNode & { - __markerId?: string, - __children?: string[], - }; + walkVueElements<string | null>([ast], null, (node, parentId) => { + if (node.tag !== 'SearchMarker') return; + + const markerIdProp = findAttribute(node.props, 'markerId'); - function traverse(node: RootNode | TemplateChildNode | SimpleExpressionNode | CompoundExpressionNode, currentParent?: SearchMarkerElementNode) { - if (node.type === NodeTypes.ELEMENT && node.tag === 'SearchMarker') { - // 行番号はコード先頭からの改行数で取得 - const lineNumber = code.slice(0, node.loc.start.offset).split('\n').length; + let nodeMarkerId: string; + if (markerIdProp != null) { + if (markerIdProp.type !== NodeTypes.ATTRIBUTE) return logger.error(`markerId must be a attribute at ${id}:${markerIdProp.loc.start.line}`); + if (markerIdProp.value == null) return logger.error(`markerId must have a value at ${id}:${markerIdProp.loc.start.line}`); + nodeMarkerId = markerIdProp.value.content; + } else { // ファイルパスと行番号からハッシュ値を生成 // この際実行環境で差が出ないようにファイルパスを正規化 const idKey = id.replace(/\\/g, '/').split('packages/frontend/')[1] - const generatedMarkerId = toBase62(hash(`${idKey}:${lineNumber}`)); - - const props = node.props || []; - const hasMarkerIdProp = props.some((prop) => prop.type === NodeTypes.ATTRIBUTE && prop.name === 'markerId'); - const nodeMarkerId = hasMarkerIdProp - ? props.find((prop): prop is AttributeNode => prop.type === NodeTypes.ATTRIBUTE && prop.name === 'markerId')?.value?.content as string - : generatedMarkerId; - (node as SearchMarkerElementNode).__markerId = nodeMarkerId; - - // 子マーカーの場合、親ノードに __children を設定しておく - if (currentParent) { - currentParent.__children = currentParent.__children || []; - currentParent.__children.push(nodeMarkerId); - } - - const parentMarkerId = currentParent && currentParent.__markerId; - markerRelations.push({ - parentId: parentMarkerId, - markerId: nodeMarkerId, - node: node, - }); - - if (!hasMarkerIdProp) { - const nodeStart = node.loc.start.offset; - let endOfStartTag; - - if (node.children && node.children.length > 0) { - // 子要素がある場合、最初の子要素の開始位置を基準にする - endOfStartTag = code.lastIndexOf('>', node.children[0].loc.start.offset); - } else if (node.loc.end.offset > nodeStart) { - // 子要素がない場合、自身の終了位置から逆算 - const nodeSource = code.substring(nodeStart, node.loc.end.offset); - // 自己終了タグか通常の終了タグかを判断 - if (nodeSource.includes('/>')) { - endOfStartTag = code.indexOf('/>', nodeStart) - 1; - } else { - endOfStartTag = code.indexOf('>', nodeStart); - } - } + const generatedMarkerId = toBase62(hash(`${idKey}:${node.loc.start.line}`)); - if (endOfStartTag !== undefined && endOfStartTag !== -1) { - // markerId が既に存在しないことを確認 - const tagText = code.substring(nodeStart, endOfStartTag + 1); - const markerIdRegex = /\s+markerId\s*=\s*["'][^"']*["']/; + // markerId attribute を追加 + const endOfStartTag = findEndOfStartTagAttributes(node); + s.appendRight(endOfStartTag, ` markerId="${generatedMarkerId}" data-in-app-search-marker-id="${generatedMarkerId}"`); - if (!markerIdRegex.test(tagText)) { - s.appendRight(endOfStartTag, ` markerId="${generatedMarkerId}" data-in-app-search-marker-id="${generatedMarkerId}"`); - logger.info(`Adding markerId="${generatedMarkerId}" to ${id}:${lineNumber}`); - } else { - logger.info(`markerId already exists in ${id}:${lineNumber}`); - } - } - } + nodeMarkerId = generatedMarkerId; } - const newParent: SearchMarkerElementNode | undefined = node.type === NodeTypes.ELEMENT && node.tag === 'SearchMarker' ? node : currentParent; - if ('children' in node) { - for (const child of node.children) { - if (typeof child == 'object') { - traverse(child, newParent); - } - } - } - } + markerRelations.push({ + parentId: parentId ?? undefined, + markerId: nodeMarkerId, + node: node, + }); - traverse(ast); // AST を traverse (1段階目: ID 生成と親子関係記録) + return nodeMarkerId; + }) // 2段階目: :children 属性の追加 // 最初に親マーカーごとに子マーカーIDを集約する処理を追加 @@ -1266,93 +567,42 @@ export class MarkerIdAssigner { if (!parentChildrenMap.has(relation.parentId)) { parentChildrenMap.set(relation.parentId, []); } - parentChildrenMap.get(relation.parentId)?.push(relation.markerId); + parentChildrenMap.get(relation.parentId)!.push(relation.markerId); } }); // 2. 親ごとにまとめて :children 属性を処理 for (const [parentId, childIds] of parentChildrenMap.entries()) { const parentRelation = markerRelations.find(r => r.markerId === parentId); - if (!parentRelation || !parentRelation.node) continue; + if (!parentRelation) continue; const parentNode = parentRelation.node; - const childrenProp = parentNode.props?.find((prop): prop is DirectiveNode => - prop.type === NodeTypes.DIRECTIVE && - prop.name === 'bind' && - prop.arg?.type === NodeTypes.SIMPLE_EXPRESSION && - prop.arg.content === 'children'); - - // 親ノードの開始位置を特定 - const parentNodeStart = parentNode.loc!.start.offset; - const endOfParentStartTag = parentNode.children && parentNode.children.length > 0 - ? code.lastIndexOf('>', parentNode.children[0].loc!.start.offset) - : code.indexOf('>', parentNodeStart); - - if (endOfParentStartTag === -1) continue; - - // 親タグのテキストを取得 - const parentTagText = code.substring(parentNodeStart, endOfParentStartTag + 1); + const childrenProp = findAttribute(parentNode.props, 'children'); + if (childrenProp != null) { + if (childrenProp.type !== NodeTypes.DIRECTIVE) { + console.error(`children prop should be directive (:children) at ${id}:${childrenProp.loc.start.line}`); + continue; + } - if (childrenProp) { // AST で :children 属性が検出された場合、それを更新 - try { - const childrenStart = code.indexOf('[', childrenProp.exp!.loc.start.offset); - const childrenEnd = code.indexOf(']', childrenProp.exp!.loc.start.offset); - if (childrenStart !== -1 && childrenEnd !== -1) { - const childrenArrayStr = code.slice(childrenStart, childrenEnd + 1); - let childrenArray = JSON5.parse(childrenArrayStr.replace(/'/g, '"')); + const childrenValue = getStringArrayProp(childrenProp, id); + if (childrenValue == null) continue; - // 新しいIDを追加(重複は除外) - const newIds = childIds.filter(id => !childrenArray.includes(id)); - if (newIds.length > 0) { - childrenArray = [...childrenArray, ...newIds]; - const updatedChildrenArrayStr = JSON5.stringify(childrenArray).replace(/"/g, "'"); - s.overwrite(childrenStart, childrenEnd + 1, updatedChildrenArrayStr); - logger.info(`Added ${newIds.length} child markerIds to existing :children in ${id}`); - } + const newValue: string[] = [...childrenValue]; + for (const childId of [...childIds]) { + if (!newValue.includes(childId)) { + newValue.push(childId); } - } catch (e) { - logger.error('Error updating :children attribute:', e); } - } else { - // AST では検出されなかった場合、タグテキストを調べる - const childrenRegex = /:children\s*=\s*["']\[(.*?)\]["']/; - const childrenMatch = parentTagText.match(childrenRegex); - - if (childrenMatch) { - // テキストから :children 属性値を解析して更新 - try { - const childrenContent = childrenMatch[1]; - const childrenArrayStr = `[${childrenContent}]`; - const childrenArray = JSON5.parse(childrenArrayStr.replace(/'/g, '"')); - - // 新しいIDを追加(重複は除外) - const newIds = childIds.filter(id => !childrenArray.includes(id)); - if (newIds.length > 0) { - childrenArray.push(...newIds); - // :children="[...]" の位置を特定して上書き - const attrStart = parentTagText.indexOf(':children='); - if (attrStart > -1) { - const attrValueStart = parentTagText.indexOf('[', attrStart); - const attrValueEnd = parentTagText.indexOf(']', attrValueStart) + 1; - if (attrValueStart > -1 && attrValueEnd > -1) { - const absoluteStart = parentNodeStart + attrValueStart; - const absoluteEnd = parentNodeStart + attrValueEnd; - const updatedArrayStr = JSON5.stringify(childrenArray).replace(/"/g, "'"); - s.overwrite(absoluteStart, absoluteEnd, updatedArrayStr); - logger.info(`Updated existing :children in tag text for ${id}`); - } - } - } - } catch (e) { - logger.error('Error updating :children in tag text:', e); - } - } else { - // :children 属性がまだない場合、新規作成 - s.appendRight(endOfParentStartTag, ` :children="${JSON5.stringify(childIds).replace(/"/g, "'")}"`); - logger.info(`Created new :children attribute with ${childIds.length} markerIds in ${id}`); - } + const expression = JSON.stringify(newValue).replaceAll(/"/g, "'"); + s.overwrite(childrenProp.exp!.loc.start.offset, childrenProp.exp!.loc.end.offset, expression); + logger.info(`Added ${childIds.length} child markerIds to existing :children in ${id}`); + } else { + // :children 属性がまだない場合、新規作成 + const endOfParentStartTag = findEndOfStartTagAttributes(parentNode); + s.appendRight(endOfParentStartTag, ` :children="${JSON5.stringify(childIds).replace(/"/g, "'")}"`); + logger.info(`Created new :children attribute with ${childIds.length} markerIds in ${id}`); } } @@ -1490,7 +740,7 @@ export function pluginCreateSearchIndexVirtualModule(options: Options, asigner: this.addWatchFile(searchIndexFilePath); const code = await asigner.getOrLoad(searchIndexFilePath); - return generateJavaScriptCode(collectSearchItemIndexes([collectFileMarkers([[id, code]])])); + return generateJavaScriptCode(collectFileMarkers(searchIndexFilePath, code)); } return null; }, @@ -1504,13 +754,3 @@ export function pluginCreateSearchIndexVirtualModule(options: Options, asigner: } }; } - -// i18n参照を検出するためのヘルパー関数を追加 -function isI18nReference(text: string | null | undefined): boolean { - if (!text) return false; - // ドット記法(i18n.ts.something) - const dotPattern = /i18n\.ts\.\w+/; - // ブラケット記法(i18n.ts['something']) - const bracketPattern = /i18n\.ts\[['"][^'"]+['"]\]/; - return dotPattern.test(text) || bracketPattern.test(text); -} diff --git a/packages/frontend/src/components/MkAntennaEditor.vue b/packages/frontend/src/components/MkAntennaEditor.vue index e1c8200b73..59099d54bd 100644 --- a/packages/frontend/src/components/MkAntennaEditor.vue +++ b/packages/frontend/src/components/MkAntennaEditor.vue @@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch> <MkSwitch v-model="caseSensitive">{{ i18n.ts.caseSensitive }}</MkSwitch> <MkSwitch v-model="withFile">{{ i18n.ts.withFileAntenna }}</MkSwitch> - <MkSwitch v-model="hideNotesInSensitiveChannel">{{ i18n.ts.hideNotesInSensitiveChannel }}</MkSwitch> + <MkSwitch v-model="excludeNotesInSensitiveChannel">{{ i18n.ts.excludeNotesInSensitiveChannel }}</MkSwitch> </div> <div :class="$style.actions"> <div class="_buttons"> @@ -54,6 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; +import type { DeepPartial } from '@/utility/merge.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; @@ -63,7 +64,6 @@ import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { deepMerge } from '@/utility/merge.js'; -import type { DeepPartial } from '@/utility/merge.js'; type PartialAllowedAntenna = Omit<Misskey.entities.Antenna, 'id' | 'createdAt' | 'updatedAt'> & { id?: string; @@ -87,7 +87,7 @@ const initialAntenna = deepMerge<PartialAllowedAntenna>(props.antenna ?? {}, { caseSensitive: false, localOnly: false, withFile: false, - hideNotesInSensitiveChannel: false, + excludeNotesInSensitiveChannel: false, isActive: true, hasUnreadNote: false, notify: false, @@ -110,7 +110,7 @@ const localOnly = ref<boolean>(initialAntenna.localOnly); const excludeBots = ref<boolean>(initialAntenna.excludeBots); const withReplies = ref<boolean>(initialAntenna.withReplies); const withFile = ref<boolean>(initialAntenna.withFile); -const hideNotesInSensitiveChannel = ref<boolean>(initialAntenna.hideNotesInSensitiveChannel); +const excludeNotesInSensitiveChannel = ref<boolean>(initialAntenna.excludeNotesInSensitiveChannel); const userLists = ref<Misskey.entities.UserList[] | null>(null); watch(() => src.value, async () => { @@ -127,7 +127,7 @@ async function saveAntenna() { excludeBots: excludeBots.value, withReplies: withReplies.value, withFile: withFile.value, - hideNotesInSensitiveChannel: hideNotesInSensitiveChannel.value, + excludeNotesInSensitiveChannel: excludeNotesInSensitiveChannel.value, caseSensitive: caseSensitive.value, localOnly: localOnly.value, users: users.value.trim().split('\n').map(x => x.trim()), diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index c688cb4b1f..8be4792cf8 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -473,7 +473,7 @@ onBeforeUnmount(() => { } } - &:not(.widthSpecified) { + &:not(.asDrawer):not(.widthSpecified) { > .menu { max-width: 400px; } diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 7bff8cff83..a4591a35f7 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -28,6 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.t_exportCompleted]: notification.type === 'exportCompleted', [$style.t_login]: notification.type === 'login', [$style.t_createToken]: notification.type === 'createToken', + [$style.t_chatRoomInvitationReceived]: notification.type === 'chatRoomInvitationReceived', [$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null, [$style.t_pollEnded]: notification.type === 'edited', [$style.t_roleAssigned]: notification.type === 'scheduledNoteFailed', @@ -424,6 +425,12 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification) pointer-events: none; } +.t_chatRoomInvitationReceived { + padding: 3px; + background: var(--eventOther); + pointer-events: none; +} + .tail { flex: 1; min-width: 0; diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index ef1d15cc49..d5b43cbf2e 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -113,7 +113,7 @@ windowRouter.addListener('change', ctx => { windowRouter.init(); provide(DI.router, windowRouter); -provide('inAppSearchMarkerId', searchMarkerId); +provide(DI.inAppSearchMarkerId, searchMarkerId); provideMetadataReceiver((metadataGetter) => { const info = metadataGetter(); pageMetadata.value = info; @@ -121,7 +121,7 @@ provideMetadataReceiver((metadataGetter) => { provideReactiveMetadata(pageMetadata); provide('shouldOmitHeaderTitle', true); provide('shouldHeaderThin', true); -provide('forceSpacerMin', true); +provide(DI.forceSpacerMin, true); provide('shouldBackButton', false); const contextmenu = computed(() => ([{ diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue index 9a03869437..e48ea1f661 100644 --- a/packages/frontend/src/components/MkSuperMenu.vue +++ b/packages/frontend/src/components/MkSuperMenu.vue @@ -93,11 +93,11 @@ export type SuperMenuDef = { </script> <script lang="ts" setup> -import { useTemplateRef, ref, watch, nextTick } from 'vue'; +import { useTemplateRef, ref, watch, nextTick, computed } from 'vue'; +import { getScrollContainer } from '@@/js/scroll.js'; import type { SearchIndexItem } from '@/utility/settings-search-index.js'; import MkInput from '@/components/MkInput.vue'; import { i18n } from '@/i18n.js'; -import { getScrollContainer } from '@@/js/scroll.js'; import { useRouter } from '@/router.js'; import { initIntlString, compareStringIncludes } from '@/utility/intl-string.js'; @@ -124,6 +124,7 @@ const searchResult = ref<{ isRoot: boolean; parentLabels: string[]; }[]>([]); +const searchIndexItemByIdComputed = computed(() => props.searchIndex && new Map<string, SearchIndexItem>(props.searchIndex.map(i => [i.id, i]))); watch(searchQuery, (value) => { rawSearchQuery.value = value; @@ -137,32 +138,41 @@ watch(rawSearchQuery, (value) => { return; } - const dive = (items: SearchIndexItem[], parents: SearchIndexItem[] = []) => { - for (const item of items) { - const matched = ( - compareStringIncludes(item.label, value) || - item.keywords.some((x) => compareStringIncludes(x, value)) - ); + const searchIndexItemById = searchIndexItemByIdComputed.value; + if (searchIndexItemById != null) { + const addSearchResult = (item: SearchIndexItem) => { + let path: string | undefined = item.path; + let icon: string | undefined = item.icon; + const parentLabels: string[] = []; - if (matched) { - searchResult.value.push({ - id: item.id, - path: item.path ?? parents.find((x) => x.path != null)?.path ?? '/', // never gets `/` - label: item.label, - parentLabels: parents.map((x) => x.label).toReversed(), - icon: item.icon ?? parents.find((x) => x.icon != null)?.icon, - isRoot: parents.length === 0, - }); + for (let current = searchIndexItemById.get(item.parentId ?? ''); + current != null; + current = searchIndexItemById.get(current.parentId ?? '')) { + path ??= current.path; + icon ??= current.icon; + parentLabels.push(current.label); } - if (item.children) { - dive(item.children, [item, ...parents]); + if (_DEV_ && path == null) throw new Error('path is null for ' + item.id); + + searchResult.value.push({ + id: item.id, + path: path ?? '/', // never gets `/` + label: item.label, + parentLabels: parentLabels.toReversed(), + icon, + isRoot: item.parentId == null, + }); + }; + + for (const item of searchIndexItemById.values()) { + if ( + compareStringIncludes(item.label, value) || + item.keywords.some((x) => compareStringIncludes(x, value)) + ) { + addSearchResult(item); } } - }; - - if (props.searchIndex) { - dive(props.searchIndex); } }); diff --git a/packages/frontend/src/components/MkWaitingDialog.vue b/packages/frontend/src/components/MkWaitingDialog.vue index 282da00ee1..820cf05e1f 100644 --- a/packages/frontend/src/components/MkWaitingDialog.vue +++ b/packages/frontend/src/components/MkWaitingDialog.vue @@ -22,7 +22,7 @@ const modal = useTemplateRef('modal'); const props = defineProps<{ success: boolean; showing: boolean; - text?: string; + text?: string | null; }>(); const emit = defineEmits<{ diff --git a/packages/frontend/src/components/global/MkSpacer.vue b/packages/frontend/src/components/global/MkSpacer.vue index 6080bad9cd..c3bc37cb92 100644 --- a/packages/frontend/src/components/global/MkSpacer.vue +++ b/packages/frontend/src/components/global/MkSpacer.vue @@ -14,6 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { inject } from 'vue'; import { deviceKind } from '@/utility/device-kind.js'; +import { DI } from '@/di.js'; const props = withDefaults(defineProps<{ contentMax?: number | null; @@ -25,7 +26,7 @@ const props = withDefaults(defineProps<{ marginMax: 24, }); -const forceSpacerMin = inject('forceSpacerMin', false) || deviceKind === 'smartphone'; +const forceSpacerMin = inject(DI.forceSpacerMin, false) || deviceKind === 'smartphone'; </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/global/SearchIcon.vue b/packages/frontend/src/components/global/SearchIcon.vue new file mode 100644 index 0000000000..27a284faf0 --- /dev/null +++ b/packages/frontend/src/components/global/SearchIcon.vue @@ -0,0 +1,14 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<slot></slot> +</template> + +<script lang="ts" setup> +</script> + +<style lang="scss" module> +</style> diff --git a/packages/frontend/src/components/global/SearchMarker.vue b/packages/frontend/src/components/global/SearchMarker.vue index 061ce3f47d..ded1f9a28b 100644 --- a/packages/frontend/src/components/global/SearchMarker.vue +++ b/packages/frontend/src/components/global/SearchMarker.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div ref="root" :class="[$style.root, { [$style.highlighted]: highlighted }]"> - <slot></slot> + <slot :isParentOfTarget="isParentOfTarget"></slot> </div> </template> @@ -21,7 +21,7 @@ import { useTemplateRef, inject, } from 'vue'; -import type { Ref } from 'vue'; +import { DI } from '@/di.js'; const props = defineProps<{ markerId?: string; @@ -36,12 +36,13 @@ const rootEl = useTemplateRef('root'); const rootElMutationObserver = new MutationObserver(() => { checkChildren(); }); -const injectedSearchMarkerId = inject<Ref<string | null> | null>('inAppSearchMarkerId', null); +const injectedSearchMarkerId = inject(DI.inAppSearchMarkerId, null); const searchMarkerId = computed(() => injectedSearchMarkerId?.value ?? window.location.hash.slice(1)); const highlighted = ref(props.markerId === searchMarkerId.value); +const isParentOfTarget = computed(() => props.children?.includes(searchMarkerId.value)); function checkChildren() { - if (props.children?.includes(searchMarkerId.value)) { + if (isParentOfTarget.value) { const el = window.document.querySelector(`[data-in-app-search-marker-id="${searchMarkerId.value}"]`); highlighted.value = el == null; } @@ -105,8 +106,8 @@ onBeforeUnmount(dispose); @keyframes blink { 0%, 100% { - background: color(from var(--MI_THEME-accent) srgb r g b / 0.05); - border: 1px solid color(from var(--MI_THEME-accent) srgb r g b / 0.7); + background: color(from var(--MI_THEME-accent) srgb r g b / 0.1); + border: 1px solid color(from var(--MI_THEME-accent) srgb r g b / 0.75); } 50% { background: transparent; diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts index 6c6903c3a4..34cf598b84 100644 --- a/packages/frontend/src/components/index.ts +++ b/packages/frontend/src/components/index.ts @@ -30,6 +30,7 @@ import PageWithAnimBg from './global/PageWithAnimBg.vue'; import SearchMarker from './global/SearchMarker.vue'; import SearchLabel from './global/SearchLabel.vue'; import SearchKeyword from './global/SearchKeyword.vue'; +import SearchIcon from './global/SearchIcon.vue'; import type { App } from 'vue'; @@ -67,6 +68,7 @@ export const components = { SearchMarker: SearchMarker, SearchLabel: SearchLabel, SearchKeyword: SearchKeyword, + SearchIcon: SearchIcon, }; declare module '@vue/runtime-core' { @@ -98,5 +100,6 @@ declare module '@vue/runtime-core' { SearchMarker: typeof SearchMarker; SearchLabel: typeof SearchLabel; SearchKeyword: typeof SearchKeyword; + SearchIcon: typeof SearchIcon; } } diff --git a/packages/frontend/src/di.ts b/packages/frontend/src/di.ts index 2dfe242cf4..58a2cce207 100644 --- a/packages/frontend/src/di.ts +++ b/packages/frontend/src/di.ts @@ -16,4 +16,6 @@ export const DI = { currentStickyBottom: Symbol() as InjectionKey<Ref<number>>, mfmEmojiReactCallback: Symbol() as InjectionKey<(emoji: string) => void>, inModal: Symbol() as InjectionKey<boolean>, + inAppSearchMarkerId: Symbol() as InjectionKey<Ref<string | null>>, + forceSpacerMin: Symbol() as InjectionKey<boolean>, }; diff --git a/packages/frontend/src/lib/nirax.ts b/packages/frontend/src/lib/nirax.ts index a97803e879..a166df9eb0 100644 --- a/packages/frontend/src/lib/nirax.ts +++ b/packages/frontend/src/lib/nirax.ts @@ -266,18 +266,20 @@ export class Nirax<DEF extends RouteDef[]> extends EventEmitter<RouterEvents> { throw new Error('no route found for: ' + fullPath); } - if ('redirect' in res.route) { - let redirectPath: string; - if (typeof res.route.redirect === 'function') { - redirectPath = res.route.redirect(res.props); - } else { - redirectPath = res.route.redirect + (res._parsedRoute.queryString ? '?' + res._parsedRoute.queryString : '') + (res._parsedRoute.hash ? '#' + res._parsedRoute.hash : ''); - } - if (_DEV_) console.log('Redirecting to: ', redirectPath); - if (_redirected && this.redirectCount++ > 10) { - throw new Error('redirect loop detected'); + for (let current: PathResolvedResult | undefined = res; current; current = current.child) { + if ('redirect' in current.route) { + let redirectPath: string; + if (typeof current.route.redirect === 'function') { + redirectPath = current.route.redirect(current.props); + } else { + redirectPath = current.route.redirect + (current._parsedRoute.queryString ? '?' + current._parsedRoute.queryString : '') + (current._parsedRoute.hash ? '#' + current._parsedRoute.hash : ''); + } + if (_DEV_) console.log('Redirecting to: ', redirectPath); + if (_redirected && this.redirectCount++ > 10) { + throw new Error('redirect loop detected'); + } + return this.navigate(redirectPath, emitChange, true); } - return this.navigate(redirectPath, emitChange, true); } if (res.route.loginRequired && !this.isLoggedIn) { diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts index 3ebff5a3f6..8e40cb284a 100644 --- a/packages/frontend/src/navbar.ts +++ b/packages/frontend/src/navbar.ts @@ -134,6 +134,7 @@ export const navbarItemDef = reactive({ title: i18n.ts.chat, icon: 'ti ti-messages', to: '/chat', + show: computed(() => $i != null && $i.policies.chatAvailability !== 'unavailable'), indicated: computed(() => $i != null && $i.hasUnreadChatMessages), }, achievements: { diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index 0023d07be8..5a12e3ae6d 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -562,12 +562,13 @@ export function success(): Promise<void> { }); } -export function waiting(): Promise<void> { +export function waiting(text?: string | null): Promise<void> { return new Promise(resolve => { const showing = ref(true); const { dispose } = popup(MkWaitingDialog, { success: false, showing: showing, + text, }, { done: () => resolve(), closed: () => dispose(), diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index b1bd45baab..85c8ba15ba 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -363,7 +363,7 @@ defineExpose({ box-sizing: border-box; border-right: solid 0.5px var(--MI_THEME-divider); overflow: auto; - height: 100dvh; + height: 100cqh; } > .main { diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index d04996dac0..c49a1bf286 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -224,21 +224,24 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.canChat, 'canChat'])"> - <template #label>{{ i18n.ts._role._options.canChat }}</template> + <MkFolder v-if="matchQuery([i18n.ts._role._options.chatAvailability, 'chatAvailability'])"> + <template #label>{{ i18n.ts._role._options.chatAvailability }}</template> <template #suffix> - <span v-if="role.policies.canChat.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> - <span v-else>{{ role.policies.canChat.value ? i18n.ts.yes : i18n.ts.no }}</span> - <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canChat)"></i></span> + <span v-if="role.policies.chatAvailability.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> + <span v-else>{{ role.policies.chatAvailability.value === 'available' ? i18n.ts.yes : role.policies.chatAvailability.value === 'readonly' ? i18n.ts.readonly : i18n.ts.no }}</span> + <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.chatAvailability)"></i></span> </template> <div class="_gaps"> - <MkSwitch v-model="role.policies.canChat.useDefault" :readonly="readonly"> + <MkSwitch v-model="role.policies.chatAvailability.useDefault" :readonly="readonly"> <template #label>{{ i18n.ts._role.useBaseValue }}</template> </MkSwitch> - <MkSwitch v-model="role.policies.canChat.value" :disabled="role.policies.canChat.useDefault" :readonly="readonly"> + <MkSelect v-model="role.policies.chatAvailability.value" :disabled="role.policies.chatAvailability.useDefault" :readonly="readonly"> <template #label>{{ i18n.ts.enable }}</template> - </MkSwitch> - <MkRange v-model="role.policies.canChat.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <option value="available">{{ i18n.ts.enabled }}</option> + <option value="readonly">{{ i18n.ts.readonly }}</option> + <option value="unavailable">{{ i18n.ts.disabled }}</option> + </MkSelect> + <MkRange v-model="role.policies.chatAvailability.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> <template #label>{{ i18n.ts._role.priority }}</template> </MkRange> </div> diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index b8527a309d..63e89a47f2 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -78,12 +78,15 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.canChat, 'canChat'])"> - <template #label>{{ i18n.ts._role._options.canChat }}</template> - <template #suffix>{{ policies.canChat ? i18n.ts.yes : i18n.ts.no }}</template> - <MkSwitch v-model="policies.canChat"> + <MkFolder v-if="matchQuery([i18n.ts._role._options.chatAvailability, 'chatAvailability'])"> + <template #label>{{ i18n.ts._role._options.chatAvailability }}</template> + <template #suffix>{{ policies.chatAvailability === 'available' ? i18n.ts.yes : policies.chatAvailability === 'readonly' ? i18n.ts.readonly : i18n.ts.no }}</template> + <MkSelect v-model="policies.chatAvailability"> <template #label>{{ i18n.ts.enable }}</template> - </MkSwitch> + <option value="available">{{ i18n.ts.enabled }}</option> + <option value="readonly">{{ i18n.ts.readonly }}</option> + <option value="unavailable">{{ i18n.ts.disabled }}</option> + </MkSelect> </MkFolder> <MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])"> @@ -322,6 +325,7 @@ import MkInput from '@/components/MkInput.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkButton from '@/components/MkButton.vue'; +import MkSelect from '@/components/MkSelect.vue'; import MkRange from '@/components/MkRange.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkRolePreview from '@/components/MkRolePreview.vue'; diff --git a/packages/frontend/src/pages/chat/XMessage.vue b/packages/frontend/src/pages/chat/XMessage.vue index 7524283641..78c1c66f52 100644 --- a/packages/frontend/src/pages/chat/XMessage.vue +++ b/packages/frontend/src/pages/chat/XMessage.vue @@ -85,7 +85,7 @@ const isMe = computed(() => props.message.fromUserId === $i.id); const urls = computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []); provide(DI.mfmEmojiReactCallback, (reaction) => { - if (!$i.policies.canChat) return; + if ($i.policies.chatAvailability !== 'available') return; sound.playMisskeySfx('reaction'); misskeyApi('chat/messages/react', { @@ -95,7 +95,7 @@ provide(DI.mfmEmojiReactCallback, (reaction) => { }); function react(ev: MouseEvent) { - if (!$i.policies.canChat) return; + if ($i.policies.chatAvailability !== 'available') return; const targetEl = getHTMLElementOrNull(ev.currentTarget ?? ev.target); if (!targetEl) return; @@ -110,7 +110,7 @@ function react(ev: MouseEvent) { } function onReactionClick(record: Misskey.entities.ChatMessage['reactions'][0]) { - if (!$i.policies.canChat) return; + if ($i.policies.chatAvailability !== 'available') return; if (record.user.id === $i.id) { misskeyApi('chat/messages/unreact', { @@ -138,7 +138,7 @@ function onContextmenu(ev: MouseEvent) { function showMenu(ev: MouseEvent, contextmenu = false) { const menu: MenuItem[] = []; - if (!isMe.value && $i.policies.canChat) { + if (!isMe.value && $i.policies.chatAvailability === 'available') { menu.push({ text: i18n.ts.reaction, icon: 'ti ti-mood-plus', @@ -164,7 +164,7 @@ function showMenu(ev: MouseEvent, contextmenu = false) { type: 'divider', }); - if (isMe.value && $i.policies.canChat) { + if (isMe.value && $i.policies.chatAvailability === 'available') { menu.push({ text: i18n.ts.delete, icon: 'ti ti-trash', diff --git a/packages/frontend/src/pages/chat/home.home.vue b/packages/frontend/src/pages/chat/home.home.vue index 17f0e0fbcd..a8ed891de0 100644 --- a/packages/frontend/src/pages/chat/home.home.vue +++ b/packages/frontend/src/pages/chat/home.home.vue @@ -5,9 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_gaps"> - <MkButton v-if="$i.policies.canChat" primary gradate rounded :class="$style.start" @click="start"><i class="ti ti-plus"></i> {{ i18n.ts.startChat }}</MkButton> + <MkButton v-if="$i.policies.chatAvailability === 'available'" primary gradate rounded :class="$style.start" @click="start"><i class="ti ti-plus"></i> {{ i18n.ts.startChat }}</MkButton> - <MkInfo v-else>{{ i18n.ts._chat.chatNotAvailableForThisAccountOrServer }}</MkInfo> + <MkInfo v-else>{{ $i.policies.chatAvailability === 'readonly' ? i18n.ts._chat.chatIsReadOnlyForThisAccountOrServer : i18n.ts._chat.chatNotAvailableForThisAccountOrServer }}</MkInfo> <MkAd :preferForms="['horizontal', 'horizontal-big']"/> diff --git a/packages/frontend/src/pages/chat/room.vue b/packages/frontend/src/pages/chat/room.vue index 9942dbeee9..8b351c1ec8 100644 --- a/packages/frontend/src/pages/chat/room.vue +++ b/packages/frontend/src/pages/chat/room.vue @@ -6,54 +6,56 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader v-model:tab="tab" :reversed="tab === 'chat'" :tabs="headerTabs" :actions="headerActions"> <MkSpacer v-if="tab === 'chat'" :contentMax="700"> - <div v-if="initializing"> - <MkLoading/> - </div> + <div class="_gaps"> + <div v-if="initializing"> + <MkLoading/> + </div> - <div v-else-if="messages.length === 0"> - <div class="_gaps" style="text-align: center;"> - <div>{{ i18n.ts._chat.noMessagesYet }}</div> - <template v-if="user"> - <div v-if="user.chatScope === 'followers'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromFollowers }}</div> - <div v-else-if="user.chatScope === 'following'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromFollowing }}</div> - <div v-else-if="user.chatScope === 'mutual'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromMutualFollowing }}</div> - <div v-else-if="user.chatScope === 'none'">{{ i18n.ts._chat.thisUserNotAllowedChatAnyone }}</div> - </template> - <template v-else-if="room"> - <div>{{ i18n.ts._chat.inviteUserToChat }}</div> - </template> + <div v-else-if="messages.length === 0"> + <div class="_gaps" style="text-align: center;"> + <div>{{ i18n.ts._chat.noMessagesYet }}</div> + <template v-if="user"> + <div v-if="user.chatScope === 'followers'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromFollowers }}</div> + <div v-else-if="user.chatScope === 'following'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromFollowing }}</div> + <div v-else-if="user.chatScope === 'mutual'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromMutualFollowing }}</div> + <div v-else-if="user.chatScope === 'none'">{{ i18n.ts._chat.thisUserNotAllowedChatAnyone }}</div> + </template> + <template v-else-if="room"> + <div>{{ i18n.ts._chat.inviteUserToChat }}</div> + </template> + </div> </div> - </div> - <div v-else ref="timelineEl" class="_gaps"> - <div v-if="canFetchMore"> - <MkButton :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMore">{{ i18n.ts.loadMore }}</MkButton> + <div v-else ref="timelineEl" class="_gaps"> + <div v-if="canFetchMore"> + <MkButton :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMore">{{ i18n.ts.loadMore }}</MkButton> + </div> + + <TransitionGroup + :enterActiveClass="prefer.s.animation ? $style.transition_x_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_x_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_x_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_x_leaveTo : ''" + :moveClass="prefer.s.animation ? $style.transition_x_move : ''" + tag="div" class="_gaps" + > + <template v-for="item in timeline.toReversed()" :key="item.id"> + <XMessage v-if="item.type === 'item'" :message="item.data"/> + <div v-else-if="item.type === 'date'" :class="$style.dateDivider"> + <span><i class="ti ti-chevron-up"></i> {{ item.nextText }}</span> + <span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span> + <span>{{ item.prevText }} <i class="ti ti-chevron-down"></i></span> + </div> + </template> + </TransitionGroup> </div> - <TransitionGroup - :enterActiveClass="prefer.s.animation ? $style.transition_x_enterActive : ''" - :leaveActiveClass="prefer.s.animation ? $style.transition_x_leaveActive : ''" - :enterFromClass="prefer.s.animation ? $style.transition_x_enterFrom : ''" - :leaveToClass="prefer.s.animation ? $style.transition_x_leaveTo : ''" - :moveClass="prefer.s.animation ? $style.transition_x_move : ''" - tag="div" class="_gaps" - > - <template v-for="item in timeline.toReversed()" :key="item.id"> - <XMessage v-if="item.type === 'item'" :message="item.data"/> - <div v-else-if="item.type === 'date'" :class="$style.dateDivider"> - <span><i class="ti ti-chevron-up"></i> {{ item.nextText }}</span> - <span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span> - <span>{{ item.prevText }} <i class="ti ti-chevron-down"></i></span> - </div> - </template> - </TransitionGroup> - </div> + <div v-if="user && (!user.canChat || user.host !== null)"> + <MkInfo warn>{{ i18n.ts._chat.chatNotAvailableInOtherAccount }}</MkInfo> + </div> - <div v-if="user && (!user.canChat || user.host !== null)"> - <MkInfo warn>{{ i18n.ts._chat.chatNotAvailableInOtherAccount }}</MkInfo> + <MkInfo v-if="$i.policies.chatAvailability !== 'available'" warn>{{ $i.policies.chatAvailability === 'readonly' ? i18n.ts._chat.chatIsReadOnlyForThisAccountOrServer : i18n.ts._chat.chatNotAvailableForThisAccountOrServer }}</MkInfo> </div> - - <MkInfo v-if="!$i.policies.canChat" warn>{{ i18n.ts._chat.chatNotAvailableForThisAccountOrServer }}</MkInfo> </MkSpacer> <MkSpacer v-else-if="tab === 'search'" :contentMax="700"> diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue index 6f5e35b586..257ed3edd8 100644 --- a/packages/frontend/src/pages/settings/mute-block.vue +++ b/packages/frontend/src/pages/settings/mute-block.vue @@ -62,12 +62,11 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> <SearchMarker - :label="`${i18n.ts.mutedUsers} (${ i18n.ts.renote })`" :keywords="['renote', 'mute', 'hide', 'user']" > <MkFolder> <template #icon><i class="ti ti-repeat-off"></i></template> - <template #label>{{ i18n.ts.mutedUsers }} ({{ i18n.ts.renote }})</template> + <template #label><SearchLabel>{{ i18n.ts.mutedUsers }} ({{ i18n.ts.renote }})</SearchLabel></template> <MkPagination :pagination="renoteMutingPagination"> <template #empty> diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue index 91968c5300..b322b03a21 100644 --- a/packages/frontend/src/pages/settings/navbar.vue +++ b/packages/frontend/src/pages/settings/navbar.vue @@ -103,7 +103,6 @@ function removeItem(index: number) { async function save() { prefer.commit('menu', items.value.map(x => x.type)); - await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); } function reset() { diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue index 3c1b6c8032..168bff681d 100644 --- a/packages/frontend/src/pages/settings/notifications.vue +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -4,64 +4,66 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="_gaps_m"> - <MkFeatureBanner icon="/client-assets/bell_3d.png" color="#ffff00"> - <SearchKeyword>{{ i18n.ts._settings.notificationsBanner }}</SearchKeyword> - </MkFeatureBanner> +<SearchMarker path="/settings/notifications" :label="i18n.ts.notifications" :keywords="['notifications']" icon="ti ti-bell"> + <div class="_gaps_m"> + <MkFeatureBanner icon="/client-assets/bell_3d.png" color="#ffff00"> + <SearchKeyword>{{ i18n.ts._settings.notificationsBanner }}</SearchKeyword> + </MkFeatureBanner> - <FormSection first> - <template #label>{{ i18n.ts.notificationRecieveConfig }}</template> - <div class="_gaps_s"> - <MkFolder v-for="type in notificationTypes.filter(x => !nonConfigurableNotificationTypes.includes(x))" :key="type"> - <template #label>{{ i18n.ts._notification._types[type] }}</template> - <template #suffix> - {{ - $i.notificationRecieveConfig[type]?.type === 'never' ? i18n.ts.none : - $i.notificationRecieveConfig[type]?.type === 'following' ? i18n.ts.following : - $i.notificationRecieveConfig[type]?.type === 'follower' ? i18n.ts.followers : - $i.notificationRecieveConfig[type]?.type === 'mutualFollow' ? i18n.ts.mutualFollow : - $i.notificationRecieveConfig[type]?.type === 'followingOrFollower' ? i18n.ts.followingOrFollower : - $i.notificationRecieveConfig[type]?.type === 'list' ? i18n.ts.userList : - i18n.ts.all - }} - </template> + <FormSection first> + <template #label>{{ i18n.ts.notificationRecieveConfig }}</template> + <div class="_gaps_s"> + <MkFolder v-for="type in notificationTypes.filter(x => !nonConfigurableNotificationTypes.includes(x))" :key="type"> + <template #label>{{ i18n.ts._notification._types[type] }}</template> + <template #suffix> + {{ + $i.notificationRecieveConfig[type]?.type === 'never' ? i18n.ts.none : + $i.notificationRecieveConfig[type]?.type === 'following' ? i18n.ts.following : + $i.notificationRecieveConfig[type]?.type === 'follower' ? i18n.ts.followers : + $i.notificationRecieveConfig[type]?.type === 'mutualFollow' ? i18n.ts.mutualFollow : + $i.notificationRecieveConfig[type]?.type === 'followingOrFollower' ? i18n.ts.followingOrFollower : + $i.notificationRecieveConfig[type]?.type === 'list' ? i18n.ts.userList : + i18n.ts.all + }} + </template> - <XNotificationConfig - :userLists="userLists" - :value="$i.notificationRecieveConfig[type] ?? { type: 'all' }" - :configurableTypes="onlyOnOrOffNotificationTypes.includes(type) ? ['all', 'never'] : undefined" - @update="(res) => updateReceiveConfig(type, res)" - /> - </MkFolder> - </div> - </FormSection> - <FormSection> - <div class="_gaps_m"> - <FormLink @click="readAllNotifications">{{ i18n.ts.markAsReadAllNotifications }}</FormLink> - </div> - </FormSection> - <FormSection> - <div class="_gaps_m"> - <FormLink @click="testNotification">{{ i18n.ts._notification.sendTestNotification }}</FormLink> - <FormLink @click="flushNotification">{{ i18n.ts._notification.flushNotification }}</FormLink> - </div> - </FormSection> - <FormSection> - <template #label>{{ i18n.ts.pushNotification }}</template> + <XNotificationConfig + :userLists="userLists" + :value="$i.notificationRecieveConfig[type] ?? { type: 'all' }" + :configurableTypes="onlyOnOrOffNotificationTypes.includes(type) ? ['all', 'never'] : undefined" + @update="(res) => updateReceiveConfig(type, res)" + /> + </MkFolder> + </div> + </FormSection> + <FormSection> + <div class="_gaps_m"> + <FormLink @click="readAllNotifications">{{ i18n.ts.markAsReadAllNotifications }}</FormLink> + </div> + </FormSection> + <FormSection> + <div class="_gaps_m"> + <FormLink @click="testNotification">{{ i18n.ts._notification.sendTestNotification }}</FormLink> + <FormLink @click="flushNotification">{{ i18n.ts._notification.flushNotification }}</FormLink> + </div> + </FormSection> + <FormSection> + <template #label>{{ i18n.ts.pushNotification }}</template> - <div class="_gaps_m"> - <MkPushNotificationAllowButton ref="allowButton"/> - <MkSwitch :disabled="!pushRegistrationInServer" :modelValue="sendReadMessage" @update:modelValue="onChangeSendReadMessage"> - <template #label>{{ i18n.ts.sendPushNotificationReadMessage }}</template> - <template #caption> - <I18n :src="i18n.ts.sendPushNotificationReadMessageCaption"> - <template #emptyPushNotificationMessage>{{ i18n.ts._notification.emptyPushNotificationMessage }}</template> - </I18n> - </template> - </MkSwitch> - </div> - </FormSection> -</div> + <div class="_gaps_m"> + <MkPushNotificationAllowButton ref="allowButton"/> + <MkSwitch :disabled="!pushRegistrationInServer" :modelValue="sendReadMessage" @update:modelValue="onChangeSendReadMessage"> + <template #label>{{ i18n.ts.sendPushNotificationReadMessage }}</template> + <template #caption> + <I18n :src="i18n.ts.sendPushNotificationReadMessageCaption"> + <template #emptyPushNotificationMessage>{{ i18n.ts._notification.emptyPushNotificationMessage }}</template> + </I18n> + </template> + </MkSwitch> + </div> + </FormSection> + </div> +</SearchMarker> </template> <script lang="ts" setup> diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue index e6405954e8..d1b972fe55 100644 --- a/packages/frontend/src/pages/settings/other.vue +++ b/packages/frontend/src/pages/settings/other.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_s"> <SearchMarker :keywords="['account', 'info']"> <MkFolder> - <template #icon><i class="ti ti-info-circle"></i></template> + <template #icon><SearchIcon><i class="ti ti-info-circle"></i></SearchIcon></template> <template #label><SearchLabel>{{ i18n.ts.accountInfo }}</SearchLabel></template> <div class="_gaps_m"> @@ -33,23 +33,25 @@ SPDX-License-Identifier: AGPL-3.0-only <template #value><MkTime :time="$i.createdAt" mode="detail"/></template> </MkKeyValue> - <MkFolder> - <template #icon><i class="ti ti-badges"></i></template> - <template #label><SearchLabel>{{ i18n.ts._role.policies }}</SearchLabel></template> + <SearchMarker :keywords="['role', 'policy']"> + <MkFolder> + <template #icon><i class="ti ti-badges"></i></template> + <template #label><SearchLabel>{{ i18n.ts._role.policies }}</SearchLabel></template> - <div class="_gaps_s"> - <div v-for="policy in Object.keys($i.policies)" :key="policy"> - {{ policy }} ... {{ $i.policies[policy] }} + <div class="_gaps_s"> + <div v-for="policy in Object.keys($i.policies)" :key="policy"> + {{ policy }} ... {{ $i.policies[policy] }} + </div> </div> - </div> - </MkFolder> + </MkFolder> + </SearchMarker> </div> </MkFolder> </SearchMarker> <SearchMarker :keywords="['roles']"> <MkFolder> - <template #icon><i class="ti ti-badges"></i></template> + <template #icon><SearchIcon><i class="ti ti-badges"></i></SearchIcon></template> <template #label><SearchLabel>{{ i18n.ts.rolesAssignedToMe }}</SearchLabel></template> <MkRolePreview v-for="role in $i.roles" :key="role.id" :role="role" :forModeration="false"/> @@ -58,7 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['account', 'move', 'migration']"> <MkFolder> - <template #icon><i class="ti ti-plane"></i></template> + <template #icon><SearchIcon><i class="ti ti-plane"></i></SearchIcon></template> <template #label><SearchLabel>{{ i18n.ts.accountMigration }}</SearchLabel></template> <XMigration/> @@ -80,7 +82,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['account', 'close', 'delete']"> <MkFolder> - <template #icon><i class="ti ti-alert-triangle"></i></template> + <template #icon><SearchIcon><i class="ti ti-alert-triangle"></i></SearchIcon></template> <template #label><SearchLabel>{{ i18n.ts.closeAccount }}</SearchLabel></template> <div class="_gaps_m"> @@ -94,7 +96,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['experimental', 'feature', 'flags']"> <MkFolder> - <template #icon><i class="ti ti-flask"></i></template> + <template #icon><SearchIcon><i class="ti ti-flask"></i></SearchIcon></template> <template #label><SearchLabel>{{ i18n.ts.experimentalFeatures }}</SearchLabel></template> <div class="_gaps_m"> @@ -113,7 +115,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['developer', 'mode', 'debug']"> <MkFolder> - <template #icon><i class="ti ti-code"></i></template> + <template #icon><SearchIcon><i class="ti ti-code"></i></SearchIcon></template> <template #label><SearchLabel>{{ i18n.ts.developer }}</SearchLabel></template> <div class="_gaps_m"> @@ -208,7 +210,6 @@ async function deleteAccount() { } function migrate() { - os.waiting(); migrateOldSettings(); } diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue index 3475767d28..782eb47729 100644 --- a/packages/frontend/src/pages/settings/preferences.vue +++ b/packages/frontend/src/pages/settings/preferences.vue @@ -11,10 +11,10 @@ SPDX-License-Identifier: AGPL-3.0-only </MkFeatureBanner> <div class="_gaps_s"> - <SearchMarker :keywords="['general']"> - <MkFolder> + <SearchMarker v-slot="slotProps" :keywords="['general']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> <template #label><SearchLabel>{{ i18n.ts.general }}</SearchLabel></template> - <template #icon><i class="ti ti-settings"></i></template> + <template #icon><SearchIcon><i class="ti ti-settings"></i></SearchIcon></template> <div class="_gaps_m"> <SearchMarker :keywords="['language']"> @@ -108,10 +108,10 @@ SPDX-License-Identifier: AGPL-3.0-only </MkFolder> </SearchMarker> - <SearchMarker :keywords="['timeline', 'note']"> - <MkFolder> + <SearchMarker v-slot="slotProps" :keywords="['timeline', 'note']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> <template #label><SearchLabel>{{ i18n.ts._settings.timelineAndNote }}</SearchLabel></template> - <template #icon><i class="ti ti-notes"></i></template> + <template #icon><SearchIcon><i class="ti ti-notes"></i></SearchIcon></template> <div class="_gaps_m"> <div class="_gaps_s"> @@ -338,10 +338,10 @@ SPDX-License-Identifier: AGPL-3.0-only </MkFolder> </SearchMarker> - <SearchMarker :keywords="['post', 'form']"> - <MkFolder> + <SearchMarker v-slot="slotProps" :keywords="['post', 'form']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> <template #label><SearchLabel>{{ i18n.ts.postForm }}</SearchLabel></template> - <template #icon><i class="ti ti-edit"></i></template> + <template #icon><SearchIcon><i class="ti ti-edit"></i></SearchIcon></template> <div class="_gaps_m"> <div class="_gaps_s"> @@ -400,10 +400,10 @@ SPDX-License-Identifier: AGPL-3.0-only </MkFolder> </SearchMarker> - <SearchMarker :keywords="['notification']"> - <MkFolder> + <SearchMarker v-slot="slotProps" :keywords="['notification']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> <template #label><SearchLabel>{{ i18n.ts.notifications }}</SearchLabel></template> - <template #icon><i class="ti ti-bell"></i></template> + <template #icon><SearchIcon><i class="ti ti-bell"></i></SearchIcon></template> <div class="_gaps_m"> <SearchMarker :keywords="['group']"> @@ -462,49 +462,51 @@ SPDX-License-Identifier: AGPL-3.0-only </MkFolder> </SearchMarker> - <SearchMarker :keywords="['chat', 'messaging']"> - <MkFolder> - <template #label><SearchLabel>{{ i18n.ts.chat }}</SearchLabel></template> - <template #icon><i class="ti ti-messages"></i></template> + <template v-if="$i.policies.chatAvailability !== 'unavailable'"> + <SearchMarker v-slot="slotProps" :keywords="['chat', 'messaging']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> + <template #label><SearchLabel>{{ i18n.ts.chat }}</SearchLabel></template> + <template #icon><SearchIcon><i class="ti ti-messages"></i></SearchIcon></template> - <div class="_gaps_s"> - <SearchMarker :keywords="['show', 'sender', 'name']"> - <MkPreferenceContainer k="chat.showSenderName"> - <MkSwitch v-model="chatShowSenderName"> - <template #label><SearchLabel>{{ i18n.ts._settings._chat.showSenderName }}</SearchLabel></template> - </MkSwitch> - </MkPreferenceContainer> - </SearchMarker> + <div class="_gaps_s"> + <SearchMarker :keywords="['show', 'sender', 'name']"> + <MkPreferenceContainer k="chat.showSenderName"> + <MkSwitch v-model="chatShowSenderName"> + <template #label><SearchLabel>{{ i18n.ts._settings._chat.showSenderName }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> - <SearchMarker :keywords="['send', 'enter', 'newline']"> - <MkPreferenceContainer k="chat.sendOnEnter"> - <MkSwitch v-model="chatSendOnEnter"> - <template #label><SearchLabel>{{ i18n.ts._settings._chat.sendOnEnter }}</SearchLabel></template> - <template #caption> - <div class="_gaps_s"> - <div> - <b>{{ i18n.ts._settings.ifOn }}:</b> - <div>{{ i18n.ts._chat.send }}: Enter</div> - <div>{{ i18n.ts._chat.newline }}: Shift + Enter</div> - </div> - <div> - <b>{{ i18n.ts._settings.ifOff }}:</b> - <div>{{ i18n.ts._chat.send }}: Ctrl + Enter</div> - <div>{{ i18n.ts._chat.newline }}: Enter</div> + <SearchMarker :keywords="['send', 'enter', 'newline']"> + <MkPreferenceContainer k="chat.sendOnEnter"> + <MkSwitch v-model="chatSendOnEnter"> + <template #label><SearchLabel>{{ i18n.ts._settings._chat.sendOnEnter }}</SearchLabel></template> + <template #caption> + <div class="_gaps_s"> + <div> + <b>{{ i18n.ts._settings.ifOn }}:</b> + <div>{{ i18n.ts._chat.send }}: Enter</div> + <div>{{ i18n.ts._chat.newline }}: Shift + Enter</div> + </div> + <div> + <b>{{ i18n.ts._settings.ifOff }}:</b> + <div>{{ i18n.ts._chat.send }}: Ctrl + Enter</div> + <div>{{ i18n.ts._chat.newline }}: Enter</div> + </div> </div> - </div> - </template> - </MkSwitch> - </MkPreferenceContainer> - </SearchMarker> - </div> - </MkFolder> - </SearchMarker> + </template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + </div> + </MkFolder> + </SearchMarker> + </template> - <SearchMarker :keywords="['accessibility']"> - <MkFolder> + <SearchMarker v-slot="slotProps" :keywords="['accessibility']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> <template #label><SearchLabel>{{ i18n.ts.accessibility }}</SearchLabel></template> - <template #icon><i class="ti ti-accessible"></i></template> + <template #icon><SearchIcon><i class="ti ti-accessible"></i></SearchIcon></template> <div class="_gaps_m"> <MkFeatureBanner icon="/client-assets/mens_room_3d.png" color="#0011ff"> @@ -611,17 +613,17 @@ SPDX-License-Identifier: AGPL-3.0-only </MkFolder> </SearchMarker> - <SearchMarker :keywords="['performance']"> - <MkFolder> + <SearchMarker v-slot="slotProps" :keywords="['performance']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> <template #label><SearchLabel>{{ i18n.ts.performance }}</SearchLabel></template> - <template #icon><i class="ti ti-battery-vertical-eco"></i></template> + <template #icon><SearchIcon><i class="ti ti-battery-vertical-eco"></i></SearchIcon></template> <div class="_gaps_s"> <SearchMarker :keywords="['blur']"> <MkPreferenceContainer k="useBlurEffect"> <MkSwitch v-model="useBlurEffect"> <template #label><SearchLabel>{{ i18n.ts.useBlurEffect }}</SearchLabel></template> - <template #caption><SearchLabel>{{ i18n.ts.turnOffToImprovePerformance }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts.turnOffToImprovePerformance }}</SearchKeyword></template> </MkSwitch> </MkPreferenceContainer> </SearchMarker> @@ -630,7 +632,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPreferenceContainer k="useBlurEffectForModal"> <MkSwitch v-model="useBlurEffectForModal"> <template #label><SearchLabel>{{ i18n.ts.useBlurEffectForModal }}</SearchLabel></template> - <template #caption><SearchLabel>{{ i18n.ts.turnOffToImprovePerformance }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts.turnOffToImprovePerformance }}</SearchKeyword></template> </MkSwitch> </MkPreferenceContainer> </SearchMarker> @@ -639,7 +641,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPreferenceContainer k="useStickyIcons"> <MkSwitch v-model="useStickyIcons"> <template #label><SearchLabel>{{ i18n.ts._settings.useStickyIcons }}</SearchLabel></template> - <template #caption><SearchLabel>{{ i18n.ts.turnOffToImprovePerformance }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts.turnOffToImprovePerformance }}</SearchKeyword></template> </MkSwitch> </MkPreferenceContainer> </SearchMarker> @@ -647,10 +649,10 @@ SPDX-License-Identifier: AGPL-3.0-only </MkFolder> </SearchMarker> - <SearchMarker :keywords="['datasaver']"> - <MkFolder> + <SearchMarker v-slot="slotProps" :keywords="['datasaver']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> <template #label><SearchLabel>{{ i18n.ts.dataSaver }}</SearchLabel></template> - <template #icon><i class="ti ti-antenna-bars-3"></i></template> + <template #icon><SearchIcon><i class="ti ti-antenna-bars-3"></i></SearchIcon></template> <div class="_gaps_m"> <MkInfo>{{ i18n.ts.reloadRequiredToApplySettings }}</MkInfo> @@ -681,10 +683,10 @@ SPDX-License-Identifier: AGPL-3.0-only </MkFolder> </SearchMarker> - <SearchMarker :keywords="['other']"> - <MkFolder> + <SearchMarker v-slot="slotProps" :keywords="['other']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> <template #label><SearchLabel>{{ i18n.ts.other }}</SearchLabel></template> - <template #icon><i class="ti ti-settings-cog"></i></template> + <template #icon><SearchIcon><i class="ti ti-settings-cog"></i></SearchIcon></template> <div class="_gaps_m"> <div class="_gaps_s"> @@ -861,12 +863,13 @@ import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; import { globalEvents } from '@/events.js'; import { claimAchievement } from '@/utility/achievements.js'; import { instance } from '@/instance.js'; +import { ensureSignin } from '@/i.js'; import MkDisableSection from '@/components/MkDisableSection.vue'; - -// Sharkey imports import { searchEngineMap } from '@/utility/search-engine-map.js'; import { worksOnInstance } from '@/utility/favicon-dot.js'; +const $i = ensureSignin(); + const lang = ref(miLocalStorage.getItem('lang')); const dataSaver = ref(prefer.s.dataSaver); diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue index 8d8d8ae21c..84f13b2afc 100644 --- a/packages/frontend/src/pages/settings/privacy.vue +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -92,19 +92,26 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> </SearchMarker> - <FormSection> - <SearchMarker :keywords="['chat']"> - <MkSelect v-model="chatScope" @update:modelValue="save()"> - <template #label><SearchLabel>{{ i18n.ts._chat.chatAllowedUsers }}</SearchLabel></template> - <option value="everyone">{{ i18n.ts._chat._chatAllowedUsers.everyone }}</option> - <option value="followers">{{ i18n.ts._chat._chatAllowedUsers.followers }}</option> - <option value="following">{{ i18n.ts._chat._chatAllowedUsers.following }}</option> - <option value="mutual">{{ i18n.ts._chat._chatAllowedUsers.mutual }}</option> - <option value="none">{{ i18n.ts._chat._chatAllowedUsers.none }}</option> - <template #caption>{{ i18n.ts._chat.chatAllowedUsers_note }}</template> - </MkSelect> - </SearchMarker> - </FormSection> + <SearchMarker :keywords="['chat']"> + <FormSection> + <template #label><SearchLabel>{{ i18n.ts.chat }}</SearchLabel></template> + + <div class="_gaps_m"> + <MkInfo v-if="$i.policies.chatAvailability === 'unavailable'">{{ i18n.ts._chat.chatNotAvailableForThisAccountOrServer }}</MkInfo> + <SearchMarker :keywords="['chat']"> + <MkSelect v-model="chatScope" @update:modelValue="save()"> + <template #label><SearchLabel>{{ i18n.ts._chat.chatAllowedUsers }}</SearchLabel></template> + <option value="everyone">{{ i18n.ts._chat._chatAllowedUsers.everyone }}</option> + <option value="followers">{{ i18n.ts._chat._chatAllowedUsers.followers }}</option> + <option value="following">{{ i18n.ts._chat._chatAllowedUsers.following }}</option> + <option value="mutual">{{ i18n.ts._chat._chatAllowedUsers.mutual }}</option> + <option value="none">{{ i18n.ts._chat._chatAllowedUsers.none }}</option> + <template #caption>{{ i18n.ts._chat.chatAllowedUsers_note }}</template> + </MkSelect> + </SearchMarker> + </div> + </FormSection> + </SearchMarker> <SearchMarker :keywords="['lockdown']"> <FormSection> diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue index 88ddbb7660..075dc3e3ba 100644 --- a/packages/frontend/src/pages/settings/theme.vue +++ b/packages/frontend/src/pages/settings/theme.vue @@ -211,15 +211,12 @@ import FormLink from '@/components/form/link.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkThemePreview from '@/components/MkThemePreview.vue'; import { getBuiltinThemesRef, getThemesRef } from '@/theme.js'; -import { selectFile } from '@/utility/select-file.js'; import { isDeviceDarkmode } from '@/utility/is-device-darkmode.js'; import { store } from '@/store.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import { uniqueBy } from '@/utility/array.js'; import { definePage } from '@/page.js'; -import { miLocalStorage } from '@/local-storage.js'; -import { reloadAsk } from '@/utility/reload-ask.js'; import { prefer } from '@/preferences.js'; const installedThemes = getThemesRef(); diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index 365e2f248f..b8e14cb920 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div ref="rootEl" class="_pageScrollable"> <MkStickyContainer> - <template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :displayMyAvatar="true"/></template> + <template #header><MkPageHeader v-model:tab="src" :displayMyAvatar="true" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin"/></template> <MkSpacer :contentMax="800"> <MkInfo v-if="isBasicTimeline(src) && !store.r.timelineTutorials.value[src]" style="margin-bottom: var(--MI-margin);" closable @close="closeTutorial()"> {{ i18n.ts._timelineDescription[src] }} diff --git a/packages/frontend/src/pref-migrate.ts b/packages/frontend/src/pref-migrate.ts index ed08d5382f..0ed8b4e33d 100644 --- a/packages/frontend/src/pref-migrate.ts +++ b/packages/frontend/src/pref-migrate.ts @@ -11,14 +11,19 @@ import { prefer } from '@/preferences.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { deckStore } from '@/ui/deck/deck-store.js'; import { unisonReload } from '@/utility/unison-reload.js'; +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; // TODO: そのうち消す export function migrateOldSettings() { + os.waiting(i18n.ts.settingsMigrating); + store.loaded.then(async () => { - const themes = await misskeyApi('i/registry/get', { scope: ['client'], key: 'themes' }).catch(() => []) as Theme[]; - if (themes.length > 0) { - prefer.commit('themes', themes); - } + misskeyApi('i/registry/get', { scope: ['client'], key: 'themes' }).catch(() => []).then((themes: Theme[]) => { + if (themes.length > 0) { + prefer.commit('themes', themes); + } + }); const plugins = ColdDeviceStorage.get('plugins'); prefer.commit('plugins', plugins.map(p => ({ @@ -154,6 +159,6 @@ export function migrateOldSettings() { window.setTimeout(() => { unisonReload(); - }, 5000); + }, 10000); }); } diff --git a/packages/frontend/src/router.definition.ts b/packages/frontend/src/router.definition.ts index 943aa67cd3..0058fdad39 100644 --- a/packages/frontend/src/router.definition.ts +++ b/packages/frontend/src/router.definition.ts @@ -16,6 +16,10 @@ export const page = (loader: AsyncComponentLoader) => defineAsyncComponent({ errorComponent: MkError, }); +function chatPage(...args: Parameters<typeof page>) { + return $i?.policies.chatAvailability !== 'unavailable' ? page(...args) : page(() => import('@/pages/not-found.vue')); +} + export const ROUTE_DEF = [{ path: '/@:username/pages/:pageName(*)', component: page(() => import('@/pages/page.vue')), @@ -42,19 +46,19 @@ export const ROUTE_DEF = [{ component: page(() => import('@/pages/clip.vue')), }, { path: '/chat', - component: page(() => import('@/pages/chat/home.vue')), + component: chatPage(() => import('@/pages/chat/home.vue')), loginRequired: true, }, { path: '/chat/user/:userId', - component: page(() => import('@/pages/chat/room.vue')), + component: chatPage(() => import('@/pages/chat/room.vue')), loginRequired: true, }, { path: '/chat/room/:roomId', - component: page(() => import('@/pages/chat/room.vue')), + component: chatPage(() => import('@/pages/chat/room.vue')), loginRequired: true, }, { path: '/chat/messages/:messageId', - component: page(() => import('@/pages/chat/message.vue')), + component: chatPage(() => import('@/pages/chat/message.vue')), loginRequired: true, }, { path: '/instance-info/:host', diff --git a/packages/frontend/src/signout.ts b/packages/frontend/src/signout.ts index 161125e0ec..dac9d8dda4 100644 --- a/packages/frontend/src/signout.ts +++ b/packages/frontend/src/signout.ts @@ -19,8 +19,10 @@ export async function signout() { localStorage.clear(); defaultMemoryStorage.clear(); - const idbPromises = ['MisskeyClient', 'keyval-store'].map((name, i, arr) => new Promise((res, rej) => { - indexedDB.deleteDatabase(name); + const idbPromises = ['MisskeyClient', 'keyval-store'].map((name, i, arr) => new Promise<void>((res, rej) => { + const delidb = indexedDB.deleteDatabase(name); + delidb.onsuccess = () => res(); + delidb.onerror = e => rej(e); })); await Promise.all(idbPromises); diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue index 7254c0da30..fd7a89dc22 100644 --- a/packages/frontend/src/ui/_common_/common.vue +++ b/packages/frontend/src/ui/_common_/common.vue @@ -4,6 +4,59 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> +<Transition + :enterActiveClass="prefer.s.animation ? $style.transition_menuDrawerBg_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_menuDrawerBg_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_menuDrawerBg_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_menuDrawerBg_leaveTo : ''" +> + <div + v-if="drawerMenuShowing" + :class="$style.menuDrawerBg" + class="_modalBg" + @click="drawerMenuShowing = false" + @touchstart.passive="drawerMenuShowing = false" + ></div> +</Transition> + +<Transition + :enterActiveClass="prefer.s.animation ? $style.transition_menuDrawer_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_menuDrawer_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_menuDrawer_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_menuDrawer_leaveTo : ''" +> + <div v-if="drawerMenuShowing" :class="$style.menuDrawer"> + <XDrawerMenu/> + </div> +</Transition> + +<Transition + :enterActiveClass="prefer.s.animation ? $style.transition_widgetsDrawerBg_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_widgetsDrawerBg_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_widgetsDrawerBg_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_widgetsDrawerBg_leaveTo : ''" +> + <div + v-if="widgetsShowing" + :class="$style.widgetsDrawerBg" + class="_modalBg" + @click="widgetsShowing = false" + @touchstart.passive="widgetsShowing = false" + ></div> +</Transition> + +<Transition + :enterActiveClass="prefer.s.animation ? $style.transition_widgetsDrawer_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_widgetsDrawer_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_widgetsDrawer_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_widgetsDrawer_leaveTo : ''" +> + <div v-if="widgetsShowing" :class="$style.widgetsDrawer"> + <button class="_button" :class="$style.widgetsCloseButton" @click="widgetsShowing = false"><i class="ti ti-x"></i></button> + <XWidgets/> + </div> +</Transition> + <component :is="popup.component" v-for="popup in popups" @@ -61,11 +114,16 @@ import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; import { globalEvents } from '@/events.js'; +import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue'; import { store } from '@/store.js'; const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue')); const XUpload = defineAsyncComponent(() => import('./upload.vue')); const SkOneko = defineAsyncComponent(() => import('@/components/SkOneko.vue')); +const XWidgets = defineAsyncComponent(() => import('./widgets.vue')); + +const drawerMenuShowing = defineModel<boolean>('drawerMenuShowing'); +const widgetsShowing = defineModel<boolean>('widgetsShowing'); const dev = _DEV_; @@ -108,6 +166,50 @@ function getPointerEvents() { </script> <style lang="scss" module> +.transition_menuDrawerBg_enterActive, +.transition_menuDrawerBg_leaveActive { + opacity: 1; + transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.transition_menuDrawerBg_enterFrom, +.transition_menuDrawerBg_leaveTo { + opacity: 0; +} + +.transition_menuDrawer_enterActive, +.transition_menuDrawer_leaveActive { + opacity: 1; + transform: translateX(0); + transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.transition_menuDrawer_enterFrom, +.transition_menuDrawer_leaveTo { + opacity: 0; + transform: translateX(-240px); +} + +.transition_widgetsDrawerBg_enterActive, +.transition_widgetsDrawerBg_leaveActive { + opacity: 1; + transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.transition_widgetsDrawerBg_enterFrom, +.transition_widgetsDrawerBg_leaveTo { + opacity: 0; +} + +.transition_widgetsDrawer_enterActive, +.transition_widgetsDrawer_leaveActive { + opacity: 1; + transform: translateX(0); + transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.transition_widgetsDrawer_enterFrom, +.transition_widgetsDrawer_leaveTo { + opacity: 0; + transform: translateX(-240px); +} + .transition_notification_move, .transition_notification_enterActive, .transition_notification_leaveActive { @@ -122,6 +224,54 @@ function getPointerEvents() { transform: translateX(-250px); } +.menuDrawerBg { + z-index: 1001; +} + +.menuDrawer { + position: fixed; + top: 0; + left: 0; + z-index: 1001; + height: 100dvh; + width: 240px; + box-sizing: border-box; + contain: strict; + overflow: auto; + overscroll-behavior: contain; + background: var(--MI_THEME-navBg); +} + +.widgetsDrawerBg { + z-index: 1001; +} + +.widgetsDrawer { + position: fixed; + top: 0; + left: 0; + z-index: 1001; + width: 310px; + height: 100dvh; + padding: var(--MI-margin) var(--MI-margin) calc(var(--MI-margin) + env(safe-area-inset-bottom, 0px)) !important; + box-sizing: border-box; + overflow: auto; + overscroll-behavior: contain; + background: var(--MI_THEME-bg); +} + +.widgetsCloseButton { + padding: 8px; + display: block; + margin: 0 auto; +} + +@media (min-width: 370px) { + .widgetsCloseButton { + display: none; + } +} + .notifications { position: fixed; z-index: 3900000; diff --git a/packages/frontend/src/ui/_common_/mobile-footer-menu.vue b/packages/frontend/src/ui/_common_/mobile-footer-menu.vue new file mode 100644 index 0000000000..37b70847ca --- /dev/null +++ b/packages/frontend/src/ui/_common_/mobile-footer-menu.vue @@ -0,0 +1,144 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div ref="rootEl" :class="$style.root"> + <button :class="$style.item" class="_button" @click="drawerMenuShowing = true"> + <div :class="$style.itemInner"> + <i :class="$style.itemIcon" class="ti ti-menu-2"></i><span v-if="menuIndicated" :class="$style.itemIndicator" class="_blink"><i class="_indicatorCircle"></i></span> + </div> + </button> + + <button :class="$style.item" class="_button" @click="mainRouter.push('/')"> + <div :class="$style.itemInner"> + <i :class="$style.itemIcon" class="ti ti-home"></i> + </div> + </button> + + <button :class="$style.item" class="_button" @click="mainRouter.push('/my/notifications')"> + <div :class="$style.itemInner"> + <i :class="$style.itemIcon" class="ti ti-bell"></i> + <span v-if="$i?.hasUnreadNotification" :class="$style.itemIndicator" class="_blink"> + <span class="_indicateCounter" :class="$style.itemIndicateValueIcon">{{ $i.unreadNotificationsCount > 99 ? '99+' : $i.unreadNotificationsCount }}</span> + </span> + </div> + </button> + + <button :class="$style.item" class="_button" @click="widgetsShowing = true"> + <div :class="$style.itemInner"> + <i :class="$style.itemIcon" class="ti ti-apps"></i> + </div> + </button> + + <button :class="[$style.item, $style.post]" class="_button" @click="os.post()"> + <div :class="$style.itemInner"> + <i :class="$style.itemIcon" class="ti ti-pencil"></i> + </div> + </button> +</div> +</template> + +<script lang="ts" setup> +import { computed, ref, useTemplateRef, watch } from 'vue'; +import { $i } from '@/i.js'; +import * as os from '@/os.js'; +import { mainRouter } from '@/router.js'; +import { navbarItemDef } from '@/navbar.js'; + +const drawerMenuShowing = defineModel<boolean>('drawerMenuShowing'); +const widgetsShowing = defineModel<boolean>('widgetsShowing'); + +const rootEl = useTemplateRef('rootEl'); + +const menuIndicated = computed(() => { + for (const def in navbarItemDef) { + if (def === 'notifications') continue; // 通知は下にボタンとして表示されてるから + if (navbarItemDef[def].indicated) return true; + } + return false; +}); + +const rootElHeight = ref(0); + +watch(rootEl, () => { + if (rootEl.value) { + rootElHeight.value = rootEl.value.offsetHeight; + window.document.body.style.setProperty('--MI-minBottomSpacing', 'var(--MI-minBottomSpacingMobile)'); + } else { + rootElHeight.value = 0; + window.document.body.style.setProperty('--MI-minBottomSpacing', '0px'); + } +}, { + immediate: true, +}); +</script> + +<style lang="scss" module> +.root { + padding: 12px 12px max(12px, env(safe-area-inset-bottom, 0px)) 12px; + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr 1fr; + grid-gap: 8px; + width: 100%; + box-sizing: border-box; + background: var(--MI_THEME-bg); + border-top: solid 0.5px var(--MI_THEME-divider); +} + +.item { + &.post { + .itemInner { + background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB)); + color: var(--MI_THEME-fgOnAccent); + + &:hover { + background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); + } + + &:active { + background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); + } + } + } +} + +.itemInner { + position: relative; + padding: 0; + aspect-ratio: 1; + width: 100%; + max-width: 50px; + margin: auto; + align-content: center; + border-radius: 100%; + background: var(--MI_THEME-panel); + color: var(--MI_THEME-fg); + + &:hover { + background: var(--MI_THEME-panelHighlight); + } + + &:active { + background: hsl(from var(--MI_THEME-panel) h s calc(l - 2)); + } +} + +.itemIcon { + font-size: 14px; +} + +.itemIndicator { + position: absolute; + top: 0; + left: 0; + color: var(--MI_THEME-indicator); + font-size: 16px; + + &:has(.itemIndicateValueIcon) { + animation: none; + font-size: 12px; + } +} +</style> diff --git a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue index 138c04b5d4..94f333da41 100644 --- a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue +++ b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkA :class="$style.item" :activeClass="$style.active" to="/" exact> <i :class="$style.itemIcon" class="ti ti-home ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.timeline }}</span> </MkA> - <template v-for="item in menu"> + <template v-for="item in prefer.r.menu.value"> <div v-if="item === '-'" :class="$style.divider"></div> <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" class="_button" :class="[$style.item, { [$style.active]: navbarItemDef[item].active }]" :activeClass="$style.active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"> <i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[item].icon]"></i><span :class="$style.itemText">{{ navbarItemDef[item].title }}</span> @@ -49,7 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, defineAsyncComponent, toRef } from 'vue'; +import { computed, defineAsyncComponent } from 'vue'; import { openInstanceMenu } from './common.js'; import * as os from '@/os.js'; import { navbarItemDef } from '@/navbar.js'; @@ -59,10 +59,9 @@ import { instance } from '@/instance.js'; import { openAccountMenu as openAccountMenu_ } from '@/accounts.js'; import { $i } from '@/i.js'; -const menu = toRef(prefer.s, 'menu'); const otherMenuItemIndicated = computed(() => { for (const def in navbarItemDef) { - if (menu.value.includes(def)) continue; + if (prefer.r.menu.value.includes(def)) continue; if (navbarItemDef[def].indicated) return true; } return false; diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue index a579a2c012..2708683acb 100644 --- a/packages/frontend/src/ui/_common_/navbar.vue +++ b/packages/frontend/src/ui/_common_/navbar.vue @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkA v-tooltip.noDelay.right="i18n.ts.timeline" :class="$style.item" :activeClass="$style.active" to="/" exact> <i :class="$style.itemIcon" class="ti ti-home ti-fw" style="viewTransitionName: navbar-homeIcon;"></i><span :class="$style.itemText">{{ i18n.ts.timeline }}</span> </MkA> - <template v-for="item in menu"> + <template v-for="item in prefer.r.menu.value"> <div v-if="item === '-'" :class="$style.divider"></div> <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" @@ -120,10 +120,9 @@ const iconOnly = computed(() => { return forceIconOnly.value || (store.r.menuDisplay.value === 'sideIcon'); }); -const menu = computed(() => prefer.s.menu); const otherMenuItemIndicated = computed(() => { for (const def in navbarItemDef) { - if (menu.value.includes(def)) continue; + if (prefer.r.menu.value.includes(def)) continue; if (navbarItemDef[def].indicated) return true; } return false; diff --git a/packages/frontend/src/ui/universal.widgets.vue b/packages/frontend/src/ui/_common_/widgets.vue index 1a6d62e19b..1a6d62e19b 100644 --- a/packages/frontend/src/ui/universal.widgets.vue +++ b/packages/frontend/src/ui/_common_/widgets.vue diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue index 09d362fa42..6323d3c5fd 100644 --- a/packages/frontend/src/ui/deck.vue +++ b/packages/frontend/src/ui/deck.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only <XAnnouncements v-if="$i"/> <XStatusBars/> <div :class="$style.columnsWrapper"> - <!-- passive: https://bugs.webkit.org/show_bug.cgi?id=281300 --> + <!-- passive: https://bugs.webkit.org/show_bug.cgi?id=281300 --> <div ref="columnsEl" :class="[$style.columns, { [$style.center]: prefer.r['deck.columnAlign'].value === 'center', [$style.snapScroll]: snapScroll }]" @contextmenu.self.prevent="onContextmenu" @wheel.passive.self="onWheel"> <!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため --> <section @@ -73,58 +73,21 @@ SPDX-License-Identifier: AGPL-3.0-only <XNavbarH v-if="!isMobile && prefer.r['deck.navbarPosition'].value === 'bottom'"/> - <div v-if="isMobile" :class="$style.nav"> - <button :class="$style.navButton" class="_button" @click="drawerMenuShowing = true"><i :class="$style.navButtonIcon" class="ti ti-menu-2"></i><span v-if="menuIndicated" :class="$style.navButtonIndicator" class="_blink"><i class="_indicatorCircle"></i></span></button> - <button :class="$style.navButton" class="_button" @click="mainRouter.push('/')"><i :class="$style.navButtonIcon" class="ti ti-home"></i></button> - <button :class="$style.navButton" class="_button" @click="mainRouter.push('/my/notifications')"> - <i :class="$style.navButtonIcon" class="ti ti-bell"></i> - <span v-if="$i?.hasUnreadNotification" :class="$style.navButtonIndicator" class="_blink"> - <span class="_indicateCounter" :class="$style.itemIndicateValueIcon">{{ $i.unreadNotificationsCount > 99 ? '99+' : $i.unreadNotificationsCount }}</span> - </span> - </button> - <button :class="$style.postButton" class="_button" @click="os.post()"><i :class="$style.navButtonIcon" class="ti ti-pencil"></i></button> - </div> + <XMobileFooterMenu v-if="isMobile" v-model:drawerMenuShowing="drawerMenuShowing" v-model:widgetsShowing="widgetsShowing"/> </div> - <Transition - :enterActiveClass="prefer.s.animation ? $style.transition_menuDrawerBg_enterActive : ''" - :leaveActiveClass="prefer.s.animation ? $style.transition_menuDrawerBg_leaveActive : ''" - :enterFromClass="prefer.s.animation ? $style.transition_menuDrawerBg_enterFrom : ''" - :leaveToClass="prefer.s.animation ? $style.transition_menuDrawerBg_leaveTo : ''" - > - <div - v-if="drawerMenuShowing" - :class="$style.menuBg" - class="_modalBg" - @click="drawerMenuShowing = false" - @touchstart.passive="drawerMenuShowing = false" - ></div> - </Transition> - - <Transition - :enterActiveClass="prefer.s.animation ? $style.transition_menuDrawer_enterActive : ''" - :leaveActiveClass="prefer.s.animation ? $style.transition_menuDrawer_leaveActive : ''" - :enterFromClass="prefer.s.animation ? $style.transition_menuDrawer_enterFrom : ''" - :leaveToClass="prefer.s.animation ? $style.transition_menuDrawer_leaveTo : ''" - > - <div v-if="drawerMenuShowing" :class="$style.menu"> - <XDrawerMenu/> - </div> - </Transition> - - <XCommon/> + <XCommon v-model:drawerMenuShowing="drawerMenuShowing" v-model:widgetsShowing="widgetsShowing"/> </div> </template> <script lang="ts" setup> -import { computed, defineAsyncComponent, ref, useTemplateRef } from 'vue'; +import { defineAsyncComponent, ref, useTemplateRef } from 'vue'; import { v4 as uuid } from 'uuid'; import XCommon from './_common_/common.vue'; import XSidebar from '@/ui/_common_/navbar.vue'; import XNavbarH from '@/ui/_common_/navbar-h.vue'; -import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue'; +import XMobileFooterMenu from '@/ui/_common_/mobile-footer-menu.vue'; import * as os from '@/os.js'; -import { navbarItemDef } from '@/navbar.js'; import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; import { deviceKind } from '@/utility/device-kind.js'; @@ -179,6 +142,7 @@ window.addEventListener('resize', () => { const snapScroll = ref(deviceKind === 'smartphone' || deviceKind === 'tablet'); const withWallpaper = prefer.s['deck.wallpaper'] != null; const drawerMenuShowing = ref(false); +const widgetsShowing = ref(false); const gap = prefer.r['deck.columnGap']; /* @@ -188,14 +152,6 @@ watch(route, () => { }); */ -const menuIndicated = computed(() => { - if ($i == null) return false; - for (const def in navbarItemDef) { - if (navbarItemDef[def].indicated) return true; - } - return false; -}); - function showSettings() { os.pageWindow('/settings/deck'); } @@ -235,8 +191,8 @@ function pointerEvent(ev: PointerEvent) { window.document.addEventListener('pointerdown', pointerEvent, { passive: true }); function onWheel(ev: WheelEvent) { - // WheelEvent はマウスからしか発火しないのでスナップスクロールは無効化する - snapScroll.value = false; + // WheelEvent はマウスからしか発火しないのでスナップスクロールは無効化する + snapScroll.value = false; if (ev.deltaX === 0 && columnsEl.value != null) { columnsEl.value.scrollLeft += ev.deltaY; } @@ -265,28 +221,6 @@ if (prefer.s['deck.wallpaper'] != null) { </script> <style lang="scss" module> -.transition_menuDrawerBg_enterActive, -.transition_menuDrawerBg_leaveActive { - opacity: 1; - transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.transition_menuDrawerBg_enterFrom, -.transition_menuDrawerBg_leaveTo { - opacity: 0; -} - -.transition_menuDrawer_enterActive, -.transition_menuDrawer_leaveActive { - opacity: 1; - transform: translateX(0); - transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.transition_menuDrawer_enterFrom, -.transition_menuDrawer_leaveTo { - opacity: 0; - transform: translateX(-240px); -} - .root { $nav-hide-threshold: 650px; // TODO: どこかに集約したい @@ -318,6 +252,9 @@ if (prefer.s['deck.wallpaper'] != null) { flex: 1; display: flex; flex-direction: row; + + // これがないと狭い画面でマージンが広いデッキを表示したときにナビゲーションフッターが画面の外に追いやられて操作不能になる場合がある + min-height: 0; } .columns { @@ -415,90 +352,4 @@ if (prefer.s['deck.wallpaper'] != null) { .bottomMenuRight { margin-left: auto; } - -.menuBg { - z-index: 1001; -} - -.menu { - position: fixed; - top: 0; - left: 0; - z-index: 1001; - height: 100dvh; - width: 240px; - box-sizing: border-box; - contain: strict; - overflow: auto; - overscroll-behavior: contain; - background: var(--MI_THEME-navBg); -} - -.nav { - padding: 12px 12px max(12px, env(safe-area-inset-bottom, 0px)) 12px; - display: grid; - grid-template-columns: 1fr 1fr 1fr 1fr; - grid-gap: 8px; - width: 100%; - box-sizing: border-box; - -webkit-backdrop-filter: var(--MI-blur, blur(32px)); - backdrop-filter: var(--MI-blur, blur(32px)); - background-color: var(--MI_THEME-header); - border-top: solid 0.5px var(--MI_THEME-divider); -} - -.navButton { - position: relative; - padding: 0; - height: 32px; - width: 100%; - max-width: 60px; - margin: auto; - border-radius: var(--MI-radius-lg); - background: transparent; - color: var(--MI_THEME-fg); - - &:hover { - color: var(--MI_THEME-accent); - } - - &:active { - color: var(--MI_THEME-accent); - background: hsl(from var(--MI_THEME-panel) h s calc(l - 2)); - } -} - -.postButton { - composes: navButton; - background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB)); - color: var(--MI_THEME-fgOnAccent); - - &:hover { - background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); - color: var(--MI_THEME-fgOnAccent); - } - - &:active { - background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); - color: var(--MI_THEME-fgOnAccent); - } -} - -.navButtonIcon { - font-size: 16px; - vertical-align: middle; -} - -.navButtonIndicator { - position: absolute; - top: 0; - left: 0; - color: var(--MI_THEME-indicator); - font-size: 16px; - - &:has(.itemIndicateValueIcon) { - animation: none; - font-size: 12px; - } -} </style> diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue index a865eef3b3..4c816f1544 100644 --- a/packages/frontend/src/ui/deck/column.vue +++ b/packages/frontend/src/ui/deck/column.vue @@ -49,10 +49,11 @@ import { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownCo import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; +import { DI } from '@/di.js'; provide('shouldHeaderThin', true); provide('shouldOmitHeaderTitle', true); -provide('forceSpacerMin', true); +provide(DI.forceSpacerMin, true); const withWallpaper = prefer.s['deck.wallpaper'] != null; diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue index 86bd7cf055..940cf72e28 100644 --- a/packages/frontend/src/ui/universal.vue +++ b/packages/frontend/src/ui/universal.vue @@ -15,90 +15,26 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <StackingRouterView v-if="prefer.s['experimental.stackingRouterView']" :class="$style.content"/> <RouterView v-else :class="$style.content"/> - <div v-if="isMobile" ref="navFooter" :class="$style.nav"> - <button :class="$style.navButton" class="_button" @click="drawerMenuShowing = true"><i :class="$style.navButtonIcon" class="ti ti-menu-2"></i><span v-if="menuIndicated" :class="$style.navButtonIndicator" class="_blink"><i class="_indicatorCircle"></i></span></button> - <button :class="$style.navButton" class="_button" @click="mainRouter.push('/')"><i :class="$style.navButtonIcon" class="ti ti-home"></i></button> - <button :class="$style.navButton" class="_button" @click="mainRouter.push('/my/notifications')"> - <i :class="$style.navButtonIcon" class="ti ti-bell"></i> - <span v-if="$i?.hasUnreadNotification" :class="$style.navButtonIndicator" class="_blink"> - <span class="_indicateCounter" :class="$style.itemIndicateValueIcon">{{ $i.unreadNotificationsCount > 99 ? '99+' : $i.unreadNotificationsCount }}</span> - </span> - </button> - <button :class="$style.navButton" class="_button" @click="widgetsShowing = true"><i :class="$style.navButtonIcon" class="ti ti-apps"></i></button> - <button :class="$style.postButton" class="_button" @click="os.post()"><i :class="$style.navButtonIcon" class="ti ti-pencil"></i></button> - </div> + <XMobileFooterMenu v-if="isMobile" ref="navFooter" v-model:drawerMenuShowing="drawerMenuShowing" v-model:widgetsShowing="widgetsShowing"/> </div> <div v-if="isDesktop && !pageMetadata?.needWideArea" :class="$style.widgets"> <XWidgets/> </div> - <Transition - :enterActiveClass="prefer.s.animation ? $style.transition_menuDrawerBg_enterActive : ''" - :leaveActiveClass="prefer.s.animation ? $style.transition_menuDrawerBg_leaveActive : ''" - :enterFromClass="prefer.s.animation ? $style.transition_menuDrawerBg_enterFrom : ''" - :leaveToClass="prefer.s.animation ? $style.transition_menuDrawerBg_leaveTo : ''" - > - <div - v-if="drawerMenuShowing" - :class="$style.menuDrawerBg" - class="_modalBg" - @click="drawerMenuShowing = false" - @touchstart.passive="drawerMenuShowing = false" - ></div> - </Transition> - - <Transition - :enterActiveClass="prefer.s.animation ? $style.transition_menuDrawer_enterActive : ''" - :leaveActiveClass="prefer.s.animation ? $style.transition_menuDrawer_leaveActive : ''" - :enterFromClass="prefer.s.animation ? $style.transition_menuDrawer_enterFrom : ''" - :leaveToClass="prefer.s.animation ? $style.transition_menuDrawer_leaveTo : ''" - > - <div v-if="drawerMenuShowing" :class="$style.menuDrawer"> - <XDrawerMenu/> - </div> - </Transition> - - <Transition - :enterActiveClass="prefer.s.animation ? $style.transition_widgetsDrawerBg_enterActive : ''" - :leaveActiveClass="prefer.s.animation ? $style.transition_widgetsDrawerBg_leaveActive : ''" - :enterFromClass="prefer.s.animation ? $style.transition_widgetsDrawerBg_enterFrom : ''" - :leaveToClass="prefer.s.animation ? $style.transition_widgetsDrawerBg_leaveTo : ''" - > - <div - v-if="widgetsShowing" - :class="$style.widgetsDrawerBg" - class="_modalBg" - @click="widgetsShowing = false" - @touchstart.passive="widgetsShowing = false" - ></div> - </Transition> - - <Transition - :enterActiveClass="prefer.s.animation ? $style.transition_widgetsDrawer_enterActive : ''" - :leaveActiveClass="prefer.s.animation ? $style.transition_widgetsDrawer_leaveActive : ''" - :enterFromClass="prefer.s.animation ? $style.transition_widgetsDrawer_enterFrom : ''" - :leaveToClass="prefer.s.animation ? $style.transition_widgetsDrawer_leaveTo : ''" - > - <div v-if="widgetsShowing" :class="$style.widgetsDrawer"> - <button class="_button" :class="$style.widgetsCloseButton" @click="widgetsShowing = false"><i class="ti ti-x"></i></button> - <XWidgets/> - </div> - </Transition> - - <XCommon/> + <XCommon v-model:drawerMenuShowing="drawerMenuShowing" v-model:widgetsShowing="widgetsShowing"/> </div> </template> <script lang="ts" setup> -import { defineAsyncComponent, provide, onMounted, computed, ref, watch, useTemplateRef } from 'vue'; +import { defineAsyncComponent, provide, onMounted, computed, ref } from 'vue'; import { instanceName } from '@@/js/config.js'; import { isLink } from '@@/js/is-link.js'; import XCommon from './_common_/common.vue'; import type { PageMetadata } from '@/page.js'; -import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue'; +import XMobileFooterMenu from '@/ui/_common_/mobile-footer-menu.vue'; +import XPreferenceRestore from '@/ui/_common_/PreferenceRestore.vue'; import * as os from '@/os.js'; -import { navbarItemDef } from '@/navbar.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/i.js'; import { provideMetadataReceiver, provideReactiveMetadata } from '@/page.js'; @@ -109,11 +45,10 @@ import { prefer } from '@/preferences.js'; import { shouldSuggestRestoreBackup } from '@/preferences/utility.js'; import { DI } from '@/di.js'; -const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue')); +const XWidgets = defineAsyncComponent(() => import('./_common_/widgets.vue')); const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue')); const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue')); -const XPreferenceRestore = defineAsyncComponent(() => import('@/ui/_common_/PreferenceRestore.vue')); const isRoot = computed(() => mainRouter.currentRoute.value.name === 'index'); @@ -129,7 +64,6 @@ window.addEventListener('resize', () => { const pageMetadata = ref<null | PageMetadata>(null); const widgetsShowing = ref(false); -const navFooter = useTemplateRef('navFooter'); provide(DI.router, mainRouter); provideMetadataReceiver((metadataGetter) => { @@ -145,14 +79,6 @@ provideMetadataReceiver((metadataGetter) => { }); provideReactiveMetadata(pageMetadata); -const menuIndicated = computed(() => { - for (const def in navbarItemDef) { - if (def === 'notifications') continue; // 通知は下にボタンとして表示されてるから - if (navbarItemDef[def].indicated) return true; - } - return false; -}); - const drawerMenuShowing = ref(false); mainRouter.on('change', () => { @@ -192,70 +118,12 @@ const onContextmenu = (ev) => { }, }], ev); }; - -const navFooterHeight = ref(0); - -watch(navFooter, () => { - if (navFooter.value) { - navFooterHeight.value = navFooter.value.offsetHeight; - window.document.body.style.setProperty('--MI-minBottomSpacing', 'var(--MI-minBottomSpacingMobile)'); - } else { - navFooterHeight.value = 0; - window.document.body.style.setProperty('--MI-minBottomSpacing', '0px'); - } -}, { - immediate: true, -}); </script> <style lang="scss" module> $ui-font-size: 1em; // TODO: どこかに集約したい $widgets-hide-threshold: 1090px; -.transition_menuDrawerBg_enterActive, -.transition_menuDrawerBg_leaveActive { - opacity: 1; - transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.transition_menuDrawerBg_enterFrom, -.transition_menuDrawerBg_leaveTo { - opacity: 0; -} - -.transition_menuDrawer_enterActive, -.transition_menuDrawer_leaveActive { - opacity: 1; - transform: translateX(0); - transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.transition_menuDrawer_enterFrom, -.transition_menuDrawer_leaveTo { - opacity: 0; - transform: translateX(-240px); -} - -.transition_widgetsDrawerBg_enterActive, -.transition_widgetsDrawerBg_leaveActive { - opacity: 1; - transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.transition_widgetsDrawerBg_enterFrom, -.transition_widgetsDrawerBg_leaveTo { - opacity: 0; -} - -.transition_widgetsDrawer_enterActive, -.transition_widgetsDrawer_leaveActive { - opacity: 1; - transform: translateX(0); - transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.transition_widgetsDrawer_enterFrom, -.transition_widgetsDrawer_leaveTo { - opacity: 0; - transform: translateX(-240px); -} - .root { height: 100dvh; overflow: clip; @@ -282,91 +150,6 @@ $widgets-hide-threshold: 1090px; min-height: 0; } -.nav { - padding: 12px 12px max(12px, env(safe-area-inset-bottom, 0px)) 12px; - display: grid; - grid-template-columns: 1fr 1fr 1fr 1fr 1fr; - grid-gap: 8px; - width: 100%; - box-sizing: border-box; - background: var(--MI_THEME-bg); - border-top: solid 0.5px var(--MI_THEME-divider); -} - -.navButton { - position: relative; - padding: 0; - height: 32px; - width: 100%; - max-width: 60px; - margin: auto; - border-radius: var(--MI-radius-lg); - background: transparent; - color: var(--MI_THEME-fg); - - &:hover { - background: var(--MI_THEME-panelHighlight); - color: var(--MI_THEME-accent); - } - - &:active { - background: hsl(from var(--MI_THEME-panel) h s calc(l - 2)); - color: var(--MI_THEME-accent); - } -} - -.postButton { - composes: navButton; - background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB)); - color: var(--MI_THEME-fgOnAccent); - - &:hover { - background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); - color: var(--MI_THEME-fgOnAccent); - } - - &:active { - color: var(--MI_THEME-fgOnAccent); - background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); - } -} - -.navButtonIcon { - font-size: 16px; - vertical-align: middle; -} - -.navButtonIndicator { - position: absolute; - top: 0; - left: 0; - color: var(--MI_THEME-indicator); - font-size: 16px; - - &:has(.itemIndicateValueIcon) { - animation: none; - font-size: 12px; - } -} - -.menuDrawerBg { - z-index: 1001; -} - -.menuDrawer { - position: fixed; - top: 0; - left: 0; - z-index: 1001; - height: 100dvh; - width: 240px; - box-sizing: border-box; - contain: strict; - overflow: auto; - overscroll-behavior: contain; - background: var(--MI_THEME-navBg); -} - .statusbars { position: sticky; top: 0; @@ -386,34 +169,4 @@ $widgets-hide-threshold: 1090px; display: none; } } - -.widgetsDrawerBg { - z-index: 1001; -} - -.widgetsDrawer { - position: fixed; - top: 0; - left: 0; - z-index: 1001; - width: 310px; - height: 100dvh; - padding: var(--MI-margin) var(--MI-margin) calc(var(--MI-margin) + env(safe-area-inset-bottom, 0px)) !important; - box-sizing: border-box; - overflow: auto; - overscroll-behavior: contain; - background: var(--MI_THEME-bg); -} - -.widgetsCloseButton { - padding: 8px; - display: block; - margin: 0 auto; -} - -@media (min-width: 370px) { - .widgetsCloseButton { - display: none; - } -} </style> diff --git a/packages/frontend/src/ui/zen.vue b/packages/frontend/src/ui/zen.vue index 66b4496827..ef39753326 100644 --- a/packages/frontend/src/ui/zen.vue +++ b/packages/frontend/src/ui/zen.vue @@ -6,16 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root"> <div :class="$style.contents"> - <div style="flex: 1; min-height: 0;"> - <RouterView/> - </div> - <!-- デッキUIが設定されている場合はデッキUIに戻れるようにする (ただし?zenが明示された場合は表示しない) See https://github.com/misskey-dev/misskey/issues/10905 --> - <div v-if="showBottom" :class="$style.bottom"> - <button v-tooltip="i18n.ts.goToMisskey" :class="['_button', '_shadow', $style.button]" @click="goToMisskey"><i class="ti ti-home"></i></button> + <button v-if="showDeckNav" class="_buttonPrimary" :class="$style.deckNav" @click="goToDeck">{{ i18n.ts.goToDeck }}</button> + + <div style="flex: 1; min-height: 0;"> + <RouterView/> </div> </div> @@ -37,7 +35,7 @@ const isRoot = computed(() => mainRouter.currentRoute.value.name === 'index'); const pageMetadata = ref<null | PageMetadata>(null); -const showBottom = !(new URLSearchParams(window.location.search)).has('zen') && ui === 'deck'; +const showDeckNav = !(new URLSearchParams(window.location.search)).has('zen') && ui === 'deck'; provide(DI.router, mainRouter); provideMetadataReceiver((metadataGetter) => { @@ -53,7 +51,7 @@ provideMetadataReceiver((metadataGetter) => { }); provideReactiveMetadata(pageMetadata); -function goToMisskey() { +function goToDeck() { window.location.href = '/'; } </script> @@ -68,10 +66,8 @@ function goToMisskey() { height: 100dvh; } -.bottom { - height: calc(60px + (var(--MI-margin) * 2) + env(safe-area-inset-bottom, 0px)); - width: 100%; - margin-top: auto; +.deckNav { + padding: 4px; } .button { diff --git a/packages/frontend/src/utility/get-user-menu.ts b/packages/frontend/src/utility/get-user-menu.ts index eecd87147b..9b7320586a 100644 --- a/packages/frontend/src/utility/get-user-menu.ts +++ b/packages/frontend/src/utility/get-user-menu.ts @@ -363,7 +363,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router }, }); - if ($i.policies.canChat && user.canChat && user.host == null) { + if ($i.policies.chatAvailability === 'available' && user.canChat && user.host == null) { menuItems.push({ type: 'link', icon: 'ti ti-messages', diff --git a/packages/frontend/src/utility/intl-string.ts b/packages/frontend/src/utility/intl-string.ts index 4bc51e2cb0..cf715513a5 100644 --- a/packages/frontend/src/utility/intl-string.ts +++ b/packages/frontend/src/utility/intl-string.ts @@ -65,10 +65,11 @@ const hyphens = [ ]; const hyphensCodePoints = hyphens.map(code => `\\u{${code.toString(16).padStart(4, '0')}}`); +const hyphensRegex = new RegExp(`[${hyphensCodePoints.join('')}]`, 'ug'); /** ハイフンを統一(ローマ字半角入力時に`ー`と`-`が判定できない問題の調整) */ export function normalizeHyphens(str: string) { - return str.replace(new RegExp(`[${hyphensCodePoints.join('')}]`, 'ug'), '\u002d'); + return str.replace(hyphensRegex, '\u002d'); } /** diff --git a/packages/frontend/src/utility/settings-search-index.ts b/packages/frontend/src/utility/settings-search-index.ts index 22e2407b15..7ed97ed34f 100644 --- a/packages/frontend/src/utility/settings-search-index.ts +++ b/packages/frontend/src/utility/settings-search-index.ts @@ -8,35 +8,27 @@ import type { GeneratedSearchIndexItem } from 'search-index:settings'; export type SearchIndexItem = { id: string; + parentId?: string; path?: string; label: string; keywords: string[]; icon?: string; - children?: SearchIndexItem[]; }; const rootMods = new Map(generated.map(item => [item.id, item])); -function walk(item: GeneratedSearchIndexItem) { +// link inlining here +for (const item of generated) { if (item.inlining) { for (const id of item.inlining) { const inline = rootMods.get(id); if (inline) { - (item.children ??= []).push(inline); - rootMods.delete(id); + inline.parentId = item.id; } else { console.log('[Settings Search Index] Failed to inline', id); } } } - - for (const child of item.children ?? []) { - walk(child); - } -} - -for (const item of generated) { - walk(item); } export const searchIndexes: SearchIndexItem[] = generated; diff --git a/packages/frontend/src/utility/virtual.d.ts b/packages/frontend/src/utility/virtual.d.ts index 59470a1f5e..63dc4372b7 100644 --- a/packages/frontend/src/utility/virtual.d.ts +++ b/packages/frontend/src/utility/virtual.d.ts @@ -6,12 +6,12 @@ declare module 'search-index:settings' { export type GeneratedSearchIndexItem = { id: string; + parentId?: string; path?: string; label: string; keywords: string[]; icon?: string; inlining?: string[]; - children?: GeneratedSearchIndexItem[]; }; export const searchIndexes: GeneratedSearchIndexItem[]; diff --git a/packages/misskey-js/package.json b/packages/misskey-js/package.json index 4d0d2205d0..61a51640b0 100644 --- a/packages/misskey-js/package.json +++ b/packages/misskey-js/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "misskey-js", - "version": "2025.4.0-rc.1", + "version": "2025.4.0", "description": "Misskey SDK for JavaScript", "license": "MIT", "main": "./built/index.js", diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 449d901a24..c0a32a8fb7 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -5166,7 +5166,7 @@ export type components = { /** @default false */ notify: boolean; /** @default false */ - hideNotesInSensitiveChannel: boolean; + excludeNotesInSensitiveChannel: boolean; }; Clip: { /** @@ -5451,7 +5451,8 @@ export type components = { canImportMuting: boolean; canImportUserLists: boolean; scheduleNoteMax: number; - canChat: boolean; + /** @enum {string} */ + chatAvailability: 'available' | 'readonly' | 'unavailable'; }; ReversiGameLite: { /** Format: id */ @@ -12145,7 +12146,7 @@ export type operations = { excludeBots?: boolean; withReplies: boolean; withFile: boolean; - hideNotesInSensitiveChannel?: boolean; + excludeNotesInSensitiveChannel?: boolean; }; }; }; @@ -12457,7 +12458,7 @@ export type operations = { excludeBots?: boolean; withReplies?: boolean; withFile?: boolean; - hideNotesInSensitiveChannel?: boolean; + excludeNotesInSensitiveChannel?: boolean; }; }; }; |