diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2023-01-21 18:45:50 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2023-01-21 18:45:50 +0900 |
| commit | 38fde26d603951b389d23ade204f22b0674b2cde (patch) | |
| tree | 6883103e35b7e836c6fe94558cbb26bce2e374ce /packages | |
| parent | Merge branch 'develop' (diff) | |
| parent | 13.1.0 (diff) | |
| download | misskey-38fde26d603951b389d23ade204f22b0674b2cde.tar.gz misskey-38fde26d603951b389d23ade204f22b0674b2cde.tar.bz2 misskey-38fde26d603951b389d23ade204f22b0674b2cde.zip | |
Merge branch 'develop'
Diffstat (limited to 'packages')
67 files changed, 1806 insertions, 192 deletions
diff --git a/packages/backend/.swcrc b/packages/backend/.swcrc index c82564eab3..55a88456ef 100644 --- a/packages/backend/.swcrc +++ b/packages/backend/.swcrc @@ -9,7 +9,17 @@ "transform": { "legacyDecorator": true, "decoratorMetadata": true - } + }, + "experimental": { + "keepImportAssertions": true + }, + "baseUrl": ".", + "paths": { + "@/*": [ + "./src/*" + ] + }, + "target": "es2021" }, "minify": false } diff --git a/packages/backend/migration/1674086433654-flashScriptLength.js b/packages/backend/migration/1674086433654-flashScriptLength.js new file mode 100644 index 0000000000..a4d149fe15 --- /dev/null +++ b/packages/backend/migration/1674086433654-flashScriptLength.js @@ -0,0 +1,11 @@ +export class flashScriptLength1674086433654 { + name = 'flashScriptLength1674086433654' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "flash" ALTER COLUMN "script" TYPE character varying(32768)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "flash" ALTER COLUMN "script" TYPE character varying(16384)`); + } +} diff --git a/packages/backend/migration/1674118260469-achievement.js b/packages/backend/migration/1674118260469-achievement.js new file mode 100644 index 0000000000..131ab96f80 --- /dev/null +++ b/packages/backend/migration/1674118260469-achievement.js @@ -0,0 +1,33 @@ +export class achievement1674118260469 { + name = 'achievement1674118260469' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "notification" ADD "achievement" character varying(128)`); + await queryRunner.query(`ALTER TABLE "user_profile" ADD "achievements" jsonb NOT NULL DEFAULT '[]'`); + await queryRunner.query(`ALTER TYPE "public"."notification_type_enum" RENAME TO "notification_type_enum_old"`); + await queryRunner.query(`CREATE TYPE "public"."notification_type_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'achievementEarned', 'app')`); + await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum" USING "type"::"text"::"public"."notification_type_enum"`); + await queryRunner.query(`DROP TYPE "public"."notification_type_enum_old"`); + await queryRunner.query(`ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum" RENAME TO "user_profile_mutingnotificationtypes_enum_old"`); + await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'achievementEarned', 'app')`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" TYPE "public"."user_profile_mutingnotificationtypes_enum"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum"[]`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`); + await queryRunner.query(`DROP TYPE "public"."user_profile_mutingnotificationtypes_enum_old"`); + } + + async down(queryRunner) { + await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'pollEnded')`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" TYPE "public"."user_profile_mutingnotificationtypes_enum_old"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum_old"[]`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`); + await queryRunner.query(`DROP TYPE "public"."user_profile_mutingnotificationtypes_enum"`); + await queryRunner.query(`ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum_old" RENAME TO "user_profile_mutingnotificationtypes_enum"`); + await queryRunner.query(`CREATE TYPE "public"."notification_type_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`); + await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum_old" USING "type"::"text"::"public"."notification_type_enum_old"`); + await queryRunner.query(`DROP TYPE "public"."notification_type_enum"`); + await queryRunner.query(`ALTER TYPE "public"."notification_type_enum_old" RENAME TO "notification_type_enum"`); + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "achievements"`); + await queryRunner.query(`ALTER TABLE "notification" DROP COLUMN "achievement"`); + } +} diff --git a/packages/backend/migration/1674255666603-loggedInDates.js b/packages/backend/migration/1674255666603-loggedInDates.js new file mode 100644 index 0000000000..6d75ab6436 --- /dev/null +++ b/packages/backend/migration/1674255666603-loggedInDates.js @@ -0,0 +1,11 @@ +export class loggedInDates1674255666603 { + name = 'loggedInDates1674255666603' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" ADD "loggedInDates" character varying(32) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "loggedInDates"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index a9ba3ebaf1..68cfbb05ad 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -7,6 +7,8 @@ "start": "node ./built/index.js", "start:test": "NODE_ENV=test node ./built/index.js", "migrate": "pnpm typeorm migration:run -d ormconfig.js", + "build:swc": "swc src -d built -D", + "watch:swc": "swc src -d built -D -w", "build": "tsc -p tsconfig.json || echo done. && tsc-alias -p tsconfig.json", "watch": "node watch.mjs", "lint": "tsc --noEmit && eslint --quiet \"src/**/*.ts\"", @@ -129,6 +131,7 @@ }, "devDependencies": { "@redocly/openapi-core": "1.0.0-beta.120", + "@swc/cli": "^0.1.59", "@swc/core": "1.3.26", "@swc/jest": "0.2.24", "@types/accepts": "1.3.5", diff --git a/packages/backend/src/RootModule.ts b/packages/backend/src/MainModule.ts index 3fc3927768..fc568e883e 100644 --- a/packages/backend/src/RootModule.ts +++ b/packages/backend/src/MainModule.ts @@ -1,13 +1,13 @@ import { Module } from '@nestjs/common'; import { ServerModule } from '@/server/ServerModule.js'; import { GlobalModule } from '@/GlobalModule.js'; -import { QueueProcessorModule } from '@/queue/QueueProcessorModule.js'; +import { DaemonModule } from '@/daemons/DaemonModule.js'; @Module({ imports: [ GlobalModule, ServerModule, - QueueProcessorModule, + DaemonModule, ], }) -export class RootModule {} +export class MainModule {} diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts index 4630217c4c..93cb3131ba 100644 --- a/packages/backend/src/boot/master.ts +++ b/packages/backend/src/boot/master.ts @@ -17,6 +17,9 @@ import { JanitorService } from '@/daemons/JanitorService.js'; import { QueueStatsService } from '@/daemons/QueueStatsService.js'; import { ServerStatsService } from '@/daemons/ServerStatsService.js'; import { NestLogger } from '@/NestLogger.js'; +import { ChartManagementService } from '@/core/chart/ChartManagementService.js'; +import { ServerService } from '@/server/ServerService.js'; +import { MainModule } from '@/MainModule.js'; import { envOption } from '../env.js'; const _filename = fileURLToPath(import.meta.url); @@ -70,6 +73,15 @@ export async function masterMain() { process.exit(1); } + const app = await NestFactory.createApplicationContext(MainModule, { + logger: new NestLogger(), + }); + app.enableShutdownHooks(); + + // start server + const serverService = app.get(ServerService); + serverService.launch(); + bootLogger.succ('Misskey initialized'); if (!envOption.disableClustering) { @@ -78,15 +90,10 @@ export async function masterMain() { bootLogger.succ(`Now listening on port ${config.port} on ${config.url}`, null, true); - if (!envOption.noDaemons) { - const daemons = await NestFactory.createApplicationContext(DaemonModule, { - logger: new NestLogger(), - }); - daemons.enableShutdownHooks(); - daemons.get(JanitorService).start(); - daemons.get(QueueStatsService).start(); - daemons.get(ServerStatsService).start(); - } + app.get(ChartManagementService).start(); + app.get(JanitorService).start(); + app.get(QueueStatsService).start(); + app.get(ServerStatsService).start(); } function showEnvironment(): void { diff --git a/packages/backend/src/boot/worker.ts b/packages/backend/src/boot/worker.ts index f29e37de78..e0574643b7 100644 --- a/packages/backend/src/boot/worker.ts +++ b/packages/backend/src/boot/worker.ts @@ -1,32 +1,23 @@ import cluster from 'node:cluster'; import { NestFactory } from '@nestjs/core'; -import { envOption } from '@/env.js'; import { ChartManagementService } from '@/core/chart/ChartManagementService.js'; -import { ServerService } from '@/server/ServerService.js'; import { QueueProcessorService } from '@/queue/QueueProcessorService.js'; import { NestLogger } from '@/NestLogger.js'; -import { RootModule } from '../RootModule.js'; +import { QueueProcessorModule } from '@/queue/QueueProcessorModule.js'; /** * Init worker process */ export async function workerMain() { - const app = await NestFactory.createApplicationContext(RootModule, { + const jobQueue = await NestFactory.createApplicationContext(QueueProcessorModule, { logger: new NestLogger(), }); - app.enableShutdownHooks(); - - // start server - const serverService = app.get(ServerService); - serverService.launch(); + jobQueue.enableShutdownHooks(); // start job queue - if (!envOption.onlyServer) { - const queueProcessorService = app.get(QueueProcessorService); - queueProcessorService.start(); - } + jobQueue.get(QueueProcessorService).start(); - app.get(ChartManagementService).run(); + jobQueue.get(ChartManagementService).start(); if (cluster.isWorker) { // Send a 'ready' message to parent process diff --git a/packages/backend/src/core/AchievementService.ts b/packages/backend/src/core/AchievementService.ts new file mode 100644 index 0000000000..26dd356d36 --- /dev/null +++ b/packages/backend/src/core/AchievementService.ts @@ -0,0 +1,118 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { UserProfilesRepository, UsersRepository } from '@/models/index.js'; +import type { User } from '@/models/entities/User.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import { CreateNotificationService } from '@/core/CreateNotificationService.js'; + +const ACHIEVEMENT_TYPES = [ + 'notes1', + 'notes10', + 'notes100', + 'notes500', + 'notes1000', + 'notes5000', + 'notes10000', + 'notes20000', + 'notes30000', + 'notes40000', + 'notes50000', + 'notes60000', + 'notes70000', + 'notes80000', + 'notes90000', + 'notes100000', + 'login3', + 'login7', + 'login15', + 'login30', + 'login60', + 'login100', + 'login200', + 'login300', + 'login400', + 'login500', + 'login600', + 'login700', + 'login800', + 'login900', + 'login1000', + 'passedSinceAccountCreated1', + 'passedSinceAccountCreated2', + 'passedSinceAccountCreated3', + 'loggedInOnBirthday', + 'loggedInOnNewYearsDay', + 'noteClipped1', + 'noteFavorited1', + 'profileFilled', + 'markedAsCat', + 'following1', + 'following10', + 'following50', + 'following100', + 'following300', + 'followers1', + 'followers10', + 'followers50', + 'followers100', + 'followers300', + 'followers500', + 'followers1000', + 'collectAchievements30', + 'viewAchievements3min', + 'iLoveMisskey', + 'client30min', + 'noteDeletedWithin1min', + 'postedAtLateNight', + 'postedAt0min0sec', + 'selfQuote', + 'htl20npm', + 'outputHelloWorldOnScratchpad', + 'open3windows', + 'driveFolderCircularReference', + 'reactWithoutRead', + 'clickedClickHere', + 'justPlainLucky', + 'setNameToSyuilo', + 'cookieClicked', + 'brainDiver', +] as const; + +@Injectable() +export class AchievementService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + private createNotificationService: CreateNotificationService, + ) { + } + + @bindThis + public async create( + userId: User['id'], + type: string, + ): Promise<void> { + if (!ACHIEVEMENT_TYPES.includes(type)) return; + + const date = Date.now(); + + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: userId }); + + if (profile.achievements.some(a => a.name === type)) return; + + await this.userProfilesRepository.update(userId, { + achievements: [...profile.achievements, { + name: type, + unlockedAt: date, + }], + }); + + this.createNotificationService.createNotification(userId, 'achievementEarned', { + achievement: type, + }); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 0ae1ee32b2..eddf407940 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -4,6 +4,7 @@ import { AccountUpdateService } from './AccountUpdateService.js'; import { AiService } from './AiService.js'; import { AntennaService } from './AntennaService.js'; import { AppLockService } from './AppLockService.js'; +import { AchievementService } from './AchievementService.js'; import { CaptchaService } from './CaptchaService.js'; import { CreateNotificationService } from './CreateNotificationService.js'; import { CreateSystemUserService } from './CreateSystemUserService.js'; @@ -128,6 +129,7 @@ const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useEx const $AiService: Provider = { provide: 'AiService', useExisting: AiService }; const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService }; const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService }; +const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService }; const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService }; const $CreateNotificationService: Provider = { provide: 'CreateNotificationService', useExisting: CreateNotificationService }; const $CreateSystemUserService: Provider = { provide: 'CreateSystemUserService', useExisting: CreateSystemUserService }; @@ -255,6 +257,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AiService, AntennaService, AppLockService, + AchievementService, CaptchaService, CreateNotificationService, CreateSystemUserService, @@ -376,6 +379,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AiService, $AntennaService, $AppLockService, + $AchievementService, $CaptchaService, $CreateNotificationService, $CreateSystemUserService, @@ -498,6 +502,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AiService, AntennaService, AppLockService, + AchievementService, CaptchaService, CreateNotificationService, CreateSystemUserService, @@ -618,6 +623,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AiService, $AntennaService, $AppLockService, + $AchievementService, $CaptchaService, $CreateNotificationService, $CreateSystemUserService, diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts index 8639b5713d..2864ad4405 100644 --- a/packages/backend/src/core/HttpRequestService.ts +++ b/packages/backend/src/core/HttpRequestService.ts @@ -125,7 +125,7 @@ export class UndiciFetcher { ...(options.headers ?? {}), }, }).catch((err) => { - this.logger?.error('fetch error', err); + this.logger?.error(`fetch error to ${typeof url === 'string' ? url : url.href}`, err); throw new StatusError('Resource Unreachable', 500, 'Resource Unreachable'); }); if (!res.ok && !privateOptions.noOkError) { diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts index d44d06a442..ab22a0c411 100644 --- a/packages/backend/src/core/activitypub/ApRequestService.ts +++ b/packages/backend/src/core/activitypub/ApRequestService.ts @@ -57,7 +57,7 @@ export class ApRequestService { method: 'POST', headers: this.objectAssignWithLcKey({ 'Date': new Date().toUTCString(), - 'Host': u.hostname, + 'Host': u.host, 'Content-Type': 'application/activity+json', 'Digest': digestHeader, }, args.additionalHeaders), @@ -83,7 +83,7 @@ export class ApRequestService { headers: this.objectAssignWithLcKey({ 'Accept': 'application/activity+json, application/ld+json', 'Date': new Date().toUTCString(), - 'Host': new URL(args.url).hostname, + 'Host': new URL(args.url).host, }, args.additionalHeaders), }; @@ -106,6 +106,8 @@ export class ApRequestService { request.headers = this.objectAssignWithLcKey(request.headers, { Signature: signatureHeader, }); + // node-fetch will generate this for us. if we keep 'Host', it won't change with redirects! + delete request.headers['host']; return { request, diff --git a/packages/backend/src/core/chart/ChartManagementService.ts b/packages/backend/src/core/chart/ChartManagementService.ts index 37de30b71c..4fba1b57d0 100644 --- a/packages/backend/src/core/chart/ChartManagementService.ts +++ b/packages/backend/src/core/chart/ChartManagementService.ts @@ -54,7 +54,7 @@ export class ChartManagementService implements OnApplicationShutdown { } @bindThis - public async run() { + public async start() { // 20分おきにメモリ情報をDBに書き込み this.saveIntervalId = setInterval(() => { for (const chart of this.charts) { diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index a1c2c9cffb..a8210eea02 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -114,6 +114,9 @@ export class NotificationEntityService implements OnModuleInit { ...(notification.type === 'groupInvited' ? { invitation: this.userGroupInvitationEntityService.pack(notification.userGroupInvitationId!), } : {}), + ...(notification.type === 'achievementEarned' ? { + achievement: notification.achievement, + } : {}), ...(notification.type === 'app' ? { body: notification.customBody, header: notification.customHeader ?? token?.name, diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index bf6f6f4553..34b523e143 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -12,7 +12,7 @@ import { Cache } from '@/misc/cache.js'; import type { Instance } from '@/models/entities/Instance.js'; import type { ILocalUser, IRemoteUser, User } from '@/models/entities/User.js'; import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js'; -import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, NotificationsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, MessagingMessagesRepository, UserGroupJoiningsRepository, AnnouncementsRepository, AntennaNotesRepository, PagesRepository } from '@/models/index.js'; +import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, NotificationsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, MessagingMessagesRepository, UserGroupJoiningsRepository, AnnouncementsRepository, AntennaNotesRepository, PagesRepository, UserProfile } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import type { OnModuleInit } from '@nestjs/common'; @@ -343,6 +343,7 @@ export class UserEntityService implements OnModuleInit { options?: { detail?: D, includeSecrets?: boolean, + userProfile?: UserProfile, }, ): Promise<IsMeAndIsUserDetailed<ExpectsMe, D>> { const opts = Object.assign({ @@ -375,7 +376,7 @@ export class UserEntityService implements OnModuleInit { .innerJoinAndSelect('pin.note', 'note') .orderBy('pin.id', 'DESC') .getMany() : []; - const profile = opts.detail ? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }) : null; + const profile = opts.detail ? (opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id })) : null; const followingCount = profile == null ? null : (profile.ffVisibility === 'public') || isMe ? user.followingCount : @@ -493,6 +494,8 @@ export class UserEntityService implements OnModuleInit { mutingNotificationTypes: profile!.mutingNotificationTypes, emailNotificationTypes: profile!.emailNotificationTypes, showTimelineReplies: user.showTimelineReplies ?? falsy, + achievements: profile!.achievements, + loggedInDays: profile!.loggedInDates.length, } : {}), ...(opts.includeSecrets ? { diff --git a/packages/backend/src/models/entities/Flash.ts b/packages/backend/src/models/entities/Flash.ts index d9a6ac987c..07039d4fa1 100644 --- a/packages/backend/src/models/entities/Flash.ts +++ b/packages/backend/src/models/entities/Flash.ts @@ -44,7 +44,7 @@ export class Flash { public user: User | null; @Column('varchar', { - length: 16384, + length: 32768, }) public script: string; diff --git a/packages/backend/src/models/entities/Notification.ts b/packages/backend/src/models/entities/Notification.ts index 6679cdb809..66f131d1c0 100644 --- a/packages/backend/src/models/entities/Notification.ts +++ b/packages/backend/src/models/entities/Notification.ts @@ -64,6 +64,7 @@ export class Notification { * receiveFollowRequest - フォローリクエストされた * followRequestAccepted - 自分の送ったフォローリクエストが承認された * groupInvited - グループに招待された + * achievementEarned - 実績を獲得 * app - アプリ通知 */ @Index() @@ -129,6 +130,11 @@ export class Notification { }) public choice: number | null; + @Column('varchar', { + length: 128, nullable: true, + }) + public achievement: string | null; + /** * アプリ通知のbody */ diff --git a/packages/backend/src/models/entities/UserProfile.ts b/packages/backend/src/models/entities/UserProfile.ts index c561da87ce..86df8d5d98 100644 --- a/packages/backend/src/models/entities/UserProfile.ts +++ b/packages/backend/src/models/entities/UserProfile.ts @@ -213,6 +213,19 @@ export class UserProfile { }) public mutingNotificationTypes: typeof notificationTypes[number][]; + @Column('varchar', { + length: 32, array: true, default: '{}', + }) + public loggedInDates: string[]; + + @Column('jsonb', { + default: [], + }) + public achievements: { + name: string; + unlockedAt: number; + }[]; + //#region Denormalized fields @Index() @Column('varchar', { diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index 034e9cc5a5..6a8f35cdda 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { CoreModule } from '@/core/CoreModule.js'; +import { GlobalModule } from '@/GlobalModule.js'; import { QueueLoggerService } from './QueueLoggerService.js'; import { QueueProcessorService } from './QueueProcessorService.js'; import { DbQueueProcessorsService } from './DbQueueProcessorsService.js'; @@ -34,6 +35,7 @@ import { ExportFavoritesProcessorService } from './processors/ExportFavoritesPro @Module({ imports: [ + GlobalModule, CoreModule, ], providers: [ diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index fac8497b5e..eb6a3795eb 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -14,6 +14,7 @@ import { genIdenticon } from '@/misc/gen-identicon.js'; import { createTemp } from '@/misc/create-temp.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { LoggerService } from '@/core/LoggerService.js'; +import { bindThis } from '@/decorators.js'; import { ActivityPubServerService } from './ActivityPubServerService.js'; import { NodeinfoServerService } from './NodeinfoServerService.js'; import { ApiServerService } from './api/ApiServerService.js'; @@ -22,7 +23,6 @@ import { WellKnownServerService } from './WellKnownServerService.js'; import { MediaProxyServerService } from './MediaProxyServerService.js'; import { FileServerService } from './FileServerService.js'; import { ClientServerService } from './web/ClientServerService.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class ServerService { @@ -82,13 +82,13 @@ export class ServerService { fastify.get<{ Params: { path: string }; Querystring: { static?: any; }; }>('/emoji/:path(.*)', async (request, reply) => { const path = request.params.path; + reply.header('Cache-Control', 'public, max-age=86400'); + if (!path.match(/^[a-zA-Z0-9\-_@\.]+?\.webp$/)) { reply.code(404); return; } - reply.header('Cache-Control', 'public, max-age=86400'); - const name = path.split('@')[0].replace('.webp', ''); const host = path.split('@')[1]?.replace('.webp', ''); @@ -101,7 +101,12 @@ export class ServerService { reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); if (emoji == null) { - return await reply.redirect('/static-assets/emoji-unknown.png'); + if ('fallback' in request.query) { + return await reply.redirect('/static-assets/emoji-unknown.png'); + } else { + reply.code(404); + return; + } } const url = new URL('/proxy/emoji.webp', this.config.url); @@ -127,6 +132,8 @@ export class ServerService { relations: ['avatar'], }); + reply.header('Cache-Control', 'public, max-age=86400'); + if (user) { reply.redirect(this.userEntityService.getAvatarUrlSync(user)); } else { @@ -138,6 +145,7 @@ export class ServerService { const [temp, cleanup] = await createTemp(); await genIdenticon(request.params.x, fs.createWriteStream(temp)); reply.header('Content-Type', 'image/png'); + reply.header('Cache-Control', 'public, max-age=86400'); return fs.createReadStream(temp).on('close', () => cleanup()); }); diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 14927da7d6..466651f379 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -175,6 +175,7 @@ import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js'; import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js'; import * as ep___i_apps from './endpoints/i/apps.js'; import * as ep___i_authorizedApps from './endpoints/i/authorized-apps.js'; +import * as ep___i_claimAchievement from './endpoints/i/claim-achievement.js'; import * as ep___i_changePassword from './endpoints/i/change-password.js'; import * as ep___i_deleteAccount from './endpoints/i/delete-account.js'; import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js'; @@ -329,6 +330,7 @@ import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by import * as ep___users_search from './endpoints/users/search.js'; import * as ep___users_show from './endpoints/users/show.js'; import * as ep___users_stats from './endpoints/users/stats.js'; +import * as ep___users_achievements from './endpoints/users/achievements.js'; import * as ep___fetchRss from './endpoints/fetch-rss.js'; import * as ep___retention from './endpoints/retention.js'; import { GetterService } from './GetterService.js'; @@ -509,6 +511,7 @@ const $i_2fa_removeKey: Provider = { provide: 'ep:i/2fa/remove-key', useClass: e const $i_2fa_unregister: Provider = { provide: 'ep:i/2fa/unregister', useClass: ep___i_2fa_unregister.default }; const $i_apps: Provider = { provide: 'ep:i/apps', useClass: ep___i_apps.default }; const $i_authorizedApps: Provider = { provide: 'ep:i/authorized-apps', useClass: ep___i_authorizedApps.default }; +const $i_claimAchievement: Provider = { provide: 'ep:i/claim-achievement', useClass: ep___i_claimAchievement.default }; const $i_changePassword: Provider = { provide: 'ep:i/change-password', useClass: ep___i_changePassword.default }; const $i_deleteAccount: Provider = { provide: 'ep:i/delete-account', useClass: ep___i_deleteAccount.default }; const $i_exportBlocking: Provider = { provide: 'ep:i/export-blocking', useClass: ep___i_exportBlocking.default }; @@ -663,6 +666,7 @@ const $users_searchByUsernameAndHost: Provider = { provide: 'ep:users/search-by- const $users_search: Provider = { provide: 'ep:users/search', useClass: ep___users_search.default }; const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_show.default }; const $users_stats: Provider = { provide: 'ep:users/stats', useClass: ep___users_stats.default }; +const $users_achievements: Provider = { provide: 'ep:users/achievements', useClass: ep___users_achievements.default }; const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default }; const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default }; @@ -847,6 +851,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_2fa_unregister, $i_apps, $i_authorizedApps, + $i_claimAchievement, $i_changePassword, $i_deleteAccount, $i_exportBlocking, @@ -1001,6 +1006,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_search, $users_show, $users_stats, + $users_achievements, $fetchRss, $retention, ], @@ -1179,6 +1185,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_2fa_unregister, $i_apps, $i_authorizedApps, + $i_claimAchievement, $i_changePassword, $i_deleteAccount, $i_exportBlocking, @@ -1331,6 +1338,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_search, $users_show, $users_stats, + $users_achievements, $fetchRss, $retention, ], diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 54c4206ea4..3678fe14e8 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -174,6 +174,7 @@ import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js'; import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js'; import * as ep___i_apps from './endpoints/i/apps.js'; import * as ep___i_authorizedApps from './endpoints/i/authorized-apps.js'; +import * as ep___i_claimAchievement from './endpoints/i/claim-achievement.js'; import * as ep___i_changePassword from './endpoints/i/change-password.js'; import * as ep___i_deleteAccount from './endpoints/i/delete-account.js'; import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js'; @@ -328,6 +329,7 @@ import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by import * as ep___users_search from './endpoints/users/search.js'; import * as ep___users_show from './endpoints/users/show.js'; import * as ep___users_stats from './endpoints/users/stats.js'; +import * as ep___users_achievements from './endpoints/users/achievements.js'; import * as ep___fetchRss from './endpoints/fetch-rss.js'; import * as ep___retention from './endpoints/retention.js'; @@ -506,6 +508,7 @@ const eps = [ ['i/2fa/unregister', ep___i_2fa_unregister], ['i/apps', ep___i_apps], ['i/authorized-apps', ep___i_authorizedApps], + ['i/claim-achievement', ep___i_claimAchievement], ['i/change-password', ep___i_changePassword], ['i/delete-account', ep___i_deleteAccount], ['i/export-blocking', ep___i_exportBlocking], @@ -660,6 +663,7 @@ const eps = [ ['users/search', ep___users_search], ['users/show', ep___users_show], ['users/stats', ep___users_stats], + ['users/achievements', ep___users_achievements], ['fetch-rss', ep___fetchRss], ['retention', ep___retention], ]; diff --git a/packages/backend/src/server/api/endpoints/drive/folders/update.ts b/packages/backend/src/server/api/endpoints/drive/folders/update.ts index ee63d291b2..ff0a78b929 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders/update.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders/update.ts @@ -28,8 +28,8 @@ export const meta = { recursiveNesting: { message: 'It can not be structured like nesting folders recursively.', - code: 'NO_SUCH_PARENT_FOLDER', - id: 'ce104e3a-faaf-49d5-b459-10ff0cbbcaa1', + code: 'RECURSIVE_NESTING', + id: 'dbeb024837894013aed44279f9199740', }, }, diff --git a/packages/backend/src/server/api/endpoints/i.ts b/packages/backend/src/server/api/endpoints/i.ts index 3bcd6ff8fb..6beef5ab85 100644 --- a/packages/backend/src/server/api/endpoints/i.ts +++ b/packages/backend/src/server/api/endpoints/i.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository } from '@/models/index.js'; +import type { UserProfilesRepository, UsersRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DI } from '@/di-symbols.js'; @@ -29,15 +29,36 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { @Inject(DI.usersRepository) private usersRepository: UsersRepository, + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + private userEntityService: UserEntityService, ) { super(meta, paramDef, async (ps, user, token) => { const isSecure = token == null; - // ここで渡ってきている user はキャッシュされていて古い可能性もあるので id だけ渡す - return await this.userEntityService.pack<true, true>(user.id, user, { + const now = new Date(); + const today = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`; + + // 渡ってきている user はキャッシュされていて古い可能性があるので改めて取得 + const userProfile = await this.userProfilesRepository.findOneOrFail({ + where: { + userId: user.id, + }, + relations: ['user'], + }); + + if (!userProfile.loggedInDates.includes(today)) { + this.userProfilesRepository.update({ userId: user.id }, { + loggedInDates: [...userProfile.loggedInDates, today], + }); + userProfile.loggedInDates = [...userProfile.loggedInDates, today]; + } + + return await this.userEntityService.pack<true, true>(userProfile.user!, userProfile.user!, { detail: true, includeSecrets: isSecure, + userProfile, }); }); } diff --git a/packages/backend/src/server/api/endpoints/i/claim-achievement.ts b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts new file mode 100644 index 0000000000..52ae5475b6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts @@ -0,0 +1,28 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { AchievementService } from '@/core/AchievementService.js'; + +export const meta = { + requireCredential: true, +} as const; + +export const paramDef = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { + constructor( + private achievementService: AchievementService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.achievementService.create(me.id, ps.name); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/achievements.ts b/packages/backend/src/server/api/endpoints/users/achievements.ts new file mode 100644 index 0000000000..2a095d83ea --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/achievements.ts @@ -0,0 +1,31 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { UserProfilesRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + requireCredential: true, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { + constructor( + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: ps.userId }); + + return profile.achievements; + }); + } +} diff --git a/packages/backend/src/server/api/integration/TwitterServerService.ts b/packages/backend/src/server/api/integration/TwitterServerService.ts index 9cfadbfa1a..f31a788d31 100644 --- a/packages/backend/src/server/api/integration/TwitterServerService.ts +++ b/packages/backend/src/server/api/integration/TwitterServerService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import Redis from 'ioredis'; import { v4 as uuid } from 'uuid'; import { IsNull } from 'typeorm'; -import autwh from 'autwh'; +import * as autwh from 'autwh'; import type { Config } from '@/config.js'; import type { UserProfilesRepository, UsersRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index e2fc27fecd..a4513696a1 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -24,6 +24,11 @@ const v = localStorage.getItem('v') || VERSION; + let forceError = localStorage.getItem('forceError'); + if (forceError != null) { + renderError('FORCED_ERROR', 'This error is forced by having forceError in local storage.') + } + //#region Detect language & fetch translations const localeVersion = localStorage.getItem('localeVersion'); const localeOutdated = (localeVersion == null || localeVersion !== v); diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 573e2faf87..7e9e193362 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -1,4 +1,4 @@ -export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app'] as const; +export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'achievementEarned', 'app'] as const; export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const; diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts index 93916ccf2f..31c125d3ae 100644 --- a/packages/frontend/src/account.ts +++ b/packages/frontend/src/account.ts @@ -2,11 +2,11 @@ import { defineAsyncComponent, reactive } from 'vue'; import * as misskey from 'misskey-js'; import { showSuspendedDialog } from './scripts/show-suspended-dialog'; import { i18n } from './i18n'; +import { miLocalStorage } from './local-storage'; import { del, get, set } from '@/scripts/idb-proxy'; import { apiUrl } from '@/config'; import { waiting, api, popup, popupMenu, success, alert } from '@/os'; import { unisonReload, reloadChannel } from '@/scripts/unison-reload'; -import { miLocalStorage } from './local-storage'; // TODO: 他のタブと永続化されたstateを同期 @@ -20,6 +20,11 @@ export const $i = accountData ? reactive(JSON.parse(accountData) as Account) : n export const iAmModerator = $i != null && ($i.isAdmin || $i.isModerator); export const iAmAdmin = $i != null && $i.isAdmin; +export let notesCount = $i == null ? 0 : $i.notesCount; +export function incNotesCount() { + notesCount++; +} + export async function signout() { waiting(); miLocalStorage.removeItem('account'); diff --git a/packages/frontend/src/components/MkAchievements.vue b/packages/frontend/src/components/MkAchievements.vue new file mode 100644 index 0000000000..64fea96354 --- /dev/null +++ b/packages/frontend/src/components/MkAchievements.vue @@ -0,0 +1,224 @@ +<template> +<div> + <div v-if="achievements" :class="$style.root"> + <div v-for="achievement in achievements" :key="achievement" :class="$style.achievement" class="_panel"> + <div :class="$style.icon"> + <div :class="[$style.iconFrame, $style['iconFrame_' + ACHIEVEMENT_BADGES[achievement.name].frame]]"> + <div :class="[$style.iconInner]" :style="{ background: ACHIEVEMENT_BADGES[achievement.name].bg }"> + <img :class="$style.iconImg" :src="ACHIEVEMENT_BADGES[achievement.name].img"> + </div> + </div> + </div> + <div :class="$style.body"> + <div :class="$style.header"> + <span :class="$style.title">{{ i18n.ts._achievements._types['_' + achievement.name].title }}</span> + <span :class="$style.time"> + <time v-tooltip="new Date(achievement.unlockedAt).toLocaleString()">{{ new Date(achievement.unlockedAt).getFullYear() }}/{{ new Date(achievement.unlockedAt).getMonth() + 1 }}/{{ new Date(achievement.unlockedAt).getDate() }}</time> + </span> + </div> + <div :class="$style.description">{{ i18n.ts._achievements._types['_' + achievement.name].description }}</div> + <div v-if="i18n.ts._achievements._types['_' + achievement.name].flavor" :class="$style.flavor">{{ i18n.ts._achievements._types['_' + achievement.name].flavor }}</div> + </div> + </div> + <template v-if="withLocked"> + <div v-for="achievement in lockedAchievements" :key="achievement" :class="[$style.achievement, $style.locked]" class="_panel" @click="achievement === 'clickedClickHere' ? clickHere() : () => {}"> + <div :class="$style.icon"> + </div> + <div :class="$style.body"> + <div :class="$style.header"> + <span :class="$style.title">???</span> + </div> + <div :class="$style.description">???</div> + </div> + </div> + </template> + </div> + <div v-else> + <MkLoading/> + </div> +</div> +</template> + +<script lang="ts" setup> +import * as misskey from 'misskey-js'; +import { onMounted } from 'vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/scripts/achievements'; + +const props = withDefaults(defineProps<{ + user: misskey.entities.User; + withLocked: boolean; +}>(), { + withLocked: true, +}); + +let achievements = $ref(); +const lockedAchievements = $computed(() => ACHIEVEMENT_TYPES.filter(x => !(achievements ?? []).some(a => a.name === x))); + +function fetch() { + os.api('users/achievements', { userId: props.user.id }).then(res => { + achievements = []; + for (const t of ACHIEVEMENT_TYPES) { + const a = res.find(x => x.name === t); + if (a) achievements.push(a); + } + //achievements = res.sort((a, b) => b.unlockedAt - a.unlockedAt); + }); +} + +function clickHere() { + claimAchievement('clickedClickHere'); + fetch(); +} + +onMounted(() => { + fetch(); +}); +</script> + +<style lang="scss" module> +.root { + display: grid; + grid-template-columns: repeat(auto-fill, min(380px, 100%)); + grid-gap: 12px; + place-content: center; +} + +.achievement { + display: flex; + padding: 16px; + + &.locked { + opacity: 0.5; + } +} + +.icon { + flex-shrink: 0; + margin-right: 12px; +} + +@keyframes shine { + 0% { translate: -30px; } + 100% { translate: -130px; } +} + +.iconFrame { + width: 58px; + height: 58px; + padding: 6px; + border-radius: 100%; + box-sizing: border-box; + pointer-events: none; + user-select: none; + filter: drop-shadow(0px 2px 2px #00000044); + box-shadow: 0 1px 0px #ffffff88 inset; + overflow: clip; +} +.iconFrame_bronze { + background: linear-gradient(0deg, #703827, #d37566); + + > .iconInner { + background: linear-gradient(0deg, #d37566, #703827); + } +} +.iconFrame_silver { + background: linear-gradient(0deg, #7c7c7c, #e1e1e1); + + > .iconInner { + background: linear-gradient(0deg, #e1e1e1, #7c7c7c); + } +} +.iconFrame_gold { + background: linear-gradient(0deg, rgba(255,182,85,1) 0%, rgba(233,133,0,1) 49%, rgba(255,243,93,1) 51%, rgba(255,187,25,1) 100%); + + > .iconInner { + background: linear-gradient(0deg, #ffee20, #eb7018); + } + + &:before { + content: ""; + display: block; + position: absolute; + top: 30px; + width: 200px; + height: 8px; + rotate: -45deg; + translate: -30px; + background: #ffffff88; + animation: shine 2s infinite; + } +} +.iconFrame_platinum { + background: linear-gradient(0deg, rgba(154,154,154,1) 0%, rgba(226,226,226,1) 49%, rgba(255,255,255,1) 51%, rgba(195,195,195,1) 100%); + + > .iconInner { + background: linear-gradient(0deg, #e1e1e1, #7c7c7c); + } + + &:before { + content: ""; + display: block; + position: absolute; + top: 30px; + width: 200px; + height: 8px; + rotate: -45deg; + translate: -30px; + background: #ffffffee; + animation: shine 2s infinite; + } +} + +.iconInner { + position: relative; + width: 100%; + height: 100%; + border-radius: 100%; + box-shadow: 0 1px 0px #ffffff88 inset; +} + +.iconImg { + width: calc(100% - 12px); + height: calc(100% - 12px); + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + margin: auto; + filter: drop-shadow(0px 1px 2px #000000aa); +} + +.body { + flex: 1; + min-width: 0; +} + +.header { + margin-bottom: 8px; + display: flex; +} + +.title { + font-weight: bold; +} + +.time { + margin-left: auto; + font-size: 85%; + opacity: 0.7; +} + +.description { + font-size: 85%; +} + +.flavor { + opacity: 0.7; + transform: skewX(-15deg); + font-size: 85%; + margin-top: 8px; +} +</style> diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue index cbdf924538..4f463d73d9 100644 --- a/packages/frontend/src/components/MkAsUi.vue +++ b/packages/frontend/src/components/MkAsUi.vue @@ -7,9 +7,9 @@ </div> <span v-else-if="c.type === 'text'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, fontWeight: c.bold ? 'bold' : null, color: c.color ?? null }">{{ c.text }}</span> <Mfm v-else-if="c.type === 'mfm'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, fontWeight: c.bold ? 'bold' : null, color: c.color ?? null }" :text="c.text"/> - <MkButton v-else-if="c.type === 'button'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" @click="c.onClick">{{ c.text }}</MkButton> - <div v-else-if="c.type === 'buttons'" class="_buttons"> - <MkButton v-for="button in c.buttons" :primary="button.primary" :rounded="button.rounded" :small="size === 'small'" @click="button.onClick">{{ button.text }}</MkButton> + <MkButton v-else-if="c.type === 'button'" :primary="c.primary" :rounded="c.rounded" :disabled="c.disabled" :small="size === 'small'" inline @click="c.onClick">{{ c.text }}</MkButton> + <div v-else-if="c.type === 'buttons'" class="_buttons" :style="{ justifyContent: align }"> + <MkButton v-for="button in c.buttons" :primary="button.primary" :rounded="button.rounded" :disabled="button.disabled" inline :small="size === 'small'" @click="button.onClick">{{ button.text }}</MkButton> </div> <MkSwitch v-else-if="c.type === 'switch'" :model-value="valueForSwitch" @update:model-value="onSwitchUpdate"> <template v-if="c.label" #label>{{ c.label }}</template> @@ -41,7 +41,7 @@ </MkFolder> <div v-else-if="c.type === 'container'" :class="[$style.container, { [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace', [$style.containerCenter]: c.align === 'center' }]" :style="{ backgroundColor: c.bgColor ?? null, color: c.fgColor ?? null, borderWidth: c.borderWidth ? `${c.borderWidth}px` : 0, borderColor: c.borderColor ?? 'var(--divider)', padding: c.padding ? `${c.padding}px` : 0, borderRadius: c.rounded ? '8px' : 0 }"> <template v-for="child in c.children" :key="child"> - <MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/> + <MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size" :align="c.align"/> </template> </div> </div> @@ -62,8 +62,10 @@ const props = withDefaults(defineProps<{ component: AsUiComponent; components: Ref<AsUiComponent>[]; size: 'small' | 'medium' | 'large'; + align: 'left' | 'center' | 'right'; }>(), { size: 'medium', + align: 'left', }); const c = props.component; diff --git a/packages/frontend/src/components/MkClickerGame.vue b/packages/frontend/src/components/MkClickerGame.vue index 03736ac5e4..68e0f8185d 100644 --- a/packages/frontend/src/components/MkClickerGame.vue +++ b/packages/frontend/src/components/MkClickerGame.vue @@ -20,6 +20,7 @@ import * as os from '@/os'; import { useInterval } from '@/scripts/use-interval'; import * as game from '@/scripts/clicker-game'; import number from '@/filters/number'; +import { claimAchievement } from '@/scripts/achievements'; defineProps<{ }>(); @@ -30,14 +31,18 @@ let cps = $ref(0); let prevCookies = $ref(0); function onClick(ev: MouseEvent) { + const x = ev.clientX; + const y = ev.clientY; + os.popup(MkPlusOneEffect, { x, y }, {}, 'end'); + saveData.value!.cookies++; saveData.value!.totalCookies++; saveData.value!.totalHandmadeCookies++; saveData.value!.clicked++; - const x = ev.clientX; - const y = ev.clientY; - os.popup(MkPlusOneEffect, { x, y }, {}, 'end'); + if (cookies.value === 1) { + claimAchievement('cookieClicked'); + } } useInterval(() => { diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index 82653ca0b4..156013b9aa 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -33,6 +33,7 @@ import * as Misskey from 'misskey-js'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { defaultStore } from '@/store'; +import { claimAchievement } from '@/scripts/achievements'; const props = withDefaults(defineProps<{ folder: Misskey.entities.DriveFolder; @@ -159,9 +160,11 @@ function onDrop(ev: DragEvent) { }).then(() => { // noop }).catch(err => { - switch (err) { - case 'detected-circular-definition': + switch (err.code) { + case 'RECURSIVE_NESTING': + claimAchievement('driveFolderCircularReference'); os.alert({ + type: 'error', title: i18n.ts.unableToProcess, text: i18n.ts.circularReferenceFolder, }); diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index 112a64f52d..af7175e5cd 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -99,6 +99,7 @@ import { stream } from '@/stream'; import { defaultStore } from '@/store'; import { i18n } from '@/i18n'; import { uploadFile, uploads } from '@/scripts/upload'; +import { claimAchievement } from '@/scripts/achievements'; const props = withDefaults(defineProps<{ initialFolder?: Misskey.entities.DriveFolder; @@ -268,9 +269,11 @@ function onDrop(ev: DragEvent): any { }).then(() => { // noop }).catch(err => { - switch (err) { - case 'detected-circular-definition': + switch (err.code) { + case 'RECURSIVE_NESTING': + claimAchievement('driveFolderCircularReference'); os.alert({ + type: 'error', title: i18n.ts.unableToProcess, text: i18n.ts.circularReferenceFolder, }); diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue index ee256d9263..de8db54bfa 100644 --- a/packages/frontend/src/components/MkFollowButton.vue +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -35,6 +35,8 @@ import * as Misskey from 'misskey-js'; import * as os from '@/os'; import { stream } from '@/stream'; import { i18n } from '@/i18n'; +import { claimAchievement } from '@/scripts/achievements'; +import { $i } from '@/account'; const props = withDefaults(defineProps<{ user: Misskey.entities.UserDetailed, @@ -90,6 +92,21 @@ async function onClick() { userId: props.user.id, }); hasPendingFollowRequestFromYou = true; + + claimAchievement('following1'); + + if ($i.followingCount >= 10) { + claimAchievement('following10'); + } + if ($i.followingCount >= 50) { + claimAchievement('following50'); + } + if ($i.followingCount >= 100) { + claimAchievement('following100'); + } + if ($i.followingCount >= 300) { + claimAchievement('following300'); + } } } } catch (err) { diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue index 9912faffe8..c0638c0feb 100644 --- a/packages/frontend/src/components/MkMediaImage.vue +++ b/packages/frontend/src/components/MkMediaImage.vue @@ -13,7 +13,7 @@ :href="image.url" :title="image.name" > - <ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment" :title="image.comment" :cover="false"/> + <ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment || image.name" :title="image.comment || image.name" :cover="false"/> <div v-if="image.type === 'image/gif'" class="gif">GIF</div> </a> <button v-tooltip="$ts.hide" class="_button hide" @click="hide = true"><i class="ti ti-eye-off"></i></button> diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index c6f8612182..f263ae0ce9 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -45,7 +45,8 @@ onMounted(() => { src: media.url, w: media.properties.width, h: media.properties.height, - alt: media.name, + alt: media.comment || media.name, + comment: media.comment || media.name, }; if (media.properties.orientation != null && media.properties.orientation >= 5) { [item.w, item.h] = [item.h, item.w]; @@ -69,6 +70,7 @@ onMounted(() => { }, imageClickAction: 'close', tapAction: 'toggle-controls', + bgOpacity: 1, pswpModule: PhotoSwipe, }); @@ -88,9 +90,28 @@ onMounted(() => { [itemData.w, itemData.h] = [itemData.h, itemData.w]; } itemData.msrc = file.thumbnailUrl; + itemData.alt = file.comment || file.name; + itemData.comment = file.comment || file.name; itemData.thumbCropped = true; }); + lightbox.on('uiRegister', () => { + lightbox.pswp.ui.registerElement({ + name: 'altText', + className: 'pwsp__alt-text-container', + appendTo: 'wrapper', + onInit: (el, pwsp) => { + let textBox = document.createElement('p'); + textBox.className = 'pwsp__alt-text _acrylic'; + el.appendChild(textBox); + + pwsp.on('change', (a) => { + textBox.textContent = pwsp.currSlide.data.comment; + }); + }, + }); + }); + lightbox.init(); }); @@ -185,5 +206,36 @@ const previewable = (file: misskey.entities.DriveFile): boolean => { // なぜか機能しない //z-index: v-bind(pswpZIndex); z-index: 2000000; + --pswp-bg: var(--modalBg); +} + +.pswp__bg { + background: var(--modalBg); + backdrop-filter: var(--modalBgFilter); +} + +.pwsp__alt-text-container { + display: flex; + flex-direction: row; + align-items: center; + + position: absolute; + bottom: 30px; + left: 50%; + transform: translateX(-50%); + + width: 75%; + max-width: 800px; +} + +.pwsp__alt-text { + color: var(--fg); + margin: 0 auto; + text-align: center; + padding: var(--margin); + border-radius: var(--radius); + max-height: 8em; + overflow-y: auto; + text-shadow: var(--bg) 0 0 10px, var(--bg) 0 0 3px, var(--bg) 0 0 3px; } </style> diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 9b2501a2ed..1f6a2883d7 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -143,6 +143,7 @@ import { getNoteMenu } from '@/scripts/get-note-menu'; import { useNoteCapture } from '@/scripts/use-note-capture'; import { deepClone } from '@/scripts/clone'; import { useTooltip } from '@/scripts/use-tooltip'; +import { claimAchievement } from '@/scripts/achievements'; const props = defineProps<{ note: misskey.entities.Note; @@ -268,6 +269,9 @@ function react(viaKeyboard = false): void { noteId: appearNote.id, reaction: reaction, }); + if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) { + claimAchievement('reactWithoutRead'); + } }, () => { focus(); }); diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 56061e0e6f..48ace56d9c 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -159,6 +159,7 @@ import { getNoteMenu } from '@/scripts/get-note-menu'; import { useNoteCapture } from '@/scripts/use-note-capture'; import { deepClone } from '@/scripts/clone'; import { useTooltip } from '@/scripts/use-tooltip'; +import { claimAchievement } from '@/scripts/achievements'; const props = defineProps<{ note: misskey.entities.Note; @@ -279,6 +280,9 @@ function react(viaKeyboard = false): void { noteId: appearNote.id, reaction: reaction, }); + if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) { + claimAchievement('reactWithoutRead'); + } }, () => { focus(); }); diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 4f82579917..e992495a78 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -2,6 +2,7 @@ <div ref="elRef" :class="$style.root"> <div v-once :class="$style.head"> <MkAvatar v-if="notification.type === 'pollEnded'" :class="$style.icon" :user="notification.note.user" link preview/> + <MkAvatar v-else-if="notification.type === 'achievementEarned'" :class="$style.icon" :user="$i" link preview/> <MkAvatar v-else-if="notification.user" :class="$style.icon" :user="notification.user" link preview/> <img v-else-if="notification.icon" :class="$style.icon" :src="notification.icon" alt=""/> <div :class="[$style.subIcon, $style['t_' + notification.type]]"> @@ -14,6 +15,7 @@ <i v-else-if="notification.type === 'mention'" class="ti ti-at"></i> <i v-else-if="notification.type === 'quote'" class="ti ti-quote"></i> <i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i> + <i v-else-if="notification.type === 'achievementEarned'" class="ti ti-military-award"></i> <!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 --> <MkReactionIcon v-else-if="notification.type === 'reaction'" @@ -28,6 +30,7 @@ <div :class="$style.tail"> <header :class="$style.header"> <span v-if="notification.type === 'pollEnded'">{{ i18n.ts._notification.pollEnded }}</span> + <span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span> <MkA v-else-if="notification.user" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA> <span v-else>{{ notification.header }}</span> <MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/> @@ -57,6 +60,9 @@ <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :author="notification.note.user"/> <i class="ti ti-quote" :class="$style.quote"></i> </MkA> + <MkA v-else-if="notification.type === 'achievementEarned'" :class="$style.text" to="/my/achievements"> + {{ i18n.ts._achievements._types['_' + notification.achievement].title }} + </MkA> <span v-else-if="notification.type === 'follow'" :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}<div v-if="full"><MkFollowButton :user="notification.user" :full="true"/></div></span> <span v-else-if="notification.type === 'followRequestAccepted'" :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.followRequestAccepted }}</span> <span v-else-if="notification.type === 'receiveFollowRequest'" :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.receiveFollowRequest }}<div v-if="full && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ i18n.ts.accept }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ i18n.ts.reject }}</button></div></span> @@ -82,6 +88,7 @@ import { i18n } from '@/i18n'; import * as os from '@/os'; import { stream } from '@/stream'; import { useTooltip } from '@/scripts/use-tooltip'; +import { $i } from '@/account'; const props = withDefaults(defineProps<{ notification: misskey.entities.Notification; @@ -240,6 +247,12 @@ useTooltip(reactionRef, (showing) => { pointer-events: none; } +.t_achievementEarned { + padding: 3px; + background: #88a6b7; + pointer-events: none; +} + .tail { flex: 1; min-width: 0; @@ -267,9 +280,9 @@ useTooltip(reactionRef, (showing) => { } .text { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + display: flex; + width: 100%; + overflow: clip; } .quote { diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index ab5dff8db5..f5ae7bcee4 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -10,7 +10,7 @@ <template #default="{ items: notifications }"> <MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :no-gap="true"> <XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/> - <XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/> + <XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="false" class="_panel notification"/> </MkDateSeparatedList> </template> </MkPagination> diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index 25b9da2d0b..d12aafd06d 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -24,7 +24,7 @@ </template> <script lang="ts" setup> -import { ComputedRef, inject, provide } from 'vue'; +import { ComputedRef, inject, onMounted, onUnmounted, provide } from 'vue'; import RouterView from '@/components/global/RouterView.vue'; import MkWindow from '@/components/MkWindow.vue'; import { popout as _popout } from '@/scripts/popout'; @@ -35,6 +35,8 @@ import { mainRouter, routes } from '@/router'; import { Router } from '@/nirax'; import { i18n } from '@/i18n'; import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; +import { openingWindowsCount } from '@/os'; +import { claimAchievement } from '@/scripts/achievements'; const props = defineProps<{ initialPath: string; @@ -128,6 +130,17 @@ function popout() { windowEl.close(); } +onMounted(() => { + openingWindowsCount.value++; + if (openingWindowsCount.value >= 3) { + claimAchievement('open3windows'); + } +}); + +onUnmounted(() => { + openingWindowsCount.value--; +}); + defineExpose({ close, }); diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 6822caf4f4..c7e7e85b2e 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -93,11 +93,12 @@ import { defaultStore, notePostInterruptors, postFormActions } from '@/store'; import MkInfo from '@/components/MkInfo.vue'; import { i18n } from '@/i18n'; import { instance } from '@/instance'; -import { $i, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account'; +import { $i, notesCount, incNotesCount, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account'; import { uploadFile } from '@/scripts/upload'; import { deepClone } from '@/scripts/clone'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { miLocalStorage } from '@/local-storage'; +import { claimAchievement } from '@/scripts/achievements'; const modal = inject('modal'); @@ -627,6 +628,34 @@ async function post(ev?: MouseEvent) { } posting = false; postAccount = null; + + incNotesCount(); + if (notesCount === 1) { + claimAchievement('notes1'); + } + + const text = postData.text?.toLowerCase() ?? ''; + if ((text.includes('love') || text.includes('❤')) && text.includes('misskey')) { + claimAchievement('iLoveMisskey'); + } + if (text.includes('Efrlqw8ytg4'.toLowerCase()) || text.includes('XVCwzwxdHuA'.toLowerCase())) { + claimAchievement('brainDiver'); + } + + if (props.renote && (props.renote.userId === $i.id) && text.length > 0) { + claimAchievement('selfQuote'); + } + + const date = new Date(); + const h = date.getHours(); + const m = date.getMinutes(); + const s = date.getSeconds(); + if (h >= 0 && h <= 3) { + claimAchievement('postedAtLateNight'); + } + if (m === 0 && s === 0) { + claimAchievement('postedAt0min0sec'); + } }); }).catch(err => { posting = false; diff --git a/packages/frontend/src/components/MkReactedUsersDialog.vue b/packages/frontend/src/components/MkReactedUsersDialog.vue new file mode 100644 index 0000000000..2a8dffc014 --- /dev/null +++ b/packages/frontend/src/components/MkReactedUsersDialog.vue @@ -0,0 +1,92 @@ +<template> +<MkModalWindow + ref="dialog" + :width="400" + :height="450" + @close="dialog.close()" + @closed="emit('closed')" +> + <template #header>{{ i18n.ts.reactions }}</template> + + <MkSpacer :margin-min="20" :margin-max="28"> + <div v-if="note" class="_gaps"> + <div :class="$style.tabs"> + <button v-for="reaction in reactions" :key="reaction" :class="[$style.tab, { [$style.tabActive]: tab === reaction }]" class="_button" @click="tab = reaction"> + <MkReactionIcon :reaction="reaction"/> + <span style="margin-left: 4px;">{{ note.reactions[reaction] }}</span> + </button> + </div> + <MkA v-for="user in users" :key="user.id" :to="userPage(user)"> + <MkUserCardMini :user="user" :with-chart="false"/> + </MkA> + </div> + <div v-else> + <MkLoading/> + </div> + </MkSpacer> +</MkModalWindow> +</template> + +<script lang="ts" setup> +import { onMounted, watch } from 'vue'; +import * as misskey from 'misskey-js'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import MkReactionIcon from '@/components/MkReactionIcon.vue'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import { userPage } from '@/filters/user'; +import { i18n } from '@/i18n'; +import * as os from '@/os'; + +const emit = defineEmits<{ + (ev: 'closed'): void, +}>(); + +const props = defineProps<{ + noteId: misskey.entities.Note['id']; +}>(); + +const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>(); + +let note = $ref<misskey.entities.Note>(); +let tab = $ref<string>(); +let reactions = $ref<string[]>(); +let users = $ref(); + +watch($$(tab), async () => { + const res = await os.api('notes/reactions', { + noteId: props.noteId, + type: tab, + limit: 30, + }); + + users = res.map(x => x.user); +}); + +onMounted(() => { + os.api('notes/show', { + noteId: props.noteId, + }).then((res) => { + reactions = Object.keys(res.reactions); + tab = reactions[0]; + note = res; + }); +}); +</script> + +<style lang="scss" module> +.tabs { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.tab { + padding: 4px 6px; + border: solid 1px var(--divider); + border-radius: 6px; +} + +.tabActive { + border-color: var(--accent); +} +</style> diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index e90dd7ea69..ec4042d18c 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -20,6 +20,7 @@ import * as os from '@/os'; import { useTooltip } from '@/scripts/use-tooltip'; import { $i } from '@/account'; import MkReactionEffect from '@/components/MkReactionEffect.vue'; +import { claimAchievement } from '@/scripts/achievements'; const props = defineProps<{ reaction: string; @@ -52,6 +53,9 @@ const toggleReaction = () => { noteId: props.note.id, reaction: props.reaction, }); + if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) { + claimAchievement('reactWithoutRead'); + } } }; diff --git a/packages/frontend/src/components/MkUserCardMini.vue b/packages/frontend/src/components/MkUserCardMini.vue index ec199ad277..457504e6ca 100644 --- a/packages/frontend/src/components/MkUserCardMini.vue +++ b/packages/frontend/src/components/MkUserCardMini.vue @@ -11,20 +11,28 @@ <script lang="ts" setup> import * as misskey from 'misskey-js'; +import { onMounted } from 'vue'; import MkMiniChart from '@/components/MkMiniChart.vue'; import * as os from '@/os'; import { acct } from '@/filters/user'; -const props = defineProps<{ +const props = withDefaults(defineProps<{ user: misskey.entities.User; -}>(); + withChart: boolean; +}>(), { + withChart: true, +}); let chartValues = $ref<number[] | null>(null); -os.apiGet('charts/user/notes', { userId: props.user.id, limit: 16 + 1, span: 'day' }).then(res => { - // 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く - res.inc.splice(0, 1); - chartValues = res.inc; +onMounted(() => { + if (props.withChart) { + os.apiGet('charts/user/notes', { userId: props.user.id, limit: 16 + 1, span: 'day' }).then(res => { + // 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く + res.inc.splice(0, 1); + chartValues = res.inc; + }); + } }); </script> diff --git a/packages/frontend/src/components/global/MkEmoji.vue b/packages/frontend/src/components/global/MkEmoji.vue index bc88cf3be4..b7dd0296cd 100644 --- a/packages/frontend/src/components/global/MkEmoji.vue +++ b/packages/frontend/src/components/global/MkEmoji.vue @@ -1,5 +1,6 @@ <template> -<img v-if="isCustom" :class="[$style.root, $style.custom, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" :src="url" :alt="alt" :title="alt" decoding="async"/> +<span v-if="isCustom && errored">:{{ customEmojiName }}:</span> +<img v-else-if="isCustom" :class="[$style.root, $style.custom, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" :src="url" :alt="alt" :title="alt" decoding="async" @error="errored = true"/> <img v-else-if="char && !useOsNativeEmojis" :class="$style.root" :src="url" :alt="alt" decoding="async" @pointerenter="computeTitle"/> <span v-else-if="char && useOsNativeEmojis" :alt="alt" @pointerenter="computeTitle">{{ char }}</span> <span v-else>{{ emoji }}</span> @@ -37,6 +38,7 @@ const url = computed(() => { } }); const alt = computed(() => isCustom.value ? `:${customEmojiName}:` : char.value); +let errored = $ref(false); // Searching from an array with 2000 items for every emoji felt like too energy-consuming, so I decided to do it lazily on pointerenter function computeTitle(event: PointerEvent): void { diff --git a/packages/frontend/src/init.ts b/packages/frontend/src/init.ts index e10315e1ad..09fb7caf14 100644 --- a/packages/frontend/src/init.ts +++ b/packages/frontend/src/init.ts @@ -44,6 +44,7 @@ import { reactionPicker } from '@/scripts/reaction-picker'; import { getUrlWithoutLoginId } from '@/scripts/login-id'; import { getAccountFromId } from '@/scripts/get-account-from-id'; import { miLocalStorage } from './local-storage'; +import { claimAchievement, claimedAchievements } from './scripts/achievements'; (async () => { console.info(`Misskey v${version}`); @@ -345,6 +346,87 @@ import { miLocalStorage } from './local-storage'; }); } + const now = new Date(); + const m = now.getMonth() + 1; + const d = now.getDate(); + + if ($i.birthday) { + const bm = parseInt($i.birthday.split('-')[1]); + const bd = parseInt($i.birthday.split('-')[2]); + if (m === bm && d === bd) { + claimAchievement('loggedInOnBirthday'); + } + } + + if (m === 1 && d === 1) { + claimAchievement('loggedInOnNewYearsDay'); + } + + if ($i.loggedInDays >= 3) claimAchievement('login3'); + if ($i.loggedInDays >= 7) claimAchievement('login7'); + if ($i.loggedInDays >= 15) claimAchievement('login15'); + if ($i.loggedInDays >= 30) claimAchievement('login30'); + if ($i.loggedInDays >= 60) claimAchievement('login60'); + if ($i.loggedInDays >= 100) claimAchievement('login100'); + if ($i.loggedInDays >= 200) claimAchievement('login200'); + if ($i.loggedInDays >= 300) claimAchievement('login300'); + if ($i.loggedInDays >= 400) claimAchievement('login400'); + if ($i.loggedInDays >= 500) claimAchievement('login500'); + if ($i.loggedInDays >= 600) claimAchievement('login600'); + if ($i.loggedInDays >= 700) claimAchievement('login700'); + if ($i.loggedInDays >= 800) claimAchievement('login800'); + if ($i.loggedInDays >= 900) claimAchievement('login900'); + if ($i.loggedInDays >= 1000) claimAchievement('login1000'); + + if ($i.notesCount > 0) claimAchievement('notes1'); + if ($i.notesCount >= 10) claimAchievement('notes10'); + if ($i.notesCount >= 100) claimAchievement('notes100'); + if ($i.notesCount >= 500) claimAchievement('notes500'); + if ($i.notesCount >= 1000) claimAchievement('notes1000'); + if ($i.notesCount >= 5000) claimAchievement('notes5000'); + if ($i.notesCount >= 10000) claimAchievement('notes10000'); + if ($i.notesCount >= 20000) claimAchievement('notes20000'); + if ($i.notesCount >= 30000) claimAchievement('notes30000'); + if ($i.notesCount >= 40000) claimAchievement('notes40000'); + if ($i.notesCount >= 50000) claimAchievement('notes50000'); + if ($i.notesCount >= 60000) claimAchievement('notes60000'); + if ($i.notesCount >= 70000) claimAchievement('notes70000'); + if ($i.notesCount >= 80000) claimAchievement('notes80000'); + if ($i.notesCount >= 90000) claimAchievement('notes90000'); + if ($i.notesCount >= 100000) claimAchievement('notes100000'); + + if ($i.followersCount > 0) claimAchievement('followers1'); + if ($i.followersCount >= 10) claimAchievement('followers10'); + if ($i.followersCount >= 50) claimAchievement('followers50'); + if ($i.followersCount >= 100) claimAchievement('followers100'); + if ($i.followersCount >= 300) claimAchievement('followers300'); + if ($i.followersCount >= 500) claimAchievement('followers500'); + if ($i.followersCount >= 1000) claimAchievement('followers1000'); + + if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365) { + claimAchievement('passedSinceAccountCreated1'); + } + if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 2) { + claimAchievement('passedSinceAccountCreated2'); + } + if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 3) { + claimAchievement('passedSinceAccountCreated3'); + } + + if (claimedAchievements.length >= 30) { + claimAchievement('collectAchievements30'); + } + + window.setInterval(() => { + if (Math.floor(Math.random() * 10000) === 0) { + claimAchievement('justPlainLucky'); + } + }, 1000 * 10); + + window.setTimeout(() => { + claimAchievement('client30min'); + }, 1000 * 60 * 30); + const lastUsed = miLocalStorage.getItem('lastUsed'); if (lastUsed) { const lastUsedDate = parseInt(lastUsed, 10); diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts index 9ee78741dc..3d16a52e62 100644 --- a/packages/frontend/src/navbar.ts +++ b/packages/frontend/src/navbar.ts @@ -1,11 +1,11 @@ import { computed, ref, reactive } from 'vue'; import { $i } from './account'; +import { miLocalStorage } from './local-storage'; import { search } from '@/scripts/search'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { ui } from '@/config'; import { unisonReload } from '@/scripts/unison-reload'; -import { miLocalStorage } from './local-storage'; export const navbarItemDef = reactive({ notifications: { @@ -103,6 +103,12 @@ export const navbarItemDef = reactive({ icon: 'ti ti-device-tv', to: '/channels', }, + achievements: { + title: i18n.ts.achievements, + icon: 'ti ti-military-award', + show: computed(() => $i != null), + to: '/my/achievements', + }, ui: { title: i18n.ts.switchUi, icon: 'ti ti-devices', diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index b19443aa55..01f8244060 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -1,5 +1,7 @@ // TODO: なんでもかんでもos.tsに突っ込むのやめたいのでよしなに分割する +import { pendingApiRequestsCount, api, apiGet } from '@/scripts/api'; +export { pendingApiRequestsCount, api, apiGet }; import { Component, markRaw, Ref, ref, defineAsyncComponent } from 'vue'; import { EventEmitter } from 'eventemitter3'; import insertTextAtCursor from 'insert-text-at-cursor'; @@ -7,9 +9,16 @@ import * as Misskey from 'misskey-js'; import { i18n } from './i18n'; import MkPostFormDialog from '@/components/MkPostFormDialog.vue'; import MkWaitingDialog from '@/components/MkWaitingDialog.vue'; +import MkPageWindow from '@/components/MkPageWindow.vue'; +import MkToast from '@/components/MkToast.vue'; +import MkDialog from '@/components/MkDialog.vue'; +import MkEmojiPickerDialog from '@/components/MkEmojiPickerDialog.vue'; +import MkEmojiPickerWindow from '@/components/MkEmojiPickerWindow.vue'; +import MkPopupMenu from '@/components/MkPopupMenu.vue'; +import MkContextMenu from '@/components/MkContextMenu.vue'; import { MenuItem } from '@/types/menu'; -import { pendingApiRequestsCount, api, apiGet } from '@/scripts/api'; -export { pendingApiRequestsCount, api, apiGet }; + +export const openingWindowsCount = ref(0); export const apiWithDialog = (( endpoint: string, @@ -124,7 +133,7 @@ export async function popup(component: Component, props: Record<string, any>, ev } export function pageWindow(path: string) { - popup(defineAsyncComponent(() => import('@/components/MkPageWindow.vue')), { + popup(MkPageWindow, { initialPath: path, }, {}, 'closed'); } @@ -136,7 +145,7 @@ export function modalPageWindow(path: string) { } export function toast(message: string) { - popup(defineAsyncComponent(() => import('@/components/MkToast.vue')), { + popup(MkToast, { message, }, {}, 'closed'); } @@ -147,7 +156,7 @@ export function alert(props: { text?: string | null; }): Promise<void> { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), props, { + popup(MkDialog, props, { done: result => { resolve(); }, @@ -161,7 +170,7 @@ export function confirm(props: { text?: string | null; }): Promise<{ canceled: boolean }> { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { + popup(MkDialog, { ...props, showCancelButton: true, }, { @@ -182,7 +191,7 @@ export function inputText(props: { canceled: false; result: string; }> { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { + popup(MkDialog, { title: props.title, text: props.text, input: { @@ -207,7 +216,7 @@ export function inputNumber(props: { canceled: false; result: number; }> { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { + popup(MkDialog, { title: props.title, text: props.text, input: { @@ -232,7 +241,7 @@ export function inputDate(props: { canceled: false; result: Date; }> { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { + popup(MkDialog, { title: props.title, text: props.text, input: { @@ -269,7 +278,7 @@ export function select<C = any>(props: { canceled: false; result: C; }> { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { + popup(MkDialog, { title: props.title, text: props.text, select: { @@ -291,7 +300,7 @@ export function success() { window.setTimeout(() => { showing.value = false; }, 1000); - popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { + popup(MkWaitingDialog, { success: true, showing: showing, }, { @@ -303,7 +312,7 @@ export function success() { export function waiting() { return new Promise((resolve, reject) => { const showing = ref(true); - popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { + popup(MkWaitingDialog, { success: false, showing: showing, }, { @@ -366,7 +375,7 @@ export async function selectDriveFolder(multiple: boolean) { export async function pickEmoji(src: HTMLElement | null, opts) { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { + popup(MkEmojiPickerDialog, { src, ...opts, }, { @@ -431,7 +440,7 @@ export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea: characterData: false, }); - openingEmojiPicker = await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerWindow.vue')), { + openingEmojiPicker = await popup(MkEmojiPickerWindow, { src, ...opts, }, { @@ -454,7 +463,7 @@ export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement }) { return new Promise((resolve, reject) => { let dispose; - popup(defineAsyncComponent(() => import('@/components/MkPopupMenu.vue')), { + popup(MkPopupMenu, { items, src, width: options?.width, @@ -478,7 +487,7 @@ export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent) ev.preventDefault(); return new Promise((resolve, reject) => { let dispose; - popup(defineAsyncComponent(() => import('@/components/MkContextMenu.vue')), { + popup(MkContextMenu, { items, ev, }, { diff --git a/packages/frontend/src/pages/achievements.vue b/packages/frontend/src/pages/achievements.vue new file mode 100644 index 0000000000..14b8520696 --- /dev/null +++ b/packages/frontend/src/pages/achievements.vue @@ -0,0 +1,54 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader/></template> + <MkSpacer :content-max="1200"> + <MkAchievements :user="$i"/> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue'; +import MkAchievements from '@/components/MkAchievements.vue'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { $i } from '@/account'; +import { claimAchievement } from '@/scripts/achievements'; + +let timer: number | null; + +function viewAchievements3min() { + claimAchievement('viewAchievements3min'); +} + +onMounted(() => { + if (timer == null) timer = window.setTimeout(viewAchievements3min, 1000 * 60 * 3); +}); + +onUnmounted(() => { + if (timer != null) { + window.clearTimeout(timer); + timer = null; + } +}); + +onActivated(() => { + if (timer == null) timer = window.setTimeout(viewAchievements3min, 1000 * 60 * 3); +}); + +onDeactivated(() => { + if (timer != null) { + window.clearTimeout(timer); + timer = null; + } +}); + +definePageMetadata({ + title: i18n.ts.achievements, + icon: 'ti ti-military-award', +}); +</script> + +<style lang="scss" module> + +</style> diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index 6e0c038982..ff8f8a356f 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -7,7 +7,7 @@ <MkButton primary rounded @click="create"><i class="ti ti-plus"></i> {{ i18n.ts._role.new }}</MkButton> <MkFolder> <template #label>{{ i18n.ts._role.baseRole }}</template> - <div class="_gaps"> + <div class="_gaps_s"> <MkFolder> <template #label>{{ i18n.ts._role._options.rateLimitFactor }}</template> <template #suffix>{{ Math.floor(policies.rateLimitFactor * 100) }}%</template> diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue index 5ba226c10d..321477259b 100644 --- a/packages/frontend/src/pages/flash/flash-edit.vue +++ b/packages/frontend/src/pages/flash/flash-edit.vue @@ -16,6 +16,7 @@ <div class="_buttons"> <MkButton primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> <MkButton @click="show"><i class="ti ti-eye"></i> {{ i18n.ts.show }}</MkButton> + <MkButton v-if="flash" danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> </div> </div> </MkSpacer> @@ -94,6 +95,85 @@ Ui:render([ ]) `; +const PRESET_SHUFFLE = `/// @ 0.12.2 +// 巻き戻し可能な文字シャッフルのプリセット + +let string = "ペペロンチーノ" +let length = string.len + +// 過去の結果を保存しておくやつ +var results = [] + +// どれだけ巻き戻しているか +var cursor = 0 + +@do() { + if (cursor != 0) { + results = results.slice(0 (cursor + 1)) + cursor = 0 + } + + let chars = [] + for (let i, length) { + let r = Math:rnd(0 (length - 1)) + chars.push(string.pick(r)) + } + let result = chars.join("") + + results.push(result) + + // UIを表示 + render(result) +} + +@back() { + cursor = cursor + 1 + let result = results[results.len - (cursor + 1)] + render(result) +} + +@forward() { + cursor = cursor - 1 + let result = results[results.len - (cursor + 1)] + render(result) +} + +@render(result) { + Ui:render([ + Ui:C:container({ + align: 'center' + children: [ + Ui:C:mfm({ text: result }) + Ui:C:buttons({ + buttons: [{ + text: "←" + disabled: !(results.len > 1 && (results.len - cursor) > 1) + onClick: back + } { + text: "→" + disabled: !(results.len > 1 && cursor > 0) + onClick: forward + } { + text: "引き直す" + onClick: do + }] + }) + Ui:C:postFormButton({ + text: "投稿する" + rounded: true + primary: true + form: { + text: \`{result}{Str:lf}{THIS_URL}\` + } + }) + ] + }) + ]) +} + +do() +`; + const PRESET_TIMELINE = `/// @ 0.12.2 // APIリクエストを行いローカルタイムラインを表示するプリセット @@ -175,6 +255,11 @@ function selectPreset(ev: MouseEvent) { script = PRESET_OMIKUJI; }, }, { + text: 'Shuffle', + action: () => { + script = PRESET_SHUFFLE; + }, + }, { text: 'Timeline viewer', action: () => { script = PRESET_TIMELINE; @@ -212,6 +297,19 @@ function show() { } } +async function del() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('deleteAreYouSure', { x: flash.title }), + }); + if (canceled) return; + + await os.apiWithDialog('flash/delete', { + flashId: props.id, + }); + router.push('/play'); +} + const headerActions = $computed(() => []); const headerTabs = $computed(() => []); diff --git a/packages/frontend/src/pages/flash/flash-index.vue b/packages/frontend/src/pages/flash/flash-index.vue index 7a1080d3f0..a3a48d3b97 100644 --- a/packages/frontend/src/pages/flash/flash-index.vue +++ b/packages/frontend/src/pages/flash/flash-index.vue @@ -11,12 +11,14 @@ </div> <div v-else-if="tab === 'my'" class="my"> - <MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton> - <MkPagination v-slot="{items}" :pagination="myFlashsPagination"> - <div class="_gaps_s"> - <MkFlashPreview v-for="flash in items" :key="flash.id" class="" :flash="flash"/> - </div> - </MkPagination> + <div class="_gaps"> + <MkButton class="new" gradate rounded style="margin: 0 auto;" @click="create()"><i class="ti ti-plus"></i></MkButton> + <MkPagination v-slot="{items}" :pagination="myFlashsPagination"> + <div class="_gaps_s"> + <MkFlashPreview v-for="flash in items" :key="flash.id" class="" :flash="flash"/> + </div> + </MkPagination> + </div> </div> <div v-else-if="tab === 'liked'" class=""> diff --git a/packages/frontend/src/pages/messaging/messaging-room.vue b/packages/frontend/src/pages/messaging/messaging-room.vue index f1749d449d..0867f003a3 100644 --- a/packages/frontend/src/pages/messaging/messaging-room.vue +++ b/packages/frontend/src/pages/messaging/messaging-room.vue @@ -1,11 +1,15 @@ <template> +<MkStickyContainer> +<template #header> + <MkPageHeader /> +</template> <div ref="rootEl" - class="root" + :class="$style['root']" @dragover.prevent.stop="onDragover" @drop.prevent.stop="onDrop" > - <div class="body"> + <div :class="$style['body']"> <MkPagination v-if="pagination" ref="pagingComponent" :key="userAcct || groupId" :pagination="pagination"> <template #empty> <div class="_fullinfo"> @@ -17,7 +21,7 @@ <MkDateSeparatedList v-if="messages.length > 0" v-slot="{ item: message }" - :class="{ messages: true, 'deny-move-transition': pFetching }" + :class="{ [$style['messages']]: true, 'deny-move-transition': pFetching }" :items="messages" direction="up" reversed @@ -27,23 +31,26 @@ </template> </MkPagination> </div> - <footer> - <div v-if="typers.length > 0" class="typers"> - <I18n :src="i18n.ts.typingUsers" text-tag="span" class="users"> + <footer :class="$style['footer']"> + <div v-if="typers.length > 0" :class="$style['typers']"> + <I18n :src="i18n.ts.typingUsers" text-tag="span"> <template #users> - <b v-for="typer in typers" :key="typer.id" class="user">{{ typer.username }}</b> + <b v-for="typer in typers" :key="typer.id" :class="$style['user']">{{ typer.username }}</b> </template> </I18n> <MkEllipsis/> </div> <Transition :name="animation ? 'fade' : ''"> - <div v-show="showIndicator" class="new-message"> - <button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas ti-fw fa-arrow-circle-down"></i>{{ i18n.ts.newMessageExists }}</button> + <div v-show="showIndicator" :class="$style['new-message']"> + <button class="_buttonPrimary" @click="onIndicatorClick" :class="$style['new-message-button']"> + <i class="fas ti-fw fa-arrow-circle-down" :class="$style['new-message-icon']"></i>{{ i18n.ts.newMessageExists }} + </button> </div> </Transition> - <XForm v-if="!fetching" ref="formEl" :user="user" :group="group" class="form"/> + <XForm v-if="!fetching" ref="formEl" :user="user" :group="group" :class="$style['form']"/> </footer> </div> +</MkStickyContainer> </template> <script lang="ts" setup> @@ -303,103 +310,98 @@ definePageMetadata(computed(() => !fetching ? user ? { } : null)); </script> -<style lang="scss" scoped> +<style lang="scss" module> .root { display: content; +} - > .body { - min-height: 80%; - - .more { - display: block; - margin: 16px auto; - padding: 0 12px; - line-height: 24px; - color: #fff; - background: rgba(#000, 0.3); - border-radius: 12px; - - &:hover { - background: rgba(#000, 0.4); - } - - &:active { - background: rgba(#000, 0.5); - } +.body { + min-height: 80%; +} - &.fetching { - cursor: wait; - } +.more { + display: block; + margin: 16px auto; + padding: 0 12px; + line-height: 24px; + color: #fff; + background: rgba(#000, 0.3); + border-radius: 12px; + &:hover { + background: rgba(#000, 0.4); + } + &:active { + background: rgba(#000, 0.5); + } + > i { + margin-right: 4px; + } +} - > i { - margin-right: 4px; - } - } +.fetching { + cursor: wait; +} - .messages { - padding: 8px 0; +.messages { + padding: 16px 0 0; - > ::v-deep(*) { - margin-bottom: 16px; - } - } + > * { + margin-bottom: 16px; } +} - > footer { - width: 100%; - position: sticky; - z-index: 2; - padding-top: 8px; - bottom: 0; - bottom: env(safe-area-inset-bottom, 0px); +.footer { + width: 100%; + position: sticky; + z-index: 2; + padding-top: 8px; + bottom: var(--minBottomSpacing); +} - > .new-message { - width: 100%; - padding-bottom: 8px; - text-align: center; +.new-message { + width: 100%; + padding-bottom: 8px; + text-align: center; +} - > button { - display: inline-block; - margin: 0; - padding: 0 12px; - line-height: 32px; - font-size: 12px; - border-radius: 16px; +.new-message-button { + display: inline-block; + margin: 0; + padding: 0 12px; + line-height: 32px; + font-size: 12px; + border-radius: 16px; +} - > i { - display: inline-block; - margin-right: 8px; - } - } - } +.new-message-icon { + display: inline-block; + margin-right: 8px; +} - > .typers { - position: absolute; - bottom: 100%; - padding: 0 8px 0 8px; - font-size: 0.9em; - color: var(--fgTransparentWeak); +.typers { + position: absolute; + bottom: 100%; + padding: 0 8px 0 8px; + font-size: 0.9em; + color: var(--fgTransparentWeak); +} - > .users { - > .user + .user:before { - content: ", "; - font-weight: normal; - } - > .user:last-of-type:after { - content: " "; - } - } - } +.user + .user:before { + content: ", "; + font-weight: normal; +} - > .form { - max-height: 12em; - overflow-y: scroll; - border-top: solid 0.5px var(--divider); - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - } - } +.user:last-of-type:after { + content: " "; +} + +.form { + max-height: 12em; + overflow-y: scroll; + border-top: solid 0.5px var(--divider); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } .fade-enter-active, .fade-leave-active { diff --git a/packages/frontend/src/pages/scratchpad.vue b/packages/frontend/src/pages/scratchpad.vue index bd68df724e..0d52850b5d 100644 --- a/packages/frontend/src/pages/scratchpad.vue +++ b/packages/frontend/src/pages/scratchpad.vue @@ -47,6 +47,7 @@ import { definePageMetadata } from '@/scripts/page-metadata'; import { AsUiComponent, AsUiRoot, patch, registerAsUiLib, render } from '@/scripts/aiscript/ui'; import MkAsUi from '@/components/MkAsUi.vue'; import { miLocalStorage } from '@/local-storage'; +import { claimAchievement } from '@/scripts/achievements'; const parser = new Parser(); let aiscript: Interpreter; @@ -90,6 +91,9 @@ async function run() { }); }, out: (value) => { + if (value.type === 'str' && value.value.toLowerCase().replace(',', '').includes('hello world')) { + claimAchievement('outputHelloWorldOnScratchpad'); + } logs.value.push({ id: Math.random(), text: value.type === 'str' ? value.value : utils.valToString(value), diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index ae74224db6..da7d3d3703 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -85,6 +85,7 @@ import { i18n } from '@/i18n'; import { $i } from '@/account'; import { langmap } from '@/scripts/langmap'; import { definePageMetadata } from '@/scripts/page-metadata'; +import { claimAchievement } from '@/scripts/achievements'; const profile = reactive({ name: $i.name, @@ -133,6 +134,13 @@ function save() { isCat: !!profile.isCat, showTimelineReplies: !!profile.showTimelineReplies, }); + claimAchievement('profileFilled'); + if (profile.name === 'syuilo' || profile.name === 'しゅいろ') { + claimAchievement('setNameToSyuilo'); + } + if (profile.isCat) { + claimAchievement('markedAsCat'); + } } function changeAvatar(ev) { @@ -155,6 +163,7 @@ function changeAvatar(ev) { }); $i.avatarId = i.avatarId; $i.avatarUrl = i.avatarUrl; + claimAchievement('profileFilled'); }); } diff --git a/packages/frontend/src/pages/user-info.vue b/packages/frontend/src/pages/user-info.vue index 13942da7c7..7ba8a3d16b 100644 --- a/packages/frontend/src/pages/user-info.vue +++ b/packages/frontend/src/pages/user-info.vue @@ -113,7 +113,8 @@ <div v-for="role in info.roles" :key="role.id" :class="$style.roleItem"> <MkRolePreview :class="$style.role" :role="role"/> - <button class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button> + <button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button> + <button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button> </div> </div> </MkFolder> diff --git a/packages/frontend/src/pages/user/achievements.vue b/packages/frontend/src/pages/user/achievements.vue new file mode 100644 index 0000000000..eaea26db4a --- /dev/null +++ b/packages/frontend/src/pages/user/achievements.vue @@ -0,0 +1,52 @@ +<template> +<MkSpacer :content-max="1200"> + <MkAchievements :user="user" :with-locked="false"/> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue'; +import * as misskey from 'misskey-js'; +import MkAchievements from '@/components/MkAchievements.vue'; +import { i18n } from '@/i18n'; +import { claimAchievement } from '@/scripts/achievements'; +import { $i } from '@/account'; + +const props = defineProps<{ + user: misskey.entities.User; +}>(); + +let timer: number | null; + +function viewAchievements3min() { + if ($i && (props.user.id === $i.id)) { + claimAchievement('viewAchievements3min'); + } +} + +onMounted(() => { + if (timer == null) timer = window.setTimeout(viewAchievements3min, 1000 * 60 * 3); +}); + +onUnmounted(() => { + if (timer != null) { + window.clearTimeout(timer); + timer = null; + } +}); + +onActivated(() => { + if (timer == null) timer = window.setTimeout(viewAchievements3min, 1000 * 60 * 3); +}); + +onDeactivated(() => { + if (timer != null) { + window.clearTimeout(timer); + timer = null; + } +}); +</script> + +<style lang="scss" module> + +</style> diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue index 7abae1ea30..d63aa3a3a5 100644 --- a/packages/frontend/src/pages/user/index.vue +++ b/packages/frontend/src/pages/user/index.vue @@ -6,6 +6,7 @@ <div v-if="user"> <XHome v-if="tab === 'home'" :user="user"/> <XActivity v-else-if="tab === 'activity'" :user="user"/> + <XAchievements v-else-if="tab === 'achievements'" :user="user"/> <XReactions v-else-if="tab === 'reactions'" :user="user"/> <XClips v-else-if="tab === 'clips'" :user="user"/> <XPages v-else-if="tab === 'pages'" :user="user"/> @@ -34,6 +35,7 @@ import { $i } from '@/account'; const XHome = defineAsyncComponent(() => import('./home.vue')); const XActivity = defineAsyncComponent(() => import('./activity.vue')); +const XAchievements = defineAsyncComponent(() => import('./achievements.vue')); const XReactions = defineAsyncComponent(() => import('./reactions.vue')); const XClips = defineAsyncComponent(() => import('./clips.vue')); const XPages = defineAsyncComponent(() => import('./pages.vue')); @@ -76,7 +78,11 @@ const headerTabs = $computed(() => user ? [{ key: 'activity', title: i18n.ts.activity, icon: 'ti ti-chart-line', -}, ...($i && ($i.id === user.id)) || user.publicReactions ? [{ +}, ...(user.host == null ? [{ + key: 'achievements', + title: i18n.ts.achievements, + icon: 'ti ti-military-award', +}] : []), ...($i && ($i.id === user.id)) || user.publicReactions ? [{ key: 'reactions', title: i18n.ts.reaction, icon: 'ti ti-mood-happy', diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts index 26c73c610f..22106e1595 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -428,6 +428,10 @@ export const routes = [{ component: page(() => import('./pages/favorites.vue')), loginRequired: true, }, { + path: '/my/achievements', + component: page(() => import('./pages/achievements.vue')), + loginRequired: true, +}, { name: 'messaging', path: '/my/messaging', component: page(() => import('./pages/messaging/index.vue')), diff --git a/packages/frontend/src/scripts/achievements.ts b/packages/frontend/src/scripts/achievements.ts new file mode 100644 index 0000000000..8f484f8925 --- /dev/null +++ b/packages/frontend/src/scripts/achievements.ts @@ -0,0 +1,449 @@ +import * as os from '@/os'; +import { $i } from '@/account'; + +export const ACHIEVEMENT_TYPES = [ + 'notes1', + 'notes10', + 'notes100', + 'notes500', + 'notes1000', + 'notes5000', + 'notes10000', + 'notes20000', + 'notes30000', + 'notes40000', + 'notes50000', + 'notes60000', + 'notes70000', + 'notes80000', + 'notes90000', + 'notes100000', + 'login3', + 'login7', + 'login15', + 'login30', + 'login60', + 'login100', + 'login200', + 'login300', + 'login400', + 'login500', + 'login600', + 'login700', + 'login800', + 'login900', + 'login1000', + 'passedSinceAccountCreated1', + 'passedSinceAccountCreated2', + 'passedSinceAccountCreated3', + 'loggedInOnBirthday', + 'loggedInOnNewYearsDay', + 'noteClipped1', + 'noteFavorited1', + 'profileFilled', + 'markedAsCat', + 'following1', + 'following10', + 'following50', + 'following100', + 'following300', + 'followers1', + 'followers10', + 'followers50', + 'followers100', + 'followers300', + 'followers500', + 'followers1000', + 'collectAchievements30', + 'viewAchievements3min', + 'iLoveMisskey', + 'client30min', + 'noteDeletedWithin1min', + 'postedAtLateNight', + 'postedAt0min0sec', + 'selfQuote', + 'htl20npm', + 'outputHelloWorldOnScratchpad', + 'open3windows', + 'driveFolderCircularReference', + 'reactWithoutRead', + 'clickedClickHere', + 'justPlainLucky', + 'setNameToSyuilo', + 'cookieClicked', + 'brainDiver', +] as const; + +export const ACHIEVEMENT_BADGES = { + 'notes1': { + img: '/fluent-emoji/1f4dd.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'notes10': { + img: '/fluent-emoji/1f4d1.png', + bg: null, + frame: 'bronze', + }, + 'notes100': { + img: '/fluent-emoji/1f4d2.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'notes500': { + img: '/fluent-emoji/1f4da.png', + bg: null, + frame: 'bronze', + }, + 'notes1000': { + img: '/fluent-emoji/1f5c3.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'notes5000': { + img: '/fluent-emoji/1f304.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'notes10000': { + img: '/fluent-emoji/1f3d9.png', + bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', + frame: 'silver', + }, + 'notes20000': { + img: '/fluent-emoji/1f307.png', + bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', + frame: 'silver', + }, + 'notes30000': { + img: '/fluent-emoji/1f306.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'silver', + }, + 'notes40000': { + img: '/fluent-emoji/1f303.png', + bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))', + frame: 'silver', + }, + 'notes50000': { + img: '/fluent-emoji/1fa90.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'gold', + }, + 'notes60000': { + img: '/fluent-emoji/2604.png', + bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))', + frame: 'gold', + }, + 'notes70000': { + img: '/fluent-emoji/1f30c.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'gold', + }, + 'notes80000': { + img: '/fluent-emoji/1f30c.png', + bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))', + frame: 'gold', + }, + 'notes90000': { + img: '/fluent-emoji/1f30c.png', + bg: 'linear-gradient(0deg, rgb(255 232 119), rgb(255 140 41))', + frame: 'gold', + }, + 'notes100000': { + img: '/fluent-emoji/267e.png', + bg: 'linear-gradient(0deg, rgb(255 232 119), rgb(255 140 41))', + frame: 'platinum', + }, + 'login3': { + img: '/fluent-emoji/1f331.png', + bg: null, + frame: 'bronze', + }, + 'login7': { + img: '/fluent-emoji/1f331.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'login15': { + img: '/fluent-emoji/1f331.png', + bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))', + frame: 'bronze', + }, + 'login30': { + img: '/fluent-emoji/1fab4.png', + bg: null, + frame: 'bronze', + }, + 'login60': { + img: '/fluent-emoji/1fab4.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'login100': { + img: '/fluent-emoji/1fab4.png', + bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))', + frame: 'silver', + }, + 'login200': { + img: '/fluent-emoji/1f333.png', + bg: null, + frame: 'silver', + }, + 'login300': { + img: '/fluent-emoji/1f333.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'silver', + }, + 'login400': { + img: '/fluent-emoji/1f333.png', + bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))', + frame: 'silver', + }, + 'login500': { + img: '/fluent-emoji/1f304.png', + bg: null, + frame: 'silver', + }, + 'login600': { + img: '/fluent-emoji/1f304.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'gold', + }, + 'login700': { + img: '/fluent-emoji/1f304.png', + bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))', + frame: 'gold', + }, + 'login800': { + img: '/fluent-emoji/1f307.png', + bg: null, + frame: 'gold', + }, + 'login900': { + img: '/fluent-emoji/1f307.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'gold', + }, + 'login1000': { + img: '/fluent-emoji/1f307.png', + bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))', + frame: 'platinum', + }, + 'noteClipped1': { + img: '/fluent-emoji/1f587.png', + bg: null, + frame: 'bronze', + }, + 'noteFavorited1': { + img: '/fluent-emoji/1f31f.png', + bg: null, + frame: 'bronze', + }, + 'profileFilled': { + img: '/fluent-emoji/1f44c.png', + bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))', + frame: 'bronze', + }, + 'markedAsCat': { + img: '/fluent-emoji/1f408.png', + bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))', + frame: 'bronze', + }, + 'following1': { + img: '/fluent-emoji/2618.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'following10': { + img: '/fluent-emoji/1f6b8.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'following50': { + img: '/fluent-emoji/1f91d.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'following100': { + img: '/fluent-emoji/1f4af.png', + bg: 'linear-gradient(0deg, rgb(255 53 184), rgb(255 206 69))', + frame: 'silver', + }, + 'following300': { + img: '/fluent-emoji/1f970.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'silver', + }, + 'followers1': { + img: '/fluent-emoji/2618.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'followers10': { + img: '/fluent-emoji/1f44b.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'followers50': { + img: '/fluent-emoji/1f411.png', + bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', + frame: 'bronze', + }, + 'followers100': { + img: '/fluent-emoji/1f60e.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'silver', + }, + 'followers300': { + img: '/fluent-emoji/1f3c6.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'silver', + }, + 'followers500': { + img: '/fluent-emoji/1f4e1.png', + bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', + frame: 'gold', + }, + 'followers1000': { + img: '/fluent-emoji/1f451.png', + bg: 'linear-gradient(0deg, rgb(255 232 119), rgb(255 140 41))', + frame: 'platinum', + }, + 'collectAchievements30': { + img: '/fluent-emoji/1f3c5.png', + bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))', + frame: 'silver', + }, + 'viewAchievements3min': { + img: '/fluent-emoji/1f3c5.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'bronze', + }, + 'iLoveMisskey': { + img: '/fluent-emoji/2764.png', + bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))', + frame: 'silver', + }, + 'client30min': { + img: '/fluent-emoji/1f552.png', + bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', + frame: 'bronze', + }, + 'noteDeletedWithin1min': { + img: '/fluent-emoji/1f5d1.png', + bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', + frame: 'bronze', + }, + 'postedAtLateNight': { + img: '/fluent-emoji/1f319.png', + bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))', + frame: 'bronze', + }, + 'postedAt0min0sec': { + img: '/fluent-emoji/1f55b.png', + bg: 'linear-gradient(0deg, rgb(58 231 198), rgb(37 194 255))', + frame: 'bronze', + }, + 'selfQuote': { + img: '/fluent-emoji/1f4dd.png', + bg: null, + frame: 'bronze', + }, + 'htl20npm': { + img: '/fluent-emoji/1f30a.png', + bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', + frame: 'bronze', + }, + 'outputHelloWorldOnScratchpad': { + img: '/fluent-emoji/1f530.png', + bg: 'linear-gradient(0deg, rgb(58 231 198), rgb(37 194 255))', + frame: 'bronze', + }, + 'open3windows': { + img: '/fluent-emoji/1f5a5.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'bronze', + }, + 'driveFolderCircularReference': { + img: '/fluent-emoji/1f4c2.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'bronze', + }, + 'reactWithoutRead': { + img: '/fluent-emoji/2753.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'bronze', + }, + 'clickedClickHere': { + img: '/fluent-emoji/2757.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'bronze', + }, + 'justPlainLucky': { + img: '/fluent-emoji/1f340.png', + bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))', + frame: 'silver', + }, + 'setNameToSyuilo': { + img: '/fluent-emoji/1f36e.png', + bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))', + frame: 'bronze', + }, + 'passedSinceAccountCreated1': { + img: '/fluent-emoji/0031-20e3.png', + bg: null, + frame: 'bronze', + }, + 'passedSinceAccountCreated2': { + img: '/fluent-emoji/0032-20e3.png', + bg: null, + frame: 'silver', + }, + 'passedSinceAccountCreated3': { + img: '/fluent-emoji/0033-20e3.png', + bg: null, + frame: 'gold', + }, + 'loggedInOnBirthday': { + img: '/fluent-emoji/1f382.png', + bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))', + frame: 'silver', + }, + 'loggedInOnNewYearsDay': { + img: '/fluent-emoji/1f38d.png', + bg: 'linear-gradient(0deg, rgb(255 144 144), rgb(255 232 168))', + frame: 'silver', + }, + 'cookieClicked': { + img: '/fluent-emoji/1f36a.png', + bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))', + frame: 'bronze', + }, + 'brainDiver': { + img: '/fluent-emoji/1f9e0.png', + bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))', + frame: 'bronze', + }, +} as const satisfies Record<typeof ACHIEVEMENT_TYPES[number], { + img: string; + bg: string | null; + frame: 'bronze' | 'silver' | 'gold' | 'platinum'; +}>; + +export const claimedAchievements = ($i && $i.achievements) ? $i.achievements.map(x => x.name) : []; + +export function claimAchievement(type: typeof ACHIEVEMENT_TYPES[number]) { + if (claimedAchievements.includes(type)) return; + os.api('i/claim-achievement', { name: type }); + claimedAchievements.push(type); +} + +if (_DEV_) { + (window as any).unlockAllAchievements = async () => { + for (const t of ACHIEVEMENT_TYPES) { + await new Promise(resolve => setTimeout(resolve, 100)); + claimAchievement(t); + } + }; +} diff --git a/packages/frontend/src/scripts/aiscript/ui.ts b/packages/frontend/src/scripts/aiscript/ui.ts index 2555cd391b..b1895a5f33 100644 --- a/packages/frontend/src/scripts/aiscript/ui.ts +++ b/packages/frontend/src/scripts/aiscript/ui.ts @@ -50,6 +50,7 @@ export type AsUiButton = AsUiComponentBase & { onClick?: () => void; primary?: boolean; rounded?: boolean; + disabled?: boolean; }; export type AsUiButtons = AsUiComponentBase & { @@ -302,6 +303,8 @@ function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, if (primary) utils.assertBoolean(primary); const rounded = def.value.get('rounded'); if (rounded) utils.assertBoolean(rounded); + const disabled = button.value.get('disabled'); + if (disabled) utils.assertBoolean(disabled); return { text: text?.value, @@ -310,6 +313,7 @@ function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, }, primary: primary?.value, rounded: rounded?.value, + disabled: disabled?.value, }; } @@ -330,6 +334,8 @@ function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, if (primary) utils.assertBoolean(primary); const rounded = button.value.get('rounded'); if (rounded) utils.assertBoolean(rounded); + const disabled = button.value.get('disabled'); + if (disabled) utils.assertBoolean(disabled); return { text: text.value, @@ -338,6 +344,7 @@ function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, }, primary: primary?.value, rounded: rounded?.value, + disabled: disabled?.value, }; }) : [], }; diff --git a/packages/frontend/src/scripts/api.ts b/packages/frontend/src/scripts/api.ts index f9fd11f069..5f34f5333e 100644 --- a/packages/frontend/src/scripts/api.ts +++ b/packages/frontend/src/scripts/api.ts @@ -45,7 +45,7 @@ export function api<E extends keyof Endpoints, P extends Endpoints[E]['req']>(en } // Implements Misskey.api.ApiClient.request -export function apiGet<E extends keyof Endpoints, P extends Endpoints[E]['req']>(endpoint: E, data: P = {} as any): Promise<Endpoints[E]['res']> { +export function apiGet <E extends keyof Endpoints, P extends Endpoints[E]['req']>(endpoint: E, data: P = {} as any): Promise<Endpoints[E]['res']> { pendingApiRequestsCount.value++; const onFinally = () => { diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts index 54de3d95df..b5d2251d28 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -1,6 +1,7 @@ import { defineAsyncComponent, Ref, inject } from 'vue'; import * as misskey from 'misskey-js'; import { pleaseLogin } from './please-login'; +import { claimAchievement } from './achievements'; import { $i } from '@/account'; import { i18n } from '@/i18n'; import { instance } from '@/instance'; @@ -38,6 +39,10 @@ export function getNoteMenu(props: { os.api('notes/delete', { noteId: appearNote.id, }); + + if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60) { + claimAchievement('noteDeletedWithin1min'); + } }); } @@ -53,10 +58,15 @@ export function getNoteMenu(props: { }); os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel }); + + if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60) { + claimAchievement('noteDeletedWithin1min'); + } }); } function toggleFavorite(favorite: boolean): void { + claimAchievement('noteFavorited1'); os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { noteId: appearNote.id, }); @@ -118,11 +128,13 @@ export function getNoteMenu(props: { const clip = await os.apiWithDialog('clips/create', result); + claimAchievement('noteClipped1'); os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id }); }, }, null, ...clips.map(clip => ({ text: clip.name, action: () => { + claimAchievement('noteClipped1'); os.promiseDialog( os.api('clips/add-note', { clipId: clip.id, noteId: appearNote.id }), null, @@ -174,9 +186,17 @@ export function getNoteMenu(props: { url: `${url}/notes/${appearNote.id}`, }); } - function notedetails(): void { + + function openDetail(): void { os.pageWindow(`/notes/${appearNote.id}`); } + + function showReactions(): void { + os.popup(defineAsyncComponent(() => import('@/components/MkReactedUsersDialog.vue')), { + noteId: appearNote.id, + }, {}, 'closed'); + } + async function translate(): Promise<void> { if (props.translation.value != null) return; props.translating.value = true; @@ -205,7 +225,11 @@ export function getNoteMenu(props: { ), { icon: 'ti ti-info-circle', text: i18n.ts.details, - action: notedetails, + action: openDetail, + }, { + icon: 'ti ti-users', + text: i18n.ts.reactions, + action: showReactions, }, { icon: 'ti ti-copy', text: i18n.ts.copyContent, diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index ba82eda609..0bf35ec1b4 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -97,6 +97,7 @@ export default defineConfig(({ command, mode }) => { output: { manualChunks: { vue: ['vue'], + photoswipe: ['photoswipe', 'photoswipe/lightbox', 'photoswipe/style.css'], }, }, }, |