Add Achievements Part 1/2, code by syuilo, Syuilotan@yahoo.co.jp

Signed-off-by: limepotato <limepot@protonmail.ch>
This commit is contained in:
Crimekillz 2024-03-26 12:40:21 +01:00 committed by limepotato
parent 8aa97e21d2
commit ea2d709116
22 changed files with 1144 additions and 4 deletions

View file

@ -1,3 +1,4 @@
Copyright 2024 The TrashPoss contributors
Copyright 2023 The Iceshrimp contributors
Copyright 2023 The Firefish contributors

View file

@ -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: "フォロー"

View file

@ -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"`);
}
}

View file

@ -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"`);
}
}

View file

@ -12,6 +12,7 @@ export const notificationTypes = [
"groupInvited",
"app",
"bite",
"achievementEarned",
] as const;
export const noteVisibilities = [

View file

@ -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");

View file

@ -0,0 +1,224 @@
<template>
<div>
<div v-if="achievements" :class=".root">
<div v-for="achievement in achievements" :key="achievement" :class=".achievement" class="_panel">
<div :class=".icon">
<div :class="[.iconFrame, ['iconFrame_' + ACHIEVEMENT_BADGES[achievement.name].frame]]">
<div :class="[.iconInner]" :style="{ background: ACHIEVEMENT_BADGES[achievement.name].bg }">
<img :class=".iconImg" :src="ACHIEVEMENT_BADGES[achievement.name].img">
</div>
</div>
</div>
<div :class=".body">
<div :class=".header">
<span :class=".title">{{ i18n.ts._achievements._types['_' + achievement.name].title }}</span>
<span :class=".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=".description">{{ i18n.ts._achievements._types['_' + achievement.name].description }}</div>
<div v-if="i18n.ts._achievements._types['_' + achievement.name].flavor" :class=".flavor">{{ i18n.ts._achievements._types['_' + achievement.name].flavor }}</div>
</div>
</div>
<template v-if="withLocked">
<div v-for="achievement in lockedAchievements" :key="achievement" :class="[.achievement, .locked]" class="_panel" @click="achievement === 'clickedClickHere' ? clickHere() : () => {}">
<div :class=".icon">
</div>
<div :class=".body">
<div :class=".header">
<span :class=".title">???</span>
</div>
<div :class=".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>

View file

@ -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,
});

View file

@ -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,
});

View file

@ -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) {

View file

@ -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();

View file

@ -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();

View file

@ -11,6 +11,11 @@
class="icon"
:user="notification.note.user"
/>
<MkAvatar
v-else-if="notification.type === 'achievementEarned'"
class="icon"
:user="notification.user"
/>
<MkAvatar
v-else-if="notification.user"
class="icon"
@ -63,6 +68,10 @@
v-else-if="notification.type === 'pollEnded'"
class="ph-microphone-stage ph-bold"
></i>
<i
v-else-if="notification.type === 'achievementEarned'"
class="ph-medal-military ph-bold"
></i>
<!-- notification.reaction null になることはまずないがここでoptional chaining使うと一部ブラウザで刺さるので念の為 -->
<XReactionIcon
v-else-if="
@ -94,6 +103,9 @@
<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"
@ -210,6 +222,12 @@
/>
<i class="ph-quotes ph-fill ph-lg"></i>
</MkA>
<MkA
v-if="notification.type === 'achievementEarned'"
class="text"
:to="/my/achievements">
{{ i18n.ts._achievements._types['_' + notification.achievement].title }}
</MkA>
<span
v-if="notification.type === 'follow'"
class="text"
@ -489,6 +507,12 @@ useTooltip(reactionRef, (showing) => {
pointer-events: none;
}
&.mention {
padding: 3px;
background: #88a6b7;
pointer-events: none;
}
&.pollVote {
padding: 3px;
background: #908caa;

View file

@ -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) => {

View file

@ -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");
}
};

View file

@ -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") {

View file

@ -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",

View file

@ -0,0 +1,25 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader/></template>
<MkSpacer :content-max="1200">
<MkAchievements :user="$i"/>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import MkAchievements from '@/components/MkAchievements.vue';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { $i } from '@/account';
definePageMetadata({
title: i18n.ts.achievements,
icon: 'ph-awards-military ph-bold',
});
</script>
<style lang="scss" module>
</style>

View file

@ -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');
},
);
}

View file

@ -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",

View file

@ -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<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);
}
};
}

View file

@ -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,