Merge branch 'misskey-dev:develop' into develop

This commit is contained in:
老兄 2023-07-23 14:36:16 +08:00 committed by GitHub
commit a712af8d59
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 2585 additions and 1551 deletions

View File

@ -82,6 +82,8 @@ redis:
#pass: example-pass
#prefix: example-prefix
#db: 1
# You can specify more ioredis options...
#username: example-username
#redisForPubsub:
# host: localhost
@ -90,6 +92,8 @@ redis:
# #pass: example-pass
# #prefix: example-prefix
# #db: 1
# # You can specify more ioredis options...
# #username: example-username
#redisForJobQueue:
# host: localhost
@ -98,6 +102,8 @@ redis:
# #pass: example-pass
# #prefix: example-prefix
# #db: 1
# # You can specify more ioredis options...
# #username: example-username
# ┌───────────────────────────┐
#───┘ MeiliSearch configuration └─────────────────────────────

View File

@ -12,20 +12,22 @@
-->
## 13.x.x (unreleased)
## 13.14.1
### General
- identicon生成を無効にしてパフォーマンスを向上させることができるようになりました
- サーバーのマシン情報の公開を無効にしてパフォーマンスを向上させることができるようになりました
- 招待機能を改善しました
* 過去に発行した招待コードを確認できるようになりました
* ロールごとに招待コードの発行数制限と制限対象期間、有効期限を設定できるようになりました
* 招待コードを作成したユーザーと使用したユーザーを確認できるようになりました
- ユーザーにロールが期限付きでアサインされている場合、その期限をユーザーのモデレーションページで確認できるようになりました
- identicon生成を無効にしてパフォーマンスを向上させることができるようになりました
- サーバーのマシン情報の公開を無効にしてパフォーマンスを向上させることができるようになりました
### Client
- deck UIのカラムのメニューからアンテナとリストの編集画面を開けるように
- ドライブファイルのメニューで画像をクロップできるように
- 画像を動画と同様に簡単に隠せるように
- Enhance: ノートの埋め込みが複数画像と動画を表示されるように
- オリジナル画像を保持せずにアップロードする場合webpでアップロードされるように(Safari以外)
- 見たことのあるRenoteを省略して表示をオンのときに自分のnoteのrenoteを省略するように
- フォルダーやファイルに対しても開発者モード使用時、IDをコピーできるように
@ -41,7 +43,9 @@
- ロール設定画面でロールIDを確認できるように
- コンテキストメニュー表示時のパフォーマンスを改善
- フォロー/フォロワー非公開時の表示を改善
- AiScriptを0.14.0に更新
- 本文にMFMが含まれている場合に自動でたたまれる機能が、返信先や引用RNにも適用されるように
- position は対象外になりました
- AiScriptを0.15.0に更新
- Fix: サーバーメトリクスが90度傾いている
- Fix: 非ログイン時にクレデンシャルが必要なページに行くとエラーが出る問題を修正
- Fix: sparkle内にリンクを入れるとクリック不能になる問題の修正
@ -58,15 +62,19 @@
- nsfwjs のモデルロードを排他することで、重複ロードによってメモリ使用量が増加しないように
- 連合の配送ジョブのパフォーマンスを向上ロック機構の見直し、Redisキャッシュの活用
- featuredートのsignedGet回数を減らしました
- リモートサーバーからのNSFW映像のキャッシュだけを無効化できるオプションを追加
- ActivityPubの署名用鍵長を2048bitに変更しパフォーマンスを向上(新規アカウントのみ)
- リモートサーバーのセンシティブなファイルのキャッシュだけを無効化できるオプションを追加
- MeilisearchにIndexするートの範囲を設定できるように
- Export notes with file detail
- Add unix socket support
- 設定ファイルでioredisの全てのオプションを指定可能に
- Fix: エクスポートしたカスタム絵文字のzipが大きいと読み込めない問題を修正
- Fix: リモートサーバーに無意味なActivityPubの配信を行うことがあるのを修正
- Fix: Remove Meilisearch index when notes are deleted
- Fix: 非英語環境でのPostgreSQLのエラーハンドリングを修正
- Fix: インスタンスのアイコンがbase64の場合の挙動を修正
- Fix: ローカルの `Person` を指す `acct` URI を解析するときのバグを修正しました
- Fix: 無効化されたアンテナが再度有効化されないことがある問題を修正
## 13.13.2

View File

