diff --git a/packages/backend/src/migration/1705528046452-federated-bite.ts b/packages/backend/src/migration/1705528046452-federated-bite.ts index 90d31c5b0..eb969470b 100644 --- a/packages/backend/src/migration/1705528046452-federated-bite.ts +++ b/packages/backend/src/migration/1705528046452-federated-bite.ts @@ -8,7 +8,7 @@ export class FederatedBite1705528046452 implements MigrationInterface { await queryRunner.query(`CREATE TABLE "bite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "uri" character varying(512), "userId" character varying(32) NOT NULL, "targetType" "public"."bite_targettype_enum" NOT NULL, "targetUserId" character varying(32), "targetBiteId" character varying(32), "replied" boolean NOT NULL DEFAULT true, CONSTRAINT "CHK_c3a20c5756ccff3133f8927500" CHECK ("targetUserId" IS NOT NULL OR "targetBiteId" IS NOT NULL), CONSTRAINT "PK_1887f3f621a4a7655a1b78bfd66" PRIMARY KEY ("id")); COMMENT ON COLUMN "bite"."uri" IS 'null if local'`); await queryRunner.query(`ALTER TABLE "notification" ADD "biteId" character varying(32)`); await queryRunner.query(`ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum" RENAME TO "user_profile_mutingnotificationtypes_enum_old"`); - await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'bite')`); + await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'achievementEarned', 'app', 'bite')`); await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" DROP DEFAULT`); await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" TYPE "public"."user_profile_mutingnotificationtypes_enum"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum"[]`); await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`); @@ -18,7 +18,7 @@ export class FederatedBite1705528046452 implements MigrationInterface { await queryRunner.query(`ALTER TABLE "bite" ADD CONSTRAINT "FK_5d5f68610583f2e0b6785d3c0e9" FOREIGN KEY ("targetBiteId") REFERENCES "bite"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); await queryRunner.query(`ALTER TABLE "notification" ADD CONSTRAINT "FK_c54844158c1eead7042e7ca4c83" FOREIGN KEY ("biteId") REFERENCES "bite"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); await queryRunner.query(`ALTER TYPE "public"."notification_type_enum" RENAME TO "notification_type_enum_old"`); - await queryRunner.query(`CREATE TYPE "public"."notification_type_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'bite')`); + await queryRunner.query(`CREATE TYPE "public"."notification_type_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'achievementEarned', 'app', 'bite')`); await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum" USING "type"::"text"::"public"."notification_type_enum"`); await queryRunner.query(`DROP TYPE "public"."notification_type_enum_old"`); } @@ -28,7 +28,7 @@ export class FederatedBite1705528046452 implements MigrationInterface { await queryRunner.query(`ALTER TABLE "bite" DROP CONSTRAINT "FK_5d5f68610583f2e0b6785d3c0e9"`); await queryRunner.query(`ALTER TABLE "bite" DROP CONSTRAINT "FK_a646fbbeb6efa2531c75fec46b9"`); await queryRunner.query(`ALTER TABLE "bite" DROP CONSTRAINT "FK_8d00aa79e157364ac1f60c15098"`); - await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`); + await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'achievementEarned', 'app')`); await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" DROP DEFAULT`); await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" TYPE "public"."user_profile_mutingnotificationtypes_enum_old"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum_old"[]`); await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`); @@ -37,7 +37,7 @@ export class FederatedBite1705528046452 implements MigrationInterface { await queryRunner.query(`ALTER TABLE "notification" DROP COLUMN "biteId"`); await queryRunner.query(`DROP TABLE "bite"`); await queryRunner.query(`DROP TYPE "public"."bite_targettype_enum"`); - await queryRunner.query(`CREATE TYPE "public"."notification_type_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`); + await queryRunner.query(`CREATE TYPE "public"."notification_type_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'achievementEarned', 'app')`); await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum_old" USING "type"::"text"::"public"."notification_type_enum_old"`); await queryRunner.query(`DROP TYPE "public"."notification_type_enum"`); await queryRunner.query(`ALTER TYPE "public"."notification_type_enum_old" RENAME TO "notification_type_enum"`); diff --git a/packages/backend/src/models/entities/notification.ts b/packages/backend/src/models/entities/notification.ts index f8619a655..4d30fecf8 100644 --- a/packages/backend/src/models/entities/notification.ts +++ b/packages/backend/src/models/entities/notification.ts @@ -138,6 +138,11 @@ export class Notification { }) public choice: number | null; + @Column('varchar', { + length: 128, nullable: true, + }) + public achievement: string | null; + /** * App notification body */ diff --git a/packages/backend/src/models/entities/user-profile.ts b/packages/backend/src/models/entities/user-profile.ts index f53360862..64b4448e6 100644 --- a/packages/backend/src/models/entities/user-profile.ts +++ b/packages/backend/src/models/entities/user-profile.ts @@ -239,6 +239,19 @@ export class UserProfile { }) public mutingNotificationTypes: typeof notificationTypes[number][]; + @Column('varchar', { + length: 32, array: true, default: '{}', + }) + public loggedInDates: string[]; + + @Column('jsonb', { + default: [], + }) + public achievements: { + name: string; + unlockedAt: number; + }[]; + //#region Denormalized fields @Index() @Column("varchar", { diff --git a/packages/backend/src/models/repositories/notification.ts b/packages/backend/src/models/repositories/notification.ts index 79526fe10..f33ae2f9f 100644 --- a/packages/backend/src/models/repositories/notification.ts +++ b/packages/backend/src/models/repositories/notification.ts @@ -149,6 +149,11 @@ export const NotificationRepository = db.getRepository(Notification).extend({ bite: notification.bite ?? await Bites.findOneBy({ id: notification.biteId! }), } : {}), + ...(notification.type === "achievementEarned" + ? { + achievement: notification.achievement, + } + : {}), }); }, diff --git a/packages/backend/src/models/repositories/user.ts b/packages/backend/src/models/repositories/user.ts index 37a84779a..5c9b6a398 100644 --- a/packages/backend/src/models/repositories/user.ts +++ b/packages/backend/src/models/repositories/user.ts @@ -37,6 +37,7 @@ import { } from "../index.js"; import type { Instance } from "../entities/instance.js"; import AsyncLock from "async-lock"; +import { UserProfile } from "../entities/user-profile.js"; const userInstanceCache = new Cache( "userInstance", @@ -412,6 +413,7 @@ export const UserRepository = db.getRepository(User).extend({ detail?: D; includeSecrets?: boolean; isPrivateMode?: boolean; + userProfile?: UserProfile, }, ): Promise> { const opts = Object.assign( @@ -447,9 +449,7 @@ export const UserRepository = db.getRepository(User).extend({ .orderBy("pin.id", "DESC") .getMany() : []; - const profile = opts.detail - ? await UserProfiles.findOneByOrFail({ userId: user.id }) - : null; + const profile = opts.detail ? (opts.userProfile ?? await UserProfiles.findOneByOrFail({ userId: user.id })) : null; const followingCount = profile == null @@ -625,6 +625,8 @@ export const UserRepository = db.getRepository(User).extend({ mutedInstances: profile!.mutedInstances, mutingNotificationTypes: profile!.mutingNotificationTypes, emailNotificationTypes: profile!.emailNotificationTypes, + achievements: profile!.achievements, + loggedInDays: profile!.loggedInDates.length, } : {}), diff --git a/packages/backend/src/models/schema/notification.ts b/packages/backend/src/models/schema/notification.ts index cb6bc47fe..f53f954ad 100644 --- a/packages/backend/src/models/schema/notification.ts +++ b/packages/backend/src/models/schema/notification.ts @@ -81,5 +81,10 @@ export const packedNotificationSchema = { optional: true, nullable: true, }, + achievement: { + type: "object", + optional: true, + nullable: true, + }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index bf12e4f83..d7e484207 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -175,6 +175,7 @@ import * as ep___i_2fa_removeKey from "./endpoints/i/2fa/remove-key.js"; import * as ep___i_2fa_unregister from "./endpoints/i/2fa/unregister.js"; import * as ep___i_apps from "./endpoints/i/apps.js"; import * as ep___i_authorizedApps from "./endpoints/i/authorized-apps.js"; +import * as ep___i_claimAchievement from './endpoints/i/claim-achievement.js'; import * as ep___i_changePassword from "./endpoints/i/change-password.js"; import * as ep___i_deleteAccount from "./endpoints/i/delete-account.js"; import * as ep___i_exportBlocking from "./endpoints/i/export-blocking.js"; @@ -332,6 +333,7 @@ import * as ep___users_searchByUsernameAndHost from "./endpoints/users/search-by import * as ep___users_search from "./endpoints/users/search.js"; import * as ep___users_show from "./endpoints/users/show.js"; import * as ep___users_stats from "./endpoints/users/stats.js"; +import * as ep___users_achievements from './endpoints/users/achievements.js'; import * as ep___fetchRss from "./endpoints/fetch-rss.js"; import * as ep___admin_driveCapOverride from "./endpoints/admin/drive-capacity-override.js"; import * as ep___bites_create from "./endpoints/bites/create.js"; @@ -525,6 +527,7 @@ const eps = [ ["i/2fa/unregister", ep___i_2fa_unregister], ["i/apps", ep___i_apps], ["i/authorized-apps", ep___i_authorizedApps], + ["i/claim-achievement", ep___i_claimAchievement], ["i/change-password", ep___i_changePassword], ["i/delete-account", ep___i_deleteAccount], ["i/export-blocking", ep___i_exportBlocking], @@ -681,6 +684,7 @@ const eps = [ ["users/search", ep___users_search], ["users/show", ep___users_show], ["users/stats", ep___users_stats], + ["users/achievements", ep___users_achievements], ["admin/drive-capacity-override", ep___admin_driveCapOverride], ["fetch-rss", ep___fetchRss], ["get-sounds", ep___sounds], diff --git a/packages/backend/src/server/api/endpoints/i.ts b/packages/backend/src/server/api/endpoints/i.ts index 39543442c..ac7675a81 100644 --- a/packages/backend/src/server/api/endpoints/i.ts +++ b/packages/backend/src/server/api/endpoints/i.ts @@ -1,4 +1,4 @@ -import { Users } from "@/models/index.js"; +import { UserProfiles, Users } from "@/models/index.js"; import define from "../define.js"; export const meta = { @@ -23,9 +23,27 @@ export const paramDef = { export default define(meta, paramDef, async (ps, user, token) => { const isSecure = token == null; - // ここで渡ってきている user はキャッシュされていて古い可能性もあるので id だけ渡す - return await Users.pack(user.id, user, { + const now = new Date(); + const today = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`; + + // 渡ってきている user はキャッシュされていて古い可能性があるので改めて取得 + const userProfile = await UserProfiles.findOneOrFail({ + where: { + userId: user.id, + }, + relations: ['user'], + }); + + if (!userProfile.loggedInDates.includes(today)) { + UserProfiles.update({ userId: user.id }, { + loggedInDates: [...userProfile.loggedInDates, today], + }); + userProfile.loggedInDates = [...userProfile.loggedInDates, today]; + } + + return await Users.pack(userProfile.user!, userProfile.user!, { detail: true, includeSecrets: isSecure, + userProfile, }); }); diff --git a/packages/backend/src/server/api/endpoints/i/claim-achievement.ts b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts new file mode 100644 index 000000000..b0970b3fd --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts @@ -0,0 +1,18 @@ +import { createAchievement } from '@/services/achievement-service.js'; +import define from "../../define.js"; + +export const meta = { + requireCredential: true, +} as const; + +export const paramDef = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + await createAchievement(me.id, ps.name); +}); diff --git a/packages/backend/src/server/api/endpoints/users/achievements.ts b/packages/backend/src/server/api/endpoints/users/achievements.ts new file mode 100644 index 000000000..3b4c07602 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/achievements.ts @@ -0,0 +1,22 @@ +import { UserProfiles } from '@/models/index.js'; +import define from "../../define.js"; + +export const meta = { + tags: ["users", "achievements"], + requireCredential: true, + description: "Show all achievements this user made.", +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const profile = await UserProfiles.findOneByOrFail({ userId: ps.userId }); + + return profile.achievements; +}); diff --git a/packages/backend/src/services/achievement-service.ts b/packages/backend/src/services/achievement-service.ts new file mode 100644 index 000000000..9cb6b828e --- /dev/null +++ b/packages/backend/src/services/achievement-service.ts @@ -0,0 +1,96 @@ +import { UserProfiles, Users } from '@/models/index.js'; +import type { User } from '@/models/entities/user.js'; +import { createNotification } from '@/services/create-notification.js'; + +const ACHIEVEMENT_TYPES = [ + 'notes1', + 'notes10', + 'notes100', + 'notes500', + 'notes1000', + 'notes5000', + 'notes10000', + 'notes20000', + 'notes30000', + 'notes40000', + 'notes50000', + 'notes60000', + 'notes70000', + 'notes80000', + 'notes90000', + 'notes100000', + 'login3', + 'login7', + 'login15', + 'login30', + 'login60', + 'login100', + 'login200', + 'login300', + 'login400', + 'login500', + 'login600', + 'login700', + 'login800', + 'login900', + 'login1000', + 'passedSinceAccountCreated1', + 'passedSinceAccountCreated2', + 'passedSinceAccountCreated3', + 'loggedInOnBirthday', + 'noteClipped1', + 'noteFavorited1', + 'profileFilled', + 'markedAsCat', + 'following1', + 'following10', + 'following50', + 'following100', + 'following300', + 'followers1', + 'followers10', + 'followers50', + 'followers100', + 'followers300', + 'followers500', + 'followers1000', + 'collectAchievements30', + 'iLoveMisskey', + 'client30min', + 'noteDeletedWithin1min', + 'postedAtLateNight', + 'postedAt0min0sec', + 'selfQuote', + 'htl20npm', + 'driveFolderCircularReference', + 'reactWithoutRead', + 'clickedClickHere', + 'justPlainLucky', + 'setNameToSyuilo', + 'cookieClicked', + 'brainDiver', +] as const; + +export async function createAchievement( + userId: User['id'], + type: string, +) { + if (!ACHIEVEMENT_TYPES.includes(type)) return; + + const date = Date.now(); + + const profile = await UserProfiles.findOneByOrFail({ userId: userId }); + + if (profile.achievements.some(a => a.name === type)) return; + + await UserProfiles.update(userId, { + achievements: [...profile.achievements, { + name: type, + unlockedAt: date, + }], + }); + + createNotification(userId, 'achievementEarned', { + achievement: type, + }); +} diff --git a/packages/client/src/components/MkPostForm.vue b/packages/client/src/components/MkPostForm.vue index 266e83827..6619c6b95 100644 --- a/packages/client/src/components/MkPostForm.vue +++ b/packages/client/src/components/MkPostForm.vue @@ -912,7 +912,7 @@ async function post() { } const text = postData.text?.toLowerCase() ?? ''; - if ((text.includes('love') || text.includes('❤')) && text.includes('misskey')) { + if ((text.includes('love') || text.includes('❤')) && text.includes('trashposs')) { claimAchievement('iLoveMisskey'); } if (text.includes('Efrlqw8ytg4'.toLowerCase()) || text.includes('XVCwzwxdHuA'.toLowerCase())) { diff --git a/packages/client/src/pages/settings/profile.vue b/packages/client/src/pages/settings/profile.vue index 255bf1cac..ea5f46a3b 100644 --- a/packages/client/src/pages/settings/profile.vue +++ b/packages/client/src/pages/settings/profile.vue @@ -245,7 +245,7 @@ function save() { speakAsCat: !!profile.speakAsCat, }); claimAchievement('profileFilled'); - if (profile.name === 'syuilo' || profile.name === 'しゅいろ') { + if (profile.name === 'Crimekillz' || profile.name === 'crimekillz') { claimAchievement('setNameToSyuilo'); } if (profile.isCat) { diff --git a/packages/client/src/scripts/achievements.ts b/packages/client/src/scripts/achievements.ts index c8245ad3d..11823971c 100644 --- a/packages/client/src/scripts/achievements.ts +++ b/packages/client/src/scripts/achievements.ts @@ -72,332 +72,332 @@ export const ACHIEVEMENT_TYPES = [ export const ACHIEVEMENT_BADGES = { 'notes1': { - img: '/fluent-emoji/1f4dd.png', + img: '/twemoji/1f4dd.svg', bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', frame: 'bronze', }, 'notes10': { - img: '/fluent-emoji/1f4d1.png', + img: '/twemoji/1f4d1.svg', bg: null, frame: 'bronze', }, 'notes100': { - img: '/fluent-emoji/1f4d2.png', + img: '/twemoji/1f4d2.svg', bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', frame: 'bronze', }, 'notes500': { - img: '/fluent-emoji/1f4da.png', + img: '/twemoji/1f4da.svg', bg: null, frame: 'bronze', }, 'notes1000': { - img: '/fluent-emoji/1f5c3.png', + img: '/twemoji/1f5c3.svg', bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', frame: 'bronze', }, 'notes5000': { - img: '/fluent-emoji/1f304.png', + img: '/twemoji/1f304.svg', bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', frame: 'bronze', }, 'notes10000': { - img: '/fluent-emoji/1f3d9.png', + img: '/twemoji/1f3d9.svg', bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', frame: 'silver', }, 'notes20000': { - img: '/fluent-emoji/1f307.png', + img: '/twemoji/1f307.svg', bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', frame: 'silver', }, 'notes30000': { - img: '/fluent-emoji/1f306.png', + img: '/twemoji/1f306.svg', bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', frame: 'silver', }, 'notes40000': { - img: '/fluent-emoji/1f303.png', + img: '/twemoji/1f303.svg', bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))', frame: 'silver', }, 'notes50000': { - img: '/fluent-emoji/1fa90.png', + img: '/twemoji/1fa90.svg', bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', frame: 'gold', }, 'notes60000': { - img: '/fluent-emoji/2604.png', + img: '/twemoji/2604.svg', bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))', frame: 'gold', }, 'notes70000': { - img: '/fluent-emoji/1f30c.png', + img: '/twemoji/1f30c.svg', bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', frame: 'gold', }, 'notes80000': { - img: '/fluent-emoji/1f30c.png', + img: '/twemoji/1f30c.svg', bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))', frame: 'gold', }, 'notes90000': { - img: '/fluent-emoji/1f30c.png', + img: '/twemoji/1f30c.svg', bg: 'linear-gradient(0deg, rgb(255 232 119), rgb(255 140 41))', frame: 'gold', }, 'notes100000': { - img: '/fluent-emoji/267e.png', + img: '/twemoji/267e.svg', bg: 'linear-gradient(0deg, rgb(255 232 119), rgb(255 140 41))', frame: 'platinum', }, 'login3': { - img: '/fluent-emoji/1f331.png', + img: '/twemoji/1f331.svg', bg: null, frame: 'bronze', }, 'login7': { - img: '/fluent-emoji/1f331.png', + img: '/twemoji/1f331.svg', bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', frame: 'bronze', }, 'login15': { - img: '/fluent-emoji/1f331.png', + img: '/twemoji/1f331.svg', bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))', frame: 'bronze', }, 'login30': { - img: '/fluent-emoji/1fab4.png', + img: '/twemoji/1fab4.svg', bg: null, frame: 'bronze', }, 'login60': { - img: '/fluent-emoji/1fab4.png', + img: '/twemoji/1fab4.svg', bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', frame: 'bronze', }, 'login100': { - img: '/fluent-emoji/1fab4.png', + img: '/twemoji/1fab4.svg', bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))', frame: 'silver', }, 'login200': { - img: '/fluent-emoji/1f333.png', + img: '/twemoji/1f333.svg', bg: null, frame: 'silver', }, 'login300': { - img: '/fluent-emoji/1f333.png', + img: '/twemoji/1f333.svg', bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', frame: 'silver', }, 'login400': { - img: '/fluent-emoji/1f333.png', + img: '/twemoji/1f333.svg', bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))', frame: 'silver', }, 'login500': { - img: '/fluent-emoji/1f304.png', + img: '/twemoji/1f304.svg', bg: null, frame: 'silver', }, 'login600': { - img: '/fluent-emoji/1f304.png', + img: '/twemoji/1f304.svg', bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', frame: 'gold', }, 'login700': { - img: '/fluent-emoji/1f304.png', + img: '/twemoji/1f304.svg', bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))', frame: 'gold', }, 'login800': { - img: '/fluent-emoji/1f307.png', + img: '/twemoji/1f307.svg', bg: null, frame: 'gold', }, 'login900': { - img: '/fluent-emoji/1f307.png', + img: '/twemoji/1f307.svg', bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', frame: 'gold', }, 'login1000': { - img: '/fluent-emoji/1f307.png', + img: '/twemoji/1f307.svg', bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))', frame: 'platinum', }, 'noteClipped1': { - img: '/fluent-emoji/1f587.png', + img: '/twemoji/1f587.svg', bg: null, frame: 'bronze', }, 'noteFavorited1': { - img: '/fluent-emoji/1f31f.png', + img: '/twemoji/1f31f.svg', bg: null, frame: 'bronze', }, 'profileFilled': { - img: '/fluent-emoji/1f44c.png', + img: '/twemoji/1f44c.svg', bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))', frame: 'bronze', }, 'markedAsCat': { - img: '/fluent-emoji/1f408.png', + img: '/twemoji/1f408.svg', bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))', frame: 'bronze', }, 'following1': { - img: '/fluent-emoji/2618.png', + img: '/twemoji/2618.svg', bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', frame: 'bronze', }, 'following10': { - img: '/fluent-emoji/1f6b8.png', + img: '/twemoji/1f6b8.svg', bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', frame: 'bronze', }, 'following50': { - img: '/fluent-emoji/1f91d.png', + img: '/twemoji/1f91d.svg', bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', frame: 'bronze', }, 'following100': { - img: '/fluent-emoji/1f4af.png', + img: '/twemoji/1f4af.svg', bg: 'linear-gradient(0deg, rgb(255 53 184), rgb(255 206 69))', frame: 'silver', }, 'following300': { - img: '/fluent-emoji/1f970.png', + img: '/twemoji/1f970.svg', bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', frame: 'silver', }, 'followers1': { - img: '/fluent-emoji/2618.png', + img: '/twemoji/2618.svg', bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', frame: 'bronze', }, 'followers10': { - img: '/fluent-emoji/1f44b.png', + img: '/twemoji/1f44b.svg', bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', frame: 'bronze', }, 'followers50': { - img: '/fluent-emoji/1f411.png', + img: '/twemoji/1f411.svg', bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', frame: 'bronze', }, 'followers100': { - img: '/fluent-emoji/1f396.png', + img: '/twemoji/1f396.svg', bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', frame: 'silver', }, 'followers300': { - img: '/fluent-emoji/1f3c6.png', + img: '/twemoji/1f3c6.svg', bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', frame: 'silver', }, 'followers500': { - img: '/fluent-emoji/1f4e1.png', + img: '/twemoji/1f4e1.svg', bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', frame: 'gold', }, 'followers1000': { - img: '/fluent-emoji/1f451.png', + img: '/twemoji/1f451.svg', bg: 'linear-gradient(0deg, rgb(255 232 119), rgb(255 140 41))', frame: 'platinum', }, 'collectAchievements30': { - img: '/fluent-emoji/1f3c5.png', + img: '/twemoji/1f3c5.svg', bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))', frame: 'silver', }, 'iLoveMisskey': { - img: '/fluent-emoji/2764.png', + img: '/twemoji/2764.svg', bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))', frame: 'silver', }, 'client30min': { - img: '/fluent-emoji/1f552.png', + img: '/twemoji/1f552.svg', bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', frame: 'bronze', }, 'noteDeletedWithin1min': { - img: '/fluent-emoji/1f5d1.png', + img: '/twemoji/1f5d1.svg', bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', frame: 'bronze', }, 'postedAtLateNight': { - img: '/fluent-emoji/1f319.png', + img: '/twemoji/1f319.svg', bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))', frame: 'bronze', }, 'postedAt0min0sec': { - img: '/fluent-emoji/1f55b.png', + img: '/twemoji/1f55b.svg', bg: 'linear-gradient(0deg, rgb(58 231 198), rgb(37 194 255))', frame: 'bronze', }, 'selfQuote': { - img: '/fluent-emoji/1f4dd.png', + img: '/twemoji/1f4dd.svg', bg: null, frame: 'bronze', }, 'htl20npm': { - img: '/fluent-emoji/1f30a.png', + img: '/twemoji/1f30a.svg', bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', frame: 'bronze', }, 'driveFolderCircularReference': { - img: '/fluent-emoji/1f4c2.png', + img: '/twemoji/1f4c2.svg', bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', frame: 'bronze', }, 'reactWithoutRead': { - img: '/fluent-emoji/2753.png', + img: '/twemoji/2753.svg', bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', frame: 'bronze', }, 'clickedClickHere': { - img: '/fluent-emoji/2757.png', + img: '/twemoji/2757.svg', bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', frame: 'bronze', }, 'justPlainLucky': { - img: '/fluent-emoji/1f340.png', + img: '/twemoji/1f340.svg', bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))', frame: 'silver', }, 'setNameToSyuilo': { - img: '/fluent-emoji/1f36e.png', + img: '/twemoji/1f36e.svg', bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))', frame: 'bronze', }, 'passedSinceAccountCreated1': { - img: '/fluent-emoji/0031-20e3.png', + img: '/twemoji/0031-20e3.svg', bg: null, frame: 'bronze', }, 'passedSinceAccountCreated2': { - img: '/fluent-emoji/0032-20e3.png', + img: '/twemoji/0032-20e3.svg', bg: null, frame: 'silver', }, 'passedSinceAccountCreated3': { - img: '/fluent-emoji/0033-20e3.png', + img: '/twemoji/0033-20e3.svg', bg: null, frame: 'gold', }, 'loggedInOnBirthday': { - img: '/fluent-emoji/1f382.png', + img: '/twemoji/1f382.svg', bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', frame: 'silver', }, 'cookieClicked': { - img: '/fluent-emoji/1f36a.png', + img: '/twemoji/1f36a.svg', bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))', frame: 'bronze', }, 'brainDiver': { - img: '/fluent-emoji/1f9e0.png', + img: '/twemoji/1f9e0.svg', bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))', frame: 'bronze', },