summaryrefslogtreecommitdiff
path: root/packages/frontend/src
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2023-01-21 13:14:55 +0900
committerGitHub <noreply@github.com>2023-01-21 13:14:55 +0900
commit65cd605b739ae0d213b3502308e9cd523d3e1ae7 (patch)
treefe446502a4af681db8319703a6b3c14c2c8e990e /packages/frontend/src
parentadd commands for build with swc (diff)
downloadmisskey-65cd605b739ae0d213b3502308e9cd523d3e1ae7.tar.gz
misskey-65cd605b739ae0d213b3502308e9cd523d3e1ae7.tar.bz2
misskey-65cd605b739ae0d213b3502308e9cd523d3e1ae7.zip
Achievements (#9665)
* wip * Update ja-JP.yml * wip * wip * Update MkAchievements.vue * wip * :art: * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip
Diffstat (limited to 'packages/frontend/src')
-rw-r--r--packages/frontend/src/account.ts7
-rw-r--r--packages/frontend/src/components/MkAchievements.vue224
-rw-r--r--packages/frontend/src/components/MkClickerGame.vue11
-rw-r--r--packages/frontend/src/components/MkDrive.folder.vue7
-rw-r--r--packages/frontend/src/components/MkDrive.vue7
-rw-r--r--packages/frontend/src/components/MkFollowButton.vue17
-rw-r--r--packages/frontend/src/components/MkNote.vue4
-rw-r--r--packages/frontend/src/components/MkNoteDetailed.vue4
-rw-r--r--packages/frontend/src/components/MkNotification.vue13
-rw-r--r--packages/frontend/src/components/MkPostForm.vue31
-rw-r--r--packages/frontend/src/components/MkReactionsViewer.reaction.vue4
-rw-r--r--packages/frontend/src/init.ts77
-rw-r--r--packages/frontend/src/navbar.ts8
-rw-r--r--packages/frontend/src/pages/achievements.vue25
-rw-r--r--packages/frontend/src/pages/settings/profile.vue9
-rw-r--r--packages/frontend/src/router.ts4
-rw-r--r--packages/frontend/src/scripts/achievements.ts425
-rw-r--r--packages/frontend/src/scripts/get-note-menu.ts12
18 files changed, 879 insertions, 10 deletions
diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts
index 93916ccf2f..31c125d3ae 100644
--- a/packages/frontend/src/account.ts
+++ b/packages/frontend/src/account.ts
@@ -2,11 +2,11 @@ import { defineAsyncComponent, reactive } from 'vue';
import * as misskey from 'misskey-js';
import { showSuspendedDialog } from './scripts/show-suspended-dialog';
import { i18n } from './i18n';
+import { miLocalStorage } from './local-storage';
import { del, get, set } from '@/scripts/idb-proxy';
import { apiUrl } from '@/config';
import { waiting, api, popup, popupMenu, success, alert } from '@/os';
import { unisonReload, reloadChannel } from '@/scripts/unison-reload';
-import { miLocalStorage } from './local-storage';
// TODO: 他のタブと永続化されたstateを同期
@@ -20,6 +20,11 @@ export const $i = accountData ? reactive(JSON.parse(accountData) as Account) : n
export const iAmModerator = $i != null && ($i.isAdmin || $i.isModerator);
export const iAmAdmin = $i != null && $i.isAdmin;
+export let notesCount = $i == null ? 0 : $i.notesCount;
+export function incNotesCount() {
+ notesCount++;
+}
+
export async function signout() {
waiting();
miLocalStorage.removeItem('account');
diff --git a/packages/frontend/src/components/MkAchievements.vue b/packages/frontend/src/components/MkAchievements.vue
new file mode 100644
index 0000000000..64fea96354
--- /dev/null
+++ b/packages/frontend/src/components/MkAchievements.vue
@@ -0,0 +1,224 @@
+<template>
+<div>
+ <div v-if="achievements" :class="$style.root">
+ <div v-for="achievement in achievements" :key="achievement" :class="$style.achievement" class="_panel">
+ <div :class="$style.icon">
+ <div :class="[$style.iconFrame, $style['iconFrame_' + ACHIEVEMENT_BADGES[achievement.name].frame]]">
+ <div :class="[$style.iconInner]" :style="{ background: ACHIEVEMENT_BADGES[achievement.name].bg }">
+ <img :class="$style.iconImg" :src="ACHIEVEMENT_BADGES[achievement.name].img">
+ </div>
+ </div>
+ </div>
+ <div :class="$style.body">
+ <div :class="$style.header">
+ <span :class="$style.title">{{ i18n.ts._achievements._types['_' + achievement.name].title }}</span>
+ <span :class="$style.time">
+ <time v-tooltip="new Date(achievement.unlockedAt).toLocaleString()">{{ new Date(achievement.unlockedAt).getFullYear() }}/{{ new Date(achievement.unlockedAt).getMonth() + 1 }}/{{ new Date(achievement.unlockedAt).getDate() }}</time>
+ </span>
+ </div>
+ <div :class="$style.description">{{ i18n.ts._achievements._types['_' + achievement.name].description }}</div>
+ <div v-if="i18n.ts._achievements._types['_' + achievement.name].flavor" :class="$style.flavor">{{ i18n.ts._achievements._types['_' + achievement.name].flavor }}</div>
+ </div>
+ </div>
+ <template v-if="withLocked">
+ <div v-for="achievement in lockedAchievements" :key="achievement" :class="[$style.achievement, $style.locked]" class="_panel" @click="achievement === 'clickedClickHere' ? clickHere() : () => {}">
+ <div :class="$style.icon">
+ </div>
+ <div :class="$style.body">
+ <div :class="$style.header">
+ <span :class="$style.title">???</span>
+ </div>
+ <div :class="$style.description">???</div>
+ </div>
+ </div>
+ </template>
+ </div>
+ <div v-else>
+ <MkLoading/>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import * as misskey from 'misskey-js';
+import { onMounted } from 'vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/scripts/achievements';
+
+const props = withDefaults(defineProps<{
+ user: misskey.entities.User;
+ withLocked: boolean;
+}>(), {
+ withLocked: true,
+});
+
+let achievements = $ref();
+const lockedAchievements = $computed(() => ACHIEVEMENT_TYPES.filter(x => !(achievements ?? []).some(a => a.name === x)));
+
+function fetch() {
+ os.api('users/achievements', { userId: props.user.id }).then(res => {
+ achievements = [];
+ for (const t of ACHIEVEMENT_TYPES) {
+ const a = res.find(x => x.name === t);
+ if (a) achievements.push(a);
+ }
+ //achievements = res.sort((a, b) => b.unlockedAt - a.unlockedAt);
+ });
+}
+
+function clickHere() {
+ claimAchievement('clickedClickHere');
+ fetch();
+}
+
+onMounted(() => {
+ fetch();
+});
+</script>
+
+<style lang="scss" module>
+.root {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, min(380px, 100%));
+ grid-gap: 12px;
+ place-content: center;
+}
+
+.achievement {
+ display: flex;
+ padding: 16px;
+
+ &.locked {
+ opacity: 0.5;
+ }
+}
+
+.icon {
+ flex-shrink: 0;
+ margin-right: 12px;
+}
+
+@keyframes shine {
+ 0% { translate: -30px; }
+ 100% { translate: -130px; }
+}
+
+.iconFrame {
+ width: 58px;
+ height: 58px;
+ padding: 6px;
+ border-radius: 100%;
+ box-sizing: border-box;
+ pointer-events: none;
+ user-select: none;
+ filter: drop-shadow(0px 2px 2px #00000044);
+ box-shadow: 0 1px 0px #ffffff88 inset;
+ overflow: clip;
+}
+.iconFrame_bronze {
+ background: linear-gradient(0deg, #703827, #d37566);
+
+ > .iconInner {
+ background: linear-gradient(0deg, #d37566, #703827);
+ }
+}
+.iconFrame_silver {
+ background: linear-gradient(0deg, #7c7c7c, #e1e1e1);
+
+ > .iconInner {
+ background: linear-gradient(0deg, #e1e1e1, #7c7c7c);
+ }
+}
+.iconFrame_gold {
+ background: linear-gradient(0deg, rgba(255,182,85,1) 0%, rgba(233,133,0,1) 49%, rgba(255,243,93,1) 51%, rgba(255,187,25,1) 100%);
+
+ > .iconInner {
+ background: linear-gradient(0deg, #ffee20, #eb7018);
+ }
+
+ &:before {
+ content: "";
+ display: block;
+ position: absolute;
+ top: 30px;
+ width: 200px;
+ height: 8px;
+ rotate: -45deg;
+ translate: -30px;
+ background: #ffffff88;
+ animation: shine 2s infinite;
+ }
+}
+.iconFrame_platinum {
+ background: linear-gradient(0deg, rgba(154,154,154,1) 0%, rgba(226,226,226,1) 49%, rgba(255,255,255,1) 51%, rgba(195,195,195,1) 100%);
+
+ > .iconInner {
+ background: linear-gradient(0deg, #e1e1e1, #7c7c7c);
+ }
+
+ &:before {
+ content: "";
+ display: block;
+ position: absolute;
+ top: 30px;
+ width: 200px;
+ height: 8px;
+ rotate: -45deg;
+ translate: -30px;
+ background: #ffffffee;
+ animation: shine 2s infinite;
+ }
+}
+
+.iconInner {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ border-radius: 100%;
+ box-shadow: 0 1px 0px #ffffff88 inset;
+}
+
+.iconImg {
+ width: calc(100% - 12px);
+ height: calc(100% - 12px);
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ margin: auto;
+ filter: drop-shadow(0px 1px 2px #000000aa);
+}
+
+.body {
+ flex: 1;
+ min-width: 0;
+}
+
+.header {
+ margin-bottom: 8px;
+ display: flex;
+}
+
+.title {
+ font-weight: bold;
+}
+
+.time {
+ margin-left: auto;
+ font-size: 85%;
+ opacity: 0.7;
+}
+
+.description {
+ font-size: 85%;
+}
+
+.flavor {
+ opacity: 0.7;
+ transform: skewX(-15deg);
+ font-size: 85%;
+ margin-top: 8px;
+}
+</style>
diff --git a/packages/frontend/src/components/MkClickerGame.vue b/packages/frontend/src/components/MkClickerGame.vue
index 03736ac5e4..68e0f8185d 100644
--- a/packages/frontend/src/components/MkClickerGame.vue
+++ b/packages/frontend/src/components/MkClickerGame.vue
@@ -20,6 +20,7 @@ import * as os from '@/os';
import { useInterval } from '@/scripts/use-interval';
import * as game from '@/scripts/clicker-game';
import number from '@/filters/number';
+import { claimAchievement } from '@/scripts/achievements';
defineProps<{
}>();
@@ -30,14 +31,18 @@ let cps = $ref(0);
let prevCookies = $ref(0);
function onClick(ev: MouseEvent) {
+ const x = ev.clientX;
+ const y = ev.clientY;
+ os.popup(MkPlusOneEffect, { x, y }, {}, 'end');
+
saveData.value!.cookies++;
saveData.value!.totalCookies++;
saveData.value!.totalHandmadeCookies++;
saveData.value!.clicked++;
- const x = ev.clientX;
- const y = ev.clientY;
- os.popup(MkPlusOneEffect, { x, y }, {}, 'end');
+ if (cookies.value === 1) {
+ claimAchievement('cookieClicked');
+ }
}
useInterval(() => {
diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue
index 82653ca0b4..156013b9aa 100644
--- a/packages/frontend/src/components/MkDrive.folder.vue
+++ b/packages/frontend/src/components/MkDrive.folder.vue
@@ -33,6 +33,7 @@ import * as Misskey from 'misskey-js';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { defaultStore } from '@/store';
+import { claimAchievement } from '@/scripts/achievements';
const props = withDefaults(defineProps<{
folder: Misskey.entities.DriveFolder;
@@ -159,9 +160,11 @@ function onDrop(ev: DragEvent) {
}).then(() => {
// noop
}).catch(err => {
- switch (err) {
- case 'detected-circular-definition':
+ switch (err.code) {
+ case 'RECURSIVE_NESTING':
+ claimAchievement('driveFolderCircularReference');
os.alert({
+ type: 'error',
title: i18n.ts.unableToProcess,
text: i18n.ts.circularReferenceFolder,
});
diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue
index 112a64f52d..af7175e5cd 100644
--- a/packages/frontend/src/components/MkDrive.vue
+++ b/packages/frontend/src/components/MkDrive.vue
@@ -99,6 +99,7 @@ import { stream } from '@/stream';
import { defaultStore } from '@/store';
import { i18n } from '@/i18n';
import { uploadFile, uploads } from '@/scripts/upload';
+import { claimAchievement } from '@/scripts/achievements';
const props = withDefaults(defineProps<{
initialFolder?: Misskey.entities.DriveFolder;
@@ -268,9 +269,11 @@ function onDrop(ev: DragEvent): any {
}).then(() => {
// noop
}).catch(err => {
- switch (err) {
- case 'detected-circular-definition':
+ switch (err.code) {
+ case 'RECURSIVE_NESTING':
+ claimAchievement('driveFolderCircularReference');
os.alert({
+ type: 'error',
title: i18n.ts.unableToProcess,
text: i18n.ts.circularReferenceFolder,
});
diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue
index ee256d9263..de8db54bfa 100644
--- a/packages/frontend/src/components/MkFollowButton.vue
+++ b/packages/frontend/src/components/MkFollowButton.vue
@@ -35,6 +35,8 @@ import * as Misskey from 'misskey-js';
import * as os from '@/os';
import { stream } from '@/stream';
import { i18n } from '@/i18n';
+import { claimAchievement } from '@/scripts/achievements';
+import { $i } from '@/account';
const props = withDefaults(defineProps<{
user: Misskey.entities.UserDetailed,
@@ -90,6 +92,21 @@ async function onClick() {
userId: props.user.id,
});
hasPendingFollowRequestFromYou = true;
+
+ claimAchievement('following1');
+
+ if ($i.followingCount >= 10) {
+ claimAchievement('following10');
+ }
+ if ($i.followingCount >= 50) {
+ claimAchievement('following50');
+ }
+ if ($i.followingCount >= 100) {
+ claimAchievement('following100');
+ }
+ if ($i.followingCount >= 300) {
+ claimAchievement('following300');
+ }
}
}
} catch (err) {
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 9b2501a2ed..1f6a2883d7 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -143,6 +143,7 @@ import { getNoteMenu } from '@/scripts/get-note-menu';
import { useNoteCapture } from '@/scripts/use-note-capture';
import { deepClone } from '@/scripts/clone';
import { useTooltip } from '@/scripts/use-tooltip';
+import { claimAchievement } from '@/scripts/achievements';
const props = defineProps<{
note: misskey.entities.Note;
@@ -268,6 +269,9 @@ function react(viaKeyboard = false): void {
noteId: appearNote.id,
reaction: reaction,
});
+ if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
+ claimAchievement('reactWithoutRead');
+ }
}, () => {
focus();
});
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 56061e0e6f..48ace56d9c 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -159,6 +159,7 @@ import { getNoteMenu } from '@/scripts/get-note-menu';
import { useNoteCapture } from '@/scripts/use-note-capture';
import { deepClone } from '@/scripts/clone';
import { useTooltip } from '@/scripts/use-tooltip';
+import { claimAchievement } from '@/scripts/achievements';
const props = defineProps<{
note: misskey.entities.Note;
@@ -279,6 +280,9 @@ function react(viaKeyboard = false): void {
noteId: appearNote.id,
reaction: reaction,
});
+ if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
+ claimAchievement('reactWithoutRead');
+ }
}, () => {
focus();
});
diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue
index 5b8041c1d4..e992495a78 100644
--- a/packages/frontend/src/components/MkNotification.vue
+++ b/packages/frontend/src/components/MkNotification.vue
@@ -2,6 +2,7 @@
<div ref="elRef" :class="$style.root">
<div v-once :class="$style.head">
<MkAvatar v-if="notification.type === 'pollEnded'" :class="$style.icon" :user="notification.note.user" link preview/>
+ <MkAvatar v-else-if="notification.type === 'achievementEarned'" :class="$style.icon" :user="$i" link preview/>
<MkAvatar v-else-if="notification.user" :class="$style.icon" :user="notification.user" link preview/>
<img v-else-if="notification.icon" :class="$style.icon" :src="notification.icon" alt=""/>
<div :class="[$style.subIcon, $style['t_' + notification.type]]">
@@ -14,6 +15,7 @@
<i v-else-if="notification.type === 'mention'" class="ti ti-at"></i>
<i v-else-if="notification.type === 'quote'" class="ti ti-quote"></i>
<i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i>
+ <i v-else-if="notification.type === 'achievementEarned'" class="ti ti-military-award"></i>
<!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 -->
<MkReactionIcon
v-else-if="notification.type === 'reaction'"
@@ -28,6 +30,7 @@
<div :class="$style.tail">
<header :class="$style.header">
<span v-if="notification.type === 'pollEnded'">{{ i18n.ts._notification.pollEnded }}</span>
+ <span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span>
<MkA v-else-if="notification.user" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
<span v-else>{{ notification.header }}</span>
<MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/>
@@ -57,6 +60,9 @@
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :author="notification.note.user"/>
<i class="ti ti-quote" :class="$style.quote"></i>
</MkA>
+ <MkA v-else-if="notification.type === 'achievementEarned'" :class="$style.text" to="/my/achievements">
+ {{ i18n.ts._achievements._types['_' + notification.achievement].title }}
+ </MkA>
<span v-else-if="notification.type === 'follow'" :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}<div v-if="full"><MkFollowButton :user="notification.user" :full="true"/></div></span>
<span v-else-if="notification.type === 'followRequestAccepted'" :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.followRequestAccepted }}</span>
<span v-else-if="notification.type === 'receiveFollowRequest'" :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.receiveFollowRequest }}<div v-if="full && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ i18n.ts.accept }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ i18n.ts.reject }}</button></div></span>
@@ -82,6 +88,7 @@ import { i18n } from '@/i18n';
import * as os from '@/os';
import { stream } from '@/stream';
import { useTooltip } from '@/scripts/use-tooltip';
+import { $i } from '@/account';
const props = withDefaults(defineProps<{
notification: misskey.entities.Notification;
@@ -240,6 +247,12 @@ useTooltip(reactionRef, (showing) => {
pointer-events: none;
}
+.t_achievementEarned {
+ padding: 3px;
+ background: #88a6b7;
+ pointer-events: none;
+}
+
.tail {
flex: 1;
min-width: 0;
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 6822caf4f4..c7e7e85b2e 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -93,11 +93,12 @@ import { defaultStore, notePostInterruptors, postFormActions } from '@/store';
import MkInfo from '@/components/MkInfo.vue';
import { i18n } from '@/i18n';
import { instance } from '@/instance';
-import { $i, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account';
+import { $i, notesCount, incNotesCount, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account';
import { uploadFile } from '@/scripts/upload';
import { deepClone } from '@/scripts/clone';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { miLocalStorage } from '@/local-storage';
+import { claimAchievement } from '@/scripts/achievements';
const modal = inject('modal');
@@ -627,6 +628,34 @@ async function post(ev?: MouseEvent) {
}
posting = false;
postAccount = null;
+
+ incNotesCount();
+ if (notesCount === 1) {
+ claimAchievement('notes1');
+ }
+
+ const text = postData.text?.toLowerCase() ?? '';
+ if ((text.includes('love') || text.includes('❤')) && text.includes('misskey')) {
+ claimAchievement('iLoveMisskey');
+ }
+ if (text.includes('Efrlqw8ytg4'.toLowerCase()) || text.includes('XVCwzwxdHuA'.toLowerCase())) {
+ claimAchievement('brainDiver');
+ }
+
+ if (props.renote && (props.renote.userId === $i.id) && text.length > 0) {
+ claimAchievement('selfQuote');
+ }
+
+ const date = new Date();
+ const h = date.getHours();
+ const m = date.getMinutes();
+ const s = date.getSeconds();
+ if (h >= 0 && h <= 3) {
+ claimAchievement('postedAtLateNight');
+ }
+ if (m === 0 && s === 0) {
+ claimAchievement('postedAt0min0sec');
+ }
});
}).catch(err => {
posting = false;
diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
index e90dd7ea69..ec4042d18c 100644
--- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
@@ -20,6 +20,7 @@ import * as os from '@/os';
import { useTooltip } from '@/scripts/use-tooltip';
import { $i } from '@/account';
import MkReactionEffect from '@/components/MkReactionEffect.vue';
+import { claimAchievement } from '@/scripts/achievements';
const props = defineProps<{
reaction: string;
@@ -52,6 +53,9 @@ const toggleReaction = () => {
noteId: props.note.id,
reaction: props.reaction,
});
+ if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) {
+ claimAchievement('reactWithoutRead');
+ }
}
};
diff --git a/packages/frontend/src/init.ts b/packages/frontend/src/init.ts
index e10315e1ad..a2723d479c 100644
--- a/packages/frontend/src/init.ts
+++ b/packages/frontend/src/init.ts
@@ -44,6 +44,7 @@ import { reactionPicker } from '@/scripts/reaction-picker';
import { getUrlWithoutLoginId } from '@/scripts/login-id';
import { getAccountFromId } from '@/scripts/get-account-from-id';
import { miLocalStorage } from './local-storage';
+import { claimAchievement, claimedAchievements } from './scripts/achievements';
(async () => {
console.info(`Misskey v${version}`);
@@ -345,6 +346,82 @@ import { miLocalStorage } from './local-storage';
});
}
+ 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);
+
const lastUsed = miLocalStorage.getItem('lastUsed');
if (lastUsed) {
const lastUsedDate = parseInt(lastUsed, 10);
diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts
index 9ee78741dc..3d16a52e62 100644
--- a/packages/frontend/src/navbar.ts
+++ b/packages/frontend/src/navbar.ts
@@ -1,11 +1,11 @@
import { computed, ref, reactive } from 'vue';
import { $i } from './account';
+import { miLocalStorage } from './local-storage';
import { search } from '@/scripts/search';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { ui } from '@/config';
import { unisonReload } from '@/scripts/unison-reload';
-import { miLocalStorage } from './local-storage';
export const navbarItemDef = reactive({
notifications: {
@@ -103,6 +103,12 @@ export const navbarItemDef = reactive({
icon: 'ti ti-device-tv',
to: '/channels',
},
+ achievements: {
+ title: i18n.ts.achievements,
+ icon: 'ti ti-military-award',
+ show: computed(() => $i != null),
+ to: '/my/achievements',
+ },
ui: {
title: i18n.ts.switchUi,
icon: 'ti ti-devices',
diff --git a/packages/frontend/src/pages/achievements.vue b/packages/frontend/src/pages/achievements.vue
new file mode 100644
index 0000000000..b6cd174b41
--- /dev/null
+++ b/packages/frontend/src/pages/achievements.vue
@@ -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: 'ti ti-military-award',
+});
+</script>
+
+<style lang="scss" module>
+
+</style>
diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue
index ae74224db6..da7d3d3703 100644
--- a/packages/frontend/src/pages/settings/profile.vue
+++ b/packages/frontend/src/pages/settings/profile.vue
@@ -85,6 +85,7 @@ import { i18n } from '@/i18n';
import { $i } from '@/account';
import { langmap } from '@/scripts/langmap';
import { definePageMetadata } from '@/scripts/page-metadata';
+import { claimAchievement } from '@/scripts/achievements';
const profile = reactive({
name: $i.name,
@@ -133,6 +134,13 @@ function save() {
isCat: !!profile.isCat,
showTimelineReplies: !!profile.showTimelineReplies,
});
+ claimAchievement('profileFilled');
+ if (profile.name === 'syuilo' || profile.name === 'しゅいろ') {
+ claimAchievement('setNameToSyuilo');
+ }
+ if (profile.isCat) {
+ claimAchievement('markedAsCat');
+ }
}
function changeAvatar(ev) {
@@ -155,6 +163,7 @@ function changeAvatar(ev) {
});
$i.avatarId = i.avatarId;
$i.avatarUrl = i.avatarUrl;
+ claimAchievement('profileFilled');
});
}
diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts
index 26c73c610f..22106e1595 100644
--- a/packages/frontend/src/router.ts
+++ b/packages/frontend/src/router.ts
@@ -428,6 +428,10 @@ export const routes = [{
component: page(() => import('./pages/favorites.vue')),
loginRequired: true,
}, {
+ path: '/my/achievements',
+ component: page(() => import('./pages/achievements.vue')),
+ loginRequired: true,
+}, {
name: 'messaging',
path: '/my/messaging',
component: page(() => import('./pages/messaging/index.vue')),
diff --git a/packages/frontend/src/scripts/achievements.ts b/packages/frontend/src/scripts/achievements.ts
new file mode 100644
index 0000000000..c8245ad3db
--- /dev/null
+++ b/packages/frontend/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<typeof ACHIEVEMENT_TYPES[number], {
+ img: string;
+ bg: string | null;
+ frame: 'bronze' | 'silver' | 'gold' | 'platinum';
+}>;
+
+export const claimedAchievements = ($i && $i.achievements) ? $i.achievements.map(x => x.name) : [];
+
+export function claimAchievement(type: typeof ACHIEVEMENT_TYPES[number]) {
+ if (claimedAchievements.includes(type)) return;
+ os.api('i/claim-achievement', { name: type });
+ claimedAchievements.push(type);
+}
+
+if (_DEV_) {
+ (window as any).unlockAllAchievements = async () => {
+ for (const t of ACHIEVEMENT_TYPES) {
+ await new Promise(resolve => setTimeout(resolve, 100));
+ claimAchievement(t);
+ }
+ };
+}
diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts
index 7a426ec722..da7f2a5c20 100644
--- a/packages/frontend/src/scripts/get-note-menu.ts
+++ b/packages/frontend/src/scripts/get-note-menu.ts
@@ -1,6 +1,7 @@
import { defineAsyncComponent, Ref, inject } from 'vue';
import * as misskey from 'misskey-js';
import { pleaseLogin } from './please-login';
+import { claimAchievement } from './achievements';
import { $i } from '@/account';
import { i18n } from '@/i18n';
import { instance } from '@/instance';
@@ -38,6 +39,10 @@ export function getNoteMenu(props: {
os.api('notes/delete', {
noteId: appearNote.id,
});
+
+ if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60) {
+ claimAchievement('noteDeletedWithin1min');
+ }
});
}
@@ -53,10 +58,15 @@ export function getNoteMenu(props: {
});
os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel });
+
+ if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60) {
+ claimAchievement('noteDeletedWithin1min');
+ }
});
}
function toggleFavorite(favorite: boolean): void {
+ claimAchievement('noteFavorited1');
os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
noteId: appearNote.id,
});
@@ -118,11 +128,13 @@ export function getNoteMenu(props: {
const clip = await os.apiWithDialog('clips/create', result);
+ claimAchievement('noteClipped1');
os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id });
},
}, null, ...clips.map(clip => ({
text: clip.name,
action: () => {
+ claimAchievement('noteClipped1');
os.promiseDialog(
os.api('clips/add-note', { clipId: clip.id, noteId: appearNote.id }),
null,