@ -214,30 +214,13 @@ Misskey uses [Storybook](https://storybook.js.org/) for UI development.
### Setup & Run
#### Universal
##### Setup
```bash
pnpm --filter misskey-js build
pnpm --filter frontend tsc -p .storybook && (node packages/frontend/.storybook/preload-locale.js & node packages/frontend/.storybook/preload-theme.js)
```
##### Run
```bash
node packages/frontend/.storybook/generate.js && pnpm --filter frontend storybook dev
```
#### macOS & Linux
##### Setup
#### Setup
```bash
pnpm --filter misskey-js build
```
##### Run
#### Run
```bash
pnpm --filter frontend storybook-dev

View File

@ -54,6 +54,7 @@ describe('After setup instance', () => {
cy.get('[data-cy-signup]').click();
cy.get('[data-cy-signup-rules-continue]').should('be.disabled');
cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click();
cy.get('[data-cy-modal-dialog-ok]').click();
cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled');
cy.get('[data-cy-signup-rules-continue]').click();
@ -78,6 +79,7 @@ describe('After setup instance', () => {
cy.get('[data-cy-signup]').click();
cy.get('[data-cy-signup-rules-continue]').should('be.disabled');
cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click();
cy.get('[data-cy-modal-dialog-ok]').click();
cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled');
cy.get('[data-cy-signup-rules-continue]').click();

View File

@ -1091,6 +1091,9 @@ usedAt: "Benutzt am"
unused: "Unbenutzt"
used: "Benutzt"
expired: "Abgelaufen"
doYouAgree: "Zustimmen?"
beSureToReadThisAsItIsImportant: "Lies bitte diese wichtige Informationen."
iHaveReadXCarefullyAndAgree: "Ich habe den Text \"{x}\" gelesen und stimme zu."
_initialAccountSetting:
accountCreated: "Dein Konto wurde erfolgreich erstellt!"
letsStartAccountSetup: "Lass uns nun dein Konto einrichten."

View File

@ -1091,6 +1091,9 @@ usedAt: "Used at"
unused: "Unused"
used: "Used"
expired: "Expired"
doYouAgree: "Agree?"
beSureToReadThisAsItIsImportant: "Please read this important information."
iHaveReadXCarefullyAndAgree: "I have read the text \"{x}\" and agree."
_initialAccountSetting:
accountCreated: "Your account was successfully created!"
letsStartAccountSetup: "For starters, let's set up your profile."

3
locales/index.d.ts vendored
View File

@ -1094,6 +1094,9 @@ export interface Locale {
"unused": string;
"used": string;
"expired": string;
"doYouAgree": string;
"beSureToReadThisAsItIsImportant": string;
"iHaveReadXCarefullyAndAgree": string;
"_initialAccountSetting": {
"accountCreated": string;
"letsStartAccountSetup": string;

View File

@ -1042,7 +1042,7 @@ vertical: "縦"
horizontal: "横"
position: "位置"
serverRules: "サーバールール"
pleaseConfirmBelowBeforeSignup: "このサーバーに登録する前に、以下を確認してください。"
pleaseConfirmBelowBeforeSignup: "このサーバーに登録するには、以下の内容を確認し同意する必要があります。"
pleaseAgreeAllToContinue: "続けるには、全ての「同意する」にチェックが入っている必要があります。"
continue: "続ける"
preservedUsernames: "予約ユーザー名"
@ -1091,6 +1091,9 @@ usedAt: "使用日時"
unused: "未使用"
used: "使用済み"
expired: "期限切れ"
doYouAgree: "同意しますか?"
beSureToReadThisAsItIsImportant: "重要ですので必ずお読みください。"
iHaveReadXCarefullyAndAgree: "「{x}」の内容をよく読み、同意します。"
_initialAccountSetting:
accountCreated: "アカウントの作成が完了しました!"

View File

@ -1067,6 +1067,9 @@ branding: "あ"
enableServerMachineStats: "サーバーのマシン情報見せびらかすで"
enableIdenticonGeneration: "ユーザーごとのIdenticon生成を有効にする"
turnOffToImprovePerformance: "オフにしたらえらい軽うなるで。"
inviteCodeCreated: "招待コード作ったで"
inviteLimitExceeded: "招待コード作りすぎやで。"
createLimitRemaining: "作成できる招待コード: 残り {limit} 個やで"
unused: "つこてへん"
used: "もうつこてる"
_initialAccountSetting:

View File

@ -40,7 +40,7 @@ favorites: "즐겨찾기"
unfavorite: "즐겨찾기에서 제거"
favorited: "즐겨찾기에 등록했습니다"
alreadyFavorited: "이미 즐겨찾기에 등록되어 있습니다"
cantFavorite: "즐겨찾기에 등록하지 못했습니다."
cantFavorite: "즐겨찾기에 등록하지 못했습니다"
pin: "프로필에 고정"
unpin: "프로필에서 고정 해제"
copyContent: "내용 복사"

View File

@ -389,10 +389,13 @@ help: "Hjälp"
close: "Stäng"
invites: "Inbjudan"
members: "Medlemmar"
transfer: "Överför"
text: "Text"
enable: "Aktivera"
next: "Nästa"
invitations: "Inbjudan"
invitationCode: "Inbjudningskod"
available: "Tillgängligt"
weakPassword: "Svagt Lösenord"
normalPassword: "Medel Lösenord"
strongPassword: "Starkt Lösenord"

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,12 @@
{
"name": "misskey",
"version": "13.14.0-beta.6",
"version": "13.14.1",
"codename": "nasubi",
"repository": {
"type": "git",
"url": "https://github.com/misskey-dev/misskey.git"
},
"packageManager": "pnpm@8.6.0",
"packageManager": "pnpm@8.6.9",
"workspaces": [
"packages/frontend",
"packages/backend",

View File

@ -2,14 +2,7 @@ import Redis from 'ioredis';
import { loadConfig } from './built/config.js';
const config = loadConfig();
const redis = new Redis({
port: config.redis.port,
host: config.redis.host,
family: config.redis.family == null ? 0 : config.redis.family,
password: config.redis.pass,
keyPrefix: `${config.redis.prefix}:`,
db: config.redis.db ?? 0,
});
const redis = new Redis(config.redis);
redis.on('connect', () => redis.disconnect());
redis.on('error', (e) => {

View File

@ -79,7 +79,6 @@
"ajv": "8.12.0",
"archiver": "5.3.1",
"async-mutex": "^0.4.0",
"autwh": "0.1.0",
"bcryptjs": "2.4.3",
"blurhash": "2.0.5",
"bullmq": "4.4.0",
@ -93,7 +92,6 @@
"content-disposition": "0.5.4",
"date-fns": "2.30.0",
"deep-email-validator": "0.1.21",
"escape-regexp": "0.0.1",
"fastify": "4.20.0",
"feed": "4.2.2",
"file-type": "18.5.0",
@ -139,7 +137,6 @@
"rename": "1.0.4",
"rss-parser": "3.13.0",
"rxjs": "7.8.1",
"s-age": "1.1.2",
"sanitize-html": "2.11.0",
"semver": "7.5.4",
"sharp": "0.32.3",
@ -157,7 +154,6 @@
"typeorm": "0.3.17",
"typescript": "5.1.6",
"ulid": "2.3.0",
"unzipper": "0.10.14",
"vary": "1.1.2",
"web-push": "3.6.3",
"ws": "8.13.0",
@ -172,7 +168,6 @@
"@types/cbor": "6.0.0",
"@types/color-convert": "2.0.0",
"@types/content-disposition": "0.5.5",
"@types/escape-regexp": "0.0.1",
"@types/fluent-ffmpeg": "2.1.21",
"@types/jest": "29.5.3",
"@types/js-yaml": "4.0.5",
@ -191,7 +186,6 @@
"@types/qrcode": "1.5.1",
"@types/random-seed": "0.3.3",
"@types/ratelimiter": "3.4.4",
"@types/redis": "4.0.11",
"@types/rename": "1.0.4",
"@types/sanitize-html": "2.9.0",
"@types/semver": "7.5.0",
@ -199,10 +193,8 @@
"@types/sinonjs__fake-timers": "8.1.2",
"@types/tinycolor2": "1.4.3",
"@types/tmp": "0.2.3",
"@types/unzipper": "0.10.6",
"@types/vary": "1.1.0",
"@types/web-push": "3.3.2",
"@types/websocket": "1.0.5",
"@types/ws": "8.5.5",
"@typescript-eslint/eslint-plugin": "5.61.0",
"@typescript-eslint/parser": "5.61.0",

View File

@ -41,14 +41,7 @@ const $meilisearch: Provider = {
const $redis: Provider = {
provide: DI.redis,
useFactory: (config: Config) => {
return new Redis.Redis({
port: config.redis.port,
host: config.redis.host,
family: config.redis.family == null ? 0 : config.redis.family,
password: config.redis.pass,
keyPrefix: `${config.redis.prefix}:`,
db: config.redis.db ?? 0,
});
return new Redis.Redis(config.redis);
},
inject: [DI.config],
};
@ -56,14 +49,7 @@ const $redis: Provider = {
const $redisForPub: Provider = {
provide: DI.redisForPub,
useFactory: (config: Config) => {
const redis = new Redis.Redis({
port: config.redisForPubsub.port,
host: config.redisForPubsub.host,
family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family,
password: config.redisForPubsub.pass,
keyPrefix: `${config.redisForPubsub.prefix}:`,
db: config.redisForPubsub.db ?? 0,
});
const redis = new Redis.Redis(config.redisForPubsub);
return redis;
},
inject: [DI.config],
@ -72,14 +58,7 @@ const $redisForPub: Provider = {
const $redisForSub: Provider = {
provide: DI.redisForSub,
useFactory: (config: Config) => {
const redis = new Redis.Redis({
port: config.redisForPubsub.port,
host: config.redisForPubsub.host,
family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family,
password: config.redisForPubsub.pass,
keyPrefix: `${config.redisForPubsub.prefix}:`,
db: config.redisForPubsub.db ?? 0,
});
const redis = new Redis.Redis(config.redisForPubsub);
redis.subscribe(config.host);
return redis;
},

View File

@ -6,6 +6,16 @@ import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import * as yaml from 'js-yaml';
import type { RedisOptions } from 'ioredis';
type RedisOptionsSource = Partial<RedisOptions> & {
host: string;
port: number;
family?: number;
pass: string;
db?: number;
prefix?: string;
};
/**
*
@ -35,30 +45,9 @@ export type Source = {
user: string;
pass: string;
}[];
redis: {
host: string;
port: number;
family?: number;
pass: string;
db?: number;
prefix?: string;
};
redisForPubsub?: {
host: string;
port: number;
family?: number;
pass: string;
db?: number;
prefix?: string;
};
redisForJobQueue?: {
host: string;
port: number;
family?: number;
pass: string;
db?: number;
prefix?: string;
};
redis: RedisOptionsSource;
redisForPubsub?: RedisOptionsSource;
redisForJobQueue?: RedisOptionsSource;
meilisearch?: {
host: string;
port: string;
@ -119,8 +108,9 @@ export type Mixin = {
mediaProxy: string;
externalMediaProxyEnabled: boolean;
videoThumbnailGenerator: string | null;
redisForPubsub: NonNullable<Source['redisForPubsub']>;
redisForJobQueue: NonNullable<Source['redisForJobQueue']>;
redis: RedisOptions & RedisOptionsSource;
redisForPubsub: RedisOptions & RedisOptionsSource;
redisForJobQueue: RedisOptions & RedisOptionsSource;
};
export type Config = Source & Mixin;
@ -182,9 +172,9 @@ export function loadConfig() {
config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator
: null;
if (!config.redis.prefix) config.redis.prefix = mixin.host;
if (config.redisForPubsub == null) config.redisForPubsub = config.redis;
if (config.redisForJobQueue == null) config.redisForJobQueue = config.redis;
mixin.redis = convertRedisOptions(config.redis, mixin.host);
mixin.redisForPubsub = config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, mixin.host) : mixin.redis;
mixin.redisForJobQueue = config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, mixin.host) : mixin.redis;
return Object.assign(config, mixin);
}
@ -196,3 +186,14 @@ function tryCreateUrl(url: string) {
throw new Error(`url="${url}" is not a valid URL.`);
}
}
function convertRedisOptions(options: RedisOptionsSource, host: string): RedisOptions & RedisOptionsSource {
return {
...options,
password: options.pass,
prefix: options.prefix ?? host,
family: options.family == null ? 0 : options.family,
keyPrefix: `${options.prefix ?? host}:`,
db: options.db ?? 0,
};
}

View File

@ -33,7 +33,7 @@ export class CreateSystemUserService {
// Generate secret
const secret = generateNativeUserToken();
const keyPair = await genRsaKeyPair(4096);
const keyPair = await genRsaKeyPair();
let account!: User;

View File

@ -108,7 +108,7 @@ export class QueueService {
removeOnFail: true,
};
await this.deliverQueue.addBulk(Array.from(inboxes.entries()).map(d => ({
await this.deliverQueue.addBulk(Array.from(inboxes.entries(), d => ({
name: d[0],
data: {
user,

View File

@ -220,14 +220,19 @@ export class RoleService implements OnApplicationShutdown {
}
@bindThis
public async getUserRoles(userId: User['id']) {
public async getUserAssigns(userId: User['id']) {
const now = Date.now();
let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
// 期限切れのロールを除外
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
const assignedRoleIds = assigns.map(x => x.roleId);
return assigns;
}
@bindThis
public async getUserRoles(userId: User['id']) {
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
const assignedRoles = roles.filter(r => assignedRoleIds.includes(r.id));
const assigns = await this.getUserAssigns(userId);
const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, r.condFormula));
return [...assignedRoles, ...matchedCondRoles];

View File

@ -92,7 +92,7 @@ export class SignupService {
const keyPair = await new Promise<string[]>((res, rej) =>
generateKeyPair('rsa', {
modulusLength: 4096,
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem',

View File

@ -220,6 +220,23 @@ export class ApPersonService implements OnModuleInit {
return null;
}
private async resolveAvatarAndBanner(user: RemoteUser, icon: any, image: any): Promise<Pick<RemoteUser, 'avatarId' | 'bannerId' | 'avatarUrl' | 'bannerUrl' | 'avatarBlurhash' | 'bannerBlurhash'>> {
const [avatar, banner] = await Promise.all([icon, image].map(img => {
if (img == null) return null;
if (user == null) throw new Error('failed to create user: user is null');
return this.apImageService.resolveImage(user, img).catch(() => null);
}));
return {
avatarId: avatar?.id ?? null,
bannerId: banner?.id ?? null,
avatarUrl: avatar ? this.driveFileEntityService.getPublicUrl(avatar, 'avatar') : null,
bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null,
avatarBlurhash: avatar?.blurhash ?? null,
bannerBlurhash: banner?.blurhash ?? null,
};
}
/**
* Personを作成します
*/
@ -259,6 +276,16 @@ export class ApPersonService implements OnModuleInit {
// Create user
let user: RemoteUser | null = null;
//#region カスタム絵文字取得
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host)
.then(_emojis => _emojis.map(emoji => emoji.name))
.catch(err => {
this.logger.error(`error occured while fetching user emojis`, { stack: err });
return [];
});
//#endregion
try {
// Start transaction
await this.db.transaction(async transactionalEntityManager => {
@ -285,6 +312,7 @@ export class ApPersonService implements OnModuleInit {
tags,
isBot,
isCat: (person as any).isCat === true,
emojis,
})) as RemoteUser;
await transactionalEntityManager.save(new UserProfile({
@ -321,6 +349,9 @@ export class ApPersonService implements OnModuleInit {
if (user == null) throw new Error('failed to create user: user is null');
// Register to the cache
this.cacheService.uriPersonCache.set(user.uri, user);
// Register host
this.federatedInstanceService.fetch(host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
@ -336,45 +367,16 @@ export class ApPersonService implements OnModuleInit {
this.hashtagService.updateUsertags(user, tags);
//#region アバターとヘッダー画像をフェッチ
const [avatar, banner] = await Promise.all([person.icon, person.image].map(img => {
if (img == null) return null;
if (user == null) throw new Error('failed to create user: user is null');
return this.apImageService.resolveImage(user, img).catch(() => null);
}));
try {
const updates = await this.resolveAvatarAndBanner(user, person.icon, person.image);
await this.usersRepository.update(user.id, updates);
user = { ...user, ...updates };
const avatarId = avatar?.id ?? null;
const bannerId = banner?.id ?? null;
const avatarUrl = avatar ? this.driveFileEntityService.getPublicUrl(avatar, 'avatar') : null;
const bannerUrl = banner ? this.driveFileEntityService.getPublicUrl(banner) : null;
const avatarBlurhash = avatar?.blurhash ?? null;
const bannerBlurhash = banner?.blurhash ?? null;
await this.usersRepository.update(user.id, {
avatarId,
bannerId,
avatarUrl,
bannerUrl,
avatarBlurhash,
bannerBlurhash,
});
user.avatarId = avatarId;
user.bannerId = bannerId;
user.avatarUrl = avatarUrl;
user.bannerUrl = bannerUrl;
user.avatarBlurhash = avatarBlurhash;
user.bannerBlurhash = bannerBlurhash;
//#endregion
//#region カスタム絵文字取得
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host).catch(err => {
this.logger.info(`extractEmojis: ${err}`);
return [];
});
const emojiNames = emojis.map(emoji => emoji.name);
await this.usersRepository.update(user.id, { emojis: emojiNames });
// Register to the cache
this.cacheService.uriPersonCache.set(user.uri, user);
} catch (err) {
this.logger.error('error occured while fetching user avatar/banner', { stack: err });
}
//#endregion
await this.updateFeatured(user.id, resolver).catch(err => this.logger.error(err));
@ -400,7 +402,7 @@ export class ApPersonService implements OnModuleInit {
if (uri.startsWith(`${this.config.url}/`)) return;
//#region このサーバーに既に登録されているか
const exist = await this.usersRepository.findOneBy({ uri }) as RemoteUser | null;
const exist = await this.fetchPerson(uri) as RemoteUser | null;
if (exist === null) return;
//#endregion
@ -413,12 +415,6 @@ export class ApPersonService implements OnModuleInit {
this.logger.info(`Updating the Person: ${person.id}`);
// アバターとヘッダー画像をフェッチ
const [avatar, banner] = await Promise.all([person.icon, person.image].map(img => {
if (img == null) return null;
return this.apImageService.resolveImage(exist, img).catch(() => null);
}));
// カスタム絵文字取得
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], exist.host).catch(e => {
this.logger.info(`extractEmojis: ${e}`);
@ -454,6 +450,7 @@ export class ApPersonService implements OnModuleInit {
movedToUri: person.movedTo ?? null,
alsoKnownAs: person.alsoKnownAs ?? null,
isExplorable: person.discoverable,
...(await this.resolveAvatarAndBanner(exist, person.icon, person.image).catch(() => ({}))),
} as Partial<RemoteUser> & Pick<RemoteUser, 'isBot' | 'isCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;
const moving = ((): boolean => {
@ -476,18 +473,6 @@ export class ApPersonService implements OnModuleInit {
if (moving) updates.movedAt = new Date();
if (avatar) {
updates.avatarId = avatar.id;
updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar');
updates.avatarBlurhash = avatar.blurhash;
}
if (banner) {
updates.bannerId = banner.id;
updates.bannerUrl = this.driveFileEntityService.getPublicUrl(banner);
updates.bannerBlurhash = banner.blurhash;
}
// Update user
await this.usersRepository.update(exist.id, updates);

View File

@ -15,11 +15,8 @@ export const QUEUE = {
export function baseQueueOptions(config: Config, queueName: typeof QUEUE[keyof typeof QUEUE]): Bull.QueueOptions {
return {
connection: {
port: config.redisForJobQueue.port,
host: config.redisForJobQueue.host,
family: config.redisForJobQueue.family == null ? 0 : config.redisForJobQueue.family,
password: config.redisForJobQueue.pass,
db: config.redisForJobQueue.db ?? 0,
...config.redisForJobQueue,
keyPrefix: undefined
},
prefix: config.redisForJobQueue.prefix ? `${config.redisForJobQueue.prefix}:queue:${queueName}` : `queue:${queueName}`,
};

View File

@ -1,7 +1,7 @@
import * as fs from 'node:fs';
import { Inject, Injectable } from '@nestjs/common';
import { ZipReader } from 'slacc';
import { DataSource } from 'typeorm';
import unzipper from 'unzipper';
import { DI } from '@/di-symbols.js';
import type { EmojisRepository, DriveFilesRepository, UsersRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
@ -72,9 +72,9 @@ export class ImportCustomEmojisProcessorService {
}
const outputPath = path + '/emojis';
const unzipStream = fs.createReadStream(destPath);
const extractor = unzipper.Extract({ path: outputPath });
extractor.on('close', async () => {
try {
this.logger.succ(`Unzipping to ${outputPath}`);
ZipReader.withDestinationPath(outputPath).viaBuffer(await fs.promises.readFile(destPath));
const metaRaw = fs.readFileSync(outputPath + '/meta.json', 'utf-8');
const meta = JSON.parse(metaRaw);
@ -115,8 +115,12 @@ export class ImportCustomEmojisProcessorService {
cleanup();
this.logger.succ('Imported');
});
unzipStream.pipe(extractor);
this.logger.succ(`Unzipping to ${outputPath}`);
} catch (e) {
if (e instanceof Error || typeof e === 'string') {
this.logger.error(e);
}
cleanup();
throw e;
}
}
}

View File

@ -4,6 +4,7 @@ import type { DriveFilesRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
import { ApiError } from '../../../error.js';
export const meta = {
@ -55,6 +56,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private customEmojiService: CustomEmojiService,
private emojiEntityService: EmojiEntityService,
private moderationLogService: ModerationLogService,
) {
super(meta, paramDef, async (ps, me) => {
@ -77,9 +79,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
emojiId: emoji.id,
});
return {
id: emoji.id,
};
return this.emojiEntityService.packDetailed(emoji);
});
}
}

View File

@ -61,6 +61,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const signins = await this.signinsRepository.findBy({ userId: user.id });
const roleAssigns = await this.roleService.getUserAssigns(user.id);
const roles = await this.roleService.getUserRoles(user.id);
return {
@ -85,6 +86,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
signins,
policies: await this.roleService.getUserPolicies(user.id),
roles: await this.roleEntityService.packMany(roles, me),
roleAssigns: roleAssigns.map(a => ({
createdAt: a.createdAt.toISOString(),
expiresAt: a.expiresAt ? a.expiresAt.toISOString() : null,
roleId: a.roleId,
})),
};
});
}

View File

@ -76,6 +76,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.noSuchAntenna);
}
this.antennasRepository.update(antenna.id, {
isActive: true,
lastUsedAt: new Date(),
});
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
const noteIdsRes = await this.redisClient.xrevrange(
`antennaTimeline:${antenna.id}`,
@ -112,11 +117,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
this.noteReadService.read(me.id, notes);
}
this.antennasRepository.update(antenna.id, {
isActive: true,
lastUsedAt: new Date(),
});
return await this.noteEntityService.packMany(notes, me);
});
}

View File

@ -112,6 +112,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
withReplies: ps.withReplies,
withFile: ps.withFile,
notify: ps.notify,
isActive: true,
lastUsedAt: new Date(),
});
this.globalEventService.publishInternalEvent('antennaUpdated', await this.antennasRepository.findOneByOrFail({ id: antenna.id }));

View File

@ -103,7 +103,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const procedures = this.twoFactorAuthenticationService.getProcedures();
if (!(procedures as any)[attestation.fmt]) {
throw new Error('unsupported fmt');
throw new Error(`unsupported fmt: ${attestation.fmt}. Supported ones: ${Object.keys(procedures)}`);
}
const verificationData = (procedures as any)[attestation.fmt].verify({

View File

@ -5,8 +5,8 @@ block vars
- const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`;
- const url = `${config.url}/notes/${note.id}`;
- const isRenote = note.renote && note.text == null && note.fileIds.length == 0 && note.poll == null;
- const image = (note.files || []).find(file => file.type.startsWith('image/') && !file.isSensitive)
- const video = (note.files || []).find(file => file.type.startsWith('video/') && !file.isSensitive)
- const images = (note.files || []).filter(file => file.type.startsWith('image/') && !file.isSensitive)
- const videos = (note.files || []).filter(file => file.type.startsWith('video/') && !file.isSensitive)
block title
= `${title} | ${instanceName}`
@ -19,14 +19,16 @@ block og
meta(property='og:title' content= title)
meta(property='og:description' content= summary)
meta(property='og:url' content= url)
if video
if videos.length
each video in videos
meta(property='og:video:url' content= video.url)
meta(property='og:video:secure_url' content= video.url)
meta(property='og:video:type' content= video.type)
// FIXME: add width and height
// FIXME: add embed player for Twitter
if image
if images.length
meta(property='twitter:card' content='summary_large_image')
each image in images
meta(property='og:image' content= image.url)
else
meta(property='twitter:card' content='summary')

View File

@ -4,8 +4,9 @@
"scripts": {
"watch": "vite",
"build": "vite build",
"storybook-dev": "chokidar 'src/**/*.{mdx,ts,vue}' -d 1000 -t 1000 --initial -i '**/*.stories.ts' -c 'pkill -f node_modules/storybook/index.js; node_modules/.bin/tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js && node_modules/.bin/storybook dev -p 6006 --ci'",
"build-storybook": "tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js && storybook build",
"storybook-dev": "nodemon --verbose --watch src --ext \"mdx,ts,vue\" --ignore \"*.stories.ts\" --exec \"pnpm build-storybook-pre && pnpm exec storybook dev -p 6006 --ci\"",
"build-storybook-pre": "tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js",
"build-storybook": "pnpm build-storybook-pre && storybook build",
"chromatic": "chromatic",
"test": "vitest --run",
"test-and-coverage": "vitest --run --coverage",
@ -19,7 +20,7 @@
"@rollup/plugin-json": "6.0.0",
"@rollup/plugin-replace": "5.0.2",
"@rollup/pluginutils": "5.0.2",
"@syuilo/aiscript": "0.14.0",
"@syuilo/aiscript": "0.15.0",
"@tabler/icons-webfont": "2.25.0",
"@vitejs/plugin-vue": "4.2.3",
"@vue-macros/reactivity-transform": "0.3.15",
@ -76,24 +77,24 @@
"vuedraggable": "next"
},
"devDependencies": {
"@storybook/addon-actions": "7.1.0",
"@storybook/addon-essentials": "7.1.0",
"@storybook/addon-interactions": "7.1.0",
"@storybook/addon-links": "7.1.0",
"@storybook/addon-storysource": "7.1.0",
"@storybook/addons": "7.1.0",
"@storybook/blocks": "7.1.0",
"@storybook/core-events": "7.1.0",
"@storybook/addon-actions": "7.0.27",
"@storybook/addon-essentials": "7.0.27",
"@storybook/addon-interactions": "7.0.27",
"@storybook/addon-links": "7.0.27",
"@storybook/addon-storysource": "7.0.27",
"@storybook/addons": "7.0.27",
"@storybook/blocks": "7.0.27",
"@storybook/core-events": "7.0.27",
"@storybook/jest": "0.1.0",
"@storybook/manager-api": "7.1.0",
"@storybook/preview-api": "7.1.0",
"@storybook/react": "7.1.0",
"@storybook/react-vite": "7.1.0",
"@storybook/manager-api": "7.0.27",
"@storybook/preview-api": "7.0.27",
"@storybook/react": "7.0.27",
"@storybook/react-vite": "7.0.27",
"@storybook/testing-library": "0.2.0",
"@storybook/theming": "7.1.0",
"@storybook/types": "7.1.0",
"@storybook/vue3": "7.1.0",
"@storybook/vue3-vite": "7.1.0",
"@storybook/theming": "7.0.27",
"@storybook/types": "7.0.27",
"@storybook/vue3": "7.0.27",
"@storybook/vue3-vite": "7.0.27",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/vue": "7.0.0",
"@types/escape-regexp": "0.0.1",
@ -116,7 +117,6 @@
"@vitest/coverage-v8": "0.33.0",
"@vue/runtime-core": "3.3.4",
"acorn": "8.10.0",
"chokidar-cli": "3.0.0",
"cross-env": "7.0.3",
"cypress": "12.17.1",
"eslint": "8.45.0",
@ -127,11 +127,12 @@
"micromatch": "4.0.5",
"msw": "1.2.2",
"msw-storybook-addon": "1.8.0",
"nodemon": "3.0.1",
"prettier": "3.0.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"start-server-and-test": "2.0.0",
"storybook": "7.1.0",
"storybook": "7.0.27",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"summaly": "github:misskey-dev/summaly",
"vite-plugin-turbosnap": "1.0.2",

View File

@ -19,7 +19,7 @@
</div>
<div v-if="file.isSensitive" :class="[$style.label, $style.red]">
<img :class="$style.labelImg" src="/client-assets/label-red.svg"/>
<p :class="$style.labelText">NSFW</p>
<p :class="$style.labelText">{{ i18n.ts.sensitive }}</p>
</div>
<MkDriveFileThumbnail :class="$style.thumbnail" :file="file" fit="contain"/>

View File

@ -20,7 +20,7 @@
<template v-if="hide">
<div :class="$style.hiddenText">
<div :class="$style.hiddenTextWrapper">
<b v-if="image.isSensitive" style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b>
<b v-if="image.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b>
<b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.enableDataSaverMode && image.size ? bytes(image.size) : i18n.ts.image }}</b>
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
</div>
@ -30,7 +30,7 @@
<div :class="$style.indicators">
<div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div>
<div v-if="image.comment" :class="$style.indicator">ALT</div>
<div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);">NSFW</div>
<div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);" :title="i18n.ts.sensitive"><i class="ti ti-eye-exclamation"></i></div>
</div>
<button :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ti ti-dots" style="vertical-align: middle;"></i></button>
<i class="ti ti-eye-off" :class="$style.hide" @click.stop="hide = true"></i>

