Merge branch 'develop' into serve-stream

This commit is contained in:
tamaina 2023-01-23 22:17:12 +00:00
commit 13b50028d8
181 changed files with 19622 additions and 19987 deletions

View file

@ -9,7 +9,17 @@
"transform": {
"legacyDecorator": true,
"decoratorMetadata": true
}
},
"experimental": {
"keepImportAssertions": true
},
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
},
"target": "es2021"
},
"minify": false
}

View file

@ -0,0 +1,11 @@
export class firstRetrievedAt1673812883772 {
name = 'firstRetrievedAt1673812883772'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "instance" RENAME COLUMN "caughtAt" TO "firstRetrievedAt"`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "instance" RENAME COLUMN "firstRetrievedAt" TO "caughtAt"`);
}
}

View file

@ -0,0 +1,11 @@
export class flashScriptLength1674086433654 {
name = 'flashScriptLength1674086433654'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "flash" ALTER COLUMN "script" TYPE character varying(32768)`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "flash" ALTER COLUMN "script" TYPE character varying(16384)`);
}
}

View file

@ -0,0 +1,33 @@
export class achievement1674118260469 {
name = 'achievement1674118260469'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "notification" ADD "achievement" character varying(128)`);
await queryRunner.query(`ALTER TABLE "user_profile" ADD "achievements" jsonb NOT NULL DEFAULT '[]'`);
await queryRunner.query(`ALTER TYPE "public"."notification_type_enum" RENAME TO "notification_type_enum_old"`);
await queryRunner.query(`CREATE TYPE "public"."notification_type_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'achievementEarned', 'app')`);
await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum" USING "type"::"text"::"public"."notification_type_enum"`);
await queryRunner.query(`DROP TYPE "public"."notification_type_enum_old"`);
await queryRunner.query(`ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum" RENAME TO "user_profile_mutingnotificationtypes_enum_old"`);
await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'achievementEarned', 'app')`);
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" TYPE "public"."user_profile_mutingnotificationtypes_enum"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum"[]`);
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`);
await queryRunner.query(`DROP TYPE "public"."user_profile_mutingnotificationtypes_enum_old"`);
}
async down(queryRunner) {
await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'pollEnded')`);
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" TYPE "public"."user_profile_mutingnotificationtypes_enum_old"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum_old"[]`);
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`);
await queryRunner.query(`DROP TYPE "public"."user_profile_mutingnotificationtypes_enum"`);
await queryRunner.query(`ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum_old" RENAME TO "user_profile_mutingnotificationtypes_enum"`);
await queryRunner.query(`CREATE TYPE "public"."notification_type_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`);
await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum_old" USING "type"::"text"::"public"."notification_type_enum_old"`);
await queryRunner.query(`DROP TYPE "public"."notification_type_enum"`);
await queryRunner.query(`ALTER TYPE "public"."notification_type_enum_old" RENAME TO "notification_type_enum"`);
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "achievements"`);
await queryRunner.query(`ALTER TABLE "notification" DROP COLUMN "achievement"`);
}
}

View file

@ -0,0 +1,11 @@
export class loggedInDates1674255666603 {
name = 'loggedInDates1674255666603'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" ADD "loggedInDates" character varying(32) array NOT NULL DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "loggedInDates"`);
}
}

View file

@ -6,24 +6,26 @@
"scripts": {
"start": "node ./built/index.js",
"start:test": "NODE_ENV=test node ./built/index.js",
"migrate": "typeorm migration:run -d ormconfig.js",
"migrate": "pnpm typeorm migration:run -d ormconfig.js",
"build:swc": "swc src -d built -D",
"watch:swc": "swc src -d built -D -w",
"build": "tsc -p tsconfig.json || echo done. && tsc-alias -p tsconfig.json",
"watch": "node watch.mjs",
"lint": "tsc --noEmit && eslint --quiet \"src/**/*.ts\"",
"jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --runInBand",
"jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --runInBand",
"jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache",
"test": "yarn jest",
"test-and-coverage": "yarn jest-and-coverage"
"test": "pnpm jest",
"test-and-coverage": "pnpm jest-and-coverage"
},
"optionalDependencies": {
"@tensorflow/tfjs": "^4.1.0",
"@tensorflow/tfjs-node": "4.1.0"
},
"dependencies": {
"@bull-board/api": "^4.10.2",
"@bull-board/fastify": "^4.10.2",
"@bull-board/ui": "^4.10.2",
"@bull-board/api": "^4.11.0",
"@bull-board/fastify": "^4.11.0",
"@bull-board/ui": "^4.11.0",
"@discordapp/twemoji": "14.0.2",
"@fastify/accepts": "4.1.0",
"@fastify/cookie": "^8.3.0",
@ -56,9 +58,9 @@
"date-fns": "2.29.3",
"deep-email-validator": "0.1.21",
"escape-regexp": "0.0.1",
"fastify": "4.11.0",
"fastify": "4.12.0",
"feed": "4.2.2",
"file-type": "18.1.0",
"file-type": "18.2.0",
"fluent-ffmpeg": "2.1.2",
"form-data": "^4.0.0",
"got": "12.5.3",
@ -87,7 +89,7 @@
"probe-image-size": "7.2.3",
"promise-limit": "2.7.0",
"pug": "3.0.2",
"punycode": "2.2.0",
"punycode": "2.3.0",
"pureimage": "0.3.15",
"qrcode": "1.5.1",
"random-seed": "0.3.0",
@ -116,8 +118,9 @@
"tsconfig-paths": "4.1.2",
"twemoji-parser": "14.0.0",
"typeorm": "0.3.11",
"typescript": "4.9.4",
"ulid": "2.3.0",
"undici": "^5.15.0",
"undici": "^5.16.0",
"unzipper": "0.10.11",
"uuid": "9.0.0",
"vary": "1.1.2",
@ -128,7 +131,8 @@
},
"devDependencies": {
"@redocly/openapi-core": "1.0.0-beta.120",
"@swc/core": "1.3.26",
"@swc/cli": "^0.1.59",
"@swc/core": "1.3.27",
"@swc/jest": "0.2.24",
"@types/accepts": "1.3.5",
"@types/archiver": "5.3.1",
@ -140,7 +144,7 @@
"@types/escape-regexp": "0.0.1",
"@types/fluent-ffmpeg": "2.1.20",
"@types/ioredis": "4.28.10",
"@types/jest": "29.2.5",
"@types/jest": "29.2.6",
"@types/js-yaml": "4.0.5",
"@types/jsdom": "20.0.1",
"@types/jsonld": "1.5.8",
@ -172,15 +176,14 @@
"@types/web-push": "3.3.2",
"@types/websocket": "1.0.5",
"@types/ws": "8.5.4",
"@typescript-eslint/eslint-plugin": "5.48.1",
"@typescript-eslint/parser": "5.48.1",
"@typescript-eslint/eslint-plugin": "5.48.2",
"@typescript-eslint/parser": "5.48.2",
"cross-env": "7.0.3",
"eslint": "8.31.0",
"eslint-plugin-import": "2.27.4",
"eslint": "8.32.0",
"eslint-plugin-import": "2.27.5",
"execa": "6.1.0",
"jest": "29.3.1",
"jest-mock": "^29.3.1",
"node-fetch": "3.3.0",
"typescript": "4.9.4"
"node-fetch": "3.3.0"
}
}

View file

@ -1,13 +1,13 @@
import { Module } from '@nestjs/common';
import { ServerModule } from '@/server/ServerModule.js';
import { GlobalModule } from '@/GlobalModule.js';
import { QueueProcessorModule } from '@/queue/QueueProcessorModule.js';
import { DaemonModule } from '@/daemons/DaemonModule.js';
@Module({
imports: [
GlobalModule,
ServerModule,
QueueProcessorModule,
DaemonModule,
],
})
export class RootModule {}
export class MainModule {}

View file

@ -0,0 +1,35 @@
import { NestFactory } from '@nestjs/core';
import { ChartManagementService } from '@/core/chart/ChartManagementService.js';
import { QueueProcessorService } from '@/queue/QueueProcessorService.js';
import { NestLogger } from '@/NestLogger.js';
import { QueueProcessorModule } from '@/queue/QueueProcessorModule.js';
import { JanitorService } from '@/daemons/JanitorService.js';
import { QueueStatsService } from '@/daemons/QueueStatsService.js';
import { ServerStatsService } from '@/daemons/ServerStatsService.js';
import { ServerService } from '@/server/ServerService.js';
import { MainModule } from '@/MainModule.js';
export async function server() {
const app = await NestFactory.createApplicationContext(MainModule, {
logger: new NestLogger(),
});
app.enableShutdownHooks();
const serverService = app.get(ServerService);
serverService.launch();
app.get(ChartManagementService).start();
app.get(JanitorService).start();
app.get(QueueStatsService).start();
app.get(ServerStatsService).start();
}
export async function jobQueue() {
const jobQueue = await NestFactory.createApplicationContext(QueueProcessorModule, {
logger: new NestLogger(),
});
jobQueue.enableShutdownHooks();
jobQueue.get(QueueProcessorService).start();
jobQueue.get(ChartManagementService).start();
}

View file

@ -6,18 +6,12 @@ import cluster from 'node:cluster';
import chalk from 'chalk';
import chalkTemplate from 'chalk-template';
import semver from 'semver';
import { NestFactory } from '@nestjs/core';
import Logger from '@/logger.js';
import { loadConfig } from '@/config.js';
import type { Config } from '@/config.js';
import { lessThan } from '@/misc/prelude/array.js';
import { showMachineInfo } from '@/misc/show-machine-info.js';
import { DaemonModule } from '@/daemons/DaemonModule.js';
import { JanitorService } from '@/daemons/JanitorService.js';
import { QueueStatsService } from '@/daemons/QueueStatsService.js';
import { ServerStatsService } from '@/daemons/ServerStatsService.js';
import { NestLogger } from '@/NestLogger.js';
import { envOption } from '../env.js';
import { envOption } from '@/env.js';
import { jobQueue, server } from './common.js';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
@ -70,6 +64,14 @@ export async function masterMain() {
process.exit(1);
}
if (envOption.onlyServer) {
await server();
} else if (envOption.onlyQueue) {
await jobQueue();
} else {
await server();
}
bootLogger.succ('Misskey initialized');
if (!envOption.disableClustering) {
@ -77,16 +79,6 @@ export async function masterMain() {
}
bootLogger.succ(`Now listening on port ${config.port} on ${config.url}`, null, true);
if (!envOption.noDaemons) {
const daemons = await NestFactory.createApplicationContext(DaemonModule, {
logger: new NestLogger(),
});
daemons.enableShutdownHooks();
daemons.get(JanitorService).start();
daemons.get(QueueStatsService).start();
daemons.get(ServerStatsService).start();
}
}
function showEnvironment(): void {

View file

@ -1,33 +1,19 @@
import cluster from 'node:cluster';
import { NestFactory } from '@nestjs/core';
import { envOption } from '@/env.js';
import { ChartManagementService } from '@/core/chart/ChartManagementService.js';
import { ServerService } from '@/server/ServerService.js';
import { QueueProcessorService } from '@/queue/QueueProcessorService.js';
import { NestLogger } from '@/NestLogger.js';
import { RootModule } from '../RootModule.js';
import { jobQueue, server } from './common.js';
/**
* Init worker process
*/
export async function workerMain() {
const app = await NestFactory.createApplicationContext(RootModule, {
logger: new NestLogger(),
});
app.enableShutdownHooks();
// start server
const serverService = app.get(ServerService);
serverService.launch();
// start job queue
if (!envOption.onlyServer) {
const queueProcessorService = app.get(QueueProcessorService);
queueProcessorService.start();
if (envOption.onlyServer) {
await server();
} else if (envOption.onlyQueue) {
await jobQueue();
} else {
await jobQueue();
}
app.get(ChartManagementService).run();
if (cluster.isWorker) {
// Send a 'ready' message to parent process
process.send!('ready');

View file

@ -0,0 +1,121 @@
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',
'loggedInOnNewYearsDay',
'noteClipped1',
'noteFavorited1',
'myNoteFavorited1',
'profileFilled',
'markedAsCat',
'following1',
'following10',
'following50',
'following100',
'following300',
'followers1',
'followers10',
'followers50',
'followers100',
'followers300',
'followers500',
'followers1000',
'collectAchievements30',
'viewAchievements3min',
'iLoveMisskey',
'foundTreasure',
'client30min',
'noteDeletedWithin1min',
'postedAtLateNight',
'postedAt0min0sec',
'selfQuote',
'htl20npm',
'viewInstanceChart',
'outputHelloWorldOnScratchpad',
'open3windows',
'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: typeof ACHIEVEMENT_TYPES[number],
): 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,
});
}
}

