diff --git a/locales/index.d.ts b/locales/index.d.ts index fba8794289..bba217f7b4 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -10427,6 +10427,10 @@ export interface Locale extends ILocale { * Edits */ "edited": string; + /** + * Accepted emoji suggestions + */ + "emojiSuggestionAccepted": string; /** * Posting scheduled note failed */ @@ -10482,6 +10486,14 @@ export interface Locale extends ILocale { * Shared access login */ "sharedAccessLogin": string; + /** + * Emoji suggestion accepted + */ + "emojiSuggestionAccepted": string; + /** + * :{name}: was added to this server. + */ + "emojiSuggestionAcceptedDescription": ParameterizedString<"name">; }; "_deck": { /** @@ -12088,6 +12100,10 @@ export interface Locale extends ILocale { * There are no pending emoji suggestions. */ "emojiSuggestionNoPending": string; + /** + * Emoji suggestions are waiting for review. + */ + "emojiSuggestionsPendingReview": string; /** * Proposed by */ diff --git a/packages/backend/src/core/EmojiSuggestionService.ts b/packages/backend/src/core/EmojiSuggestionService.ts index 39a615e627..f484c6e44f 100644 --- a/packages/backend/src/core/EmojiSuggestionService.ts +++ b/packages/backend/src/core/EmojiSuggestionService.ts @@ -20,6 +20,9 @@ import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { DriveService } from '@/core/DriveService.js'; +import { NotificationService } from '@/core/NotificationService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; import { renderInlineError } from '@/misc/render-inline-error.js'; @@ -65,6 +68,9 @@ export class EmojiSuggestionService { private readonly customEmojiService: CustomEmojiService, private readonly driveService: DriveService, + private readonly notificationService: NotificationService, + private readonly roleService: RoleService, + private readonly globalEventService: GlobalEventService, private readonly idService: IdService, loggerService: LoggerService, ) { @@ -122,6 +128,8 @@ export class EmojiSuggestionService { throw error; } + await this.publishQueueChanged(); + return { ok: true, value: suggestion }; } @@ -136,6 +144,14 @@ export class EmojiSuggestionService { }); if (suggestion == null) return { ok: false, reason: 'noSuchSuggestion' }; + const acceptedResult = async (emoji: MiEmoji): Promise> => { + this.notificationService.createNotification(suggestion.userId, 'emojiSuggestionAccepted', { + emojiName: suggestion.name, + }); + await this.publishQueueChanged(); + return { ok: true, value: emoji }; + }; + // Consume the suggestion before doing any work. This makes acceptance, // cancellation, rejection, and another acceptance mutually exclusive. const claimed = await this.emojiSuggestionsRepository.delete({ @@ -195,7 +211,7 @@ export class EmojiSuggestionService { roleIdsThatCanBeUsedThisEmojiAsReaction: [], }, { moderator }); - return { ok: true, value: emoji }; + return await acceptedResult(emoji); } catch (error) { if (emojiFile != null) { // createEmoji inserts before publishing and moderation logging. If a @@ -205,7 +221,7 @@ export class EmojiSuggestionService { host: IsNull(), originalUrl: emojiFile.url, }); - if (insertedEmoji != null) return { ok: true, value: insertedEmoji }; + if (insertedEmoji != null) return await acceptedResult(insertedEmoji); try { await this.driveService.deleteFile(emojiFile, false, moderator); @@ -231,12 +247,29 @@ export class EmojiSuggestionService { id: suggestionId, userId: user.id, }); + if (result.affected === 1) await this.publishQueueChanged(); return result.affected === 1; } @bindThis public async reject(suggestionId: string): Promise { const result = await this.emojiSuggestionsRepository.delete(suggestionId); + if (result.affected === 1) await this.publishQueueChanged(); return result.affected === 1; } + + private async publishQueueChanged(): Promise { + try { + const reviewerIds = await this.roleService.getModeratorIds({ + includeAdmins: true, + includeRoot: true, + excludeExpire: true, + }); + await Promise.all(reviewerIds.map(reviewerId => + this.globalEventService.publishAdminStream(reviewerId, 'emojiSuggestionQueueChanged', {}), + )); + } catch (error) { + this.logger.error(`Failed to publish emoji suggestion queue update: ${renderInlineError(error)}`); + } + } } diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 03e5c8ce0b..7221251609 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -154,6 +154,7 @@ export interface AdminEventTypes { reporterId: MiUser['id'], comment: string; }; + emojiSuggestionQueueChanged: EmptyObject; } export interface ChatEventTypes { diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index 931361c387..f1f41a22a5 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -186,6 +186,9 @@ export class NotificationEntityService implements OnModuleInit { ...(notification.type === 'achievementEarned' ? { achievement: notification.achievement, } : {}), + ...(notification.type === 'emojiSuggestionAccepted' ? { + emojiName: notification.emojiName, + } : {}), ...(notification.type === 'exportCompleted' ? { exportedEntity: notification.exportedEntity, fileId: notification.fileId, diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts index 38773f828f..75e84498f5 100644 --- a/packages/backend/src/models/Notification.ts +++ b/packages/backend/src/models/Notification.ts @@ -86,6 +86,11 @@ export type MiNotification = { id: string; createdAt: string; achievement: string; +} | { + type: 'emojiSuggestionAccepted'; + id: string; + createdAt: string; + emojiName: string; } | { type: 'exportCompleted'; id: string; diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts index 7f05b75fd3..90abbf77b6 100644 --- a/packages/backend/src/models/json-schema/notification.ts +++ b/packages/backend/src/models/json-schema/notification.ts @@ -314,6 +314,20 @@ export const packedNotificationSchema = { ref: 'AchievementName', }, }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['emojiSuggestionAccepted'], + }, + emojiName: { + type: 'string', + optional: false, nullable: false, + }, + }, }, { type: 'object', properties: { diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 0d5e2cf9a2..579464a7d4 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -715,6 +715,7 @@ export const packedMeDetailedOnlySchema = { roleAssigned: { optional: true, ...notificationRecieveConfig }, chatRoomInvitationReceived: { optional: true, ...notificationRecieveConfig }, achievementEarned: { optional: true, ...notificationRecieveConfig }, + emojiSuggestionAccepted: { optional: true, ...notificationRecieveConfig }, app: { optional: true, ...notificationRecieveConfig }, test: { optional: true, ...notificationRecieveConfig }, }, diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index 414e8b02d3..c3429df134 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -118,6 +118,7 @@ export const meta = { roleAssigned: { optional: true, ...notificationRecieveConfig }, chatRoomInvitationReceived: { optional: true, ...notificationRecieveConfig }, achievementEarned: { optional: true, ...notificationRecieveConfig }, + emojiSuggestionAccepted: { optional: true, ...notificationRecieveConfig }, app: { optional: true, ...notificationRecieveConfig }, test: { optional: true, ...notificationRecieveConfig }, }, diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index ea2081d4b0..966f6e442d 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -250,6 +250,7 @@ export const paramDef = { roleAssigned: notificationRecieveConfig, chatRoomInvitationReceived: notificationRecieveConfig, achievementEarned: notificationRecieveConfig, + emojiSuggestionAccepted: notificationRecieveConfig, app: notificationRecieveConfig, test: notificationRecieveConfig, }, diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index d62f997ee2..8f4f419784 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -18,6 +18,7 @@ * chatRoomInvitationReceived - チャットルームに招待された * achievementEarned - 実績を獲得 * exportCompleted - エクスポートが完了 + * emojiSuggestionAccepted - 絵文字の提案が承認された * login - ログイン * createToken - トークン作成 * app - アプリ通知 @@ -38,6 +39,7 @@ export const notificationTypes = [ 'roleAssigned', 'chatRoomInvitationReceived', 'achievementEarned', + 'emojiSuggestionAccepted', 'exportCompleted', 'importCompleted', 'login', diff --git a/packages/backend/test/unit/EmojiSuggestionService.ts b/packages/backend/test/unit/EmojiSuggestionService.ts index 126706798e..03493739be 100644 --- a/packages/backend/test/unit/EmojiSuggestionService.ts +++ b/packages/backend/test/unit/EmojiSuggestionService.ts @@ -8,10 +8,14 @@ process.env.NODE_ENV = 'test'; import { jest } from '@jest/globals'; import { IsNull, QueryFailedError } from 'typeorm'; import { EmojiSuggestionService } from '@/core/EmojiSuggestionService.js'; +import type { MiUser } from '@/models/_.js'; describe('EmojiSuggestionService', () => { const user = { id: '9abc000001', username: 'proposer', host: null }; const moderator = { id: '9abc000002', username: 'moderator', host: null }; + // These fixtures exercise service behavior and intentionally omit unrelated MiUser columns. + const proposer = user as unknown as MiUser; + const reviewer = moderator as unknown as MiUser; const file = { id: '9abc000003', userId: user.id, @@ -45,6 +49,9 @@ describe('EmojiSuggestionService', () => { emojis?: Record; customEmoji?: Record; driveService?: Record; + notificationService?: Record; + roleService?: Record; + globalEventService?: Record; }) { const suggestions = { countBy: jest.fn(async () => 0), @@ -74,20 +81,37 @@ describe('EmojiSuggestionService', () => { deleteFile: jest.fn(async () => undefined), ...overrides?.driveService, }; + const notificationService = { + createNotification: jest.fn(), + ...overrides?.notificationService, + }; + const roleService = { + getModeratorIds: jest.fn(async () => [moderator.id]), + ...overrides?.roleService, + }; + const globalEventService = { + publishAdminStream: jest.fn(async () => undefined), + ...overrides?.globalEventService, + }; const logger = { error: jest.fn(), }; - const service = new EmojiSuggestionService( - suggestions as any, - drive as any, - emojis as any, - customEmoji as any, - driveService as any, - { gen: jest.fn(() => suggestion.id) } as any, - { getLogger: jest.fn(() => logger) } as any, - ); + // Each test double implements only the dependency methods exercised by this unit. + const dependencies = [ + suggestions, + drive, + emojis, + customEmoji, + driveService, + notificationService, + roleService, + globalEventService, + { gen: jest.fn(() => suggestion.id) }, + { getLogger: jest.fn(() => logger) }, + ] as unknown as ConstructorParameters; + const service = new EmojiSuggestionService(...dependencies); - return { service, suggestions, drive, emojis, customEmoji, driveService, logger, emoji }; + return { service, suggestions, drive, emojis, customEmoji, driveService, notificationService, roleService, globalEventService, logger, emoji }; } test('submission only accepts an image owned by the proposer', async () => { @@ -103,12 +127,62 @@ describe('EmojiSuggestionService', () => { license: null, localOnly: false, isSensitive: false, - }, user as any)).resolves.toEqual({ ok: false, reason: 'noSuchFile' }); + }, proposer)).resolves.toEqual({ ok: false, reason: 'noSuchFile' }); expect(drive.findOneBy).toHaveBeenCalledWith({ id: file.id, userId: user.id }); expect(suggestions.insertOne).not.toHaveBeenCalled(); }); + test('submission publishes a queue change to every active emoji reviewer', async () => { + const adminId = '9abc000007'; + const { service, notificationService, roleService, globalEventService } = createService({ + roleService: { getModeratorIds: jest.fn(async () => [moderator.id, adminId]) }, + }); + + await expect(service.create({ + name: suggestion.name, + fileId: file.id, + category: suggestion.category, + aliases: suggestion.aliases, + license: suggestion.license, + localOnly: suggestion.localOnly, + isSensitive: suggestion.isSensitive, + }, proposer)).resolves.toEqual({ ok: true, value: { ...suggestion, user } }); + + expect(roleService.getModeratorIds).toHaveBeenCalledWith({ + includeAdmins: true, + includeRoot: true, + excludeExpire: true, + }); + expect(notificationService.createNotification).not.toHaveBeenCalled(); + expect(globalEventService.publishAdminStream).toHaveBeenCalledTimes(2); + expect(globalEventService.publishAdminStream).toHaveBeenCalledWith(moderator.id, 'emojiSuggestionQueueChanged', {}); + expect(globalEventService.publishAdminStream).toHaveBeenCalledWith(adminId, 'emojiSuggestionQueueChanged', {}); + }); + + test('a reviewer lookup failure does not discard a submitted suggestion', async () => { + const failure = new Error('role lookup failed'); + const { service, logger, notificationService, globalEventService } = createService({ + roleService: { getModeratorIds: jest.fn(async () => { throw failure; }) }, + }); + + await expect(service.create({ + name: suggestion.name, + fileId: file.id, + category: null, + aliases: [], + license: null, + localOnly: false, + isSensitive: false, + }, proposer)).resolves.toEqual({ ok: true, value: { ...suggestion, user } }); + + expect(notificationService.createNotification).not.toHaveBeenCalled(); + expect(globalEventService.publishAdminStream).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to publish emoji suggestion queue update'), + ); + }); + test('a simultaneous duplicate submission returns a domain error', async () => { const driverError = Object.assign(new Error('duplicate key'), { code: '23505' }); const duplicateError = new QueryFailedError('', [], driverError); @@ -124,13 +198,13 @@ describe('EmojiSuggestionService', () => { license: null, localOnly: false, isSensitive: false, - }, user as any)).resolves.toEqual({ ok: false, reason: 'duplicateSuggestion' }); + }, proposer)).resolves.toEqual({ ok: false, reason: 'duplicateSuggestion' }); }); test('acceptance consumes the suggestion and creates the emoji from a dedicated file', async () => { - const { service, suggestions, drive, customEmoji, driveService, emoji } = createService(); + const { service, suggestions, drive, customEmoji, driveService, notificationService, globalEventService, emoji } = createService(); - await expect(service.accept(suggestion.id, moderator as any)).resolves.toEqual({ ok: true, value: emoji }); + await expect(service.accept(suggestion.id, reviewer)).resolves.toEqual({ ok: true, value: emoji }); expect(suggestions.delete).toHaveBeenCalledWith({ id: suggestion.id, @@ -154,6 +228,10 @@ describe('EmojiSuggestionService', () => { localOnly: suggestion.localOnly, roleIdsThatCanBeUsedThisEmojiAsReaction: [], }, { moderator }); + expect(notificationService.createNotification).toHaveBeenCalledWith(user.id, 'emojiSuggestionAccepted', { + emojiName: suggestion.name, + }); + expect(globalEventService.publishAdminStream).toHaveBeenCalledWith(moderator.id, 'emojiSuggestionQueueChanged', {}); expect(drive.findOneBy).not.toHaveBeenCalled(); expect(suggestions.insert).not.toHaveBeenCalled(); }); @@ -163,7 +241,7 @@ describe('EmojiSuggestionService', () => { suggestions: { delete: jest.fn(async () => ({ affected: 0 })) }, }); - await expect(service.accept(suggestion.id, moderator as any)).resolves.toEqual({ ok: false, reason: 'noSuchSuggestion' }); + await expect(service.accept(suggestion.id, reviewer)).resolves.toEqual({ ok: false, reason: 'noSuchSuggestion' }); expect(customEmoji.createEmoji).not.toHaveBeenCalled(); expect(driveService.uploadFromUrl).not.toHaveBeenCalled(); expect(suggestions.insert).not.toHaveBeenCalled(); @@ -195,20 +273,29 @@ describe('EmojiSuggestionService', () => { }, }); - const accepting = service.accept(suggestion.id, moderator as any); + const accepting = service.accept(suggestion.id, reviewer); await copyStarted; - await expect(service.cancel(suggestion.id, user as any)).resolves.toBe(false); + await expect(service.cancel(suggestion.id, proposer)).resolves.toBe(false); releaseCopy(); await expect(accepting).resolves.toEqual({ ok: true, value: expect.objectContaining({ id: '9abc000005' }) }); }); + test('cancellation and rejection publish queue changes only after removal', async () => { + const { service, globalEventService } = createService(); + + await expect(service.cancel(suggestion.id, proposer)).resolves.toBe(true); + await expect(service.reject(suggestion.id)).resolves.toBe(true); + + expect(globalEventService.publishAdminStream).toHaveBeenCalledTimes(2); + }); + test('a failed emoji creation deletes its copy and restores the suggestion', async () => { const failure = new Error('creation failed'); - const { service, suggestions, driveService } = createService({ + const { service, suggestions, driveService, notificationService } = createService({ customEmoji: { createEmoji: jest.fn(async () => { throw failure; }) }, }); - await expect(service.accept(suggestion.id, moderator as any)).rejects.toBe(failure); + await expect(service.accept(suggestion.id, reviewer)).rejects.toBe(failure); expect(driveService.deleteFile).toHaveBeenCalledWith(emojiFile, false, moderator); expect(suggestions.insert).toHaveBeenCalledWith({ id: suggestion.id, @@ -221,6 +308,7 @@ describe('EmojiSuggestionService', () => { localOnly: suggestion.localOnly, isSensitive: suggestion.isSensitive, }); + expect(notificationService.createNotification).not.toHaveBeenCalled(); }); test('cleanup failures do not mask a duplicate-name result', async () => { @@ -234,7 +322,7 @@ describe('EmojiSuggestionService', () => { driveService: { deleteFile: jest.fn(async () => { throw deleteFailure; }) }, }); - await expect(service.accept(suggestion.id, moderator as any)).resolves.toEqual({ ok: false, reason: 'duplicateName' }); + await expect(service.accept(suggestion.id, reviewer)).resolves.toEqual({ ok: false, reason: 'duplicateName' }); expect(driveService.deleteFile).toHaveBeenCalledWith(emojiFile, false, moderator); expect(suggestions.insert).toHaveBeenCalled(); expect(logger.error).toHaveBeenCalledTimes(2); @@ -243,12 +331,12 @@ describe('EmojiSuggestionService', () => { test('a post-insert hook failure is treated as an accepted suggestion', async () => { const failure = new Error('broadcast failed'); const insertedEmoji = { id: '9abc000005', name: suggestion.name, originalUrl: emojiFile.url }; - const { service, suggestions, emojis, driveService } = createService({ + const { service, suggestions, emojis, driveService, notificationService } = createService({ emojis: { findOneBy: jest.fn(async () => insertedEmoji) }, customEmoji: { createEmoji: jest.fn(async () => { throw failure; }) }, }); - await expect(service.accept(suggestion.id, moderator as any)).resolves.toEqual({ ok: true, value: insertedEmoji }); + await expect(service.accept(suggestion.id, reviewer)).resolves.toEqual({ ok: true, value: insertedEmoji }); expect(emojis.findOneBy).toHaveBeenCalledWith({ name: suggestion.name, host: IsNull(), @@ -256,5 +344,8 @@ describe('EmojiSuggestionService', () => { }); expect(driveService.deleteFile).not.toHaveBeenCalled(); expect(suggestions.insert).not.toHaveBeenCalled(); + expect(notificationService.createNotification).toHaveBeenCalledWith(user.id, 'emojiSuggestionAccepted', { + emojiName: suggestion.name, + }); }); }); diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts index 9258814d97..afb9fc0eb1 100644 --- a/packages/frontend-shared/js/const.ts +++ b/packages/frontend-shared/js/const.ts @@ -131,6 +131,7 @@ export const notificationTypes = [ 'roleAssigned', 'chatRoomInvitationReceived', 'achievementEarned', + 'emojiSuggestionAccepted', 'exportCompleted', 'importCompleted', 'login', diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index e373ab1aaf..d3c33d1adf 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
@@ -30,6 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.t_login]: ['sharedAccessGranted', 'sharedAccessRevoked', 'sharedAccessLogin'].includes(notification.type), [$style.t_createToken]: notification.type === 'createToken', [$style.t_chatRoomInvitationReceived]: notification.type === 'chatRoomInvitationReceived', + [$style.t_emojiSuggestion]: notification.type === 'emojiSuggestionAccepted', [$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null, [$style.t_pollEnded]: notification.type === 'edited', [$style.t_roleAssigned]: notification.type === 'scheduledNoteFailed', @@ -46,6 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only + @@ -78,6 +80,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts._notification.roleAssigned }} {{ i18n.ts._notification.chatRoomInvitationReceived }} {{ i18n.ts._notification.achievementEarned }} + {{ i18n.ts._notification.emojiSuggestionAccepted }} {{ i18n.ts._notification.login }} {{ i18n.ts._notification.createToken }} {{ i18n.ts._notification.testNotification }} @@ -133,6 +136,9 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts._achievements._types['_' + notification.achievement].title }} + + {{ i18n.tsx._notification.emojiSuggestionAcceptedDescription({ name: notification.emojiName }) }} + {{ i18n.ts.showFile }} @@ -423,6 +429,11 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification) pointer-events: none; } +.t_emojiSuggestion { + background: var(--eventOther); + pointer-events: none; +} + .t_exportCompleted, .t_importCompleted { background: var(--eventOther); pointer-events: none; diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts index bb8ed4f73e..a9d6aa6ba8 100644 --- a/packages/frontend/src/navbar.ts +++ b/packages/frontend/src/navbar.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { computed, defineAsyncComponent, reactive } from 'vue'; +import { computed, defineAsyncComponent, reactive, ref } from 'vue'; import { ui } from '@@/js/config.js'; import { clearCache } from './utility/clear-cache.js'; import { instance } from './instance.js'; @@ -14,6 +14,35 @@ import { lookup } from '@/utility/lookup.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { unisonReload } from '@/utility/unison-reload.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { useStream } from '@/stream.js'; + +export const hasPendingEmojiSuggestions = ref(false); + +let emojiSuggestionQueueRequest = 0; + +export async function refreshPendingEmojiSuggestions(): Promise { + const request = ++emojiSuggestionQueueRequest; + try { + const suggestions = await misskeyApi('admin/emoji-suggestions/list', { limit: 1 }); + if (request === emojiSuggestionQueueRequest) { + hasPendingEmojiSuggestions.value = suggestions.length > 0; + } + } catch { + // An unavailable status check must not break the global navigation. + } +} + +if ($i != null && ($i.isAdmin || $i.isModerator)) { + const adminConnection = useStream().useChannel('admin'); + adminConnection.on('emojiSuggestionQueueChanged', () => { + void refreshPendingEmojiSuggestions(); + }); + void refreshPendingEmojiSuggestions(); + window.addEventListener('focus', () => { + void refreshPendingEmojiSuggestions(); + }, { passive: true }); +} export const navbarItemDef = reactive({ notifications: { diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index 0cb9d54bbe..bb882582bb 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -14,6 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.thereIsUnresolvedAbuseReportWarning }} {{ i18n.ts.check }} + {{ i18n.ts.emojiSuggestionsPendingReview }} {{ i18n.ts.check }} {{ i18n.ts.noMaintainerInformationWarning }} {{ i18n.ts.configure }} {{ i18n.ts.noInquiryUrlWarning }} {{ i18n.ts.configure }} {{ i18n.ts.noBotProtectionWarning }} {{ i18n.ts.configure }} @@ -46,6 +47,7 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import { lookupUser, lookupUserByEmail, lookupFile } from '@/utility/admin-lookup.js'; import { definePage, provideMetadataReceiver, provideReactiveMetadata } from '@/page.js'; import { useRouter } from '@/router.js'; +import { hasPendingEmojiSuggestions } from '@/navbar.js'; const isEmpty = (x: string | null) => x == null || x === ''; diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue index e1cf4dac4d..526f5bed87 100644 --- a/packages/frontend/src/pages/settings/notifications.vue +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -87,7 +87,7 @@ const $i = ensureSignin(); const nonConfigurableNotificationTypes = ['note', 'roleAssigned', 'followRequestAccepted', 'test', 'exportCompleted', 'importCompleted'] satisfies (typeof notificationTypes[number])[] as string[]; -const onlyOnOrOffNotificationTypes = ['app', 'achievementEarned', 'login', 'createToken', 'scheduledNoteFailed', 'scheduledNotePosted'] satisfies (typeof notificationTypes[number])[] as string[]; +const onlyOnOrOffNotificationTypes = ['app', 'achievementEarned', 'emojiSuggestionAccepted', 'login', 'createToken', 'scheduledNoteFailed', 'scheduledNotePosted'] satisfies (typeof notificationTypes[number])[] as string[]; const allowButton = useTemplateRef('allowButton'); const pushRegistrationInServer = computed(() => allowButton.value?.pushRegistrationInServer); diff --git a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue index efd057781f..64dd1b41f8 100644 --- a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue +++ b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue @@ -28,6 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.controlPanel }} + PlasmaTrap Info @@ -55,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, defineAsyncComponent } from 'vue'; import { openInstanceMenu } from './common.js'; import * as os from '@/os.js'; -import { navbarItemDef } from '@/navbar.js'; +import { hasPendingEmojiSuggestions, navbarItemDef } from '@/navbar.js'; import { prefer } from '@/preferences.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; diff --git a/packages/frontend/src/ui/_common_/navbar-h.vue b/packages/frontend/src/ui/_common_/navbar-h.vue index 13fc592e70..1b693d129d 100644 --- a/packages/frontend/src/ui/_common_/navbar-h.vue +++ b/packages/frontend/src/ui/_common_/navbar-h.vue @@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
+