summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authorHazelnoot <acomputerdog@gmail.com>2025-04-13 13:04:57 -0400
committerHazelnoot <acomputerdog@gmail.com>2025-04-13 13:07:09 -0400
commit71326962853fff5ff52cb11de50e135e373e0733 (patch)
tree49eb91f7ba03df788527906ba5a2e720a791c0c5 /packages
parentremove docs for regenerate-search-index, as it's now a vite plugin (diff)
parentRelease: 2025.4.0 (diff)
downloadsharkey-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')
-rw-r--r--packages/backend/migration/1744075766000-excludeNotesInSensitiveChannel.js16
-rw-r--r--packages/backend/src/core/AntennaService.ts2
-rw-r--r--packages/backend/src/core/ChatService.ts36
-rw-r--r--packages/backend/src/core/RoleService.ts12
-rw-r--r--packages/backend/src/core/entities/AntennaEntityService.ts2
-rw-r--r--packages/backend/src/core/entities/UserEntityService.ts2
-rw-r--r--packages/backend/src/models/Antenna.ts2
-rw-r--r--packages/backend/src/models/json-schema/antenna.ts2
-rw-r--r--packages/backend/src/models/json-schema/role.ts5
-rw-r--r--packages/backend/src/server/api/endpoints/antennas/create.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/antennas/update.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/chat/history.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts3
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts3
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/delete.ts3
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/react.ts3
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/search.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/show.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/unreact.ts3
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/create.ts3
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/delete.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts3
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/invitations/outbox.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/join.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/joining.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/leave.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/members.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/mute.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/owned.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/show.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/update.ts2
-rw-r--r--packages/backend/src/server/api/stream/channels/global-timeline.ts2
-rw-r--r--packages/backend/src/server/api/stream/channels/local-timeline.ts2
-rw-r--r--packages/backend/test/e2e/antennas.ts8
-rw-r--r--packages/frontend-shared/js/const.ts2
-rw-r--r--packages/frontend/lib/vite-plugin-create-search-index.ts1446
-rw-r--r--packages/frontend/src/components/MkAntennaEditor.vue10
-rw-r--r--packages/frontend/src/components/MkMenu.vue2
-rw-r--r--packages/frontend/src/components/MkNotification.vue7
-rw-r--r--packages/frontend/src/components/MkPageWindow.vue4
-rw-r--r--packages/frontend/src/components/MkSuperMenu.vue56
-rw-r--r--packages/frontend/src/components/MkWaitingDialog.vue2
-rw-r--r--packages/frontend/src/components/global/MkSpacer.vue3
-rw-r--r--packages/frontend/src/components/global/SearchIcon.vue14
-rw-r--r--packages/frontend/src/components/global/SearchMarker.vue13
-rw-r--r--packages/frontend/src/components/index.ts3
-rw-r--r--packages/frontend/src/di.ts2
-rw-r--r--packages/frontend/src/lib/nirax.ts24
-rw-r--r--packages/frontend/src/navbar.ts1
-rw-r--r--packages/frontend/src/os.ts3
-rw-r--r--packages/frontend/src/pages/admin/index.vue2
-rw-r--r--packages/frontend/src/pages/admin/roles.editor.vue21
-rw-r--r--packages/frontend/src/pages/admin/roles.vue14
-rw-r--r--packages/frontend/src/pages/chat/XMessage.vue10
-rw-r--r--packages/frontend/src/pages/chat/home.home.vue4
-rw-r--r--packages/frontend/src/pages/chat/room.vue84
-rw-r--r--packages/frontend/src/pages/settings/mute-block.vue3
-rw-r--r--packages/frontend/src/pages/settings/navbar.vue1
-rw-r--r--packages/frontend/src/pages/settings/notifications.vue112
-rw-r--r--packages/frontend/src/pages/settings/other.vue31
-rw-r--r--packages/frontend/src/pages/settings/preferences.vue131
-rw-r--r--packages/frontend/src/pages/settings/privacy.vue33
-rw-r--r--packages/frontend/src/pages/settings/theme.vue3
-rw-r--r--packages/frontend/src/pages/timeline.vue2
-rw-r--r--packages/frontend/src/pref-migrate.ts15
-rw-r--r--packages/frontend/src/router.definition.ts12
-rw-r--r--packages/frontend/src/signout.ts6
-rw-r--r--packages/frontend/src/ui/_common_/common.vue150
-rw-r--r--packages/frontend/src/ui/_common_/mobile-footer-menu.vue144
-rw-r--r--packages/frontend/src/ui/_common_/navbar-for-mobile.vue7
-rw-r--r--packages/frontend/src/ui/_common_/navbar.vue5
-rw-r--r--packages/frontend/src/ui/_common_/widgets.vue (renamed from packages/frontend/src/ui/universal.widgets.vue)0
-rw-r--r--packages/frontend/src/ui/deck.vue171
-rw-r--r--packages/frontend/src/ui/deck/column.vue3
-rw-r--r--packages/frontend/src/ui/universal.vue259
-rw-r--r--packages/frontend/src/ui/zen.vue20
-rw-r--r--packages/frontend/src/utility/get-user-menu.ts2
-rw-r--r--packages/frontend/src/utility/intl-string.ts3
-rw-r--r--packages/frontend/src/utility/settings-search-index.ts16
-rw-r--r--packages/frontend/src/utility/virtual.d.ts2
-rw-r--r--packages/misskey-js/package.json2
-rw-r--r--packages/misskey-js/src/autogen/types.ts9
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;
};
};
};