View file

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

View file

@ -34,7 +34,7 @@ export class FederatedInstanceService {
const i = await this.instancesRepository.insert({
id: this.idService.genId(),
host,
caughtAt: new Date(),
firstRetrievedAt: new Date(),
}).then(x => this.instancesRepository.findOneByOrFail(x.identifiers[0]));
this.cache.set(host, i);

View file

@ -125,7 +125,7 @@ export class UndiciFetcher {
...(options.headers ?? {}),
},
}).catch((err) => {
this.logger?.error('fetch error', err);
this.logger?.error(`fetch error to ${typeof url === 'string' ? url : url.href}`, err);
throw new StatusError('Resource Unreachable', 500, 'Resource Unreachable');
});
if (!res.ok && !privateOptions.noOkError) {

View file

@ -19,6 +19,7 @@ export type RolePolicies = {
canPublicNote: boolean;
canInvite: boolean;
canManageCustomEmojis: boolean;
canHideAds: boolean;
driveCapacityMb: number;
pinLimit: number;
antennaLimit: number;
@ -37,6 +38,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
canPublicNote: true,
canInvite: false,
canManageCustomEmojis: false,
canHideAds: false,
driveCapacityMb: 100,
pinLimit: 5,
antennaLimit: 5,
@ -212,7 +214,7 @@ export class RoleService implements OnApplicationShutdown {
if (p2.length > 0) return aggregate(p2.map(policy => policy.useDefault ? basePolicies[name] : policy.value));
const p1 = policies.filter(policy => policy.priority === 1);
if (p1.length > 0) return aggregate(p2.map(policy => policy.useDefault ? basePolicies[name] : policy.value));
if (p1.length > 0) return aggregate(p1.map(policy => policy.useDefault ? basePolicies[name] : policy.value));
return aggregate(policies.map(policy => policy.useDefault ? basePolicies[name] : policy.value));
}
@ -223,6 +225,7 @@ export class RoleService implements OnApplicationShutdown {
canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)),
canInvite: calc('canInvite', vs => vs.some(v => v === true)),
canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)),
canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),
driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)),
pinLimit: calc('pinLimit', vs => Math.max(...vs)),
antennaLimit: calc('antennaLimit', vs => Math.max(...vs)),

View file