View File

@ -165,6 +165,7 @@ import { getNoteSummary } from '@/scripts/get-note-summary';
import { MenuItem } from '@/types/menu';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/scripts/show-moved-dialog';
import { shouldCollapsed } from '@/scripts/collapsed';
const props = defineProps<{
note: misskey.entities.Note;
@ -204,17 +205,7 @@ let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note
const isMyRenote = $i && ($i.id === note.userId);
const showContent = ref(false);
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
const isLong = (appearNote.cw == null && appearNote.text != null && (
(appearNote.text.includes('$[x2')) ||
(appearNote.text.includes('$[x3')) ||
(appearNote.text.includes('$[x4')) ||
(appearNote.text.includes('$[scale')) ||
(appearNote.text.includes('$[position')) ||
(appearNote.text.split('\n').length > 9) ||
(appearNote.text.length > 500) ||
(appearNote.files.length >= 5) ||
(urls && urls.length >= 4)
));
const isLong = shouldCollapsed(appearNote);
const collapsed = ref(appearNote.cw == null && isLong);
const isDeleted = ref(false);
const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords));

View File

@ -5,7 +5,7 @@
<div :class="$style.file" @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)">
<MkDriveFileThumbnail :data-id="element.id" :class="$style.thumbnail" :file="element" fit="cover"/>
<div v-if="element.isSensitive" :class="$style.sensitive">
<i class="ti ti-alert-triangle" style="margin: auto;"></i>
<i class="ti ti-eye-exclamation" style="margin: auto;"></i>
</div>
</div>
</template>

