mirror of
https://git.joinsharkey.org/Sharkey/Sharkey.git
synced 2024-12-23 19:23:10 +02:00
Achievements (#9665)
* wip
* Update ja-JP.yml
* wip
* wip
* Update MkAchievements.vue
* wip
* 🎨
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
This commit is contained in:
parent
b8afabde2c
commit
65cd605b73
34 changed files with 1385 additions and 18 deletions
|
@ -938,6 +938,222 @@ cannotPerformTemporary: "一時的に利用できません"
|
|||
cannotPerformTemporaryDescription: "操作回数が制限を超過するため一時的に利用できません。しばらく時間を置いてから再度お試しください。"
|
||||
preset: "プリセット"
|
||||
selectFromPresets: "プリセットから選択"
|
||||
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"
|
||||
|
||||
_role:
|
||||
new: "ロールの作成"
|
||||
|
@ -1635,6 +1851,7 @@ _notification:
|
|||
pollEnded: "アンケートの結果が出ました"
|
||||
unreadAntennaNote: "アンテナ {name}"
|
||||
emptyPushNotificationMessage: "プッシュ通知の更新をしました"
|
||||
achievementEarned: "実績を獲得"
|
||||
|
||||
_types:
|
||||
all: "すべて"
|
||||
|
|
33
packages/backend/migration/1674118260469-achievement.js
Normal file
33
packages/backend/migration/1674118260469-achievement.js
Normal 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"`);
|
||||
}
|
||||
}
|
11
packages/backend/migration/1674255666603-loggedInDates.js
Normal file
11
packages/backend/migration/1674255666603-loggedInDates.js
Normal 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"`);
|
||||
}
|
||||
}
|
114
packages/backend/src/core/AchievementService.ts
Normal file
114
packages/backend/src/core/AchievementService.ts
Normal file
|
@ -0,0 +1,114 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { UserProfilesRepository, UsersRepository } from '@/models/index.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { CreateNotificationService } from '@/core/CreateNotificationService.js';
|
||||
|
||||
const ACHIEVEMENT_TYPES = [
|
||||
'notes1',
|
||||
'notes10',
|
||||
'notes100',
|
||||
'notes500',
|
||||
'notes1000',
|
||||
'notes5000',
|
||||
'notes10000',
|
||||
'notes20000',
|
||||
'notes30000',
|
||||
'notes40000',
|
||||
'notes50000',
|
||||
'notes60000',
|
||||
'notes70000',
|
||||
'notes80000',
|
||||
'notes90000',
|
||||
'notes100000',
|
||||
'login3',
|
||||
'login7',
|
||||
'login15',
|
||||
'login30',
|
||||
'login60',
|
||||
'login100',
|
||||
'login200',
|
||||
'login300',
|
||||
'login400',
|
||||
'login500',
|
||||
'login600',
|
||||
'login700',
|
||||
'login800',
|
||||
'login900',
|
||||
'login1000',
|
||||
'passedSinceAccountCreated1',
|
||||
'passedSinceAccountCreated2',
|
||||
'passedSinceAccountCreated3',
|
||||
'loggedInOnBirthday',
|
||||
'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;
|
||||
|
||||
@Injectable()
|
||||
export class AchievementService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
private createNotificationService: CreateNotificationService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async create(
|
||||
userId: User['id'],
|
||||
type: string,
|
||||
): Promise<void> {
|
||||
if (!ACHIEVEMENT_TYPES.includes(type)) return;
|
||||
|
||||
const date = Date.now();
|
||||
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: userId });
|
||||
|
||||
if (profile.achievements.some(a => a.name === type)) return;
|
||||
|
||||
await this.userProfilesRepository.update(userId, {
|
||||
achievements: [...profile.achievements, {
|
||||
name: type,
|
||||
unlockedAt: date,
|
||||
}],
|
||||
});
|
||||
|
||||
this.createNotificationService.createNotification(userId, 'achievementEarned', {
|
||||
achievement: type,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -4,6 +4,7 @@ import { AccountUpdateService } from './AccountUpdateService.js';
|
|||
import { AiService } from './AiService.js';
|
||||
import { AntennaService } from './AntennaService.js';
|
||||
import { AppLockService } from './AppLockService.js';
|
||||
import { AchievementService } from './AchievementService.js';
|
||||
import { CaptchaService } from './CaptchaService.js';
|
||||
import { CreateNotificationService } from './CreateNotificationService.js';
|
||||
import { CreateSystemUserService } from './CreateSystemUserService.js';
|
||||
|
@ -128,6 +129,7 @@ const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useEx
|
|||
const $AiService: Provider = { provide: 'AiService', useExisting: AiService };
|
||||
const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService };
|
||||
const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService };
|
||||
const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService };
|
||||
const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService };
|
||||
const $CreateNotificationService: Provider = { provide: 'CreateNotificationService', useExisting: CreateNotificationService };
|
||||
const $CreateSystemUserService: Provider = { provide: 'CreateSystemUserService', useExisting: CreateSystemUserService };
|
||||
|
@ -255,6 +257,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
AiService,
|
||||
AntennaService,
|
||||
AppLockService,
|
||||
AchievementService,
|
||||
CaptchaService,
|
||||
CreateNotificationService,
|
||||
CreateSystemUserService,
|
||||
|
@ -376,6 +379,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$AiService,
|
||||
$AntennaService,
|
||||
$AppLockService,
|
||||
$AchievementService,
|
||||
$CaptchaService,
|
||||
$CreateNotificationService,
|
||||
$CreateSystemUserService,
|
||||
|
@ -498,6 +502,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
AiService,
|
||||
AntennaService,
|
||||
AppLockService,
|
||||
AchievementService,
|
||||
CaptchaService,
|
||||
CreateNotificationService,
|
||||
CreateSystemUserService,
|
||||
|
@ -618,6 +623,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$AiService,
|
||||
$AntennaService,
|
||||
$AppLockService,
|
||||
$AchievementService,
|
||||
$CaptchaService,
|
||||
$CreateNotificationService,
|
||||
$CreateSystemUserService,
|
||||
|
|
|
@ -114,6 +114,9 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
...(notification.type === 'groupInvited' ? {
|
||||
invitation: this.userGroupInvitationEntityService.pack(notification.userGroupInvitationId!),
|
||||
} : {}),
|
||||
...(notification.type === 'achievementEarned' ? {
|
||||
achievement: notification.achievement,
|
||||
} : {}),
|
||||
...(notification.type === 'app' ? {
|
||||
body: notification.customBody,
|
||||
header: notification.customHeader ?? token?.name,
|
||||
|
|
|
@ -12,7 +12,7 @@ import { Cache } from '@/misc/cache.js';
|
|||
import type { Instance } from '@/models/entities/Instance.js';
|
||||
import type { ILocalUser, IRemoteUser, User } from '@/models/entities/User.js';
|
||||
import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js';
|
||||
import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, NotificationsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, MessagingMessagesRepository, UserGroupJoiningsRepository, AnnouncementsRepository, AntennaNotesRepository, PagesRepository } from '@/models/index.js';
|
||||
import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, NotificationsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, MessagingMessagesRepository, UserGroupJoiningsRepository, AnnouncementsRepository, AntennaNotesRepository, PagesRepository, UserProfile } from '@/models/index.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
|
@ -343,6 +343,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
options?: {
|
||||
detail?: D,
|
||||
includeSecrets?: boolean,
|
||||
userProfile?: UserProfile,
|
||||
},
|
||||
): Promise<IsMeAndIsUserDetailed<ExpectsMe, D>> {
|
||||
const opts = Object.assign({
|
||||
|
@ -375,7 +376,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
.innerJoinAndSelect('pin.note', 'note')
|
||||
.orderBy('pin.id', 'DESC')
|
||||
.getMany() : [];
|
||||
const profile = opts.detail ? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }) : null;
|
||||
const profile = opts.detail ? (opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id })) : null;
|
||||
|
||||
const followingCount = profile == null ? null :
|
||||
(profile.ffVisibility === 'public') || isMe ? user.followingCount :
|
||||
|
@ -493,6 +494,8 @@ export class UserEntityService implements OnModuleInit {
|
|||
mutingNotificationTypes: profile!.mutingNotificationTypes,
|
||||
emailNotificationTypes: profile!.emailNotificationTypes,
|
||||
showTimelineReplies: user.showTimelineReplies ?? falsy,
|
||||
achievements: profile!.achievements,
|
||||
loggedInDays: profile!.loggedInDates.length,
|
||||
} : {}),
|
||||
|
||||
...(opts.includeSecrets ? {
|
||||
|
|
|
@ -64,6 +64,7 @@ export class Notification {
|
|||
* receiveFollowRequest - フォローリクエストされた
|
||||
* followRequestAccepted - 自分の送ったフォローリクエストが承認された
|
||||
* groupInvited - グループに招待された
|
||||
* achievementEarned - 実績を獲得
|
||||
* app - アプリ通知
|
||||
*/
|
||||
@Index()
|
||||
|
@ -129,6 +130,11 @@ export class Notification {
|
|||
})
|
||||
public choice: number | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128, nullable: true,
|
||||
})
|
||||
public achievement: string | null;
|
||||
|
||||
/**
|
||||
* アプリ通知のbody
|
||||
*/
|
||||
|
|
|
@ -213,6 +213,19 @@ export class UserProfile {
|
|||
})
|
||||
public mutingNotificationTypes: typeof notificationTypes[number][];
|
||||
|
||||
@Column('varchar', {
|
||||
length: 32, array: true, default: '{}',
|
||||
})
|
||||
public loggedInDates: string[];
|
||||
|
||||
@Column('jsonb', {
|
||||
default: [],
|
||||
})
|
||||
public achievements: {
|
||||
name: string;
|
||||
unlockedAt: number;
|
||||
}[];
|
||||
|
||||
//#region Denormalized fields
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
|
|
|
@ -175,6 +175,7 @@ import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js';
|
|||
import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js';
|
||||
import * as ep___i_apps from './endpoints/i/apps.js';
|
||||
import * as ep___i_authorizedApps from './endpoints/i/authorized-apps.js';
|
||||
import * as ep___i_claimAchievement from './endpoints/i/claim-achievement.js';
|
||||
import * as ep___i_changePassword from './endpoints/i/change-password.js';
|
||||
import * as ep___i_deleteAccount from './endpoints/i/delete-account.js';
|
||||
import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js';
|
||||
|
@ -329,6 +330,7 @@ import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by
|
|||
import * as ep___users_search from './endpoints/users/search.js';
|
||||
import * as ep___users_show from './endpoints/users/show.js';
|
||||
import * as ep___users_stats from './endpoints/users/stats.js';
|
||||
import * as ep___users_achievements from './endpoints/users/achievements.js';
|
||||
import * as ep___fetchRss from './endpoints/fetch-rss.js';
|
||||
import * as ep___retention from './endpoints/retention.js';
|
||||
import { GetterService } from './GetterService.js';
|
||||
|
@ -509,6 +511,7 @@ const $i_2fa_removeKey: Provider = { provide: 'ep:i/2fa/remove-key', useClass: e
|
|||
const $i_2fa_unregister: Provider = { provide: 'ep:i/2fa/unregister', useClass: ep___i_2fa_unregister.default };
|
||||
const $i_apps: Provider = { provide: 'ep:i/apps', useClass: ep___i_apps.default };
|
||||
const $i_authorizedApps: Provider = { provide: 'ep:i/authorized-apps', useClass: ep___i_authorizedApps.default };
|
||||
const $i_claimAchievement: Provider = { provide: 'ep:i/claim-achievement', useClass: ep___i_claimAchievement.default };
|
||||
const $i_changePassword: Provider = { provide: 'ep:i/change-password', useClass: ep___i_changePassword.default };
|
||||
const $i_deleteAccount: Provider = { provide: 'ep:i/delete-account', useClass: ep___i_deleteAccount.default };
|
||||
const $i_exportBlocking: Provider = { provide: 'ep:i/export-blocking', useClass: ep___i_exportBlocking.default };
|
||||
|
@ -663,6 +666,7 @@ const $users_searchByUsernameAndHost: Provider = { provide: 'ep:users/search-by-
|
|||
const $users_search: Provider = { provide: 'ep:users/search', useClass: ep___users_search.default };
|
||||
const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_show.default };
|
||||
const $users_stats: Provider = { provide: 'ep:users/stats', useClass: ep___users_stats.default };
|
||||
const $users_achievements: Provider = { provide: 'ep:users/achievements', useClass: ep___users_achievements.default };
|
||||
const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default };
|
||||
const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default };
|
||||
|
||||
|
@ -847,6 +851,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||
$i_2fa_unregister,
|
||||
$i_apps,
|
||||
$i_authorizedApps,
|
||||
$i_claimAchievement,
|
||||
$i_changePassword,
|
||||
$i_deleteAccount,
|
||||
$i_exportBlocking,
|
||||
|
@ -1001,6 +1006,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||
$users_search,
|
||||
$users_show,
|
||||
$users_stats,
|
||||
$users_achievements,
|
||||
$fetchRss,
|
||||
$retention,
|
||||
],
|
||||
|
@ -1179,6 +1185,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||
$i_2fa_unregister,
|
||||
$i_apps,
|
||||
$i_authorizedApps,
|
||||
$i_claimAchievement,
|
||||
$i_changePassword,
|
||||
$i_deleteAccount,
|
||||
$i_exportBlocking,
|
||||
|
@ -1331,6 +1338,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||
$users_search,
|
||||
$users_show,
|
||||
$users_stats,
|
||||
$users_achievements,
|
||||
$fetchRss,
|
||||
$retention,
|
||||
],
|
||||
|
|
|
@ -174,6 +174,7 @@ import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js';
|
|||
import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js';
|
||||
import * as ep___i_apps from './endpoints/i/apps.js';
|
||||
import * as ep___i_authorizedApps from './endpoints/i/authorized-apps.js';
|
||||
import * as ep___i_claimAchievement from './endpoints/i/claim-achievement.js';
|
||||
import * as ep___i_changePassword from './endpoints/i/change-password.js';
|
||||
import * as ep___i_deleteAccount from './endpoints/i/delete-account.js';
|
||||
import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js';
|
||||
|
@ -328,6 +329,7 @@ import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by
|
|||
import * as ep___users_search from './endpoints/users/search.js';
|
||||
import * as ep___users_show from './endpoints/users/show.js';
|
||||
import * as ep___users_stats from './endpoints/users/stats.js';
|
||||
import * as ep___users_achievements from './endpoints/users/achievements.js';
|
||||
import * as ep___fetchRss from './endpoints/fetch-rss.js';
|
||||
import * as ep___retention from './endpoints/retention.js';
|
||||
|
||||
|
@ -506,6 +508,7 @@ const eps = [
|
|||
['i/2fa/unregister', ep___i_2fa_unregister],
|
||||
['i/apps', ep___i_apps],
|
||||
['i/authorized-apps', ep___i_authorizedApps],
|
||||
['i/claim-achievement', ep___i_claimAchievement],
|
||||
['i/change-password', ep___i_changePassword],
|
||||
['i/delete-account', ep___i_deleteAccount],
|
||||
['i/export-blocking', ep___i_exportBlocking],
|
||||
|
@ -660,6 +663,7 @@ const eps = [
|
|||
['users/search', ep___users_search],
|
||||
['users/show', ep___users_show],
|
||||
['users/stats', ep___users_stats],
|
||||
['users/achievements', ep___users_achievements],
|
||||
['fetch-rss', ep___fetchRss],
|
||||
['retention', ep___retention],
|
||||
];
|
||||
|
|
|
@ -28,8 +28,8 @@ export const meta = {
|
|||
|
||||
recursiveNesting: {
|
||||
message: 'It can not be structured like nesting folders recursively.',
|
||||
code: 'NO_SUCH_PARENT_FOLDER',
|
||||
id: 'ce104e3a-faaf-49d5-b459-10ff0cbbcaa1',
|
||||
code: 'RECURSIVE_NESTING',
|
||||
id: 'dbeb024837894013aed44279f9199740',
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { UsersRepository } from '@/models/index.js';
|
||||
import type { UserProfilesRepository, UsersRepository } from '@/models/index.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
@ -29,15 +29,36 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, user, token) => {
|
||||
const isSecure = token == null;
|
||||
|
||||
// ここで渡ってきている user はキャッシュされていて古い可能性もあるので id だけ渡す
|
||||
return await this.userEntityService.pack<true, true>(user.id, user, {
|
||||
const now = new Date();
|
||||
const today = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`;
|
||||
|
||||
// 渡ってきている user はキャッシュされていて古い可能性があるので改めて取得
|
||||
const userProfile = await this.userProfilesRepository.findOneOrFail({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
relations: ['user'],
|
||||
});
|
||||
|
||||
if (!userProfile.loggedInDates.includes(today)) {
|
||||
this.userProfilesRepository.update({ userId: user.id }, {
|
||||
loggedInDates: [...userProfile.loggedInDates, today],
|
||||
});
|
||||
userProfile.loggedInDates = [...userProfile.loggedInDates, today];
|
||||
}
|
||||
|
||||
return await this.userEntityService.pack<true, true>(userProfile.user!, userProfile.user!, {
|
||||
detail: true,
|
||||
includeSecrets: isSecure,
|
||||
userProfile,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { AchievementService } from '@/core/AchievementService.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
},
|
||||
required: ['name'],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
private achievementService: AchievementService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
await this.achievementService.create(me.id, ps.name);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { UserProfilesRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
userId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['userId'],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: ps.userId });
|
||||
|
||||
return profile.achievements;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app'] as const;
|
||||
export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'achievementEarned', 'app'] as const;
|
||||
|
||||
export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
|
224
packages/frontend/src/components/MkAchievements.vue
Normal file
224
packages/frontend/src/components/MkAchievements.vue
Normal file
|
@ -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>
|
|
@ -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(() => {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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',
|
||||
|
|
25
packages/frontend/src/pages/achievements.vue
Normal file
25
packages/frontend/src/pages/achievements.vue
Normal 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: 'ti ti-military-award',
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
||||
</style>
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -427,6 +427,10 @@ export const routes = [{
|
|||
path: '/my/favorites',
|
||||
component: page(() => import('./pages/favorites.vue')),
|
||||
loginRequired: true,
|
||||
}, {
|
||||
path: '/my/achievements',
|
||||
component: page(() => import('./pages/achievements.vue')),
|
||||
loginRequired: true,
|
||||
}, {
|
||||
name: 'messaging',
|
||||
path: '/my/messaging',
|
||||
|
|
425
packages/frontend/src/scripts/achievements.ts
Normal file
425
packages/frontend/src/scripts/achievements.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue