From ea2d7091163752fe138dbf887ca243edbc25ab0b Mon Sep 17 00:00:00 2001 From: Crimekillz Date: Tue, 26 Mar 2024 12:40:21 +0100 Subject: [PATCH] Add Achievements Part 1/2, code by syuilo, Syuilotan@yahoo.co.jp Signed-off-by: limepotato --- .config/LICENSE | 1 + locales/ja-JP.yml | 217 +++++++++ .../migration/1674118260469-achievement.js | 33 ++ .../migration/1674255666603-loggedInDates.js | 11 + packages/backend/src/types.ts | 1 + packages/client/src/account.ts | 5 + .../client/src/components/MkAchievements.vue | 224 +++++++++ .../client/src/components/MkDrive.folder.vue | 7 +- packages/client/src/components/MkDrive.vue | 7 +- .../client/src/components/MkFollowButton.vue | 16 + packages/client/src/components/MkNote.vue | 4 + .../client/src/components/MkNoteDetailed.vue | 4 + .../client/src/components/MkNotification.vue | 24 + packages/client/src/components/MkPostForm.vue | 30 ++ .../components/MkReactionsViewer.reaction.vue | 4 + packages/client/src/init.ts | 77 ++++ packages/client/src/navbar.ts | 6 + packages/client/src/pages/achievements.vue | 25 ++ .../client/src/pages/settings/profile.vue | 10 + packages/client/src/router.ts | 5 + packages/client/src/scripts/achievements.ts | 425 ++++++++++++++++++ packages/client/src/scripts/get-note-menu.ts | 12 + 22 files changed, 1144 insertions(+), 4 deletions(-) create mode 100644 packages/backend/src/migration/1674118260469-achievement.js create mode 100644 packages/backend/src/migration/1674255666603-loggedInDates.js create mode 100644 packages/client/src/components/MkAchievements.vue create mode 100644 packages/client/src/pages/achievements.vue create mode 100644 packages/client/src/scripts/achievements.ts diff --git a/.config/LICENSE b/.config/LICENSE index 2e3e984ec..ed7bfbda4 100644 --- a/.config/LICENSE +++ b/.config/LICENSE @@ -1,3 +1,4 @@ +Copyright 2024 The TrashPoss contributors Copyright 2023 The Iceshrimp contributors Copyright 2023 The Firefish contributors diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 0023c0f5b..b6f18cdab 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -984,6 +984,222 @@ showWithSparkles: "タイトルをキラキラさせる" youHaveUnreadAnnouncements: "未読のお知らせがあります" neverShow: "今後表示しない" remindMeLater: "また後で" +achievements: "実績" + +_achievements: + earnedAt: "獲得日時" + _types: + _notes1: + title: "just setting up my msky" + description: "初めてノートを投稿した" + flavor: "良いMisskeyライフを!" + _notes10: + title: "いくつかのノート" + description: "ノートを10回投稿した" + _notes100: + title: "たくさんのノート" + description: "ノートを100回投稿した" + _notes500: + title: "ノートまみれ" + description: "ノートを500回投稿した" + _notes1000: + title: "ノートの山" + description: "ノートを1,000回投稿した" + _notes5000: + title: "湧き出るノート" + description: "ノートを5,000回投稿した" + _notes10000: + title: "スーパーノート" + description: "ノートを10,000回投稿した" + _notes20000: + title: "ニードモアノート" + description: "ノートを20,000回投稿した" + _notes30000: + title: "ノートノートノート" + description: "ノートを30,000回投稿した" + _notes40000: + title: "ノート工場" + description: "ノートを40,000回投稿した" + _notes50000: + title: "ノートの惑星" + description: "ノートを50,000回投稿した" + _notes60000: + title: "ノートクエーサー" + description: "ノートを60,000回投稿した" + _notes70000: + title: "ブラックノートホール" + description: "ノートを70,000回投稿した" + _notes80000: + title: "ノートギャラクシー" + description: "ノートを80,000回投稿した" + _notes90000: + title: "ノートバース" + description: "ノートを90,000回投稿した" + _notes100000: + title: "ALL YOUR NOTE ARE BELONG TO US" + description: "ノートを100,000回投稿した" + flavor: "そんなに書くことある?" + _login3: + title: "ビギナーⅠ" + description: "通算ログイン日数が3日" + flavor: "今日からね僕は ミスキストってことで" + _login7: + title: "ビギナーⅡ" + description: "通算ログイン日数が7日" + flavor: "慣れてきましたか?" + _login15: + title: "ビギナーⅢ" + description: "通算ログイン日数が15日" + _login30: + title: "ミスキストⅠ" + description: "通算ログイン日数が30日" + _login60: + title: "ミスキストⅡ" + description: "通算ログイン日数が60日" + _login100: + title: "ミスキストⅢ" + description: "通算ログイン日数が100日" + flavor: "そのユーザー、ミスキストにつき" + _login200: + title: "常連Ⅰ" + description: "通算ログイン日数が200日" + _login300: + title: "常連Ⅱ" + description: "通算ログイン日数が300日" + _login400: + title: "常連Ⅲ" + description: "通算ログイン日数が400日" + _login500: + title: "ベテランⅠ" + description: "通算ログイン日数が500日" + flavor: "諸君、私はノートが好きだ" + _login600: + title: "ベテランⅡ" + description: "通算ログイン日数が600日" + _login700: + title: "ベテランⅢ" + description: "通算ログイン日数が700日" + _login800: + title: "ノートマスターⅠ" + description: "通算ログイン日数が800日" + _login900: + title: "ノートマスターⅡ" + description: "通算ログイン日数が900日" + _login1000: + title: "ノートマスターⅢ" + description: "通算ログイン日数が1,000日" + flavor: "Misskeyを使ってくれてありがとう!" + _noteClipped1: + title: "クリップせずにはいられないな" + description: "初めてノートをクリップした" + _noteFavorited1: + title: "星をみるひと" + description: "初めてノートをお気に入りに登録した" + _profileFilled: + title: "準備万端" + description: "プロフィール設定を行った" + _markedAsCat: + title: "吾輩は猫である" + description: "アカウントをCatとして設定した" + flavor: "名前はまだない。" + _following1: + title: "はじめてのフォロー" + description: "初めてフォローした" + _following10: + title: "ついてく、ついてく" + description: "フォローが10人を超した" + _following50: + title: "友達たくさん" + description: "フォローが50人を超した" + _following100: + title: "友達100人" + description: "フォローが100人を超した" + _following300: + title: "友達過多" + description: "フォローが300人を超した" + _followers1: + title: "はじめてのフォロワー" + description: "初めてフォローされた" + _followers10: + title: "フォローミー!" + description: "フォロワーが10人を超した" + _followers50: + title: "ぞろぞろ" + description: "フォロワーが50人を超した" + _followers100: + title: "人気者" + description: "フォロワーが100人を超した" + _followers300: + title: "一列でお並びください" + description: "フォロワーが300人を超した" + _followers500: + title: "基地局" + description: "フォロワーが500人を超した" + _followers1000: + title: "インフルエンサー" + description: "フォロワーが1,000人を超した" + _collectAchievements30: + title: "実績コレクター" + description: "実績を30個以上獲得した" + _iLoveMisskey: + title: "I Love Misskey" + description: "\"I ❤ #Misskey\"を投稿した" + flavor: "Misskeyを使ってくださりありがとうございます! by 開発チーム" + _client30min: + title: "ひとやすみ" + description: "クライアントを起動してから30分以上経過した" + _noteDeletedWithin1min: + title: "いまのなし" + description: "投稿してから1分以内にその投稿を削除した" + _postedAtLateNight: + title: "夜行性" + description: "深夜にノートを投稿した" + flavor: "そろそろ寝よう。" + _postedAt0min0sec: + title: "時報" + description: "0分0秒にノートを投稿した" + flavor: "ポッ ポッ ポッ ピーン" + _selfQuote: + title: "自己言及" + description: "自分のノートを引用した" + _htl20npm: + title: "流れるTL" + description: "ホームタイムラインの流速が20npmを越す" + _driveFolderCircularReference: + title: "循環参照" + description: "ドライブのフォルダを再帰的な入れ子にしようとした" + _reactWithoutRead: + title: "ちゃんと読んだ?" + description: "100文字以上のテキストを含むノートに投稿されてから3秒以内にリアクションした" + _clickedClickHere: + title: "ここをクリック" + description: "ここをクリックした" + _justPlainLucky: + title: "単なるラッキー" + description: "10秒ごとに0.01%の確率で獲得" + _setNameToSyuilo: + title: "神様コンプレックス" + description: "名前を syuilo に設定した" + _passedSinceAccountCreated1: + title: "一周年" + description: "アカウント作成から1年経過した" + _passedSinceAccountCreated2: + title: "二周年" + description: "アカウント作成から2年経過した" + _passedSinceAccountCreated3: + title: "三周年" + description: "アカウント作成から3年経過した" + _loggedInOnBirthday: + title: "ハッピーバースデー" + description: "誕生日にログインした" + _cookieClicked: + title: "クッキーをクリックするゲーム" + description: "クッキーをクリックした" + flavor: "ソフト間違ってない?" + _brainDiver: + title: "Brain Diver" + description: "Brain Diverへのリンクを投稿した" + flavor: "Misskey-Misskey La-Tu-Ma" _sensitiveMediaDetection: description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てられます。サーバーの負荷が少し増えます。" @@ -1844,6 +2060,7 @@ _notification: youWereInvitedToGroup: "{userName}があなたをグループに招待しました" pollEnded: "アンケートの結果が出ました" emptyPushNotificationMessage: "プッシュ通知の更新をしました" + achievementEarned: "実績を獲得" _types: all: "すべて" follow: "フォロー" diff --git a/packages/backend/src/migration/1674118260469-achievement.js b/packages/backend/src/migration/1674118260469-achievement.js new file mode 100644 index 000000000..131ab96f8 --- /dev/null +++ b/packages/backend/src/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/src/migration/1674255666603-loggedInDates.js b/packages/backend/src/migration/1674255666603-loggedInDates.js new file mode 100644 index 000000000..6d75ab643 --- /dev/null +++ b/packages/backend/src/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/src/types.ts b/packages/backend/src/types.ts index 2204d2ff0..cd2ed9359 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -12,6 +12,7 @@ export const notificationTypes = [ "groupInvited", "app", "bite", + "achievementEarned", ] as const; export const noteVisibilities = [ diff --git a/packages/client/src/account.ts b/packages/client/src/account.ts index c51ef8a2e..08436de8d 100644 --- a/packages/client/src/account.ts +++ b/packages/client/src/account.ts @@ -20,6 +20,11 @@ export const $i = accountData export const iAmModerator = $i != null && ($i.isAdmin || $i.isModerator); export const iAmAdmin = $i?.isAdmin; +export let notesCount = $i == null ? 0 : $i.notesCount; +export function incNotesCount() { + notesCount++; +} + export async function signout() { waiting(); localStorage.removeItem("account"); diff --git a/packages/client/src/components/MkAchievements.vue b/packages/client/src/components/MkAchievements.vue new file mode 100644 index 000000000..8e9d25274 --- /dev/null +++ b/packages/client/src/components/MkAchievements.vue @@ -0,0 +1,224 @@ + + + + + diff --git a/packages/client/src/components/MkDrive.folder.vue b/packages/client/src/components/MkDrive.folder.vue index 1bcb9aa26..60e635e62 100644 --- a/packages/client/src/components/MkDrive.folder.vue +++ b/packages/client/src/components/MkDrive.folder.vue @@ -42,6 +42,7 @@ import * as Misskey from "iceshrimp-js"; import * as os from "@/os"; import { i18n } from "@/i18n"; import { defaultStore } from "@/store"; +import { claimAchievement } from '@/scripts/achievements'; const props = withDefaults( defineProps<{ @@ -160,9 +161,11 @@ function onDrop(ev: DragEvent) { // 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/client/src/components/MkDrive.vue b/packages/client/src/components/MkDrive.vue index fdeafb2be..91067048a 100644 --- a/packages/client/src/components/MkDrive.vue +++ b/packages/client/src/components/MkDrive.vue @@ -150,6 +150,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<{ @@ -325,9 +326,11 @@ function onDrop(ev: DragEvent): any { // 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/client/src/components/MkFollowButton.vue b/packages/client/src/components/MkFollowButton.vue index ec75fcab9..a205e69e2 100644 --- a/packages/client/src/components/MkFollowButton.vue +++ b/packages/client/src/components/MkFollowButton.vue @@ -66,6 +66,7 @@ import type * as Misskey from "iceshrimp-js"; import * as os from "@/os"; import { stream } from "@/stream"; import { i18n } from "@/i18n"; +import { claimAchievement } from '@/scripts/achievements'; import { $i } from "@/account"; import { getUserMenu } from "@/scripts/get-user-menu"; import { useRouter } from "@/router"; @@ -154,6 +155,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/client/src/components/MkNote.vue b/packages/client/src/components/MkNote.vue index 02c0f5647..f85db3b9c 100644 --- a/packages/client/src/components/MkNote.vue +++ b/packages/client/src/components/MkNote.vue @@ -291,6 +291,7 @@ import { getNoteMenu } from "@/scripts/get-note-menu"; import { useNoteCapture } from "@/scripts/use-note-capture"; import { notePage } from "@/filters/note"; import { deepClone } from "@/scripts/clone"; +import { claimAchievement } from '@/scripts/achievements'; import { getNoteSummary } from "@/scripts/get-note-summary"; const router = useRouter(); @@ -394,6 +395,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/client/src/components/MkNoteDetailed.vue b/packages/client/src/components/MkNoteDetailed.vue index d3cbf19a1..d237b11f5 100644 --- a/packages/client/src/components/MkNoteDetailed.vue +++ b/packages/client/src/components/MkNoteDetailed.vue @@ -184,6 +184,7 @@ import { i18n } from "@/i18n"; import { getNoteMenu } from "@/scripts/get-note-menu"; import { useNoteCapture } from "@/scripts/use-note-capture"; import { deepClone } from "@/scripts/clone"; +import { claimAchievement } from '@/scripts/achievements'; import { stream } from "@/stream"; import { NoteUpdatedEvent } from "iceshrimp-js/src/streaming.types"; import appear from "@/directives/appear"; @@ -277,6 +278,9 @@ function react(viaKeyboard = false): void { noteId: note.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/client/src/components/MkNotification.vue b/packages/client/src/components/MkNotification.vue index cec021b7a..b99239e76 100644 --- a/packages/client/src/components/MkNotification.vue +++ b/packages/client/src/components/MkNotification.vue @@ -11,6 +11,11 @@ class="icon" :user="notification.note.user" /> + + {{ i18n.ts._notification.pollEnded }} + {{ + i18n.ts._notification.achievementEarned + }} + + {{ i18n.ts._achievements._types['_' + notification.achievement].title }} + { pointer-events: none; } + &.mention { + padding: 3px; + background: #88a6b7; + pointer-events: none; + } + &.pollVote { padding: 3px; background: #908caa; diff --git a/packages/client/src/components/MkPostForm.vue b/packages/client/src/components/MkPostForm.vue index a0a325fa6..266e83827 100644 --- a/packages/client/src/components/MkPostForm.vue +++ b/packages/client/src/components/MkPostForm.vue @@ -259,6 +259,8 @@ import { i18n } from "@/i18n"; import { instance } from "@/instance"; import { $i, + notesCount, + incNotesCount, getAccounts, openAccountMenu as openAccountMenu_, } from "@/account"; @@ -903,6 +905,34 @@ async function post() { } 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) => { diff --git a/packages/client/src/components/MkReactionsViewer.reaction.vue b/packages/client/src/components/MkReactionsViewer.reaction.vue index 1c63aa8b6..336e68c4c 100644 --- a/packages/client/src/components/MkReactionsViewer.reaction.vue +++ b/packages/client/src/components/MkReactionsViewer.reaction.vue @@ -28,6 +28,7 @@ import XReactionIcon from "@/components/MkReactionIcon.vue"; import * as os from "@/os"; import { useTooltip } from "@/scripts/use-tooltip"; import { $i } from "@/account"; +import { claimAchievement } from '@/scripts/achievements'; const props = defineProps<{ reaction: string; @@ -64,6 +65,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'); + } emit("reacted"); } }; diff --git a/packages/client/src/init.ts b/packages/client/src/init.ts index b244002a7..b929d2172 100644 --- a/packages/client/src/init.ts +++ b/packages/client/src/init.ts @@ -49,6 +49,7 @@ import { reloadChannel } from "@/scripts/unison-reload"; import { reactionPicker } from "@/scripts/reaction-picker"; import { getUrlWithoutLoginId } from "@/scripts/login-id"; import { getAccountFromId } from "@/scripts/get-account-from-id"; +import { claimAchievement, claimedAchievements } from './scripts/achievements'; function checkForSplash() { const splash = document.getElementById("splash"); @@ -440,6 +441,82 @@ function checkForSplash() { }); } + if ($i.birthday) { + const now = new Date(); + const m = now.getMonth() + 1; + const d = now.getDate(); + const bm = parseInt($i.birthday.split('-')[1]); + const bd = parseInt($i.birthday.split('-')[2]); + if (m === bm && d === bd) { + claimAchievement('loggedInOnBirthday'); + } + } + + 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); + if ("Notification" in window) { // 許可を得ていなかったらリクエスト if (Notification.permission === "default") { diff --git a/packages/client/src/navbar.ts b/packages/client/src/navbar.ts index da6077cef..3c4351f58 100644 --- a/packages/client/src/navbar.ts +++ b/packages/client/src/navbar.ts @@ -106,6 +106,12 @@ export const navbarItemDef = reactive({ icon: "ph-users-three ph-bold ph-lg", to: "/my/groups", }, + achievements: { + title: i18n.ts.achievements, + icon: 'ph-awards-military ph-bold', + show: computed(() => $i != null), + to: '/my/achievements', + }, ui: { title: "switchUi", icon: "ph-layout ph-bold ph-lg", diff --git a/packages/client/src/pages/achievements.vue b/packages/client/src/pages/achievements.vue new file mode 100644 index 000000000..a721f934a --- /dev/null +++ b/packages/client/src/pages/achievements.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/packages/client/src/pages/settings/profile.vue b/packages/client/src/pages/settings/profile.vue index 3b94a8248..255bf1cac 100644 --- a/packages/client/src/pages/settings/profile.vue +++ b/packages/client/src/pages/settings/profile.vue @@ -177,6 +177,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'; import { host } from "@/config"; const profile = reactive({ @@ -243,6 +244,14 @@ function save() { isCat: !!profile.isCat, speakAsCat: !!profile.speakAsCat, }); + claimAchievement('profileFilled'); + if (profile.name === 'syuilo' || profile.name === 'しゅいろ') { + claimAchievement('setNameToSyuilo'); + } + if (profile.isCat) { + claimAchievement('markedAsCat'); + } + } function changeAvatar(ev) { @@ -266,6 +275,7 @@ function changeAvatar(ev) { }); $i.avatarId = i.avatarId; $i.avatarUrl = i.avatarUrl; + claimAchievement('profileFilled'); }, ); } diff --git a/packages/client/src/router.ts b/packages/client/src/router.ts index ff224bba3..d1e5bef46 100644 --- a/packages/client/src/router.ts +++ b/packages/client/src/router.ts @@ -564,6 +564,11 @@ 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", diff --git a/packages/client/src/scripts/achievements.ts b/packages/client/src/scripts/achievements.ts new file mode 100644 index 000000000..c8245ad3d --- /dev/null +++ b/packages/client/src/scripts/achievements.ts @@ -0,0 +1,425 @@ +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', + '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 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/1f396.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', + }, + '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', + }, + '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(144 224 255), rgb(255 168 252))', + 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; + +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/client/src/scripts/get-note-menu.ts b/packages/client/src/scripts/get-note-menu.ts index 55e3cbf68..413d6d1da 100644 --- a/packages/client/src/scripts/get-note-menu.ts +++ b/packages/client/src/scripts/get-note-menu.ts @@ -1,5 +1,6 @@ import { defineAsyncComponent, Ref, inject } from "vue"; import * as misskey from "iceshrimp-js"; +import { claimAchievement } from './achievements'; import { $i } from "@/account"; import { i18n } from "@/i18n"; import { instance } from "@/instance"; @@ -39,6 +40,10 @@ export function getNoteMenu(props: { os.api("notes/delete", { noteId: appearNote.id, }); + + if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60) { + claimAchievement('noteDeletedWithin1min'); + } }); } @@ -59,6 +64,10 @@ export function getNoteMenu(props: { reply: appearNote.reply, channel: appearNote.channel, }); + + if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60) { + claimAchievement('noteDeletedWithin1min'); + } }); } @@ -73,6 +82,7 @@ export function getNoteMenu(props: { } function toggleFavorite(favorite: boolean): void { + claimAchievement('noteFavorited1'); os.apiWithDialog( favorite ? "notes/favorites/create" : "notes/favorites/delete", { @@ -160,6 +170,7 @@ 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, @@ -170,6 +181,7 @@ export function getNoteMenu(props: { ...clips.map((clip) => ({ text: clip.name, action: () => { + claimAchievement('noteClipped1'); os.promiseDialog( os.api("clips/add-note", { clipId: clip.id,