View File

@ -9,7 +9,10 @@
<MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo>
</div>
<div style="text-align: center;">{{ i18n.ts.pleaseConfirmBelowBeforeSignup }}</div>
<div style="text-align: center;">
<div>{{ i18n.ts.pleaseConfirmBelowBeforeSignup }}</div>
<div style="font-weight: bold; margin-top: 0.5em;">{{ i18n.ts.beSureToReadThisAsItIsImportant }}</div>
</div>
<MkFolder v-if="availableServerRules" :defaultOpen="true">
<template #label>{{ i18n.ts.serverRules }}</template>
@ -19,7 +22,7 @@
<li v-for="item in instance.serverRules" :class="$style.rule"><div :class="$style.ruleText" v-html="item"></div></li>
</ol>
<MkSwitch v-model="agreeServerRules" style="margin-top: 16px;">{{ i18n.ts.agree }}</MkSwitch>
<MkSwitch :modelValue="agreeServerRules" style="margin-top: 16px;" @update:modelValue="updateAgreeServerRules">{{ i18n.ts.agree }}</MkSwitch>
</MkFolder>
<MkFolder v-if="availableTos" :defaultOpen="true">
@ -28,7 +31,7 @@
<a :href="instance.tosUrl" class="_link" target="_blank">{{ i18n.ts.termsOfService }} <i class="ti ti-external-link"></i></a>
<MkSwitch v-model="agreeTos" style="margin-top: 16px;">{{ i18n.ts.agree }}</MkSwitch>
<MkSwitch :modelValue="agreeTos" style="margin-top: 16px;" @update:modelValue="updateAgreeTos">{{ i18n.ts.agree }}</MkSwitch>
</MkFolder>
<MkFolder :defaultOpen="true">
@ -37,7 +40,7 @@
<a href="https://misskey-hub.net/docs/notes.html" class="_link" target="_blank">{{ i18n.ts.basicNotesBeforeCreateAccount }} <i class="ti ti-external-link"></i></a>
<MkSwitch v-model="agreeNote" style="margin-top: 16px;" data-cy-signup-rules-notes-agree>{{ i18n.ts.agree }}</MkSwitch>
<MkSwitch :modelValue="agreeNote" style="margin-top: 16px;" data-cy-signup-rules-notes-agree @update:modelValue="updateAgreeNote">{{ i18n.ts.agree }}</MkSwitch>
</MkFolder>
<div v-if="!agreed" style="text-align: center;">{{ i18n.ts.pleaseAgreeAllToContinue }}</div>
@ -52,13 +55,14 @@
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { instance } from '@/instance';
import { i18n } from '@/i18n';
import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkInfo from '@/components/MkInfo.vue';
import * as os from '@/os';
const availableServerRules = instance.serverRules.length > 0;
const availableTos = instance.tosUrl != null;
@ -75,6 +79,48 @@ const emit = defineEmits<{
(ev: 'cancel'): void;
(ev: 'done'): void;
}>();
async function updateAgreeServerRules(v: boolean) {
if (v) {
const confirm = await os.confirm({
type: 'question',
title: i18n.ts.doYouAgree,
text: i18n.t('iHaveReadXCarefullyAndAgree', { x: i18n.ts.serverRules }),
});
if (confirm.canceled) return;
agreeServerRules.value = true;
} else {
agreeServerRules.value = false;
}
}
async function updateAgreeTos(v: boolean) {
if (v) {
const confirm = await os.confirm({
type: 'question',
title: i18n.ts.doYouAgree,
text: i18n.t('iHaveReadXCarefullyAndAgree', { x: i18n.ts.termsOfService }),
});
if (confirm.canceled) return;
agreeTos.value = true;
} else {
agreeTos.value = false;
}
}
async function updateAgreeNote(v: boolean) {
if (v) {
const confirm = await os.confirm({
type: 'question',
title: i18n.ts.doYouAgree,
text: i18n.t('iHaveReadXCarefullyAndAgree', { x: i18n.ts.basicNotesBeforeCreateAccount }),
});
if (confirm.canceled) return;
agreeNote.value = true;
} else {
agreeNote.value = false;
}
}
</script>
<style lang="scss" module>

View File

@ -31,16 +31,13 @@ import MkMediaList from '@/components/MkMediaList.vue';
import MkPoll from '@/components/MkPoll.vue';
import { i18n } from '@/i18n';
import { $i } from '@/account';
import { shouldCollapsed } from '@/scripts/collapsed';
const props = defineProps<{
note: misskey.entities.Note;
}>();
const isLong =
props.note.cw == null && props.note.text != null && (
(props.note.text.split('\n').length > 9) ||
(props.note.text.length > 500)
);
const isLong = shouldCollapsed(props.note);
const collapsed = $ref(isLong);
</script>

View File

@ -52,6 +52,7 @@
</footer>
</article>
</component>
<template v-if="showActions">
<div v-if="tweetId" :class="$style.action">
<MkButton :small="true" inline @click="tweetExpanded = true">
<i class="ti ti-brand-twitter"></i> {{ i18n.ts.expandTweet }}
@ -65,6 +66,7 @@
<i class="ti ti-picture-in-picture"></i> {{ i18n.ts.openInWindow }}
</MkButton>
</div>
</template>
</div>
</template>
@ -85,9 +87,11 @@ const props = withDefaults(defineProps<{
url: string;
detail?: boolean;
compact?: boolean;
showActions?: boolean;
}>(), {
detail: false,
compact: false,
showActions: true,
});
const MOBILE_THRESHOLD = 500;

