From d2a86713af95e338dbad32a7861a062cb4aa5cdc Mon Sep 17 00:00:00 2001 From: Crimekillz Date: Sat, 6 Apr 2024 19:13:08 +0200 Subject: [PATCH] Achievements 1.5/2 --- docs/examples/docker-compose.yml | 2 +- locales/en-US.yml | 278 +++++++++--------- .../migration/1705528046452-federated-bite.ts | 8 +- .../src/models/entities/notification.ts | 5 + .../src/models/entities/user-profile.ts | 13 + .../src/models/repositories/notification.ts | 5 + .../backend/src/models/repositories/user.ts | 8 +- .../backend/src/models/schema/notification.ts | 5 + packages/backend/src/server/api/endpoints.ts | 4 + .../backend/src/server/api/endpoints/i.ts | 24 +- .../api/endpoints/i/claim-achievement.ts | 18 ++ .../api/endpoints/users/achievements.ts | 22 ++ .../src/services/achievement-service.ts | 96 ++++++ .../client/src/components/MkAchievements.vue | 38 +-- packages/client/src/components/MkPostForm.vue | 2 +- .../client/src/pages/settings/profile.vue | 2 +- packages/client/src/scripts/achievements.ts | 132 ++++----- 17 files changed, 425 insertions(+), 237 deletions(-) create mode 100644 packages/backend/src/server/api/endpoints/i/claim-achievement.ts create mode 100644 packages/backend/src/server/api/endpoints/users/achievements.ts create mode 100644 packages/backend/src/services/achievement-service.ts diff --git a/docs/examples/docker-compose.yml b/docs/examples/docker-compose.yml index 8a72b7c25..6e3cf7730 100644 --- a/docs/examples/docker-compose.yml +++ b/docs/examples/docker-compose.yml @@ -2,7 +2,7 @@ version: "3" services: web: - image: iceshrimp.dev/Crimekillz/trashposs:dev + image: iceshrimp.dev/crimekillz/trashposs:dev ### If you want to build the image locally # build: . ### If you want to build the image locally AND use Docker 20.10 diff --git a/locales/en-US.yml b/locales/en-US.yml index 3e2568a56..eef60b526 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1140,218 +1140,218 @@ bitYou: "chomped you" bitYouBack: "chomped you back" achievements: "Achievements" _achievements: - earnedAt: "獲得日時" + earnedAt: "Earn date and time" _types: _notes1: title: "Just set up my TrashPoss account" description: "This is my first post" flavor: "Screm into the void!" _notes10: - title: "いくつかのノート" - description: "ノートを10回投稿した" + title: "Some Posts" + description: "Posted 10 times" _notes100: - title: "たくさんのノート" - description: "ノートを100回投稿した" + title: "Lots of Posts" + description: "Posted 100 times" _notes500: - title: "ノートまみれ" - description: "ノートを500回投稿した" + title: "Post Covered" + description: "Posted 500 times" _notes1000: - title: "ノートの山" - description: "ノートを1,000回投稿した" + title: "Pile of Posts" + description: "Posted 1,000 times" _notes5000: - title: "湧き出るノート" - description: "ノートを5,000回投稿した" + title: "Gushing Post" + description: "Posted 5,000 times" _notes10000: - title: "スーパーノート" - description: "ノートを10,000回投稿した" + title: "Super Post" + description: "Posted 10,000 times" _notes20000: - title: "ニードモアノート" - description: "ノートを20,000回投稿した" + title: "Need More Posts" + description: "Posted 20,000 times" _notes30000: - title: "ノートノートノート" - description: "ノートを30,000回投稿した" + title: "Post Post Post" + description: "Posted 30,000 times" _notes40000: - title: "ノート工場" - description: "ノートを40,000回投稿した" + title: "Post Factory" + description: "Posted 40,000 times" _notes50000: - title: "ノートの惑星" - description: "ノートを50,000回投稿した" + title: "Planet of Posts" + description: "Posted 50,000 times" _notes60000: - title: "ノートクエーサー" - description: "ノートを60,000回投稿した" + title: "Post Quasar" + description: "Posted 60,000 times" _notes70000: - title: "ブラックノートホール" - description: "ノートを70,000回投稿した" + title: "Black Post Hole" + description: "Posted 70,000 times" _notes80000: - title: "ノートギャラクシー" - description: "ノートを80,000回投稿した" + title: "Post Galaxy" + description: "Posted 80,000 times" _notes90000: - title: "ノートバース" - description: "ノートを90,000回投稿した" + title: "Postiverse" + description: "Posted 90,000 times" _notes100000: title: "ALL YOUR POSTS ARE BELONG TO US" - description: "ノートを100,000回投稿した" - flavor: "そんなに書くことある?" + description: "Posted 100,000 times" + flavor: "Do you have much to write about?" _login3: - title: "ビギナーⅠ" - description: "通算ログイン日数が3日" - flavor: "今日からね僕は ミスキストってことで" + title: "Beginner I" + description: "Total login days are 3 days" + flavor: "From today onwards, I think they're a Misquist." _login7: - title: "ビギナーⅡ" - description: "通算ログイン日数が7日" - flavor: "慣れてきましたか?" + title: "Beginner II" + description: "Total login days are 7 days" + flavor: "Are you getting used to it?" _login15: - title: "ビギナーⅢ" - description: "通算ログイン日数が15日" + title: "Beginner III" + description: "Total number of login days is 15" _login30: - title: "ミスキストⅠ" - description: "通算ログイン日数が30日" + title: "Miscist I" + description: "Total login days are 30 days" _login60: - title: "ミスキストⅡ" - description: "通算ログイン日数が60日" + title: "Miscist II" + description: "Total number of login days is 60" _login100: - title: "ミスキストⅢ" - description: "通算ログイン日数が100日" - flavor: "そのユーザー、ミスキストにつき" + title: "Miscist III" + description: "Total number of login days is 100" + flavor: "For that user, Misquist" _login200: - title: "常連Ⅰ" - description: "通算ログイン日数が200日" + title: "Regular I" + description: "Total number of login days is 200" _login300: - title: "常連Ⅱ" - description: "通算ログイン日数が300日" + title: "Regular II" + description: "Total number of login days is 300" _login400: - title: "常連Ⅲ" - description: "通算ログイン日数が400日" + title: "Regular III" + description: "Total number of login days is 400" _login500: - title: "ベテランⅠ" - description: "通算ログイン日数が500日" - flavor: "諸君、私はノートが好きだ" + title: "Veteran I" + description: "Total number of login days is 500" + flavor: "Well, I like posts..." _login600: - title: "ベテランⅡ" - description: "通算ログイン日数が600日" + title: "Veteran II" + description: "Total number of login days is 600" _login700: - title: "ベテランⅢ" - description: "通算ログイン日数が700日" + title: "Veteran III" + description: "Total number of login days is 700" _login800: - title: "ノートマスターⅠ" - description: "通算ログイン日数が800日" + title: "Post Master I" + description: "Total number of login days is 800" _login900: - title: "ノートマスターⅡ" - description: "通算ログイン日数が900日" + title: "Post Master II" + description: "Total number of login days is 900" _login1000: - title: "ノートマスターⅢ" - description: "通算ログイン日数が1,000日" - flavor: "Misskeyを使ってくれてありがとう!" + title: "Post Master III" + description: "Total number of login days is 1,000" + flavor: "Thank you for using TrashPoss!" _noteClipped1: - title: "クリップせずにはいられないな" - description: "初めてノートをクリップした" + title: "I can't help but clip" + description: "Clipped a post for the first time" _noteFavorited1: - title: "星をみるひと" - description: "初めてノートをお気に入りに登録した" + title: "People who look at the stars" + description: "I registered a note as a favorite for the first time" _profileFilled: - title: "準備万端" - description: "プロフィール設定を行った" + title: "Ready to go" + description: "Profile settings were made" _markedAsCat: - title: "吾輩は猫である" - description: "アカウントをCatとして設定した" - flavor: "名前はまだない。" + title: "I am a kitty" + description: "Account set up in Cat-mode" + flavor: "Please name me :3" _following1: - title: "はじめてのフォロー" - description: "初めてフォローした" + title: "First follow" + description: "First time following" _following10: - title: "ついてく、ついてく" - description: "フォローが10人を超した" + title: "Follow me, follow me" + description: "More than 10 followers" _following50: - title: "友達たくさん" - description: "フォローが50人を超した" + title: "Lots of friends" + description: "Following exceeded 50 people" _following100: - title: "友達100人" - description: "フォローが100人を超した" + title: "100 Friends" + description: "Following exceeded 100 people" _following300: - title: "友達過多" - description: "フォローが300人を超した" + title: "Too many friends" + description: "Over 300 followers" _followers1: - title: "はじめてのフォロワー" - description: "初めてフォローされた" + title: "First Follower" + description: "First followed" _followers10: - title: "フォローミー!" - description: "フォロワーが10人を超した" + title: "Follow me!" + description: "More than 10 followers" _followers50: - title: "ぞろぞろ" - description: "フォロワーが50人を超した" + title: "Zorozoro" + description: "Over 50 followers" _followers100: - title: "人気者" - description: "フォロワーが100人を超した" + title: "Popular" + description: "Over 100 followers" _followers300: - title: "一列でお並びください" - description: "フォロワーが300人を超した" + title: "Please stand in line" + description: "Over 300 followers" _followers500: - title: "基地局" - description: "フォロワーが500人を超した" + title: "Base Station" + description: "Over 500 followers" _followers1000: - title: "インフルエンサー" - description: "フォロワーが1,000人を超した" + title: "Influencer" + description: "Over 1,000 followers" _collectAchievements30: - title: "実績コレクター" - description: "実績を30個以上獲得した" + title: "Achievement Collector" + description: "Obtained 30 or more achievements" _iLoveMisskey: title: "I Love TrashPoss" - description: "\"I ❤ #TrashPoss\"を投稿した" - flavor: "Misskeyを使ってくださりありがとうございます! by 開発チーム" + description: "I posted \"I ❤ #TrashPoss\"" + flavor: "Thank you for using TrashPoss! by Development Team" _client30min: - title: "ひとやすみ" - description: "クライアントを起動してから30分以上経過した" + title: "Take a break" + description: "More than 30 minutes have passed since the client was started" _noteDeletedWithin1min: - title: "いまのなし" - description: "投稿してから1分以内にその投稿を削除した" + title: "Now Nothing" + description: "The post was deleted within 1 minute of posting" _postedAtLateNight: - title: "夜行性" - description: "深夜にノートを投稿した" - flavor: "そろそろ寝よう。" + title: "Nocturnal" + description: "Posted a note late at night" + flavor: "Let's go to sleep." _postedAt0min0sec: - title: "時報" - description: "0分0秒にノートを投稿した" - flavor: "ポッ ポッ ポッ ピーン" + title: "Time signal" + description: "Posted a note at 0 minutes 0 seconds" + flavor: "Pop Pop Pop Peen" _selfQuote: - title: "自己言及" - description: "自分のノートを引用した" + title: "Self-reference" + description: "Quoted from my own notes" _htl20npm: - title: "流れるTL" - description: "ホームタイムラインの流速が20npmを越す" + title: "Flowing TL" + description: "Home timeline flow rate exceeds 20npm" _driveFolderCircularReference: - title: "循環参照" - description: "ドライブのフォルダを再帰的な入れ子にしようとした" + title: "Circular Reference" + description: "Attempted to recursively nest drive folders" _reactWithoutRead: - title: "ちゃんと読んだ?" - description: "100文字以上のテキストを含むノートに投稿されてから3秒以内にリアクションした" + title: "Did you read it properly?" + description: "Reacted within 3 seconds of being posted to a note containing more than 100 characters of text." _clickedClickHere: - title: "ここをクリック" - description: "ここをクリックした" + title: "Click here" + description: "Clicked here" _justPlainLucky: - title: "単なるラッキー" - description: "10秒ごとに0.01%の確率で獲得" + title: "Just Lucky" + description: "Obtained every 10 seconds with a 0.01% chance" _setNameToSyuilo: - title: "神様コンプレックス" - description: "名前を syuilo に設定した" + title: "Poss gang, Poss gang" + description: "Name set to Crimekillz" _passedSinceAccountCreated1: - title: "一周年" - description: "アカウント作成から1年経過した" + title: "One Year Anniversary" + description: "One year has passed since account creation" _passedSinceAccountCreated2: - title: "二周年" - description: "アカウント作成から2年経過した" + title: "Second Anniversary" + description: "2 years have passed since account creation" _passedSinceAccountCreated3: - title: "三周年" - description: "アカウント作成から3年経過した" + title: "Third Anniversary" + description: "3 years have passed since account creation" _loggedInOnBirthday: - title: "ハッピーバースデー" - description: "誕生日にログインした" + title: "Happy Birthday" + description: "Logged in on my birthday" _cookieClicked: - title: "クッキーをクリックするゲーム" - description: "クッキーをクリックした" - flavor: "ソフト間違ってない?" + title: "Cookie Clicking Game" + description: "You clicked on the cookie" + flavor: "Isn't the software wrong?" _brainDiver: title: "Brain Diver" - description: "Brain Diverへのリンクを投稿した" + description: "Posted a link to Brain Diver" flavor: "TrashPoss-TrashPoss La-Tu-Ma" _sensitiveMediaDetection: description: "Reduces the effort of server moderation through automatically recognizing diff --git a/packages/backend/src/migration/1705528046452-federated-bite.ts b/packages/backend/src/migration/1705528046452-federated-bite.ts index 90d31c5b0..eb969470b 100644 --- a/packages/backend/src/migration/1705528046452-federated-bite.ts +++ b/packages/backend/src/migration/1705528046452-federated-bite.ts @@ -8,7 +8,7 @@ export class FederatedBite1705528046452 implements MigrationInterface { await queryRunner.query(`CREATE TABLE "bite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "uri" character varying(512), "userId" character varying(32) NOT NULL, "targetType" "public"."bite_targettype_enum" NOT NULL, "targetUserId" character varying(32), "targetBiteId" character varying(32), "replied" boolean NOT NULL DEFAULT true, CONSTRAINT "CHK_c3a20c5756ccff3133f8927500" CHECK ("targetUserId" IS NOT NULL OR "targetBiteId" IS NOT NULL), CONSTRAINT "PK_1887f3f621a4a7655a1b78bfd66" PRIMARY KEY ("id")); COMMENT ON COLUMN "bite"."uri" IS 'null if local'`); await queryRunner.query(`ALTER TABLE "notification" ADD "biteId" character varying(32)`); 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', 'app', 'bite')`); + 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', 'bite')`); 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 '{}'`); @@ -18,7 +18,7 @@ export class FederatedBite1705528046452 implements MigrationInterface { await queryRunner.query(`ALTER TABLE "bite" ADD CONSTRAINT "FK_5d5f68610583f2e0b6785d3c0e9" FOREIGN KEY ("targetBiteId") REFERENCES "bite"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); await queryRunner.query(`ALTER TABLE "notification" ADD CONSTRAINT "FK_c54844158c1eead7042e7ca4c83" FOREIGN KEY ("biteId") REFERENCES "bite"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); 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', 'app', 'bite')`); + await queryRunner.query(`CREATE TYPE "public"."notification_type_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'achievementEarned', 'app', 'bite')`); 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"`); } @@ -28,7 +28,7 @@ export class FederatedBite1705528046452 implements MigrationInterface { await queryRunner.query(`ALTER TABLE "bite" DROP CONSTRAINT "FK_5d5f68610583f2e0b6785d3c0e9"`); await queryRunner.query(`ALTER TABLE "bite" DROP CONSTRAINT "FK_a646fbbeb6efa2531c75fec46b9"`); await queryRunner.query(`ALTER TABLE "bite" DROP CONSTRAINT "FK_8d00aa79e157364ac1f60c15098"`); - await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`); + await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum_old" 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_old"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum_old"[]`); await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`); @@ -37,7 +37,7 @@ export class FederatedBite1705528046452 implements MigrationInterface { await queryRunner.query(`ALTER TABLE "notification" DROP COLUMN "biteId"`); await queryRunner.query(`DROP TABLE "bite"`); await queryRunner.query(`DROP TYPE "public"."bite_targettype_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(`CREATE TYPE "public"."notification_type_enum_old" 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_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"`); diff --git a/packages/backend/src/models/entities/notification.ts b/packages/backend/src/models/entities/notification.ts index f8619a655..4d30fecf8 100644 --- a/packages/backend/src/models/entities/notification.ts +++ b/packages/backend/src/models/entities/notification.ts @@ -138,6 +138,11 @@ export class Notification { }) public choice: number | null; + @Column('varchar', { + length: 128, nullable: true, + }) + public achievement: string | null; + /** * App notification body */ diff --git a/packages/backend/src/models/entities/user-profile.ts b/packages/backend/src/models/entities/user-profile.ts index f53360862..64b4448e6 100644 --- a/packages/backend/src/models/entities/user-profile.ts +++ b/packages/backend/src/models/entities/user-profile.ts @@ -239,6 +239,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/models/repositories/notification.ts b/packages/backend/src/models/repositories/notification.ts index 79526fe10..f33ae2f9f 100644 --- a/packages/backend/src/models/repositories/notification.ts +++ b/packages/backend/src/models/repositories/notification.ts @@ -149,6 +149,11 @@ export const NotificationRepository = db.getRepository(Notification).extend({ bite: notification.bite ?? await Bites.findOneBy({ id: notification.biteId! }), } : {}), + ...(notification.type === "achievementEarned" + ? { + achievement: notification.achievement, + } + : {}), }); }, diff --git a/packages/backend/src/models/repositories/user.ts b/packages/backend/src/models/repositories/user.ts index 37a84779a..a71465a17 100644 --- a/packages/backend/src/models/repositories/user.ts +++ b/packages/backend/src/models/repositories/user.ts @@ -37,6 +37,7 @@ import { } from "../index.js"; import type { Instance } from "../entities/instance.js"; import AsyncLock from "async-lock"; +import { UserProfile } from "../entities/user-profile.js"; const userInstanceCache = new Cache( "userInstance", @@ -412,6 +413,7 @@ export const UserRepository = db.getRepository(User).extend({ detail?: D; includeSecrets?: boolean; isPrivateMode?: boolean; + userProfile?: UserProfile, }, ): Promise> { const opts = Object.assign( @@ -447,9 +449,7 @@ export const UserRepository = db.getRepository(User).extend({ .orderBy("pin.id", "DESC") .getMany() : []; - const profile = opts.detail - ? await UserProfiles.findOneByOrFail({ userId: user.id }) - : null; + const profile = opts.detail ? (opts.userProfile ?? await UserProfiles.findOneByOrFail({ userId: user.id })) : null; const followingCount = profile == null @@ -625,6 +625,8 @@ export const UserRepository = db.getRepository(User).extend({ mutedInstances: profile!.mutedInstances, mutingNotificationTypes: profile!.mutingNotificationTypes, emailNotificationTypes: profile!.emailNotificationTypes, + achievements: profile!.achievements, + loggedInDays: profile!.loggedInDates.length, } : {}), diff --git a/packages/backend/src/models/schema/notification.ts b/packages/backend/src/models/schema/notification.ts index cb6bc47fe..f53f954ad 100644 --- a/packages/backend/src/models/schema/notification.ts +++ b/packages/backend/src/models/schema/notification.ts @@ -81,5 +81,10 @@ export const packedNotificationSchema = { optional: true, nullable: true, }, + achievement: { + type: "object", + optional: true, + nullable: true, + }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index bf12e4f83..d7e484207 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.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"; @@ -332,6 +333,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___admin_driveCapOverride from "./endpoints/admin/drive-capacity-override.js"; import * as ep___bites_create from "./endpoints/bites/create.js"; @@ -525,6 +527,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], @@ -681,6 +684,7 @@ const eps = [ ["users/search", ep___users_search], ["users/show", ep___users_show], ["users/stats", ep___users_stats], + ["users/achievements", ep___users_achievements], ["admin/drive-capacity-override", ep___admin_driveCapOverride], ["fetch-rss", ep___fetchRss], ["get-sounds", ep___sounds], diff --git a/packages/backend/src/server/api/endpoints/i.ts b/packages/backend/src/server/api/endpoints/i.ts index 39543442c..ac7675a81 100644 --- a/packages/backend/src/server/api/endpoints/i.ts +++ b/packages/backend/src/server/api/endpoints/i.ts @@ -1,4 +1,4 @@ -import { Users } from "@/models/index.js"; +import { UserProfiles, Users } from "@/models/index.js"; import define from "../define.js"; export const meta = { @@ -23,9 +23,27 @@ export const paramDef = { export default define(meta, paramDef, async (ps, user, token) => { const isSecure = token == null; - // ここで渡ってきている user はキャッシュされていて古い可能性もあるので id だけ渡す - return await Users.pack(user.id, user, { + const now = new Date(); + const today = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`; + + // 渡ってきている user はキャッシュされていて古い可能性があるので改めて取得 + const userProfile = await UserProfiles.findOneOrFail({ + where: { + userId: user.id, + }, + relations: ['user'], + }); + + if (!userProfile.loggedInDates.includes(today)) { + UserProfiles.update({ userId: user.id }, { + loggedInDates: [...userProfile.loggedInDates, today], + }); + userProfile.loggedInDates = [...userProfile.loggedInDates, today]; + } + + return await Users.pack(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 000000000..b0970b3fd --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts @@ -0,0 +1,18 @@ +import { createAchievement } from '@/services/achievement-service.js'; +import define from "../../define.js"; + +export const meta = { + requireCredential: true, +} as const; + +export const paramDef = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + await createAchievement(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 000000000..3b4c07602 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/achievements.ts @@ -0,0 +1,22 @@ +import { UserProfiles } from '@/models/index.js'; +import define from "../../define.js"; + +export const meta = { + tags: ["users", "achievements"], + requireCredential: true, + description: "Show all achievements this user made.", +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const profile = await UserProfiles.findOneByOrFail({ userId: ps.userId }); + + return profile.achievements; +}); diff --git a/packages/backend/src/services/achievement-service.ts b/packages/backend/src/services/achievement-service.ts new file mode 100644 index 000000000..9cb6b828e --- /dev/null +++ b/packages/backend/src/services/achievement-service.ts @@ -0,0 +1,96 @@ +import { UserProfiles, Users } from '@/models/index.js'; +import type { User } from '@/models/entities/user.js'; +import { createNotification } from '@/services/create-notification.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', + 'noteClipped1', + 'noteFavorited1', + 'profileFilled', + 'markedAsCat', + 'following1', + 'following10', + 'following50', + 'following100', + 'following300', + 'followers1', + 'followers10', + 'followers50', + 'followers100', + 'followers300', + 'followers500', + 'followers1000', + 'collectAchievements30', + 'iLoveMisskey', + 'client30min', + 'noteDeletedWithin1min', + 'postedAtLateNight', + 'postedAt0min0sec', + 'selfQuote', + 'htl20npm', + 'driveFolderCircularReference', + 'reactWithoutRead', + 'clickedClickHere', + 'justPlainLucky', + 'setNameToSyuilo', + 'cookieClicked', + 'brainDiver', +] as const; + +export async function createAchievement( + userId: User['id'], + type: string, +) { + if (!ACHIEVEMENT_TYPES.includes(type)) return; + + const date = Date.now(); + + const profile = await UserProfiles.findOneByOrFail({ userId: userId }); + + if (profile.achievements.some(a => a.name === type)) return; + + await UserProfiles.update(userId, { + achievements: [...profile.achievements, { + name: type, + unlockedAt: date, + }], + }); + + createNotification(userId, 'achievementEarned', { + achievement: type, + }); +} diff --git a/packages/client/src/components/MkAchievements.vue b/packages/client/src/components/MkAchievements.vue index 9d4bce5cf..2b8d38657 100644 --- a/packages/client/src/components/MkAchievements.vue +++ b/packages/client/src/components/MkAchievements.vue @@ -1,34 +1,34 @@