@ -57,7 +57,7 @@ export class ApRequestService {
method: 'POST',
headers: this.objectAssignWithLcKey({
'Date': new Date().toUTCString(),
'Host': u.hostname,
'Host': u.host,
'Content-Type': 'application/activity+json',
'Digest': digestHeader,
}, args.additionalHeaders),
@ -83,7 +83,7 @@ export class ApRequestService {
headers: this.objectAssignWithLcKey({
'Accept': 'application/activity+json, application/ld+json',
'Date': new Date().toUTCString(),
'Host': new URL(args.url).hostname,
'Host': new URL(args.url).host,
}, args.additionalHeaders),
};
@ -106,6 +106,8 @@ export class ApRequestService {
request.headers = this.objectAssignWithLcKey(request.headers, {
Signature: signatureHeader,
});
// node-fetch will generate this for us. if we keep 'Host', it won't change with redirects!
delete request.headers['host'];
return {
request,

View file

@ -54,7 +54,7 @@ export class ChartManagementService implements OnApplicationShutdown {
}
@bindThis
public async run() {
public async start() {
// 20分おきにメモリ情報をDBに書き込み
this.saveIntervalId = setInterval(() => {
for (const chart of this.charts) {

View file

@ -22,7 +22,7 @@ export class EmojiEntityService {
@bindThis
public async pack(
src: Emoji['id'] | Emoji,
opts: { omitHost?: boolean; omitId?: boolean; } = {},
opts: { omitHost?: boolean; omitId?: boolean; withUrl?: boolean; } = {},
): Promise<Packed<'Emoji'>> {
const emoji = typeof src === 'object' ? src : await this.emojisRepository.findOneByOrFail({ id: src });
@ -32,13 +32,15 @@ export class EmojiEntityService {
name: emoji.name,
category: emoji.category,
host: opts.omitHost ? undefined : emoji.host,
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
url: opts.withUrl ? (emoji.publicUrl || emoji.originalUrl) : undefined,
};
}
@bindThis
public packMany(
emojis: any[],
opts: { omitHost?: boolean; omitId?: boolean; } = {},
opts: { omitHost?: boolean; omitId?: boolean; withUrl?: boolean; } = {},
) {
return Promise.all(emojis.map(x => this.pack(x, opts)));
}

View file

@ -29,7 +29,7 @@ export class InstanceEntityService {
const meta = await this.metaService.fetch();
return {
id: instance.id,
caughtAt: instance.caughtAt.toISOString(),
firstRetrievedAt: instance.firstRetrievedAt.toISOString(),
host: instance.host,
usersCount: instance.usersCount,
notesCount: instance.notesCount,

View file

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

View file

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

View file

@ -44,7 +44,7 @@ export class Flash {
public user: User | null;
@Column('varchar', {
length: 16384,
length: 32768,
})
public script: string;

View file

@ -13,7 +13,7 @@ export class Instance {
@Column('timestamp with time zone', {
comment: 'The caught date of the Instance.',
})
public caughtAt: Date;
public firstRetrievedAt: Date;
/**
*

View file

@ -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
*/

View file

@ -232,6 +232,6 @@ export type CacheableUser = CacheableLocalUser | CacheableRemoteUser;
export const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const;
export const passwordSchema = { type: 'string', minLength: 1 } as const;
export const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const;
export const descriptionSchema = { type: 'string', minLength: 1, maxLength: 500 } as const;
export const descriptionSchema = { type: 'string', minLength: 1, maxLength: 1500 } as const;
export const locationSchema = { type: 'string', minLength: 1, maxLength: 50 } as const;
export const birthdaySchema = { type: 'string', pattern: /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.toString().slice(1, -1) } as const;

View file

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

View file

@ -29,5 +29,9 @@ export const packedEmojiSchema = {
optional: true, nullable: true,
description: 'The local host is represented with `null`.',
},
url: {
type: 'string',
optional: true, nullable: false,
},
},
} as const;

View file

@ -6,7 +6,7 @@ export const packedFederationInstanceSchema = {
optional: false, nullable: false,
format: 'id',
},
caughtAt: {
firstRetrievedAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',

View file

@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { CoreModule } from '@/core/CoreModule.js';
import { GlobalModule } from '@/GlobalModule.js';
import { QueueLoggerService } from './QueueLoggerService.js';
import { QueueProcessorService } from './QueueProcessorService.js';
import { DbQueueProcessorsService } from './DbQueueProcessorsService.js';
@ -34,6 +35,7 @@ import { ExportFavoritesProcessorService } from './processors/ExportFavoritesPro
@Module({
imports: [
GlobalModule,
CoreModule,
],
providers: [

View file

@ -14,6 +14,7 @@ import { genIdenticon } from '@/misc/gen-identicon.js';
import { createTemp } from '@/misc/create-temp.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js';
import { ActivityPubServerService } from './ActivityPubServerService.js';
import { NodeinfoServerService } from './NodeinfoServerService.js';
import { ApiServerService } from './api/ApiServerService.js';
@ -22,7 +23,6 @@ import { WellKnownServerService } from './WellKnownServerService.js';
import { MediaProxyServerService } from './MediaProxyServerService.js';
import { FileServerService } from './FileServerService.js';
import { ClientServerService } from './web/ClientServerService.js';
import { bindThis } from '@/decorators.js';
@Injectable()
export class ServerService {
@ -82,13 +82,13 @@ export class ServerService {
fastify.get<{ Params: { path: string }; Querystring: { static?: any; }; }>('/emoji/:path(.*)', async (request, reply) => {
const path = request.params.path;
reply.header('Cache-Control', 'public, max-age=86400');
if (!path.match(/^[a-zA-Z0-9\-_@\.]+?\.webp$/)) {
reply.code(404);
return;
}
reply.header('Cache-Control', 'public, max-age=86400');
const name = path.split('@')[0].replace('.webp', '');
const host = path.split('@')[1]?.replace('.webp', '');
@ -101,7 +101,12 @@ export class ServerService {
reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\'');
if (emoji == null) {
return await reply.redirect('/static-assets/emoji-unknown.png');
if ('fallback' in request.query) {
return await reply.redirect('/static-assets/emoji-unknown.png');
} else {
reply.code(404);
return;
}
}
const url = new URL('/proxy/emoji.webp', this.config.url);
@ -127,6 +132,8 @@ export class ServerService {
relations: ['avatar'],
});
reply.header('Cache-Control', 'public, max-age=86400');
if (user) {
reply.redirect(this.userEntityService.getAvatarUrlSync(user));
} else {
@ -138,6 +145,7 @@ export class ServerService {
const [temp, cleanup] = await createTemp();
await genIdenticon(request.params.x, fs.createWriteStream(temp));
reply.header('Content-Type', 'image/png');
reply.header('Cache-Control', 'public, max-age=86400');
return fs.createReadStream(temp).on('close', () => cleanup());
});

View file

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

View file

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

View file

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

View file

@ -83,6 +83,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
emojis: await this.emojiEntityService.packMany(emojis, {
omitId: true,
omitHost: true,
withUrl: true,
}),
};
});

View file

@ -63,8 +63,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
case '-following': query.orderBy('instance.followingCount', 'ASC'); break;
case '+followers': query.orderBy('instance.followersCount', 'DESC'); break;
case '-followers': query.orderBy('instance.followersCount', 'ASC'); break;
case '+caughtAt': query.orderBy('instance.caughtAt', 'DESC'); break;
case '-caughtAt': query.orderBy('instance.caughtAt', 'ASC'); break;
case '+firstRetrievedAt': query.orderBy('instance.firstRetrievedAt', 'DESC'); break;
case '-firstRetrievedAt': query.orderBy('instance.firstRetrievedAt', 'ASC'); break;
case '+latestRequestReceivedAt': query.orderBy('instance.latestRequestReceivedAt', 'DESC', 'NULLS LAST'); break;
case '-latestRequestReceivedAt': query.orderBy('instance.latestRequestReceivedAt', 'ASC', 'NULLS FIRST'); break;

View file

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

View file

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

View file

@ -6,6 +6,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import { GetterService } from '@/server/api/GetterService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../../error.js';
import { AchievementService } from '@/core/AchievementService.js';
export const meta = {
tags: ['notes', 'favorites'],
@ -51,6 +52,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private idService: IdService,
private getterService: GetterService,
private achievementService: AchievementService,
) {
super(meta, paramDef, async (ps, me) => {
// Get favoritee
@ -76,6 +78,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
noteId: note.id,
userId: me.id,
});
if (note.userHost == null) {
this.achievementService.create(note.userId, 'myNoteFavorited1');
}
});
}
}

View file

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

View file

@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import { v4 as uuid } from 'uuid';
import { IsNull } from 'typeorm';
import autwh from 'autwh';
import * as autwh from 'autwh';
import type { Config } from '@/config.js';
import type { UserProfilesRepository, UsersRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';

View file

@ -22,13 +22,13 @@
renderError('SOMETHING_HAPPENED_IN_PROMISE', e);
};
const v = localStorage.getItem('v') || VERSION;
let forceError = localStorage.getItem('forceError');
if (forceError != null) {
renderError('FORCED_ERROR', 'This error is forced by having forceError in local storage.')
}
//#region Detect language & fetch translations
const localeVersion = localStorage.getItem('localeVersion');
const localeOutdated = (localeVersion == null || localeVersion !== v);
if (!localStorage.hasOwnProperty('locale') || localeOutdated) {
if (!localStorage.hasOwnProperty('locale')) {
const supportedLangs = LANGS;
let lang = localStorage.getItem('lang');
if (lang == null || !supportedLangs.includes(lang)) {
@ -42,13 +42,31 @@
}
}
const res = await window.fetch(`/assets/locales/${lang}.${v}.json`);
if (res.status === 200) {
const metaRes = await window.fetch('/api/meta', {
method: 'POST',
body: JSON.stringify({}),
credentials: 'omit',
cache: 'no-cache',
headers: {
'Content-Type': 'application/json',
},
});
if (metaRes.status !== 200) {
renderError('META_FETCH');
return;
}
const meta = await metaRes.json();
const v = meta.version;
if (v == null) {
renderError('META_FETCH_V');
return;
}
const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`);
if (localRes.status === 200) {
localStorage.setItem('lang', lang);
localStorage.setItem('locale', await res.text());
localStorage.setItem('locale', await localRes.text());
localStorage.setItem('localeVersion', v);
} else {
await checkUpdate();
renderError('LOCALE_FETCH');
return;
}
@ -59,7 +77,6 @@
function importAppScript() {
import(`/vite/${CLIENT_ENTRY}`)
.catch(async e => {
await checkUpdate();
console.error(e);
renderError('APP_IMPORT', e);
});
@ -286,48 +303,4 @@
}
`)
}
// eslint-disable-next-line no-inner-declarations
async function checkUpdate() {
try {
const res = await window.fetch('/api/meta', {
method: 'POST',
cache: 'no-cache',
body: '{}',
headers: {
'Content-Type': 'application/json',
},
});
const meta = await res.json();
if (meta.version == null) {
throw new Error('failed to fetch instance metadata');
}
if (meta.version != v) {
localStorage.setItem('v', meta.version);
refresh();
}
} catch (e) {
console.error(e);
renderError('UPDATE_CHECK', e);
throw e;
}
}
// eslint-disable-next-line no-inner-declarations
function refresh() {
// Clear cache (service worker)
try {
navigator.serviceWorker.controller.postMessage('clear');
navigator.serviceWorker.getRegistrations().then(registrations => {
registrations.forEach(registration => registration.unregister());
});
} catch (e) {
console.error(e);
}
location.reload();
}
})();

View file

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

View file

@ -8,7 +8,7 @@
},
"dependencies": {
"@discordapp/twemoji": "14.0.2",
"@rollup/plugin-alias": "4.0.2",
"@rollup/plugin-alias": "4.0.3",
"@rollup/plugin-json": "6.0.0",
"@rollup/pluginutils": "5.0.2",
"@syuilo/aiscript": "0.12.2",
@ -18,10 +18,10 @@
"autobind-decorator": "2.4.0",
"autosize": "5.0.2",
"blurhash": "2.0.4",
"broadcast-channel": "4.20.1",
"broadcast-channel": "4.20.2",
"browser-image-resizer": "git+https://github.com/misskey-dev/browser-image-resizer#v2.2.1-misskey.3",
"canvas-confetti": "^1.6.0",
"chart.js": "4.1.2",
"chart.js": "4.2.0",
"chartjs-adapter-date-fns": "3.0.0",
"chartjs-chart-matrix": "^1.3.0",
"chartjs-plugin-gradient": "0.6.1",
@ -41,10 +41,10 @@
"misskey-js": "0.0.14",
"photoswipe": "5.3.4",
"prismjs": "1.29.0",
"punycode": "2.2.0",
"punycode": "2.3.0",
"querystring": "0.2.1",
"rndstr": "1.0.0",
"rollup": "3.10.0",
"rollup": "3.10.1",
"s-age": "1.1.2",
"sanitize-html": "^2.8.1",
"sass": "1.57.1",
@ -69,7 +69,7 @@
},
"devDependencies": {
"@types/escape-regexp": "0.0.1",
"@types/glob": "8.0.0",
"@types/glob": "8.0.1",
"@types/gulp": "4.0.10",
"@types/gulp-rename": "2.0.1",
"@types/matter-js": "0.18.2",
@ -82,13 +82,13 @@
"@types/uuid": "9.0.0",
"@types/websocket": "1.0.5",
"@types/ws": "8.5.4",
"@typescript-eslint/eslint-plugin": "5.48.1",
"@typescript-eslint/parser": "5.48.1",
"@typescript-eslint/eslint-plugin": "5.48.2",
"@typescript-eslint/parser": "5.48.2",
"@vue/runtime-core": "3.2.45",
"cross-env": "7.0.3",
"cypress": "12.3.0",
"eslint": "8.31.0",
"eslint-plugin-import": "2.27.4",
"eslint": "8.32.0",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-vue": "9.9.0",
"start-server-and-test": "1.15.2",
"vue-eslint-parser": "^9.1.0",

View file

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

View file

@ -2,7 +2,7 @@
<div class="bcekxzvu _margin _panel">
<div class="target">
<MkA v-user-preview="report.targetUserId" class="info" :to="`/user-info/${report.targetUserId}`">
<MkAvatar class="avatar" :user="report.targetUser" :show-indicator="true" :disable-link="true"/>
<MkAvatar class="avatar" :user="report.targetUser" indicator/>
<div class="names">
<MkUserName class="name" :user="report.targetUser"/>
<MkAcct class="acct" :user="report.targetUser" style="display: block;"/>

View file

@ -0,0 +1,226 @@
<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">{{ withDescription ? i18n.ts._achievements._types['_' + achievement.name].description : '???' }}</div>
<div v-if="i18n.ts._achievements._types['_' + achievement.name].flavor && withDescription" :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;
withDescription: boolean;
}>(), {
withLocked: true,
withDescription: true,
});
let achievements = $ref();
const lockedAchievements = $computed(() => ACHIEVEMENT_TYPES.filter(x => !(achievements ?? []).some(a => a.name === x)));
function fetch() {
os.api('users/achievements', { userId: props.user.id }).then(res => {
achievements = [];
for (const t of ACHIEVEMENT_TYPES) {
const a = res.find(x => x.name === t);
if (a) achievements.push(a);
}
//achievements = res.sort((a, b) => b.unlockedAt - a.unlockedAt);
});
}
function clickHere() {
claimAchievement('clickedClickHere');
fetch();
}
onMounted(() => {
fetch();
});
</script>
<style lang="scss" module>
.root {
display: grid;
grid-template-columns: repeat(auto-fill, min(380px, 100%));
grid-gap: 12px;
place-content: center;
}
.achievement {
display: flex;
padding: 16px;
&.locked {
opacity: 0.5;
}
}
.icon {
flex-shrink: 0;
margin-right: 12px;
}
@keyframes shine {
0% { translate: -30px; }
100% { translate: -130px; }
}
.iconFrame {
width: 58px;
height: 58px;
padding: 6px;
border-radius: 100%;
box-sizing: border-box;
pointer-events: none;
user-select: none;
filter: drop-shadow(0px 2px 2px #00000044);
box-shadow: 0 1px 0px #ffffff88 inset;
overflow: clip;
}
.iconFrame_bronze {
background: linear-gradient(0deg, #703827, #d37566);
> .iconInner {
background: linear-gradient(0deg, #d37566, #703827);
}
}
.iconFrame_silver {
background: linear-gradient(0deg, #7c7c7c, #e1e1e1);
> .iconInner {
background: linear-gradient(0deg, #e1e1e1, #7c7c7c);
}
}
.iconFrame_gold {
background: linear-gradient(0deg, rgba(255,182,85,1) 0%, rgba(233,133,0,1) 49%, rgba(255,243,93,1) 51%, rgba(255,187,25,1) 100%);
> .iconInner {
background: linear-gradient(0deg, #ffee20, #eb7018);
}
&:before {
content: "";
display: block;
position: absolute;
top: 30px;
width: 200px;
height: 8px;
rotate: -45deg;
translate: -30px;
background: #ffffff88;
animation: shine 2s infinite;
}
}
.iconFrame_platinum {
background: linear-gradient(0deg, rgba(154,154,154,1) 0%, rgba(226,226,226,1) 49%, rgba(255,255,255,1) 51%, rgba(195,195,195,1) 100%);
> .iconInner {
background: linear-gradient(0deg, #e1e1e1, #7c7c7c);
}
&:before {
content: "";
display: block;
position: absolute;
top: 30px;
width: 200px;
height: 8px;
rotate: -45deg;
translate: -30px;
background: #ffffffee;
animation: shine 2s infinite;
}
}
.iconInner {
position: relative;
width: 100%;
height: 100%;
border-radius: 100%;
box-shadow: 0 1px 0px #ffffff88 inset;
}
.iconImg {
width: calc(100% - 12px);
height: calc(100% - 12px);
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
margin: auto;
filter: drop-shadow(0px 1px 2px #000000aa);
}
.body {
flex: 1;
min-width: 0;
}
.header {
margin-bottom: 8px;
display: flex;
}
.title {
font-weight: bold;
}
.time {
margin-left: auto;
font-size: 85%;
opacity: 0.7;
}
.description {
font-size: 85%;
}
.flavor {
opacity: 0.7;
transform: skewX(-15deg);
font-size: 85%;
margin-top: 8px;
}
</style>

View file

@ -6,10 +6,10 @@
</template>
</div>
<span v-else-if="c.type === 'text'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, fontWeight: c.bold ? 'bold' : null, color: c.color ?? null }">{{ c.text }}</span>
<Mfm v-else-if="c.type === 'mfm'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, color: c.color ?? null }" :text="c.text"/>
<MkButton v-else-if="c.type === 'button'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" @click="c.onClick">{{ c.text }}</MkButton>
<div v-else-if="c.type === 'buttons'" class="_buttons">
<MkButton v-for="button in c.buttons" :primary="button.primary" :rounded="button.rounded" :small="size === 'small'" @click="button.onClick">{{ button.text }}</MkButton>
<Mfm v-else-if="c.type === 'mfm'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, fontWeight: c.bold ? 'bold' : null, color: c.color ?? null }" :text="c.text"/>
<MkButton v-else-if="c.type === 'button'" :primary="c.primary" :rounded="c.rounded" :disabled="c.disabled" :small="size === 'small'" inline @click="c.onClick">{{ c.text }}</MkButton>
<div v-else-if="c.type === 'buttons'" class="_buttons" :style="{ justifyContent: align }">
<MkButton v-for="button in c.buttons" :primary="button.primary" :rounded="button.rounded" :disabled="button.disabled" inline :small="size === 'small'" @click="button.onClick">{{ button.text }}</MkButton>
</div>
<MkSwitch v-else-if="c.type === 'switch'" :model-value="valueForSwitch" @update:model-value="onSwitchUpdate">
<template v-if="c.label" #label>{{ c.label }}</template>
@ -32,7 +32,7 @@
<template v-if="c.caption" #caption>{{ c.caption }}</template>
<option v-for="item in c.items" :key="item.value" :value="item.value">{{ item.text }}</option>
</MkSelect>
<MkButton v-else-if="c.type === 'postFormButton'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" @click="openPostForm">{{ c.text }}</MkButton>
<MkButton v-else-if="c.type === 'postFormButton'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" inline @click="openPostForm">{{ c.text }}</MkButton>
<MkFolder v-else-if="c.type === 'folder'" :default-open="c.opened">
<template #label>{{ c.title }}</template>
<template v-for="child in c.children" :key="child">
@ -41,7 +41,7 @@
</MkFolder>
<div v-else-if="c.type === 'container'" :class="[$style.container, { [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace', [$style.containerCenter]: c.align === 'center' }]" :style="{ backgroundColor: c.bgColor ?? null, color: c.fgColor ?? null, borderWidth: c.borderWidth ? `${c.borderWidth}px` : 0, borderColor: c.borderColor ?? 'var(--divider)', padding: c.padding ? `${c.padding}px` : 0, borderRadius: c.rounded ? '8px' : 0 }">
<template v-for="child in c.children" :key="child">
<MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/>
<MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size" :align="c.align"/>
</template>
</div>
</div>
@ -62,8 +62,10 @@ const props = withDefaults(defineProps<{
component: AsUiComponent;
components: Ref<AsUiComponent>[];
size: 'small' | 'medium' | 'large';
align: 'left' | 'center' | 'right';
}>(), {
size: 'medium',
align: 'left',
});
const c = props.component;

View file

@ -1,7 +1,7 @@
<template>
<div>
<div v-for="user in users" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;">
<MkAvatar :user="user" style="width:32px;height:32px;" :show-indicator="true"/>
<MkAvatar :user="user" style="width:32px;height:32px;" indicator link preview/>
</div>
</div>
</template>

View file

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

View file

@ -23,7 +23,7 @@
@leave="leave"
@after-leave="afterLeave"
>
<div v-show="showBody" ref="content" :class="[$style.content, { omitted }]">
<div v-show="showBody" ref="content" :class="[$style.content, { [$style.omitted]: omitted }]">
<slot></slot>
<button v-if="omitted" :class="$style.fade" class="_button" @click="() => { ignoreOmit = true; omitted = false; }">
<span :class="$style.fadeLabel">{{ $ts.showMore }}</span>

View file

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

View file

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

View file

@ -118,7 +118,6 @@ export default defineComponent({
z-index: 10;
position: sticky;
top: var(--stickyTop, 0px);
padding: var(--x-padding);
-webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(20px));

View file

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

View file

@ -5,7 +5,7 @@
</div>
<article>
<header>
<MkAvatar :user="post.user" class="avatar"/>
<MkAvatar :user="post.user" class="avatar" link preview/>
</header>
<footer>
<span class="title">{{ post.title }}</span>

View file

@ -13,7 +13,7 @@
:href="image.url"
:title="image.name"
>
<ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment" :title="image.comment" :cover="false"/>
<ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment || image.name" :title="image.comment || image.name" :cover="false"/>
<div v-if="image.type === 'image/gif'" class="gif">GIF</div>
</a>
<button v-tooltip="$ts.hide" class="_button hide" @click="hide = true"><i class="ti ti-eye-off"></i></button>

View file

@ -45,7 +45,8 @@ onMounted(() => {
src: media.url,
w: media.properties.width,
h: media.properties.height,
alt: media.name,
alt: media.comment || media.name,
comment: media.comment || media.name,
};
if (media.properties.orientation != null && media.properties.orientation >= 5) {
[item.w, item.h] = [item.h, item.w];
@ -69,6 +70,7 @@ onMounted(() => {
},
imageClickAction: 'close',
tapAction: 'toggle-controls',
bgOpacity: 1,
pswpModule: PhotoSwipe,
});
@ -88,9 +90,28 @@ onMounted(() => {
[itemData.w, itemData.h] = [itemData.h, itemData.w];
}
itemData.msrc = file.thumbnailUrl;
itemData.alt = file.comment || file.name;
itemData.comment = file.comment || file.name;
itemData.thumbCropped = true;
});
lightbox.on('uiRegister', () => {
lightbox.pswp.ui.registerElement({
name: 'altText',
className: 'pwsp__alt-text-container',
appendTo: 'wrapper',
onInit: (el, pwsp) => {
let textBox = document.createElement('p');
textBox.className = 'pwsp__alt-text _acrylic';
el.appendChild(textBox);
pwsp.on('change', (a) => {
textBox.textContent = pwsp.currSlide.data.comment;
});
},
});
});
lightbox.init();
});
@ -185,5 +206,36 @@ const previewable = (file: misskey.entities.DriveFile): boolean => {
//
//z-index: v-bind(pswpZIndex);
z-index: 2000000;
--pswp-bg: var(--modalBg);
}
.pswp__bg {
background: var(--modalBg);
backdrop-filter: var(--modalBgFilter);
}
.pwsp__alt-text-container {
display: flex;
flex-direction: row;
align-items: center;
position: absolute;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
width: 75%;
max-width: 800px;
}
.pwsp__alt-text {
color: var(--fg);
margin: 0 auto;
text-align: center;
padding: var(--margin);
border-radius: var(--radius);
max-height: 8em;
overflow-y: auto;
text-shadow: var(--bg) 0 0 10px, var(--bg) 0 0 3px, var(--bg) 0 0 3px;
}
</style>

View file

@ -12,7 +12,7 @@
<!--<div v-if="appearNote._prId_" class="tip"><i class="fas fa-bullhorn"></i> {{ i18n.ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.ts.hideThisNote }} <i class="ti ti-x"></i></button></div>-->
<!--<div v-if="appearNote._featuredId_" class="tip"><i class="ti ti-bolt"></i> {{ i18n.ts.featured }}</div>-->
<div v-if="isRenote" :class="$style.renote">
<MkAvatar v-once :class="$style.renoteAvatar" :user="note.user"/>
<MkAvatar v-once :class="$style.renoteAvatar" :user="note.user" link preview/>
<i class="ti ti-repeat" style="margin-right: 4px;"></i>
<I18n :src="i18n.ts.renotedBy" tag="span" :class="$style.renoteText">
<template #user>
@ -35,7 +35,7 @@
</div>
</div>
<article :class="$style.article" @contextmenu.stop="onContextmenu">
<MkAvatar v-once :class="$style.avatar" :user="appearNote.user"/>
<MkAvatar v-once :class="$style.avatar" :user="appearNote.user" link preview/>
<div :class="$style.main">
<MkNoteHeader :class="$style.header" :note="appearNote" :mini="true"/>
<MkInstanceTicker v-if="showTicker" :class="$style.ticker" :instance="appearNote.user.instance"/>
@ -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();
});

View file

@ -11,7 +11,7 @@
<MkNoteSub v-for="note in conversation" :key="note.id" class="reply-to-more" :note="note"/>
<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
<div v-if="isRenote" class="renote">
<MkAvatar class="avatar" :user="note.user"/>
<MkAvatar class="avatar" :user="note.user" link preview/>
<i class="ti ti-repeat"></i>
<I18n :src="i18n.ts.renotedBy" tag="span">
<template #user>
@ -35,7 +35,7 @@
</div>
<article class="article" @contextmenu.stop="onContextmenu">
<header class="header">
<MkAvatar class="avatar" :user="appearNote.user" :show-indicator="true"/>
<MkAvatar class="avatar" :user="appearNote.user" indicator link preview/>
<div class="body">
<div class="top">
<MkA v-user-preview="appearNote.user.id" class="name" :to="userPage(appearNote.user)">
@ -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();
});

View file

@ -1,6 +1,6 @@
<template>
<div :class="$style.root">
<MkAvatar :class="$style.avatar" :user="$i"/>
<MkAvatar :class="$style.avatar" :user="$i" link preview/>
<div :class="$style.main">
<div :class="$style.header">
<MkUserName :user="$i"/>

View file

@ -1,6 +1,6 @@
<template>
<div :class="$style.root">
<MkAvatar :class="$style.avatar" :user="note.user"/>
<MkAvatar :class="$style.avatar" :user="note.user" link preview/>
<div :class="$style.main">
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div>

View file

@ -1,7 +1,7 @@
<template>
<div :class="[$style.root, { [$style.children]: depth > 1 }]">
<div :class="$style.main">
<MkAvatar :class="$style.avatar" :user="note.user"/>
<MkAvatar :class="$style.avatar" :user="note.user" link preview/>
<div :class="$style.body">
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div>

View file

@ -1,8 +1,9 @@
<template>
<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"/>
<MkAvatar v-else-if="notification.user" :class="$style.icon" :user="notification.user"/>
<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]]">
<i v-if="notification.type === 'follow'" class="ti ti-plus"></i>
@ -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;
@ -267,9 +280,9 @@ useTooltip(reactionRef, (showing) => {
}
.text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
width: 100%;
overflow: clip;
}
.quote {

View file

@ -10,7 +10,7 @@
<template #default="{ items: notifications }">
<MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :no-gap="true">
<XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/>
<XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/>
<XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="false" class="_panel notification"/>
</MkDateSeparatedList>
</template>
</MkPagination>

View file

@ -0,0 +1,72 @@
<template>
<div ref="content" :class="[$style.content, { [$style.omitted]: omitted }]">
<slot></slot>
<button v-if="omitted" :class="$style.fade" class="_button" @click="() => { ignoreOmit = true; omitted = false; }">
<span :class="$style.fadeLabel">{{ $ts.showMore }}</span>
</button>
</div>
</template>
<script lang="ts" setup>
import { nextTick, onMounted } from 'vue';
const props = withDefaults(defineProps<{
maxHeight: number;
}>(), {
maxHeight: 200,
});
let content = $ref<HTMLElement>();
let omitted = $ref(false);
let ignoreOmit = $ref(false);
onMounted(() => {
const calcOmit = () => {
if (omitted || ignoreOmit) return;
omitted = content.offsetHeight > props.maxHeight;
};
calcOmit();
new ResizeObserver((entries, observer) => {
calcOmit();
}).observe(content);
});
</script>
<style lang="scss" module>
.content {
--stickyTop: 0px;
&.omitted {
position: relative;
max-height: v-bind("props.maxHeight + 'px'");
overflow: hidden;
> .fade {
display: block;
position: absolute;
z-index: 10;
bottom: 0;
left: 0;
width: 100%;
height: 64px;
background: linear-gradient(0deg, var(--panel), var(--X15));
> .fadeLabel {
display: inline-block;
background: var(--panel);
padding: 6px 10px;
font-size: 0.8em;
border-radius: 999px;
box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
}
&:hover {
> .fadeLabel {
background: var(--panelHighlight);
}
}
}
}
}
</style>

View file

@ -24,7 +24,7 @@
</template>
<script lang="ts" setup>
import { ComputedRef, inject, provide } from 'vue';
import { ComputedRef, inject, onMounted, onUnmounted, provide } from 'vue';
import RouterView from '@/components/global/RouterView.vue';
import MkWindow from '@/components/MkWindow.vue';
import { popout as _popout } from '@/scripts/popout';
@ -35,6 +35,8 @@ import { mainRouter, routes } from '@/router';
import { Router } from '@/nirax';
import { i18n } from '@/i18n';
import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
import { openingWindowsCount } from '@/os';
import { claimAchievement } from '@/scripts/achievements';
const props = defineProps<{
initialPath: string;
@ -128,6 +130,17 @@ function popout() {
windowEl.close();
}
onMounted(() => {
openingWindowsCount.value++;
if (openingWindowsCount.value >= 3) {
claimAchievement('open3windows');
}
});
onUnmounted(() => {
openingWindowsCount.value--;
});
defineExpose({
close,
});

View file

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

View file

@ -0,0 +1,92 @@
<template>
<MkModalWindow
ref="dialog"
:width="400"
:height="450"
@close="dialog.close()"
@closed="emit('closed')"
>
<template #header>{{ i18n.ts.reactions }}</template>
<MkSpacer :margin-min="20" :margin-max="28">
<div v-if="note" class="_gaps">
<div :class="$style.tabs">
<button v-for="reaction in reactions" :key="reaction" :class="[$style.tab, { [$style.tabActive]: tab === reaction }]" class="_button" @click="tab = reaction">
<MkReactionIcon :reaction="reaction"/>
<span style="margin-left: 4px;">{{ note.reactions[reaction] }}</span>
</button>
</div>
<MkA v-for="user in users" :key="user.id" :to="userPage(user)">
<MkUserCardMini :user="user" :with-chart="false"/>
</MkA>
</div>
<div v-else>
<MkLoading/>
</div>
</MkSpacer>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { onMounted, watch } from 'vue';
import * as misskey from 'misskey-js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
import { userPage } from '@/filters/user';
import { i18n } from '@/i18n';
import * as os from '@/os';
const emit = defineEmits<{
(ev: 'closed'): void,
}>();
const props = defineProps<{
noteId: misskey.entities.Note['id'];
}>();
const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>();
let note = $ref<misskey.entities.Note>();
let tab = $ref<string>();
let reactions = $ref<string[]>();
let users = $ref();
watch($$(tab), async () => {
const res = await os.api('notes/reactions', {
noteId: props.noteId,
type: tab,
limit: 30,
});
users = res.map(x => x.user);
});
onMounted(() => {
os.api('notes/show', {
noteId: props.noteId,
}).then((res) => {
reactions = Object.keys(res.reactions);
tab = reactions[0];
note = res;
});
});
</script>
<style lang="scss" module>
.tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tab {
padding: 4px 6px;
border: solid 1px var(--divider);
border-radius: 6px;
}
.tabActive {
border-color: var(--accent);
}
</style>

View file

@ -20,6 +20,8 @@ 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';
import { defaultStore } from '@/store';
const props = defineProps<{
reaction: string;
@ -52,11 +54,15 @@ 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');
}
}
};
const anime = () => {
if (document.hidden) return;
if (!defaultStore.state.animation) return;
const rect = buttonEl.value.getBoundingClientRect();
const x = rect.left + 16;

View file

@ -1,6 +1,11 @@
<template>
<MkA v-adaptive-bg :to="`/admin/roles/${role.id}`" class="_panel" :class="$style.root" tabindex="-1" :style="{ '--color': role.color }">
<div :class="$style.title">
<span :class="$style.icon">
<i v-if="role.isAdministrator" class="ti ti-crown" style="color: var(--accent);"></i>
<i v-else-if="role.isModerator" class="ti ti-shield" style="color: var(--accent);"></i>
<i v-else class="ti ti-user" style="opacity: 0.7;"></i>
</span>
<span :class="$style.name">{{ role.name }}</span>
<span v-if="role.target === 'manual'" :class="$style.users">{{ role.usersCount }} users</span>
<span v-else-if="role.target === 'conditional'" :class="$style.users">({{ i18n.ts._role.conditional }})</span>
@ -31,6 +36,10 @@ const props = defineProps<{
display: flex;
}
.icon {
margin-right: 8px;
}
.name {
font-weight: bold;
}
@ -42,5 +51,6 @@ const props = defineProps<{
.description {
opacity: 0.7;
font-size: 85%;
}
</style>

View file

@ -1,6 +1,6 @@
<template>
<div v-adaptive-bg :class="[$style.root, { yellow: user.isSilenced, red: user.isSuspended, gray: false }]">
<MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/>
<MkAvatar class="avatar" :user="user" indicator/>
<div class="body">
<span class="name"><MkUserName class="name" :user="user"/></span>
<span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span>
@ -11,20 +11,28 @@
<script lang="ts" setup>
import * as misskey from 'misskey-js';
import { onMounted } from 'vue';
import MkMiniChart from '@/components/MkMiniChart.vue';
import * as os from '@/os';
import { acct } from '@/filters/user';
const props = defineProps<{
const props = withDefaults(defineProps<{
user: misskey.entities.User;
}>();
withChart: boolean;
}>(), {
withChart: true,
});
let chartValues = $ref<number[] | null>(null);
os.apiGet('charts/user/notes', { userId: props.user.id, limit: 16 + 1, span: 'day' }).then(res => {
//
res.inc.splice(0, 1);
chartValues = res.inc;
onMounted(() => {
if (props.withChart) {
os.apiGet('charts/user/notes', { userId: props.user.id, limit: 16 + 1, span: 'day' }).then(res => {
//
res.inc.splice(0, 1);
chartValues = res.inc;
});
}
});
</script>

View file

@ -1,7 +1,7 @@
<template>
<div class="_panel vjnjpkug">
<div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div>
<MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/>
<MkAvatar class="avatar" :user="user" indicator/>
<div class="title">
<MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA>
<p class="username"><MkAcct :user="user"/></p>

View file

@ -5,7 +5,7 @@
<div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''">
<span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ $ts.followsYou }}</span>
</div>
<MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/>
<MkAvatar class="avatar" :user="user" indicator/>
<div class="title">
<MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA>
<p class="username"><MkAcct :user="user"/></p>

View file

@ -9,8 +9,8 @@
@closed="$emit('closed')"
>
<template #header>{{ i18n.ts.selectUser }}</template>
<div class="tbhwbxda">
<div class="form">
<div :class="$style.root">
<div :class="$style.form">
<FormSplit :min-width="170">
<MkInput v-model="username" :autofocus="true" @update:model-value="search">
<template #label>{{ i18n.ts.username }}</template>
@ -22,27 +22,27 @@
</MkInput>
</FormSplit>
</div>
<div v-if="username != '' || host != ''" class="result" :class="{ hit: users.length > 0 }">
<div v-if="users.length > 0" class="users">
<div v-for="user in users" :key="user.id" class="user" :class="{ selected: selected && selected.id === user.id }" @click="selected = user" @dblclick="ok()">
<MkAvatar :user="user" class="avatar" :show-indicator="true"/>
<div class="body">
<MkUserName :user="user" class="name"/>
<MkAcct :user="user" class="acct"/>
<div v-if="username != '' || host != ''" :class="[$style.result, { [$style.hit]: users.length > 0 }]">
<div v-if="users.length > 0" :class="$style.users">
<div v-for="user in users" :key="user.id" class="_button" :class="[$style.user, { [$style.selected]: selected && selected.id === user.id }]" @click="selected = user" @dblclick="ok()">
<MkAvatar :user="user" :class="$style.avatar" indicator/>
<div :class="$style.userBody">
<MkUserName :user="user" :class="$style.userName"/>
<MkAcct :user="user" :class="$style.userAcct"/>
</div>
</div>
</div>
<div v-else class="empty">
<div v-else :class="$style.empty">
<span>{{ i18n.ts.noUsers }}</span>
</div>
</div>
<div v-if="username == '' && host == ''" class="recent">
<div class="users">
<div v-for="user in recentUsers" :key="user.id" class="user" :class="{ selected: selected && selected.id === user.id }" @click="selected = user" @dblclick="ok()">
<MkAvatar :user="user" class="avatar" :show-indicator="true"/>
<div class="body">
<MkUserName :user="user" class="name"/>
<MkAcct :user="user" class="acct"/>
<div v-if="username == '' && host == ''" :class="$style.recent">
<div :class="$style.users">
<div v-for="user in recentUsers" :key="user.id" class="_button" :class="[$style.user, { [$style.selected]: selected && selected.id === user.id }]" @click="selected = user" @dblclick="ok()">
<MkAvatar :user="user" :class="$style.avatar" indicator/>
<div :class="$style.userBody">
<MkUserName :user="user" :class="$style.userName"/>
<MkAcct :user="user" :class="$style.userAcct"/>
</div>
</div>
</div>
@ -60,6 +60,7 @@ import MkModalWindow from '@/components/MkModalWindow.vue';
import * as os from '@/os';
import { defaultStore } from '@/store';
import { i18n } from '@/i18n';
import { $i } from '@/account';
const emit = defineEmits<{
(ev: 'ok', selected: misskey.entities.UserDetailed): void;
@ -67,6 +68,10 @@ const emit = defineEmits<{
(ev: 'closed'): void;
}>();
const props = defineProps<{
includeSelf?: boolean;
}>();
let username = $ref('');
let host = $ref('');
let users: misskey.entities.UserDetailed[] = $ref([]);
@ -110,81 +115,83 @@ onMounted(() => {
os.api('users/show', {
userIds: defaultStore.state.recentlyUsedUsers,
}).then(users => {
recentUsers = users;
if (props.includeSelf) {
recentUsers = [$i, ...users];
} else {
recentUsers = users;
}
});
});
</script>
<style lang="scss" scoped>
.tbhwbxda {
> .form {
padding: 0 var(--root-margin);
<style lang="scss" module>
.root {
}
.form {
padding: 0 var(--root-margin);
}
.result,
.recent {
display: flex;
flex-direction: column;
overflow: auto;
height: 100%;
&.result.hit {
padding: 0;
}
> .result, > .recent {
display: flex;
flex-direction: column;
overflow: auto;
height: 100%;
&.result.hit {
padding: 0;
}
&.recent {
padding: 0;
}
> .users {
flex: 1;
overflow: auto;
padding: 8px 0;
> .user {
display: flex;
align-items: center;
padding: 8px var(--root-margin);
font-size: 14px;
&:hover {
background: var(--X7);
}
&.selected {
background: var(--accent);
color: #fff;
}
> * {
pointer-events: none;
user-select: none;
}
> .avatar {
width: 45px;
height: 45px;
}
> .body {
padding: 0 8px;
min-width: 0;
> .name {
display: block;
font-weight: bold;
}
> .acct {
opacity: 0.5;
}
}
}
}
> .empty {
opacity: 0.7;
text-align: center;
}
&.recent {
padding: 0;
}
}
.users {
flex: 1;
overflow: auto;
padding: 8px 0;
}
.user {
display: flex;
align-items: center;
padding: 8px var(--root-margin);
font-size: 14px;
&:hover {
background: var(--X7);
}
&.selected {
background: var(--accent);
color: #fff;
}
}
.userBody {
padding: 0 8px;
min-width: 0;
}
.avatar {
width: 45px;
height: 45px;
}
.userName {
display: block;
font-weight: bold;
}
.userAcct {
opacity: 0.5;
}
.empty {
opacity: 0.7;
text-align: center;
padding: 16px;
}
</style>

View file

@ -1,5 +1,5 @@
<template>
<div v-if="chosen" :class="$style.root">
<div v-if="chosen && !shouldHide" :class="$style.root">
<div v-if="!showMenu" :class="[$style.main, $style['form_' + chosen.place]]">
<a :href="chosen.url" target="_blank" :class="$style.link">
<img :src="chosen.imageUrl" :class="$style.img">
@ -11,6 +11,7 @@
<div>Ads by {{ host }}</div>
<!--<MkButton class="button" primary>{{ $ts._ad.like }}</MkButton>-->
<MkButton v-if="chosen.ratio !== 0" :class="$style.menuButton" @click="reduceFrequency">{{ $ts._ad.reduceFrequencyOfThisAd }}</MkButton>
<MkButton v-if="$i && $i.policies.canHideAds" :class="$style.menuButton" @click="hide">{{ $ts._ad.hide }}</MkButton>
<button class="_textButton" @click="toggleMenu">{{ $ts._ad.back }}</button>
</div>
</div>
@ -25,6 +26,7 @@ import { host } from '@/config';
import MkButton from '@/components/MkButton.vue';
import { defaultStore } from '@/store';
import * as os from '@/os';
import { $i } from '@/account';
type Ad = (typeof instance)['ads'][number];
@ -81,6 +83,7 @@ const choseAd = (): Ad | null => {
};
const chosen = ref(choseAd());
let shouldHide = $ref(chosen.value && $i && $i.policies.canHideAds && defaultStore.state.hiddenAds.includes(chosen.value.id));
function reduceFrequency(): void {
if (chosen.value == null) return;
@ -90,6 +93,13 @@ function reduceFrequency(): void {
chosen.value = choseAd();
showMenu.value = false;
}
function hide() {
if (chosen.value == null) return;
defaultStore.push('hiddenAds', chosen.value.id);
os.success();
shouldHide = true;
}
</script>
<style lang="scss" module>

View file

@ -1,11 +1,11 @@
<template>
<span v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" :class="[$style.root, { [$style.cat]: user.isCat, [$style.square]: $store.state.squareAvatars }]" class="_noSelect" :style="{ color }" :title="acct(user)" @click="onClick">
<span v-if="!link" v-user-preview="preview ? user.id : undefined" :class="[$style.root, { [$style.cat]: user.isCat, [$style.square]: $store.state.squareAvatars }]" class="_noSelect" :style="{ color }" :title="acct(user)" @click="onClick">
<img :class="$style.inner" :src="url" decoding="async"/>
<MkUserOnlineIndicator v-if="showIndicator" :class="$style.indicator" :user="user"/>
<MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/>
</span>
<MkA v-else v-user-preview="disablePreview ? undefined : user.id" class="_noSelect" :class="[$style.root, { [$style.cat]: user.isCat, [$style.square]: $store.state.squareAvatars }]" :style="{ color }" :to="userPage(user)" :title="acct(user)" :target="target">
<MkA v-else v-user-preview="preview ? user.id : undefined" class="_noSelect" :class="[$style.root, { [$style.cat]: user.isCat, [$style.square]: $store.state.squareAvatars }]" :style="{ color }" :to="userPage(user)" :title="acct(user)" :target="target">
<img :class="$style.inner" :src="url" decoding="async"/>
<MkUserOnlineIndicator v-if="showIndicator" :class="$style.indicator" :user="user"/>
<MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/>
</MkA>
</template>
@ -21,14 +21,14 @@ import { defaultStore } from '@/store';
const props = withDefaults(defineProps<{
user: misskey.entities.User;
target?: string | null;
disableLink?: boolean;
disablePreview?: boolean;
showIndicator?: boolean;
link?: boolean;
preview?: boolean;
indicator?: boolean;
}>(), {
target: null,
disableLink: false,
disablePreview: false,
showIndicator: false,
link: false,
preview: false,
indicator: false,
});
const emit = defineEmits<{

View file

@ -1,5 +1,6 @@
<template>
<img v-if="isCustom" :class="[$style.root, $style.custom, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" :src="url" :alt="alt" :title="alt" decoding="async"/>
<span v-if="isCustom && errored">:{{ customEmojiName }}:</span>
<img v-else-if="isCustom" :class="[$style.root, $style.custom, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" :src="url" :alt="alt" :title="alt" decoding="async" @error="errored = true"/>
<img v-else-if="char && !useOsNativeEmojis" :class="$style.root" :src="url" :alt="alt" decoding="async" @pointerenter="computeTitle"/>
<span v-else-if="char && useOsNativeEmojis" :alt="alt" @pointerenter="computeTitle">{{ char }}</span>
<span v-else>{{ emoji }}</span>
@ -11,6 +12,7 @@ import { getStaticImageUrl } from '@/scripts/media-proxy';
import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base';
import { defaultStore } from '@/store';
import { getEmojiName } from '@/scripts/emojilist';
import { customEmojis } from '@/custom-emojis';
const props = defineProps<{
emoji: string;
@ -23,12 +25,15 @@ const props = defineProps<{
const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath;
const isCustom = computed(() => props.emoji.startsWith(':'));
const customEmojiName = props.emoji.substr(1, props.emoji.length - 2);
const customEmojiName = props.emoji.substr(1, props.emoji.length - 2).replace('@.', '');
const char = computed(() => isCustom.value ? undefined : props.emoji);
const useOsNativeEmojis = computed(() => defaultStore.state.emojiStyle === 'native' && !props.isReaction);
const url = computed(() => {
if (char.value) {
return char2path(char.value);
} else if (props.host == null && !customEmojiName.includes('@')) {
const found = customEmojis.find(x => x.name === customEmojiName);
return found ? found.url : null;
} else {
const rawUrl = props.host ? `/emoji/${customEmojiName}@${props.host}.webp` : `/emoji/${customEmojiName}.webp`;
return defaultStore.state.disableShowingAnimatedImages
@ -37,6 +42,7 @@ const url = computed(() => {
}
});
const alt = computed(() => isCustom.value ? `:${customEmojiName}:` : char.value);
let errored = $ref(isCustom.value && url.value == null);
// Searching from an array with 2000 items for every emoji felt like too energy-consuming, so I decided to do it lazily on pointerenter
function computeTitle(event: PointerEvent): void {

View file

@ -1,11 +1,11 @@
<template>
<div v-if="show" ref="el" :class="[$style.root, { [$style.slim]: narrow, [$style.thin]: thin_ }]" :style="{ background: bg }" @click="onClick">
<div v-if="narrow" :class="$style.buttonsLeft">
<MkAvatar v-if="props.displayMyAvatar && $i" :class="$style.avatar" :user="$i" :disable-preview="true"/>
<MkAvatar v-if="props.displayMyAvatar && $i" :class="$style.avatar" :user="$i"/>
</div>
<template v-if="metadata">
<div v-if="!hideTitle" :class="$style.titleContainer" @click="showTabsPopup">
<MkAvatar v-if="metadata.avatar" :class="$style.titleAvatar" :user="metadata.avatar" :disable-preview="true" :show-indicator="true"/>
<MkAvatar v-if="metadata.avatar" :class="$style.titleAvatar" :user="metadata.avatar" indicator/>
<i v-else-if="metadata.icon" :class="[$style.titleIcon, metadata.icon]"></i>
<div :class="$style.title">

View file

@ -14,7 +14,7 @@
<span v-if="pathname != ''" :class="$style.pathname">{{ self ? pathname.substring(1) : pathname }}</span>
<span :class="$style.query">{{ query }}</span>
<span :class="$style.hash">{{ hash }}</span>
<i v-if="target === '_blank'" class="ti ti-external-link icon"></i>
<i v-if="target === '_blank'" :class="$style.icon" class="ti ti-external-link"></i>
</component>
</template>

View file

@ -200,6 +200,12 @@ export default defineComponent({
style = `transform: translateX(${x}em) translateY(${y}em);`;
break;
}
case 'scale': {
const x = Math.min(parseInt(token.props.args.x ?? '1'), 5);
const y = Math.min(parseInt(token.props.args.y ?? '1'), 5);
style = `transform: scale(${x}, ${y});`;
break;
}
case 'fg': {
let color = token.props.args.color;
if (!/^[0-9a-f]{3,6}$/i.test(color)) color = 'f00';

View file

@ -10,8 +10,12 @@ export const apiUrl = url + '/api';
export const wsUrl = url.replace('http://', 'ws://').replace('https://', 'wss://') + '/streaming';
export const lang = miLocalStorage.getItem('lang');
export const langs = _LANGS_;
export const locale = JSON.parse(miLocalStorage.getItem('locale'));
export let locale = JSON.parse(miLocalStorage.getItem('locale'));
export const version = _VERSION_;
export const instanceName = siteName === 'Misskey' ? host : siteName;
export const ui = miLocalStorage.getItem('ui');
export const debug = miLocalStorage.getItem('debug') === 'true';
export function updateLocale(newLocale) {
locale = newLocale;
}

View file

@ -2,14 +2,17 @@ import { api } from './os';
import { miLocalStorage } from './local-storage';
const storageCache = miLocalStorage.getItem('emojis');
export let customEmojis = storageCache ? JSON.parse(storageCache) : [];
fetchCustomEmojis();
export let customEmojis: {
name: string;
aliases: string[];
category: string;
url: string;
}[] = storageCache ? JSON.parse(storageCache) : [];
export async function fetchCustomEmojis() {
const now = Date.now();
const lastFetchedAt = miLocalStorage.getItem('lastEmojisFetchedAt');
if (lastFetchedAt && (now - parseInt(lastFetchedAt)) < 1000 * 60 * 60) return;
if (lastFetchedAt && (now - parseInt(lastFetchedAt)) < 1000 * 60 * 60 * 24) return;
const res = await api('emojis', {});

View file

@ -3,3 +3,7 @@ import { locale } from '@/config';
import { I18n } from '@/scripts/i18n';
export const i18n = markRaw(new I18n(locale));
export function updateI18n(newLocale) {
i18n.ts = newLocale;
}

View file

@ -25,10 +25,10 @@ import JSON5 from 'json5';
import widgets from '@/widgets';
import directives from '@/directives';
import components from '@/components';
import { version, ui, lang, host } from '@/config';
import { version, ui, lang, host, updateLocale } from '@/config';
import { applyTheme } from '@/scripts/theme';
import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
import { i18n } from '@/i18n';
import { i18n, updateI18n } from '@/i18n';
import { confirm, alert, post, popup, toast } from '@/os';
import { stream } from '@/stream';
import * as sound from '@/scripts/sound';
@ -44,6 +44,8 @@ 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';
import { fetchCustomEmojis } from './custom-emojis';
(async () => {
console.info(`Misskey v${version}`);
@ -79,6 +81,22 @@ import { miLocalStorage } from './local-storage';
});
}
//#region Detect language & fetch translations
const localeVersion = miLocalStorage.getItem('localeVersion');
const localeOutdated = (localeVersion == null || localeVersion !== version);
if (localeOutdated) {
const res = await window.fetch(`/assets/locales/${lang}.${version}.json`);
if (res.status === 200) {
const newLocale = await res.text();
const parsedNewLocale = JSON.parse(newLocale);
miLocalStorage.setItem('locale', newLocale);
miLocalStorage.setItem('localeVersion', version);
updateLocale(parsedNewLocale);
updateI18n(parsedNewLocale);
}
}
//#endregion
// タッチデバイスでCSSの:hoverを機能させる
document.addEventListener('touchend', () => {}, { passive: true });
@ -164,6 +182,10 @@ import { miLocalStorage } from './local-storage';
initializeSw();
});
try {
await fetchCustomEmojis();
} catch (err) {}
const app = createApp(
window.location.search === '?zen' ? defineAsyncComponent(() => import('@/ui/zen.vue')) :
!$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) :
@ -345,6 +367,87 @@ import { miLocalStorage } from './local-storage';
});
}
const now = new Date();
const m = now.getMonth() + 1;
const d = now.getDate();
if ($i.birthday) {
const bm = parseInt($i.birthday.split('-')[1]);
const bd = parseInt($i.birthday.split('-')[2]);
if (m === bm && d === bd) {
claimAchievement('loggedInOnBirthday');
}
}
if (m === 1 && d === 1) {
claimAchievement('loggedInOnNewYearsDay');
}
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);

View file

@ -19,6 +19,7 @@ type Keys =
'fontSize' |
'ui' |
'locale' |
'localeVersion' |
'theme' |
'customCss' |
'message_drafts' |

View file

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

View file

@ -1,5 +1,7 @@
// TODO: なんでもかんでもos.tsに突っ込むのやめたいのでよしなに分割する
import { pendingApiRequestsCount, api, apiGet } from '@/scripts/api';
export { pendingApiRequestsCount, api, apiGet };
import { Component, markRaw, Ref, ref, defineAsyncComponent } from 'vue';
import { EventEmitter } from 'eventemitter3';
import insertTextAtCursor from 'insert-text-at-cursor';
@ -7,9 +9,16 @@ import * as Misskey from 'misskey-js';
import { i18n } from './i18n';
import MkPostFormDialog from '@/components/MkPostFormDialog.vue';
import MkWaitingDialog from '@/components/MkWaitingDialog.vue';
import MkPageWindow from '@/components/MkPageWindow.vue';
import MkToast from '@/components/MkToast.vue';
import MkDialog from '@/components/MkDialog.vue';
import MkEmojiPickerDialog from '@/components/MkEmojiPickerDialog.vue';
import MkEmojiPickerWindow from '@/components/MkEmojiPickerWindow.vue';
import MkPopupMenu from '@/components/MkPopupMenu.vue';
import MkContextMenu from '@/components/MkContextMenu.vue';
import { MenuItem } from '@/types/menu';
import { pendingApiRequestsCount, api, apiGet } from '@/scripts/api';
export { pendingApiRequestsCount, api, apiGet };
export const openingWindowsCount = ref(0);
export const apiWithDialog = ((
endpoint: string,
@ -124,7 +133,7 @@ export async function popup(component: Component, props: Record<string, any>, ev
}
export function pageWindow(path: string) {
popup(defineAsyncComponent(() => import('@/components/MkPageWindow.vue')), {
popup(MkPageWindow, {
initialPath: path,
}, {}, 'closed');
}
@ -136,7 +145,7 @@ export function modalPageWindow(path: string) {
}
export function toast(message: string) {
popup(defineAsyncComponent(() => import('@/components/MkToast.vue')), {
popup(MkToast, {
message,
}, {}, 'closed');
}
@ -147,7 +156,7 @@ export function alert(props: {
text?: string | null;
}): Promise<void> {
return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), props, {
popup(MkDialog, props, {
done: result => {
resolve();
},
@ -161,7 +170,7 @@ export function confirm(props: {
text?: string | null;
}): Promise<{ canceled: boolean }> {
return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), {
popup(MkDialog, {
...props,
showCancelButton: true,
}, {
@ -182,7 +191,7 @@ export function inputText(props: {
canceled: false; result: string;
}> {
return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), {
popup(MkDialog, {
title: props.title,
text: props.text,
input: {
@ -207,7 +216,7 @@ export function inputNumber(props: {
canceled: false; result: number;
}> {
return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), {
popup(MkDialog, {
title: props.title,
text: props.text,
input: {
@ -232,7 +241,7 @@ export function inputDate(props: {
canceled: false; result: Date;
}> {
return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), {
popup(MkDialog, {
title: props.title,
text: props.text,
input: {
@ -269,7 +278,7 @@ export function select<C = any>(props: {
canceled: false; result: C;
}> {
return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), {
popup(MkDialog, {
title: props.title,
text: props.text,
select: {
@ -291,7 +300,7 @@ export function success() {
window.setTimeout(() => {
showing.value = false;
}, 1000);
popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), {
popup(MkWaitingDialog, {
success: true,
showing: showing,
}, {
@ -303,7 +312,7 @@ export function success() {
export function waiting() {
return new Promise((resolve, reject) => {
const showing = ref(true);
popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), {
popup(MkWaitingDialog, {
success: false,
showing: showing,
}, {
@ -322,9 +331,11 @@ export function form(title, form) {
});
}
export async function selectUser() {
export async function selectUser(opts: { includeSelf?: boolean } = {}) {
return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), {}, {
popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), {
includeSelf: opts.includeSelf,
}, {
ok: user => {
resolve(user);
},
@ -364,7 +375,7 @@ export async function selectDriveFolder(multiple: boolean) {
export async function pickEmoji(src: HTMLElement | null, opts) {
return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
popup(MkEmojiPickerDialog, {
src,
...opts,
}, {
@ -429,7 +440,7 @@ export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea:
characterData: false,
});
openingEmojiPicker = await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerWindow.vue')), {
openingEmojiPicker = await popup(MkEmojiPickerWindow, {
src,
...opts,
}, {
@ -452,7 +463,7 @@ export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement
}) {
return new Promise((resolve, reject) => {
let dispose;
popup(defineAsyncComponent(() => import('@/components/MkPopupMenu.vue')), {
popup(MkPopupMenu, {
items,
src,
width: options?.width,
@ -476,7 +487,7 @@ export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent)
ev.preventDefault();
return new Promise((resolve, reject) => {
let dispose;
popup(defineAsyncComponent(() => import('@/components/MkContextMenu.vue')), {
popup(MkContextMenu, {
items,
ev,
}, {

View file

@ -4,11 +4,14 @@
<div style="overflow: clip;">
<MkSpacer :content-max="600" :margin-min="20">
<div class="_gaps_m znqjceqz">
<div ref="containerEl" v-panel class="about" :class="{ playing: easterEggEngine != null }">
<img src="/client-assets/about-icon.png" alt="" class="icon" draggable="false" @load="iconLoaded" @click="gravity"/>
<div class="misskey">Misskey</div>
<div class="version">v{{ version }}</div>
<span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }"><MkEmoji class="emoji" :emoji="emoji.emoji" :is-reaction="false" :normal="true" :no-style="true"/></span>
<div v-panel class="about">
<div ref="containerEl" class="container" :class="{ playing: easterEggEngine != null }">
<img src="/client-assets/about-icon.png" alt="" class="icon" draggable="false" @load="iconLoaded" @click="gravity"/>
<div class="misskey">Misskey</div>
<div class="version">v{{ version }}</div>
<span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }"><MkEmoji class="emoji" :emoji="emoji.emoji" :is-reaction="false" :normal="true" :no-style="true"/></span>
</div>
<button v-if="thereIsTreasure" class="_button treasure" @click="getTreasure"><img src="/fluent-emoji/1f3c6.png" class="treasureImg"></button>
</div>
<div style="text-align: center;">
{{ i18n.ts._aboutMisskey.about }}<br><a href="https://misskey-hub.net/docs/misskey.html" target="_blank" class="_link">{{ i18n.ts.learnMore }}</a>
@ -37,11 +40,31 @@
</FormSection>
<FormSection>
<template #label>{{ i18n.ts._aboutMisskey.contributors }}</template>
<div class="_formLinksGrid">
<FormLink to="https://github.com/syuilo" external>@syuilo</FormLink>
<FormLink to="https://github.com/tamaina" external>@tamaina</FormLink>
<FormLink to="https://github.com/acid-chicken" external>@acid-chicken</FormLink>
<FormLink to="https://github.com/rinsuki" external>@rinsuki</FormLink>
<div :class="$style.contributors">
<a href="https://github.com/syuilo" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/4439005?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@syuilo</span>
</a>
<a href="https://github.com/tamaina" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/7973572?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@tamaina</span>
</a>
<a href="https://github.com/acid-chicken" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/20679825?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@acid-chicken</span>
</a>
<a href="https://github.com/rinsuki" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/6533808?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@rinsuki</span>
</a>
<a href="https://github.com/mei23" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/30769358?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@mei23</span>
</a>
<a href="https://github.com/robflop" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/8159402?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@robflop</span>
</a>
</div>
<template #caption><MkLink url="https://github.com/misskey-dev/misskey/graphs/contributors">{{ i18n.ts._aboutMisskey.allContributors }}</MkLink></template>
</FormSection>
@ -70,6 +93,8 @@ import { i18n } from '@/i18n';
import { defaultStore } from '@/store';
import * as os from '@/os';
import { definePageMetadata } from '@/scripts/page-metadata';
import { claimAchievement, claimedAchievements } from '@/scripts/achievements';
import { $i } from '@/account';
const patrons = [
'まっちゃとーにゅ',
@ -152,6 +177,8 @@ const patrons = [
'pixeldesu',
];
let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure'));
let easterEggReady = false;
let easterEggEmojis = $ref([]);
let easterEggEngine = $ref(null);
@ -187,6 +214,11 @@ function iLoveMisskey() {
});
}
function getTreasure() {
thereIsTreasure = false;
claimAchievement('foundTreasure');
}
onBeforeUnmount(() => {
if (easterEggEngine) {
easterEggEngine.stop();
@ -207,54 +239,114 @@ definePageMetadata({
.znqjceqz {
> .about {
position: relative;
text-align: center;
padding: 16px;
border-radius: var(--radius);
&.playing {
&, * {
user-select: none;
}
* {
will-change: transform;
}
> .emoji {
visibility: visible;
}
}
> .icon {
display: block;
width: 80px;
margin: 0 auto;
border-radius: 16px;
}
> .misskey {
margin: 0.75em auto 0 auto;
width: max-content;
}
> .version {
margin: 0 auto;
width: max-content;
opacity: 0.5;
}
> .emoji {
> .treasure {
position: absolute;
top: 0;
top: 60px;
left: 0;
visibility: hidden;
right: 0;
margin: 0 auto;
width: min-content;
> .treasureImg {
width: 25px;
vertical-align: bottom;
}
}
> .container {
position: relative;
text-align: center;
padding: 16px;
&.playing {
&, * {
user-select: none;
}
* {
will-change: transform;
}
> .emoji {
visibility: visible;
}
}
> .icon {
display: block;
width: 80px;
margin: 0 auto;
border-radius: 16px;
position: relative;
z-index: 1;
}
> .misskey {
margin: 0.75em auto 0 auto;
width: max-content;
position: relative;
z-index: 1;
}
> .version {
margin: 0 auto;
width: max-content;
opacity: 0.5;
position: relative;
z-index: 1;
}
> .emoji {
pointer-events: none;
font-size: 24px;
width: 24px;
position: absolute;
z-index: 1;
top: 0;
left: 0;
visibility: hidden;
> .emoji {
pointer-events: none;
font-size: 24px;
width: 24px;
}
}
}
}
}
</style>
<style lang="scss" module>
.contributors {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
grid-gap: 12px;
}
.contributor {
display: flex;
align-items: center;
padding: 12px;
background: var(--buttonBg);
border-radius: 6px;
&:hover {
text-decoration: none;
background: var(--buttonHoverBg);
}
&.active {
color: var(--accent);
background: var(--buttonHoverBg);
}
}
.contributorAvatar {
width: 30px;
border-radius: 100%;
}
.contributorUsername {
margin-left: 12px;
}
</style>

View file

@ -1,5 +1,7 @@
<template>
<div class="driuhtrh">
<div class="driuhtrh _gaps">
<MkButton v-if="$i && ($i.isModerator || $i.policies.canManageCustomEmojis)" primary link to="/custom-emojis-manager">{{ i18n.ts.manageCustomEmojis }}</MkButton>
<div class="query">
<MkInput v-model="q" class="" :placeholder="$ts.search">
<template #prefix><i class="ti ti-search"></i></template>
@ -38,6 +40,7 @@ import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkTab from '@/components/MkTab.vue';
import * as os from '@/os';
import { customEmojis, getCustomEmojiCategories, getCustomEmojiTags } from '@/custom-emojis';
import { i18n } from '@/i18n';
const customEmojiCategories = getCustomEmojiCategories();
const customEmojiTags = getCustomEmojiTags();
@ -81,7 +84,6 @@ watch($$(selectedTags), () => {
> .query {
background: var(--bg);
padding: 16px;
> .tags {
> .tag {
@ -101,13 +103,10 @@ watch($$(selectedTags), () => {
}
> .emojis {
--x-padding: 0 16px;
.zuvgdzyt {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
grid-gap: 12px;
margin: 0 var(--margin) var(--margin) var(--margin);
}
}
}

View file

@ -28,8 +28,8 @@
<option value="-following">{{ i18n.ts.following }} ({{ i18n.ts.ascendingOrder }})</option>
<option value="+followers">{{ i18n.ts.followers }} ({{ i18n.ts.descendingOrder }})</option>
<option value="-followers">{{ i18n.ts.followers }} ({{ i18n.ts.ascendingOrder }})</option>
<option value="+caughtAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.descendingOrder }})</option>
<option value="-caughtAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.ascendingOrder }})</option>
<option value="+firstRetrievedAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.descendingOrder }})</option>
<option value="-firstRetrievedAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.ascendingOrder }})</option>
</MkSelect>
</FormSplit>
</div>

View file

@ -86,7 +86,7 @@
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { ref, computed, watch } from 'vue';
import XEmojis from './about.emojis.vue';
import XFederation from './about.federation.vue';
import { version, instanceName, host } from '@/config';
@ -100,6 +100,7 @@ import * as os from '@/os';
import number from '@/filters/number';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { claimAchievement } from '@/scripts/achievements';
const props = withDefaults(defineProps<{
initialTab?: string;
@ -110,6 +111,12 @@ const props = withDefaults(defineProps<{
let stats = $ref(null);
let tab = $ref(props.initialTab);
watch($$(tab), () => {
if (tab === 'charts') {
claimAchievement('viewInstanceChart');
}
});
const initStats = () => os.api('stats', {
}).then((res) => {
stats = res;

View file

@ -0,0 +1,54 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader/></template>
<MkSpacer :content-max="1200">
<MkAchievements :user="$i"/>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue';
import MkAchievements from '@/components/MkAchievements.vue';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { $i } from '@/account';
import { claimAchievement } from '@/scripts/achievements';
let timer: number | null;
function viewAchievements3min() {
claimAchievement('viewAchievements3min');
}
onMounted(() => {
if (timer == null) timer = window.setTimeout(viewAchievements3min, 1000 * 60 * 3);
});
onUnmounted(() => {
if (timer != null) {
window.clearTimeout(timer);
timer = null;
}
});
onActivated(() => {
if (timer == null) timer = window.setTimeout(viewAchievements3min, 1000 * 60 * 3);
});
onDeactivated(() => {
if (timer != null) {
window.clearTimeout(timer);
timer = null;
}
});
definePageMetadata({
title: i18n.ts.achievements,
icon: 'ti ti-military-award',
});
</script>
<style lang="scss" module>
</style>

View file

@ -32,8 +32,8 @@
<option value="-following">{{ i18n.ts.following }} ({{ i18n.ts.ascendingOrder }})</option>
<option value="+followers">{{ i18n.ts.followers }} ({{ i18n.ts.descendingOrder }})</option>
<option value="-followers">{{ i18n.ts.followers }} ({{ i18n.ts.ascendingOrder }})</option>
<option value="+caughtAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.descendingOrder }})</option>
<option value="-caughtAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.ascendingOrder }})</option>
<option value="+firstRetrievedAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.descendingOrder }})</option>
<option value="-firstRetrievedAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.ascendingOrder }})</option>
</MkSelect>
</FormSplit>
</div>

View file

@ -38,7 +38,7 @@
<template #label>Access key</template>
</MkInput>
<MkInput v-model="objectStorageSecretKey">
<MkInput v-model="objectStorageSecretKey" type="password">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>Secret key</template>
</MkInput>

View file

@ -4,7 +4,7 @@
<MkLoading v-if="fetching"/>
<div v-else :class="$style.root" class="_panel">
<MkA v-for="user in moderators" :key="user.id" class="user" :to="`/user-info/${user.id}`">
<MkAvatar :user="user" class="avatar" :show-indicator="true" :disable-link="true"/>
<MkAvatar :user="user" class="avatar" indicator/>
</MkA>
</div>
</Transition>

View file

@ -14,7 +14,7 @@
</MkInput>
<MkSelect v-model="rolePermission" :readonly="readonly">
<template #label>{{ i18n.ts._role.permission }}</template>
<template #label><i class="ti ti-shield-lock"></i> {{ i18n.ts._role.permission }}</template>
<template #caption><div v-html="i18n.ts._role.descriptionOfPermission.replaceAll('\n', '<br>')"></div></template>
<option value="normal">{{ i18n.ts.normalUser }}</option>
<option value="moderator">{{ i18n.ts.moderator }}</option>
@ -22,7 +22,7 @@
</MkSelect>
<MkSelect v-model="target" :readonly="readonly">
<template #label>{{ i18n.ts._role.assignTarget }}</template>
<template #label><i class="ti ti-users"></i> {{ i18n.ts._role.assignTarget }}</template>
<template #caption><div v-html="i18n.ts._role.descriptionOfAssignTarget.replaceAll('\n', '<br>')"></div></template>
<option value="manual">{{ i18n.ts._role.manual }}</option>
<option value="conditional">{{ i18n.ts._role.conditional }}</option>
@ -36,11 +36,19 @@
</MkFolder>
<FormSlot>
<template #label>{{ i18n.ts._role.policies }}</template>
<template #label><i class="ti ti-license"></i> {{ i18n.ts._role.policies }}</template>
<div class="_gaps_s">
<MkFolder>
<MkInput v-model="q" type="search">
<template #prefix><i class="ti ti-search"></i></template>
</MkInput>
<MkFolder v-if="matchQuery([i18n.ts._role._options.rateLimitFactor, 'rateLimitFactor'])">
<template #label>{{ i18n.ts._role._options.rateLimitFactor }}</template>
<template #suffix>{{ policies.rateLimitFactor.useDefault ? i18n.ts._role.useBaseValue : `${Math.floor(policies.rateLimitFactor.value * 100)}%` }} <i :class="getPriorityIcon(policies.rateLimitFactor)"></i></template>
<template #suffix>
<span v-if="policies.rateLimitFactor.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ `${Math.floor(policies.rateLimitFactor.value * 100)}%` }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.rateLimitFactor)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="policies.rateLimitFactor.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
@ -55,9 +63,13 @@
</div>
</MkFolder>
<MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.gtlAvailable, 'gtlAvailable'])">
<template #label>{{ i18n.ts._role._options.gtlAvailable }}</template>
<template #suffix>{{ policies.gtlAvailable.useDefault ? i18n.ts._role.useBaseValue : (policies.gtlAvailable.value ? i18n.ts.yes : i18n.ts.no) }} <i :class="getPriorityIcon(policies.gtlAvailable)"></i></template>
<template #suffix>
<span v-if="policies.gtlAvailable.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ policies.gtlAvailable.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.gtlAvailable)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="policies.gtlAvailable.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
@ -71,9 +83,13 @@
</div>
</MkFolder>
<MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.ltlAvailable, 'ltlAvailable'])">
<template #label>{{ i18n.ts._role._options.ltlAvailable }}</template>
<template #suffix>{{ policies.ltlAvailable.useDefault ? i18n.ts._role.useBaseValue : (policies.ltlAvailable.value ? i18n.ts.yes : i18n.ts.no) }} <i :class="getPriorityIcon(policies.ltlAvailable)"></i></template>
<template #suffix>
<span v-if="policies.ltlAvailable.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ policies.ltlAvailable.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.ltlAvailable)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="policies.ltlAvailable.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
@ -87,9 +103,13 @@
</div>
</MkFolder>
<MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canPublicNote, 'canPublicNote'])">
<template #label>{{ i18n.ts._role._options.canPublicNote }}</template>
<template #suffix>{{ policies.canPublicNote.useDefault ? i18n.ts._role.useBaseValue : (policies.canPublicNote.value ? i18n.ts.yes : i18n.ts.no) }} <i :class="getPriorityIcon(policies.canPublicNote)"></i></template>
<template #suffix>
<span v-if="policies.canPublicNote.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ policies.canPublicNote.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.canPublicNote)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="policies.canPublicNote.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
@ -103,9 +123,13 @@
</div>
</MkFolder>
<MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canInvite, 'canInvite'])">
<template #label>{{ i18n.ts._role._options.canInvite }}</template>
<template #suffix>{{ policies.canInvite.useDefault ? i18n.ts._role.useBaseValue : (policies.canInvite.value ? i18n.ts.yes : i18n.ts.no) }} <i :class="getPriorityIcon(policies.canInvite)"></i></template>
<template #suffix>
<span v-if="policies.canInvite.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ policies.canInvite.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.canInvite)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="policies.canInvite.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
@ -119,9 +143,13 @@
</div>
</MkFolder>
<MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canManageCustomEmojis, 'canManageCustomEmojis'])">
<template #label>{{ i18n.ts._role._options.canManageCustomEmojis }}</template>
<template #suffix>{{ policies.canManageCustomEmojis.useDefault ? i18n.ts._role.useBaseValue : (policies.canManageCustomEmojis.value ? i18n.ts.yes : i18n.ts.no) }} <i :class="getPriorityIcon(policies.canManageCustomEmojis)"></i></template>
<template #suffix>
<span v-if="policies.canManageCustomEmojis.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ policies.canManageCustomEmojis.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.canManageCustomEmojis)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="policies.canManageCustomEmojis.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
@ -135,9 +163,13 @@
</div>
</MkFolder>
<MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.driveCapacity, 'driveCapacityMb'])">
<template #label>{{ i18n.ts._role._options.driveCapacity }}</template>
<template #suffix>{{ policies.driveCapacityMb.useDefault ? i18n.ts._role.useBaseValue : (policies.driveCapacityMb.value + 'MB') }} <i :class="getPriorityIcon(policies.driveCapacityMb)"></i></template>
<template #suffix>
<span v-if="policies.driveCapacityMb.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ policies.driveCapacityMb.value + 'MB' }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.driveCapacityMb)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="policies.driveCapacityMb.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
@ -151,9 +183,13 @@
</div>
</MkFolder>
<MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.pinMax, 'pinLimit'])">
<template #label>{{ i18n.ts._role._options.pinMax }}</template>
<template #suffix>{{ policies.pinLimit.useDefault ? i18n.ts._role.useBaseValue : (policies.pinLimit.value) }} <i :class="getPriorityIcon(policies.pinLimit)"></i></template>
<template #suffix>
<span v-if="policies.pinLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ policies.pinLimit.value }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.pinLimit)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="policies.pinLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
@ -166,9 +202,13 @@
</div>
</MkFolder>
<MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.antennaMax, 'antennaLimit'])">
<template #label>{{ i18n.ts._role._options.antennaMax }}</template>
<template #suffix>{{ policies.antennaLimit.useDefault ? i18n.ts._role.useBaseValue : (policies.antennaLimit.value) }} <i :class="getPriorityIcon(policies.antennaLimit)"></i></template>
<template #suffix>
<span v-if="policies.antennaLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ policies.antennaLimit.value }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.antennaLimit)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="policies.antennaLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
@ -181,9 +221,13 @@
</div>
</MkFolder>
<MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.wordMuteMax, 'wordMuteLimit'])">
<template #label>{{ i18n.ts._role._options.wordMuteMax }}</template>
<template #suffix>{{ policies.wordMuteLimit.useDefault ? i18n.ts._role.useBaseValue : (policies.wordMuteLimit.value) }} <i :class="getPriorityIcon(policies.wordMuteLimit)"></i></template>
<template #suffix>
<span v-if="policies.wordMuteLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ policies.wordMuteLimit.value }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.wordMuteLimit)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="policies.wordMuteLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
@ -197,9 +241,13 @@
</div>
</MkFolder>
<MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.webhookMax, 'webhookLimit'])">
<template #label>{{ i18n.ts._role._options.webhookMax }}</template>
<template #suffix>{{ policies.webhookLimit.useDefault ? i18n.ts._role.useBaseValue : (policies.webhookLimit.value) }} <i :class="getPriorityIcon(policies.webhookLimit)"></i></template>
<template #suffix>
<span v-if="policies.webhookLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ policies.webhookLimit.value }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.webhookLimit)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="policies.webhookLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
@ -212,9 +260,13 @@
</div>
</MkFolder>
<MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.clipMax, 'clipLimit'])">
<template #label>{{ i18n.ts._role._options.clipMax }}</template>
<template #suffix>{{ policies.clipLimit.useDefault ? i18n.ts._role.useBaseValue : (policies.clipLimit.value) }} <i :class="getPriorityIcon(policies.clipLimit)"></i></template>
<template #suffix>
<span v-if="policies.clipLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ policies.clipLimit.value }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.clipLimit)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="policies.clipLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
@ -227,9 +279,13 @@
</div>
</MkFolder>
<MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.noteEachClipsMax, 'noteEachClipsLimit'])">
<template #label>{{ i18n.ts._role._options.noteEachClipsMax }}</template>
<template #suffix>{{ policies.noteEachClipsLimit.useDefault ? i18n.ts._role.useBaseValue : (policies.noteEachClipsLimit.value) }} <i :class="getPriorityIcon(policies.noteEachClipsLimit)"></i></template>
<template #suffix>
<span v-if="policies.noteEachClipsLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ policies.noteEachClipsLimit.value }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.noteEachClipsLimit)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="policies.noteEachClipsLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
@ -242,9 +298,13 @@
</div>
</MkFolder>
<MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.userListMax, 'userListLimit'])">
<template #label>{{ i18n.ts._role._options.userListMax }}</template>
<template #suffix>{{ policies.userListLimit.useDefault ? i18n.ts._role.useBaseValue : (policies.userListLimit.value) }} <i :class="getPriorityIcon(policies.userListLimit)"></i></template>
<template #suffix>
<span v-if="policies.userListLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ policies.userListLimit.value }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.userListLimit)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="policies.userListLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
@ -257,9 +317,13 @@
</div>
</MkFolder>
<MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.userEachUserListsMax, 'userEachUserListsLimit'])">
<template #label>{{ i18n.ts._role._options.userEachUserListsMax }}</template>
<template #suffix>{{ policies.userEachUserListsLimit.useDefault ? i18n.ts._role.useBaseValue : (policies.userEachUserListsLimit.value) }} <i :class="getPriorityIcon(policies.userEachUserListsLimit)"></i></template>
<template #suffix>
<span v-if="policies.userEachUserListsLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ policies.userEachUserListsLimit.value }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.userEachUserListsLimit)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="policies.userEachUserListsLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
@ -271,6 +335,26 @@
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canHideAds, 'canHideAds'])">
<template #label>{{ i18n.ts._role._options.canHideAds }}</template>
<template #suffix>
<span v-if="policies.canHideAds.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ policies.canHideAds.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.canHideAds)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="policies.canHideAds.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="policies.canHideAds.value" :disabled="policies.canHideAds.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="policies.canHideAds.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
</div>
</FormSlot>
@ -312,6 +396,7 @@ const ROLE_POLICIES = [
'canPublicNote',
'canInvite',
'canManageCustomEmojis',
'canHideAds',
'driveCapacityMb',
'pinLimit',
'antennaLimit',
@ -335,6 +420,7 @@ const props = defineProps<{
}>();
const role = props.role;
let q = $ref('');
let name = $ref(role?.name ?? 'New Role');
let description = $ref(role?.description ?? '');
@ -367,6 +453,11 @@ function getPriorityIcon(option) {
return 'ti ti-point';
}
function matchQuery(keywords: string[]): boolean {
if (q.trim().length === 0) return true;
return keywords.some(keyword => keyword.toLowerCase().includes(q.toLowerCase()));
}
async function save() {
if (props.readonly) return;
if (role) {
@ -403,5 +494,11 @@ async function save() {
</script>
<style lang="scss" module>
.useDefaultLabel {
opacity: 0.7;
}
.priorityIndicator {
margin-left: 8px;
}
</style>

View file

@ -77,7 +77,9 @@ async function del() {
}
function assign() {
os.selectUser().then(async (user) => {
os.selectUser({
includeSelf: true,
}).then(async (user) => {
await os.apiWithDialog('admin/roles/assign', { roleId: role.id, userId: user.id });
role.users.push(user);
});

View file

@ -7,7 +7,7 @@
<MkButton primary rounded @click="create"><i class="ti ti-plus"></i> {{ i18n.ts._role.new }}</MkButton>
<MkFolder>
<template #label>{{ i18n.ts._role.baseRole }}</template>
<div class="_gaps">
<div class="_gaps_s">
<MkFolder>
<template #label>{{ i18n.ts._role._options.rateLimitFactor }}</template>
<template #suffix>{{ Math.floor(policies.rateLimitFactor * 100) }}%</template>
@ -121,6 +121,14 @@
</MkInput>
</MkFolder>
<MkFolder>
<template #label>{{ i18n.ts._role._options.canHideAds }}</template>
<template #suffix>{{ policies.canHideAds ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canHideAds">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
<MkButton primary rounded @click="updateBaseRole">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
@ -156,6 +164,7 @@ const ROLE_POLICIES = [
'canPublicNote',
'canInvite',
'canManageCustomEmojis',
'canHideAds',
'driveCapacityMb',
'pinLimit',
'antennaLimit',

View file

@ -1,14 +1,14 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions"/></template>
<MkSpacer :content-max="800">
<MkSpacer :content-max="800">
<div v-if="clip">
<div class="okzinsic _panel">
<div v-if="clip.description" class="description">
<Mfm :text="clip.description" :is-note="false" :i="$i"/>
</div>
<div class="user">
<MkAvatar :user="clip.user" class="avatar" :show-indicator="true"/> <MkUserName :user="clip.user" :nowrap="false"/>
<MkAvatar :user="clip.user" class="avatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/>
</div>
</div>

View file

@ -1,6 +1,6 @@
<template>
<button class="zuvgdzyu _button" @click="menu">
<img :src="`/emoji/${emoji.name}.webp`" class="img" loading="lazy"/>
<img :src="emoji.url" class="img" loading="lazy"/>
<div class="body">
<div class="name _monospace">{{ emoji.name }}</div>
<div class="info">{{ emoji.aliases.join(' ') }}</div>
@ -15,7 +15,12 @@ import copyToClipboard from '@/scripts/copy-to-clipboard';
import { i18n } from '@/i18n';
const props = defineProps<{
emoji: Record<string, unknown>; // TODO
emoji: {
name: string;
aliases: string[];
category: string;
url: string;
};
}>();
function menu(ev) {

Some files were not shown because too many files have changed in this diff Show more