View File

@ -1,7 +1,7 @@
<template>
<div :class="$style.root" :style="{ zIndex, top: top + 'px', left: left + 'px' }">
<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" @afterLeave="emit('closed')">
<MkUrlPreview v-if="showing" class="_popup _shadow" :url="url"/>
<MkUrlPreview v-if="showing" class="_popup _shadow" :url="url" :showActions="false"/>
</Transition>
</div>
</template>

View File

@ -4,6 +4,9 @@ import { userEvent, waitFor, within } from '@storybook/testing-library';
import { StoryObj } from '@storybook/vue3';
import MkAd from './MkAd.vue';
import { i18n } from '@/i18n';
let lock: Promise<undefined> | undefined;
const common = {
render(args) {
return {
@ -25,7 +28,18 @@ const common = {
template: '<MkAd v-bind="props" />',
};
},
/* FIXME: disabled because it still didnt pass after applying #11267
async play({ canvasElement, args }) {
if (lock) {
console.warn('This test is unexpectedly running twice in parallel, fix it!');
console.warn('See also: https://github.com/misskey-dev/misskey/issues/11267');
await lock;
}
let resolve: (value?: any) => void;
lock = new Promise(r => resolve = r);
try {
const canvas = within(canvasElement);
const a = canvas.getByRole<HTMLAnchorElement>('link');
await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
@ -59,7 +73,12 @@ const common = {
await expect(aAgain).toBeInTheDocument();
const imgAgain = within(aAgain).getByRole('img');
await expect(imgAgain).toBeInTheDocument();
} finally {
resolve!();
lock = undefined;
}
},
*/
args: {
prefer: [],
specify: {

View File

@ -179,6 +179,9 @@ const patronsWithIcon = [{
}, {
name: 'カガミ',
icon: 'https://misskey-hub.net/patrons/226ea3a4617749548580ec2d9a263e24.jpg',
}, {
name: 'フランギ・シュウ',
icon: 'https://misskey-hub.net/patrons/3016d37e35f3430b90420176c912d304.jpg',
}];
const patrons = [
@ -276,6 +279,7 @@ const patrons = [
'ぷーざ',
'越貝鯛丸',
'Nick / pprmint.',
'kino3277',
];
let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure'));

View File

@ -32,7 +32,7 @@
<MkUserCardMini :user="file.user"/>
</MkA>
<div>
<MkSwitch v-model="isSensitive" @update:modelValue="toggleIsSensitive">NSFW</MkSwitch>
<MkSwitch v-model="isSensitive" @update:modelValue="toggleIsSensitive">{{ i18n.ts.sensitive }}</MkSwitch>
</div>
<div>

View File

@ -40,7 +40,7 @@
</div>
<div v-if="expandedItems.includes(item.id)" :class="$style.userItemSub">
<div>Assigned: <MkTime :time="item.createdAt" mode="detail"/></div>
<div v-if="item.expiresAt">Period: {{ item.expiresAt.toLocaleString() }}</div>
<div v-if="item.expiresAt">Period: {{ new Date(item.expiresAt).toLocaleString() }}</div>
<div v-else>Period: {{ i18n.ts.indefinitely }}</div>
</div>
</div>

View File

@ -26,7 +26,7 @@
</div>
</div>
<MkButton rounded style="margin: 0 auto;" @click="changeImage">{{ i18n.ts.selectFile }}</MkButton>
<MkInput v-model="name">
<MkInput v-model="name" pattern="[a-z0-9_]">
<template #label>{{ i18n.ts.name }}</template>
</MkInput>
<MkInput v-model="category" :datalist="customEmojiCategories">
@ -70,6 +70,7 @@
<script lang="ts" setup>
import { computed, watch } from 'vue';
import * as misskey from 'misskey-js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
@ -95,7 +96,7 @@ let isSensitive = $ref(props.emoji ? props.emoji.isSensitive : false);
let localOnly = $ref(props.emoji ? props.emoji.localOnly : false);
let roleIdsThatCanBeUsedThisEmojiAsReaction = $ref(props.emoji ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []);
let rolesThatCanBeUsedThisEmojiAsReaction = $ref([]);
let file = $ref();
let file = $ref<misskey.entities.DriveFile>();
watch($$(roleIdsThatCanBeUsedThisEmojiAsReaction), async () => {
rolesThatCanBeUsedThisEmojiAsReaction = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.map((id) => os.api('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null);
@ -110,6 +111,10 @@ const emit = defineEmits<{
async function changeImage(ev) {
file = await selectFile(ev.currentTarget ?? ev.target, null);
const candidate = file.name.replace(/\.(.+)$/, '');
if (candidate.match(/^[a-z0-9_]+$/)) {
name = candidate;
}
}
async function addRole() {

View File

@ -33,7 +33,7 @@ import MkTextarea from '@/components/MkTextarea.vue';
import MkInput from '@/components/MkInput.vue';
import { useRouter } from '@/router';
const PRESET_DEFAULT = `/// @ 0.14.0
const PRESET_DEFAULT = `/// @ 0.15.0
var name = ""
@ -51,7 +51,7 @@ Ui:render([
])
`;
const PRESET_OMIKUJI = `/// @ 0.14.0
const PRESET_OMIKUJI = `/// @ 0.15.0
//
//
@ -94,7 +94,7 @@ Ui:render([
])
`;
const PRESET_SHUFFLE = `/// @ 0.14.0
const PRESET_SHUFFLE = `/// @ 0.15.0
//
let string = "ペペロンチーノ"
@ -173,7 +173,7 @@ var cursor = 0
do()
`;
const PRESET_QUIZ = `/// @ 0.14.0
const PRESET_QUIZ = `/// @ 0.15.0
let title = '地理クイズ'
let qas = [{
@ -286,7 +286,7 @@ qaEls.push(Ui:C:container({
Ui:render(qaEls)
`;
const PRESET_TIMELINE = `/// @ 0.14.0
const PRESET_TIMELINE = `/// @ 0.15.0
// API
@fetch() {

View File

@ -236,6 +236,7 @@ definePageMetadata(computed(() => post ? {
border-top: solid 0.5px var(--divider);
display: flex;
align-items: center;
flex-wrap: wrap;
> .avatar {
width: 52px;

View File

@ -55,7 +55,7 @@
</div>
<div v-if="expandedMuteItems.includes(item.id)" :class="$style.userItemSub">
<div>Muted at: <MkTime :time="item.createdAt" mode="detail"/></div>
<div v-if="item.expiresAt">Period: {{ item.expiresAt.toLocaleString() }}</div>
<div v-if="item.expiresAt">Period: {{ new Date(item.expiresAt).toLocaleString() }}</div>
<div v-else>Period: {{ i18n.ts.indefinitely }}</div>
</div>
</div>
@ -85,7 +85,7 @@
</div>
<div v-if="expandedBlockItems.includes(item.id)" :class="$style.userItemSub">
<div>Blocked at: <MkTime :time="item.createdAt" mode="detail"/></div>
<div v-if="item.expiresAt">Period: {{ item.expiresAt.toLocaleString() }}</div>
<div v-if="item.expiresAt">Period: {{ new Date(item.expiresAt).toLocaleString() }}</div>
<div v-else>Period: {{ i18n.ts.indefinitely }}</div>
</div>
</div>

View File

@ -112,10 +112,18 @@
<MkButton v-if="user.host == null && iAmModerator" primary rounded @click="assignRole"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton>
<div v-for="role in info.roles" :key="role.id" :class="$style.roleItem">
<div :class="$style.roleItemMain">
<MkRolePreview :class="$style.role" :role="role" :forModeration="true"/>
<button class="_button" :class="$style.roleToggle" @click="toggleRoleItem(role)"><i class="ti ti-chevron-down"></i></button>
<button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button>
<button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button>
</div>
<div v-if="expandedRoles.includes(role.id)" :class="$style.roleItemSub">
<div>Assigned: <MkTime :time="info.roleAssigns.find(a => a.roleId === role.id).createdAt" mode="detail"/></div>
<div v-if="info.roleAssigns.find(a => a.roleId === role.id).expiresAt">Period: {{ new Date(info.roleAssigns.find(a => a.roleId === role.id).expiresAt).toLocaleString() }}</div>
<div v-else>Period: {{ i18n.ts.indefinitely }}</div>
</div>
</div>
</div>
</MkFolder>
@ -220,6 +228,7 @@ const filesPagination = {
userId: props.userId,
})),
};
let expandedRoles = $ref([]);
function createFetcher() {
if (iAmModerator) {
@ -384,6 +393,14 @@ async function unassignRole(role, ev) {
}], ev.currentTarget ?? ev.target);
}
function toggleRoleItem(role) {
if (expandedRoles.includes(role.id)) {
expandedRoles = expandedRoles.filter(x => x !== role.id);
} else {
expandedRoles.push(role.id);
}
}
watch(() => props.userId, () => {
init = createFetcher();
}, {
@ -523,11 +540,22 @@ definePageMetadata(computed(() => ({
}
.roleItem {
}
.roleItemMain {
display: flex;
}
.role {
flex: 1;
min-width: 0;
margin-right: 8px;
}
.roleItemSub {
padding: 6px 12px;
font-size: 85%;
color: var(--fgTransparentWeak);
}
.roleUnassign {

View File

@ -0,0 +1,19 @@
import * as mfm from 'mfm-js';
import * as misskey from 'misskey-js';
import { extractUrlFromMfm } from './extract-url-from-mfm';
export function shouldCollapsed(note: misskey.entities.Note): boolean {
const urls = note.text ? extractUrlFromMfm(mfm.parse(note.text)) : null;
const collapsed = note.cw == null && note.text != null && (
(note.text.includes('$[x2')) ||
(note.text.includes('$[x3')) ||
(note.text.includes('$[x4')) ||
(note.text.includes('$[scale')) ||
(note.text.split('\n').length > 9) ||
(note.text.length > 500) ||
(note.files.length >= 5) ||
(!!urls && urls.length >= 4)
);
return collapsed;
}

View File

@ -1,3 +1,20 @@
const requestIdleCallback: typeof globalThis.requestIdleCallback = globalThis.requestIdleCallback ?? ((callback) => {
const start = performance.now();
const timeoutId = setTimeout(() => {
callback({
didTimeout: false, // polyfill でタイムアウト発火することはない
timeRemaining() {
const diff = performance.now() - start;
return Math.max(0, 50 - diff); // <https://www.w3.org/TR/requestidlecallback/#idle-periods>
},
});
});
return timeoutId;
});
const cancelIdleCallback: typeof globalThis.cancelIdleCallback = globalThis.cancelIdleCallback ?? ((timeoutId) => {
clearTimeout(timeoutId);
});
class IdlingRenderScheduler {
#renderers: Set<FrameRequestCallback>;
#rafId: number;

File diff suppressed because it is too large Load Diff