Merge remote-tracking branch 'prismisskey/develop' into prismisskey
This commit is contained in:
commit
b853625500
|
@ -12,7 +12,7 @@
|
|||
|
||||
-->
|
||||
|
||||
## 2023.9.0 (unreleased)
|
||||
## 2023.9.0-mattyaski (unreleased)
|
||||
|
||||
### General
|
||||
- OAuth 2.0のサポート
|
||||
|
@ -48,7 +48,10 @@
|
|||
- Enhance: AiScriptで`LOCALE`として現在の設定言語を取得できるように
|
||||
- Enhance: Renote自体を通報できるように
|
||||
- Enhance: データセーバーモードの強化
|
||||
- Safariは非対応だけど、LTEだと自動的にオンにする機能も追加 (https://developer.mozilla.org/ja/docs/Web/API/Navigator/connection)
|
||||
- アイコンをblurで表示させるように
|
||||
- Enhance: Renoteを管理者権限で削除可能に
|
||||
- Enhance: `$[mix ]`(emojiKitchen) 記法を追加 # mattyaski独自
|
||||
- `$[rainbow ]`記法が、動きのあるMFMが無効になっていても使用できるようになりました
|
||||
- Playの操作を行うAPI TokenをAPIコンソールから発行できるように
|
||||
- リアクションの表示サイズをより大きくできるように
|
||||
|
@ -70,7 +73,10 @@
|
|||
- Webhookのペイロードにサーバーのurlが含まれるようになりました
|
||||
- Webhook設定でsecretを空に出来るように
|
||||
- 使われていないアンテナの自動停止を設定可能に
|
||||
- リプライをホーム投稿に # mattyaski独自
|
||||
- nodeinfo 2.1対応
|
||||
- Enhance: 絵文字の重複登録を不可に # mattyaski独自
|
||||
- Enhance: frontendのbuildに圧縮をかけるように # mattyaski独自
|
||||
- 自分へのメンション一覧を取得する際のパフォーマンスを向上
|
||||
- Docker環境でjemallocを使用することでメモリ使用量を削減
|
||||
- Fix: MK_ONLY_SERVERオプションを指定した際にクラッシュする問題を修正
|
||||
|
|
|
@ -1029,6 +1029,9 @@ noteIdOrUrl: "Note ID or URL"
|
|||
video: "Video"
|
||||
videos: "Videos"
|
||||
dataSaver: "Data Saver"
|
||||
cellularWithDataSaver: "Turn on Data Saver in Mobile Data Communications"
|
||||
UltimatedataSaver: "Ultimate Data Saver"
|
||||
cellularWithUltimateDataSaver: "Turn on Ultimate Data Saver in Mobile Data Communications"
|
||||
accountMigration: "Account Migration"
|
||||
accountMoved: "This user has moved to a new account:"
|
||||
accountMovedShort: "This account has been migrated."
|
||||
|
@ -1551,6 +1554,7 @@ _aboutMisskey:
|
|||
contributors: "Main contributors"
|
||||
allContributors: "All contributors"
|
||||
source: "Source code"
|
||||
forksource: "Source code for this fork"
|
||||
translation: "Translate Misskey"
|
||||
donate: "Donate to Misskey"
|
||||
morePatrons: "We also appreciate the support of many other helpers not listed here. Thank you! 🥰"
|
||||
|
|
4
locales/index.d.ts
vendored
4
locales/index.d.ts
vendored
|
@ -1032,6 +1032,9 @@ export interface Locale {
|
|||
"video": string;
|
||||
"videos": string;
|
||||
"dataSaver": string;
|
||||
"cellularWithDataSaver": string;
|
||||
"UltimateDataSaver": string;
|
||||
"cellularWithUltimateDataSaver": string;
|
||||
"accountMigration": string;
|
||||
"accountMoved": string;
|
||||
"accountMovedShort": string;
|
||||
|
@ -1656,6 +1659,7 @@ export interface Locale {
|
|||
"contributors": string;
|
||||
"allContributors": string;
|
||||
"source": string;
|
||||
"forksource": string;
|
||||
"translation": string;
|
||||
"donate": string;
|
||||
"morePatrons": string;
|
||||
|
|
|
@ -1029,6 +1029,9 @@ noteIdOrUrl: "ノートIDまたはURL"
|
|||
video: "動画"
|
||||
videos: "動画"
|
||||
dataSaver: "データセーバー"
|
||||
cellularWithDataSaver: "モバイルデータ通信でデータセーバーをオンにする"
|
||||
UltimateDataSaver: "究極のデータセーバー"
|
||||
cellularWithUltimateDataSaver: "モバイルデータ通信で究極のデータセーバーをオンにする"
|
||||
accountMigration: "アカウントの移行"
|
||||
accountMoved: "このユーザーは新しいアカウントに移行しました:"
|
||||
accountMovedShort: "このアカウントは移行されています"
|
||||
|
@ -1573,6 +1576,7 @@ _aboutMisskey:
|
|||
contributors: "主なコントリビューター"
|
||||
allContributors: "全てのコントリビューター"
|
||||
source: "ソースコード"
|
||||
forksource: "当フォークのソースコード"
|
||||
translation: "Misskeyを翻訳"
|
||||
donate: "Misskeyに寄付"
|
||||
morePatrons: "他にも多くの方が支援してくれています。ありがとうございます🥰"
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"name": "misskey",
|
||||
"version": "2023.9.0-beta.8",
|
||||
"version": "2023.9.0-beta.8-mattyaski.5",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/misskey-dev/misskey.git"
|
||||
"url": "https://github.com/mattyatea/misskey.git"
|
||||
},
|
||||
"packageManager": "pnpm@8.7.5",
|
||||
"workspaces": [
|
||||
|
|
|
@ -265,7 +265,8 @@ export class FileServerService {
|
|||
'avatar' in request.query ||
|
||||
'static' in request.query ||
|
||||
'preview' in request.query ||
|
||||
'badge' in request.query
|
||||
'badge' in request.query ||
|
||||
'datasaver' in request.query
|
||||
) {
|
||||
if (!isConvertibleImage) {
|
||||
// 画像でないなら404でお茶を濁す
|
||||
|
@ -284,7 +285,7 @@ export class FileServerService {
|
|||
} else {
|
||||
const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) }))
|
||||
.resize({
|
||||
height: 'emoji' in request.query ? 128 : 320,
|
||||
height: 'emoji' in request.query ? 64 : 128,
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.webp(webpDefault);
|
||||
|
@ -330,6 +331,27 @@ export class FileServerService {
|
|||
ext: 'png',
|
||||
type: 'image/png',
|
||||
};
|
||||
} else if ('datasaver' in request.query){
|
||||
if (!isAnimationConvertibleImage && !('static' in request.query)) {
|
||||
image = {
|
||||
data: fs.createReadStream(file.path),
|
||||
ext: file.ext,
|
||||
type: file.mime,
|
||||
};
|
||||
} else {
|
||||
const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) }))
|
||||
.resize({
|
||||
height: 32,
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.webp(webpDefault);
|
||||
|
||||
image = {
|
||||
data,
|
||||
ext: 'webp',
|
||||
type: 'image/webp',
|
||||
};
|
||||
}
|
||||
}else if (file.mime === 'image/svg+xml') {
|
||||
image = this.imageProcessingService.convertToWebpStream(file.path, 2048, 2048);
|
||||
} else if (!file.mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(file.mime)) {
|
||||
|
|
|
@ -34,6 +34,7 @@ import { GlobalTimelineChannelService } from './api/stream/channels/global-timel
|
|||
import { HashtagChannelService } from './api/stream/channels/hashtag.js';
|
||||
import { HomeTimelineChannelService } from './api/stream/channels/home-timeline.js';
|
||||
import { HybridTimelineChannelService } from './api/stream/channels/hybrid-timeline.js';
|
||||
import { HybridAllTimelineChannelService } from './api/stream/channels/hybrid-all-timeline.js';
|
||||
import { LocalTimelineChannelService } from './api/stream/channels/local-timeline.js';
|
||||
import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js';
|
||||
import { ServerStatsChannelService } from './api/stream/channels/server-stats.js';
|
||||
|
@ -79,6 +80,7 @@ import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
|
|||
RoleTimelineChannelService,
|
||||
HomeTimelineChannelService,
|
||||
HybridTimelineChannelService,
|
||||
HybridAllTimelineChannelService,
|
||||
LocalTimelineChannelService,
|
||||
QueueStatsChannelService,
|
||||
ServerStatsChannelService,
|
||||
|
|
|
@ -262,6 +262,7 @@ import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete
|
|||
import * as ep___notes_featured from './endpoints/notes/featured.js';
|
||||
import * as ep___notes_globalTimeline from './endpoints/notes/global-timeline.js';
|
||||
import * as ep___notes_hybridTimeline from './endpoints/notes/hybrid-timeline.js';
|
||||
import * as ep___notes_hybrid_All_Timeline from './endpoints/notes/hybrid-all-timeline.js';
|
||||
import * as ep___notes_localTimeline from './endpoints/notes/local-timeline.js';
|
||||
import * as ep___notes_mentions from './endpoints/notes/mentions.js';
|
||||
import * as ep___notes_polls_recommendation from './endpoints/notes/polls/recommendation.js';
|
||||
|
@ -609,6 +610,7 @@ const $notes_favorites_delete: Provider = { provide: 'ep:notes/favorites/delete'
|
|||
const $notes_featured: Provider = { provide: 'ep:notes/featured', useClass: ep___notes_featured.default };
|
||||
const $notes_globalTimeline: Provider = { provide: 'ep:notes/global-timeline', useClass: ep___notes_globalTimeline.default };
|
||||
const $notes_hybridTimeline: Provider = { provide: 'ep:notes/hybrid-timeline', useClass: ep___notes_hybridTimeline.default };
|
||||
const $notes_hybridAllTimeline: Provider = { provide: 'ep:notes/hybrid-all-timeline', useClass: ep___notes_hybrid_All_Timeline.default };
|
||||
const $notes_localTimeline: Provider = { provide: 'ep:notes/local-timeline', useClass: ep___notes_localTimeline.default };
|
||||
const $notes_mentions: Provider = { provide: 'ep:notes/mentions', useClass: ep___notes_mentions.default };
|
||||
const $notes_polls_recommendation: Provider = { provide: 'ep:notes/polls/recommendation', useClass: ep___notes_polls_recommendation.default };
|
||||
|
@ -960,6 +962,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||
$notes_featured,
|
||||
$notes_globalTimeline,
|
||||
$notes_hybridTimeline,
|
||||
$notes_hybridAllTimeline,
|
||||
$notes_localTimeline,
|
||||
$notes_mentions,
|
||||
$notes_polls_recommendation,
|
||||
|
@ -1305,6 +1308,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||
$notes_featured,
|
||||
$notes_globalTimeline,
|
||||
$notes_hybridTimeline,
|
||||
$notes_hybridAllTimeline,
|
||||
$notes_localTimeline,
|
||||
$notes_mentions,
|
||||
$notes_polls_recommendation,
|
||||
|
|
|
@ -262,6 +262,7 @@ import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete
|
|||
import * as ep___notes_featured from './endpoints/notes/featured.js';
|
||||
import * as ep___notes_globalTimeline from './endpoints/notes/global-timeline.js';
|
||||
import * as ep___notes_hybridTimeline from './endpoints/notes/hybrid-timeline.js';
|
||||
import * as ep___notes_hybrid_All_Timeline from './endpoints/notes/hybrid-all-timeline.js';
|
||||
import * as ep___notes_localTimeline from './endpoints/notes/local-timeline.js';
|
||||
import * as ep___notes_mentions from './endpoints/notes/mentions.js';
|
||||
import * as ep___notes_polls_recommendation from './endpoints/notes/polls/recommendation.js';
|
||||
|
@ -607,6 +608,7 @@ const eps = [
|
|||
['notes/featured', ep___notes_featured],
|
||||
['notes/global-timeline', ep___notes_globalTimeline],
|
||||
['notes/hybrid-timeline', ep___notes_hybridTimeline],
|
||||
['notes/hybrid-all-timeline', ep___notes_hybrid_All_Timeline],
|
||||
['notes/local-timeline', ep___notes_localTimeline],
|
||||
['notes/mentions', ep___notes_mentions],
|
||||
['notes/polls/recommendation', ep___notes_polls_recommendation],
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { DriveFilesRepository } from '@/models/_.js';
|
||||
import type { DriveFilesRepository, EmojisRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
|
@ -24,6 +24,11 @@ export const meta = {
|
|||
code: 'NO_SUCH_FILE',
|
||||
id: 'fc46b5a4-6b92-4c33-ac66-b806659bb5cf',
|
||||
},
|
||||
duplicationEmojiAdd: {
|
||||
message: 'This emoji is already added.',
|
||||
code: 'DUPLICATION_EMOJI_ADD',
|
||||
id: 'mattyaski_emoji_duplication_error',
|
||||
}
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
@ -57,7 +62,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
constructor(
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
@Inject(DI.emojisRepository)
|
||||
private emojisRepository: EmojisRepository,
|
||||
private customEmojiService: CustomEmojiService,
|
||||
|
||||
private emojiEntityService: EmojiEntityService,
|
||||
|
@ -67,6 +73,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
const driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
|
||||
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
|
||||
|
||||
const duplicationEmoji = await this.emojisRepository.find({
|
||||
where: {
|
||||
name: ps.name,
|
||||
},
|
||||
});
|
||||
|
||||
duplicationEmoji.forEach(
|
||||
(emoji) => {
|
||||
if (emoji.name === ps.name) {
|
||||
throw new ApiError(meta.errors.duplicationEmojiAdd);
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const emoji = await this.customEmojiService.add({
|
||||
driveFile,
|
||||
name: ps.name,
|
||||
|
@ -79,6 +99,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [],
|
||||
});
|
||||
|
||||
|
||||
|
||||
this.moderationLogService.insertModerationLog(me, 'addEmoji', {
|
||||
emojiId: emoji.id,
|
||||
});
|
||||
|
|
|
@ -13,6 +13,7 @@ import { DriveService } from '@/core/DriveService.js';
|
|||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
|
||||
import { ApiError } from '../../../error.js';
|
||||
import {IsNull} from "typeorm";
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
|
@ -26,6 +27,11 @@ export const meta = {
|
|||
code: 'NO_SUCH_EMOJI',
|
||||
id: 'e2785b66-dca3-4087-9cac-b93c541cc425',
|
||||
},
|
||||
duplicationEmojiAdd: {
|
||||
message: 'This emoji is already added.',
|
||||
code: 'DUPLICATION_EMOJI_ADD',
|
||||
id: 'mattyaski_emoji_duplication_error',
|
||||
}
|
||||
},
|
||||
|
||||
res: {
|
||||
|
@ -57,6 +63,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
@Inject(DI.emojisRepository)
|
||||
private emojisRepository: EmojisRepository,
|
||||
|
||||
|
||||
private emojiEntityService: EmojiEntityService,
|
||||
private idService: IdService,
|
||||
private globalEventService: GlobalEventService,
|
||||
|
@ -69,6 +76,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw new ApiError(meta.errors.noSuchEmoji);
|
||||
}
|
||||
|
||||
const duplicationEmoji = await this.emojisRepository.find({
|
||||
where: {
|
||||
name: emoji.name,
|
||||
host: IsNull()
|
||||
},
|
||||
});
|
||||
|
||||
duplicationEmoji.forEach(
|
||||
(_emoji) => {
|
||||
if (_emoji.name === emoji.name) {
|
||||
throw new ApiError(meta.errors.duplicationEmojiAdd);
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
let driveFile: MiDriveFile;
|
||||
|
||||
try {
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||
import type { DriveFilesRepository } from '@/models/_.js';
|
||||
import type { DriveFilesRepository , EmojisRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../../error.js';
|
||||
|
||||
|
@ -32,6 +32,11 @@ export const meta = {
|
|||
code: 'SAME_NAME_EMOJI_EXISTS',
|
||||
id: '7180fe9d-1ee3-bff9-647d-fe9896d2ffb8',
|
||||
},
|
||||
duplicationEmojiAdd: {
|
||||
message: 'This emoji is already added.',
|
||||
code: 'DUPLICATION_EMOJI_ADD',
|
||||
id: 'mattyaski_emoji_duplication_error',
|
||||
}
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
@ -64,7 +69,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
constructor(
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private customEmojiService: CustomEmojiService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
|
|
|
@ -17,6 +17,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
|||
import { NoteCreateService } from '@/core/NoteCreateService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
import {noteVisibilities} from "@/types.js";
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
|
@ -232,7 +233,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
let visibility = ps.visibility;
|
||||
let reply: MiNote | null = null;
|
||||
if (ps.replyId != null) {
|
||||
// Fetch reply
|
||||
|
@ -243,7 +244,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
} else if (reply.renoteId && !reply.text && !reply.fileIds && !reply.hasPoll) {
|
||||
throw new ApiError(meta.errors.cannotReplyToPureRenote);
|
||||
}
|
||||
|
||||
// ノートがリプライでパブリック投稿の場合はホームにする
|
||||
if (ps.visibility != 'home' && ps.visibility!== 'followers' && ps.visibility!=='specified' ){
|
||||
visibility = 'home';
|
||||
}
|
||||
// Check blocking
|
||||
if (reply.userId !== me.id) {
|
||||
const blockExist = await this.blockingsRepository.exist({
|
||||
|
@ -292,7 +296,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
cw: ps.cw,
|
||||
localOnly: ps.localOnly,
|
||||
reactionAcceptance: ps.reactionAcceptance,
|
||||
visibility: ps.visibility,
|
||||
visibility,
|
||||
visibleUsers,
|
||||
channel,
|
||||
apMentions: ps.noExtractMentions ? [] : undefined,
|
||||
|
|
|
@ -0,0 +1,126 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Brackets } from 'typeorm';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { NotesRepository } from '@/models/index.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'Note',
|
||||
},
|
||||
},
|
||||
|
||||
errors: {
|
||||
ltlDisabled: {
|
||||
message: 'hybrid Local timeline has been disabled.',
|
||||
code: 'LTL_DISABLED',
|
||||
id: '45a6eb02-7695-4393-b023-dd3be9aaaefd',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
withFiles: { type: 'boolean', default: false },
|
||||
withReplies: { type: 'boolean', default: false },
|
||||
fileType: { type: 'array', items: {
|
||||
type: 'string',
|
||||
} },
|
||||
excludeNsfw: { type: 'boolean', default: false },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
sinceDate: { type: 'integer' },
|
||||
untilDate: { type: 'integer' },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
private noteEntityService: NoteEntityService,
|
||||
private queryService: QueryService,
|
||||
private roleService: RoleService,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private idService: IdService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const policies = await this.roleService.getUserPolicies(me ? me.id : null);
|
||||
if (!policies.ltlAvailable) {
|
||||
throw new ApiError(meta.errors.ltlDisabled);
|
||||
}
|
||||
|
||||
//#region Construct query
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
|
||||
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで
|
||||
.andWhere('(note.visibility = \'home\') AND (note.userHost IS NULL)')
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
this.queryService.generateChannelQuery(query, me);
|
||||
this.queryService.generateRepliesQuery(query, ps.withReplies, me);
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
if (me) this.queryService.generateMutedUserQuery(query, me);
|
||||
if (me) this.queryService.generateMutedNoteQuery(query, me);
|
||||
if (me) this.queryService.generateBlockedUserQuery(query, me);
|
||||
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
|
||||
if (ps.withFiles) {
|
||||
query.andWhere('note.fileIds != \'{}\'');
|
||||
}
|
||||
|
||||
if (ps.fileType != null) {
|
||||
query.andWhere('note.fileIds != \'{}\'');
|
||||
query.andWhere(new Brackets(qb => {
|
||||
for (const type of ps.fileType!) {
|
||||
const i = ps.fileType!.indexOf(type);
|
||||
qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type });
|
||||
}
|
||||
}));
|
||||
|
||||
if (ps.excludeNsfw) {
|
||||
query.andWhere('note.cw IS NULL');
|
||||
query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)');
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
const timeline = await query.limit(ps.limit).getMany();
|
||||
|
||||
process.nextTick(() => {
|
||||
if (me) {
|
||||
this.activeUsersChart.read(me);
|
||||
}
|
||||
});
|
||||
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -48,7 +48,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
|
||||
throw err;
|
||||
});
|
||||
|
||||
return await this.noteEntityService.pack(note, me, {
|
||||
detail: true,
|
||||
});
|
||||
|
|
|
@ -19,7 +19,7 @@ import { AntennaChannelService } from './channels/antenna.js';
|
|||
import { DriveChannelService } from './channels/drive.js';
|
||||
import { HashtagChannelService } from './channels/hashtag.js';
|
||||
import { RoleTimelineChannelService } from './channels/role-timeline.js';
|
||||
|
||||
import { HybridAllTimelineChannelService } from './channels/hybrid-all-timeline.js';
|
||||
@Injectable()
|
||||
export class ChannelsService {
|
||||
constructor(
|
||||
|
@ -27,6 +27,7 @@ export class ChannelsService {
|
|||
private homeTimelineChannelService: HomeTimelineChannelService,
|
||||
private localTimelineChannelService: LocalTimelineChannelService,
|
||||
private hybridTimelineChannelService: HybridTimelineChannelService,
|
||||
private hybridAllTimelineChannelService: HybridAllTimelineChannelService,
|
||||
private globalTimelineChannelService: GlobalTimelineChannelService,
|
||||
private userListChannelService: UserListChannelService,
|
||||
private hashtagChannelService: HashtagChannelService,
|
||||
|
@ -47,6 +48,7 @@ export class ChannelsService {
|
|||
case 'homeTimeline': return this.homeTimelineChannelService;
|
||||
case 'localTimeline': return this.localTimelineChannelService;
|
||||
case 'hybridTimeline': return this.hybridTimelineChannelService;
|
||||
case 'hybridAllTimeline': return this.hybridAllTimelineChannelService;
|
||||
case 'globalTimeline': return this.globalTimelineChannelService;
|
||||
case 'userList': return this.userListChannelService;
|
||||
case 'hashtag': return this.hashtagChannelService;
|
||||
|
|
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { checkWordMute } from '@/misc/check-word-mute.js';
|
||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import Channel from '../channel.js';
|
||||
|
||||
class HybridAllTimelineChannel extends Channel {
|
||||
public readonly chName = 'hybridAllTimeline';
|
||||
public static shouldShare = true;
|
||||
public static requireCredential = false;
|
||||
private withReplies: boolean;
|
||||
|
||||
constructor(
|
||||
private metaService: MetaService,
|
||||
private roleService: RoleService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
|
||||
id: string,
|
||||
connection: Channel['connection'],
|
||||
) {
|
||||
super(id, connection);
|
||||
//this.onNote = this.onNote.bind(this);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async init(params: any) {
|
||||
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
|
||||
if (!policies.ltlAvailable) return;
|
||||
|
||||
this.withReplies = params.withReplies as boolean;
|
||||
|
||||
// Subscribe events
|
||||
this.subscriber.on('notesStream', this.onNote);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async onNote(note: Packed<'Note'>) {
|
||||
if (note.user.host !== null) return;
|
||||
if (note.visibility === "public") return;
|
||||
if (note.channelId != null && !this.followingChannels.has(note.channelId)) return;
|
||||
|
||||
// リプライなら再pack
|
||||
if (note.replyId != null) {
|
||||
note.reply = await this.noteEntityService.pack(note.replyId, this.user, {
|
||||
detail: true,
|
||||
});
|
||||
}
|
||||
// Renoteなら再pack
|
||||
if (note.renoteId != null) {
|
||||
note.renote = await this.noteEntityService.pack(note.renoteId, this.user, {
|
||||
detail: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 関係ない返信は除外
|
||||
if (note.reply && this.user && !this.withReplies) {
|
||||
const reply = note.reply;
|
||||
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
|
||||
if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return;
|
||||
}
|
||||
|
||||
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
|
||||
if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
|
||||
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
|
||||
if (isUserRelated(note, this.userIdsWhoBlockingMe)) return;
|
||||
|
||||
if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
|
||||
|
||||
// 流れてきたNoteがミュートすべきNoteだったら無視する
|
||||
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
|
||||
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
|
||||
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
|
||||
// そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる
|
||||
if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return;
|
||||
|
||||
this.connection.cacheNote(note);
|
||||
|
||||
this.send('note', note);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose() {
|
||||
// Unsubscribe events
|
||||
this.subscriber.off('notesStream', this.onNote);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HybridAllTimelineChannelService {
|
||||
public readonly shouldShare = HybridAllTimelineChannel.shouldShare;
|
||||
public readonly requireCredential = HybridAllTimelineChannel.requireCredential;
|
||||
|
||||
constructor(
|
||||
private metaService: MetaService,
|
||||
private roleService: RoleService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public create(id: string, connection: Channel['connection']): HybridAllTimelineChannel {
|
||||
return new HybridAllTimelineChannel(
|
||||
this.metaService,
|
||||
this.roleService,
|
||||
this.noteEntityService,
|
||||
id,
|
||||
connection,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -130,6 +130,7 @@
|
|||
"storybook": "7.4.1",
|
||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||
"summaly": "github:misskey-dev/summaly",
|
||||
"vite-plugin-compression2": "^0.10.4",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vitest": "0.34.4",
|
||||
"vitest-fetch-mock": "0.2.2",
|
||||
|
|
|
@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<div :class="$style.root" :style="bg">
|
||||
<img v-if="faviconUrl" :class="$style.icon" :src="faviconUrl"/>
|
||||
<img v-if="faviconUrl && !defaultStore.state.enableUltimateDataSaverMode" :class="$style.icon" :src="faviconUrl"/>
|
||||
<div :class="$style.name">{{ instance.name }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -15,6 +15,7 @@ import { } from 'vue';
|
|||
import { instanceName } from '@/config';
|
||||
import { instance as Instance } from '@/instance';
|
||||
import { getProxiedImageUrlNullable } from '@/scripts/media-proxy';
|
||||
import {defaultStore} from "@/store";
|
||||
|
||||
const props = defineProps<{
|
||||
instance?: {
|
||||
|
|
|
@ -88,6 +88,15 @@ if (props.src === 'antenna') {
|
|||
withReplies: defaultStore.state.showTimelineReplies,
|
||||
});
|
||||
connection.on('note', prepend);
|
||||
} else if (props.src === 'all') {
|
||||
endpoint = 'notes/hybrid-all-timeline';
|
||||
query = {
|
||||
withReplies: defaultStore.state.showTimelineReplies,
|
||||
};
|
||||
connection = stream.useChannel('hybridAllTimeline', {
|
||||
withReplies: defaultStore.state.showTimelineReplies,
|
||||
});
|
||||
connection.on('note', prepend);
|
||||
} else if (props.src === 'global') {
|
||||
endpoint = 'notes/global-timeline';
|
||||
query = {
|
||||
|
|
|
@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" class="_noSelect" :class="[$style.root, { [$style.animation]: animation, [$style.cat]: user.isCat, [$style.square]: squareAvatars }]" :style="{ color }" :title="acct(user)" @click="onClick">
|
||||
<MkImgWithBlurhash :class="$style.inner" :src="url" :hash="user?.avatarBlurhash" :cover="true" :onlyAvgColor="true"/>
|
||||
<MkImgWithBlurhash :class="$style.inner" :src="defaultStore.state.enableUltimateDataSaverMode ? undefined : url" :hash="user?.avatarBlurhash" :cover="true" :onlyAvgColor="true"/>
|
||||
<MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/>
|
||||
<div v-if="user.isCat" :class="[$style.ears]">
|
||||
<div :class="$style.earLeft">
|
||||
|
|
|
@ -38,13 +38,20 @@ const rawUrl = computed(() => {
|
|||
|
||||
const url = computed(() => {
|
||||
if (rawUrl.value == null) return null;
|
||||
|
||||
const useOriginalSize = props.useOriginalSize;
|
||||
const enableDataSaverMode = defaultStore.state.enableUltimateDataSaverMode;
|
||||
let datasaver_result ;
|
||||
if (enableDataSaverMode) {
|
||||
datasaver_result = useOriginalSize ? undefined : 'datasaver';
|
||||
} else {
|
||||
datasaver_result = useOriginalSize ? undefined : 'emoji';
|
||||
}
|
||||
const proxied =
|
||||
(rawUrl.value.startsWith('/emoji/') || (props.useOriginalSize && isLocal.value))
|
||||
? rawUrl.value
|
||||
: getProxiedImageUrl(
|
||||
rawUrl.value,
|
||||
props.useOriginalSize ? undefined : 'emoji',
|
||||
datasaver_result,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
|
46
packages/frontend/src/components/global/MkEmojiKitchen.vue
Normal file
46
packages/frontend/src/components/global/MkEmojiKitchen.vue
Normal file
|
@ -0,0 +1,46 @@
|
|||
<template>
|
||||
<span v-if="errored">{{ alt }}</span>
|
||||
<img v-else :class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" :src="url" :alt="alt" :title="alt" decoding="async" @error="errored = true" @load="errored = false"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
normal?: boolean;
|
||||
url: string;
|
||||
}>();
|
||||
|
||||
const rawUrl = computed(() => props.url);
|
||||
|
||||
const url = computed(() => rawUrl.value);
|
||||
|
||||
const alt = computed(() => props.name);
|
||||
let errored = $ref(url.value == null);
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
height: 2em;
|
||||
vertical-align: middle;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.normal {
|
||||
height: 1.25em;
|
||||
vertical-align: -0.25em;
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.noStyle {
|
||||
height: auto !important;
|
||||
}
|
||||
</style>
|
|
@ -1,8 +1,3 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { VNode, h } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
|
@ -11,12 +6,14 @@ import MkLink from '@/components/MkLink.vue';
|
|||
import MkMention from '@/components/MkMention.vue';
|
||||
import MkEmoji from '@/components/global/MkEmoji.vue';
|
||||
import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue';
|
||||
import MkEmojiKitchen from '@/components/global/MkEmojiKitchen.vue';
|
||||
import MkCode from '@/components/MkCode.vue';
|
||||
import MkGoogle from '@/components/MkGoogle.vue';
|
||||
import MkSparkle from '@/components/MkSparkle.vue';
|
||||
import MkA from '@/components/global/MkA.vue';
|
||||
import { host } from '@/config';
|
||||
import { defaultStore } from '@/store';
|
||||
import { mixEmoji } from '@/scripts/emojiKitchen/emojiMixer';
|
||||
|
||||
const QUOTE_STYLE = `
|
||||
display: block;
|
||||
|
@ -27,6 +24,38 @@ border-left: solid 3px var(--fg);
|
|||
opacity: 0.7;
|
||||
`.split('\n').join(' ');
|
||||
|
||||
const colorRegexp = /^([0-9a-f]{3,4}?|[0-9a-f]{6}?|[0-9a-f]{8}?)$/i;
|
||||
function checkColorHex(text: string) {
|
||||
return colorRegexp.test(text);
|
||||
}
|
||||
|
||||
const gradientCounterRegExp = /^(color|step)(\d+)/;
|
||||
|
||||
function toGradientText(args: Record<string, string>) {
|
||||
const colors: { index: number; step?: string, color?: string }[] = [];
|
||||
for (const k in args) {
|
||||
const matches = k.match(gradientCounterRegExp);
|
||||
if (matches == null) continue;
|
||||
const mindex = parseInt(matches[2]);
|
||||
let i = colors.findIndex(v => v.index === mindex);
|
||||
if (i === -1) {
|
||||
i = colors.length;
|
||||
colors.push({ index: mindex });
|
||||
}
|
||||
colors[i][matches[1]] = args[k];
|
||||
}
|
||||
let deg = parseFloat(args.deg || '90');
|
||||
let res = `linear-gradient(${deg}deg`;
|
||||
for (const colorProp of colors.sort((a, b) => a.index - b.index)) {
|
||||
let color = colorProp.color;
|
||||
if (!color || !checkColorHex(color)) color = 'f00';
|
||||
let step = parseFloat(colorProp.step ?? '');
|
||||
let stepText = isNaN(step) ? '' : ` ${step}%`;
|
||||
res += `, #${color}${stepText}`;
|
||||
}
|
||||
return res + ')';
|
||||
}
|
||||
|
||||
export default function(props: {
|
||||
text: string;
|
||||
plain?: boolean;
|
||||
|
@ -44,7 +73,7 @@ export default function(props: {
|
|||
const ast = (props.plain ? mfm.parseSimple : mfm.parse)(props.text);
|
||||
|
||||
const validTime = (t: string | null | undefined) => {
|
||||
if (t == null) return null;
|
||||
if (t == null || typeof t === 'boolean') return null;
|
||||
return t.match(/^[0-9.]+s$/) ? t : null;
|
||||
};
|
||||
|
||||
|
@ -170,18 +199,15 @@ export default function(props: {
|
|||
break;
|
||||
}
|
||||
case 'blur': {
|
||||
const radius = parseFloat(token.props.args.rad ?? '6');
|
||||
return h('span', {
|
||||
class: '_mfm_blur_',
|
||||
style: `--blur-px: ${radius}px;`
|
||||
}, genEl(token.children, scale));
|
||||
}
|
||||
case 'rainbow': {
|
||||
if (!useAnim) {
|
||||
return h('span', {
|
||||
class: '_mfm_rainbow_fallback_',
|
||||
}, genEl(token.children, scale));
|
||||
}
|
||||
const speed = validTime(token.props.args.speed) ?? '1s';
|
||||
style = `animation: mfm-rainbow ${speed} linear infinite;`;
|
||||
style = useAnim ? `animation: mfm-rainbow ${speed} linear infinite;` : '';
|
||||
break;
|
||||
}
|
||||
case 'sparkle': {
|
||||
|
@ -192,7 +218,23 @@ export default function(props: {
|
|||
}
|
||||
case 'rotate': {
|
||||
const degrees = parseFloat(token.props.args.deg ?? '90');
|
||||
style = `transform: rotate(${degrees}deg); transform-origin: center center;`;
|
||||
let rotateText = `rotate(${degrees}deg)`;
|
||||
if (!token.props.args.deg && (token.props.args.x || token.props.args.y || token.props.args.z)) {
|
||||
rotateText = '';
|
||||
}
|
||||
if (token.props.args.x) {
|
||||
const degrees = parseFloat(token.props.args.x ?? '0');
|
||||
rotateText += ` rotateX(${degrees}deg)`;
|
||||
}
|
||||
if (token.props.args.y) {
|
||||
const degrees = parseFloat(token.props.args.y ?? '0');
|
||||
rotateText += ` rotateY(${degrees}deg)`;
|
||||
}
|
||||
if (token.props.args.z) {
|
||||
const degrees = parseFloat(token.props.args.z ?? '0');
|
||||
rotateText += ` rotateZ(${degrees}deg)`;
|
||||
}
|
||||
style = `transform: ${rotateText}; transform-origin: center center;`;
|
||||
break;
|
||||
}
|
||||
case 'position': {
|
||||
|
@ -213,18 +255,101 @@ export default function(props: {
|
|||
scale = scale * Math.max(x, y);
|
||||
break;
|
||||
}
|
||||
case 'skew': {
|
||||
if (!defaultStore.state.advancedMfm) {
|
||||
style = '';
|
||||
break;
|
||||
}
|
||||
const x = parseFloat(token.props.args.x ?? '0');
|
||||
const y = parseFloat(token.props.args.y ?? '0');
|
||||
style = `transform: skew(${x}deg, ${y}deg);`;
|
||||
break;
|
||||
}
|
||||
case 'fgg': {
|
||||
if (!defaultStore.state.advancedMfm) break;
|
||||
style = `-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-image: ${toGradientText(token.props.args)};`
|
||||
break;
|
||||
}
|
||||
case 'fg': {
|
||||
let color = token.props.args.color;
|
||||
if (!/^[0-9a-f]{3,6}$/i.test(color)) color = 'f00';
|
||||
if (!checkColorHex(color)) color = 'f00';
|
||||
style = `color: #${color};`;
|
||||
break;
|
||||
}
|
||||
case 'bgg': {
|
||||
if (!defaultStore.state.advancedMfm) break;
|
||||
style = `background-image: ${toGradientText(token.props.args)};`
|
||||
break;
|
||||
}
|
||||
case 'bg': {
|
||||
let color = token.props.args.color;
|
||||
if (!/^[0-9a-f]{3,6}$/i.test(color)) color = 'f00';
|
||||
if (!checkColorHex(color)) color = 'f00';
|
||||
style = `background-color: #${color};`;
|
||||
break;
|
||||
}
|
||||
case 'clip': {
|
||||
if (!defaultStore.state.advancedMfm) break;
|
||||
|
||||
let path = '';
|
||||
if (token.props.args.circle) {
|
||||
const percent = parseFloat(token.props.args.circle ?? '');
|
||||
const percentText = isNaN(percent) ? '' : `${percent}%`;
|
||||
path = `circle(${percentText})`;
|
||||
}
|
||||
else {
|
||||
const top = parseFloat(token.props.args.t ?? '0');
|
||||
const bottom = parseFloat(token.props.args.b ?? '0');
|
||||
const left = parseFloat(token.props.args.l ?? '0');
|
||||
const right = parseFloat(token.props.args.r ?? '0');
|
||||
path = `inset(${top}% ${right}% ${bottom}% ${left}%)`;
|
||||
}
|
||||
style = `clip-path: ${path};`;
|
||||
break;
|
||||
}
|
||||
case 'move': {
|
||||
const speed = validTime(token.props.args.speed) ?? '1s';
|
||||
const fromX = parseFloat(token.props.args.fromx ?? '0');
|
||||
const fromY = parseFloat(token.props.args.fromy ?? '0');
|
||||
const toX = parseFloat(token.props.args.tox ?? '0');
|
||||
const toY = parseFloat(token.props.args.toy ?? '0');
|
||||
const ease =
|
||||
token.props.args.ease ? 'ease' :
|
||||
token.props.args.easein ? 'ease-in' :
|
||||
token.props.args.easeout ? 'ease-out' :
|
||||
token.props.args.easeinout ? 'ease-in-out' :
|
||||
'linear';
|
||||
const delay = validTime(token.props.args.delay) ?? '0s';
|
||||
const direction =
|
||||
token.props.args.rev && token.props.args.once ? 'reverse' :
|
||||
token.props.args.rev ? 'alternate-reverse' :
|
||||
token.props.args.once ? 'normal' :
|
||||
'alternate';
|
||||
style = useAnim ? `--move-fromX: ${fromX}em; --move-fromY: ${fromY}em; --move-toX: ${toX}em; --move-toY: ${toY}em; animation: ${speed} ${ease} ${delay} infinite ${direction} mfm-move;` : '';
|
||||
break;
|
||||
}
|
||||
case 'mix': {
|
||||
const ch = token.children;
|
||||
if (ch.length != 2 || ch.some(c => c.type !== 'unicodeEmoji')) {
|
||||
style = null;
|
||||
break;
|
||||
}
|
||||
|
||||
const emoji1 = ch[0].props.emoji;
|
||||
const emoji2 = ch[1].props.emoji;
|
||||
|
||||
const mixedEmojiUrl = mixEmoji(emoji1, emoji2);
|
||||
if (!mixedEmojiUrl) {
|
||||
style = null;
|
||||
break;
|
||||
}
|
||||
|
||||
return h(MkEmojiKitchen, {
|
||||
key: Math.random(),
|
||||
name: emoji1 + emoji2,
|
||||
normal: props.plain,
|
||||
url: mixedEmojiUrl
|
||||
});
|
||||
}
|
||||
}
|
||||
if (style == null) {
|
||||
return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children, scale), ']']);
|
||||
|
|
|
@ -34,6 +34,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
{{ i18n.ts._aboutMisskey.source }}
|
||||
<template #suffix>GitHub</template>
|
||||
</FormLink>
|
||||
<FormLink to="https://github.com/mattyatea/misskey" external>
|
||||
<template #icon><i class="ti ti-code"></i></template>
|
||||
{{ i18n.ts._aboutMisskey.forksource }}
|
||||
<template #suffix>GitHub</template>
|
||||
</FormLink>
|
||||
<FormLink to="https://crowdin.com/project/misskey" external>
|
||||
<template #icon><i class="ti ti-language-hiragana"></i></template>
|
||||
{{ i18n.ts._aboutMisskey.translation }}
|
||||
|
|
|
@ -113,7 +113,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkSwitch v-model="useSystemFont">{{ i18n.ts.useSystemFont }}</MkSwitch>
|
||||
<MkSwitch v-model="disableDrawer">{{ i18n.ts.disableDrawer }}</MkSwitch>
|
||||
<MkSwitch v-model="forceShowAds">{{ i18n.ts.forceShowAds }}</MkSwitch>
|
||||
<MkSwitch v-model="enableDataSaverMode">{{ i18n.ts.dataSaver }}</MkSwitch>
|
||||
<MkSwitch :disabled="enableUltimateDataSaverMode || enableCellularWithUltimateDataSaver" v-model="enableDataSaverMode">{{ i18n.ts.dataSaver }}</MkSwitch>
|
||||
<MkSwitch :disabled="enableUltimateDataSaverMode || enableCellularWithUltimateDataSaver" v-model="enableCellularWithDataSaver">{{ i18n.ts.cellularWithDataSaver }}</MkSwitch>
|
||||
<MkSwitch v-model="enableUltimateDataSaverMode">{{ i18n.ts.UltimateDataSaver }}</MkSwitch>
|
||||
<MkSwitch v-model="enableCellularWithUltimateDataSaver">{{ i18n.ts.cellularWithUltimateDataSaver }}</MkSwitch>
|
||||
</div>
|
||||
<div>
|
||||
<MkRadios v-model="emojiStyle">
|
||||
|
@ -228,6 +231,9 @@ const disableShowingAnimatedImages = computed(defaultStore.makeGetterSetter('dis
|
|||
const forceShowAds = computed(defaultStore.makeGetterSetter('forceShowAds'));
|
||||
const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages'));
|
||||
const enableDataSaverMode = computed(defaultStore.makeGetterSetter('enableDataSaverMode')) ;
|
||||
const enableCellularWithDataSaver = computed(defaultStore.makeGetterSetter('enableCellularWithDataSaver'));
|
||||
const enableUltimateDataSaverMode = computed(defaultStore.makeGetterSetter('enableUltimateDataSaverMode'))
|
||||
const enableCellularWithUltimateDataSaver = computed(defaultStore.makeGetterSetter('enableCellularWithUltimateDataSaver'));
|
||||
const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab'));
|
||||
const nsfw = computed(defaultStore.makeGetterSetter('nsfw'));
|
||||
const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm'));
|
||||
|
@ -242,6 +248,8 @@ const notificationPosition = computed(defaultStore.makeGetterSetter('notificatio
|
|||
const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificationStackAxis'));
|
||||
const showTimelineReplies = computed(defaultStore.makeGetterSetter('showTimelineReplies'));
|
||||
|
||||
|
||||
|
||||
watch(lang, () => {
|
||||
miLocalStorage.setItem('lang', lang.value as string);
|
||||
miLocalStorage.removeItem('locale');
|
||||
|
|
|
@ -47,6 +47,7 @@ const XTutorial = defineAsyncComponent(() => import('./timeline.tutorial.vue'));
|
|||
|
||||
const isLocalTimelineAvailable = ($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable);
|
||||
const isGlobalTimelineAvailable = ($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable);
|
||||
const isAdmin = ($i != null && $i.isAdmin);
|
||||
const keymap = {
|
||||
't': focus,
|
||||
};
|
||||
|
@ -102,7 +103,7 @@ async function chooseChannel(ev: MouseEvent): Promise<void> {
|
|||
os.popupMenu(items, ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
||||
function saveSrc(newSrc: 'home' | 'local' | 'social' | 'global'): void {
|
||||
function saveSrc(newSrc: 'home' | 'local' | 'social' | 'global' | 'all'): void {
|
||||
defaultStore.set('tl', {
|
||||
...defaultStore.state.tl,
|
||||
src: newSrc,
|
||||
|
@ -145,6 +146,11 @@ const headerTabs = $computed(() => [{
|
|||
title: i18n.ts._timelines.global,
|
||||
icon: 'ti ti-whirl',
|
||||
iconOnly: true,
|
||||
}] : []), ...(isAdmin ? [{
|
||||
key: 'all',
|
||||
title: 'all',
|
||||
icon: 'ti ti-whirl',
|
||||
iconOnly: true,
|
||||
}] : []), {
|
||||
icon: 'ti ti-list',
|
||||
title: i18n.ts.lists,
|
||||
|
|
37031
packages/frontend/src/scripts/emojiKitchen/emojiData.ts
Normal file
37031
packages/frontend/src/scripts/emojiKitchen/emojiData.ts
Normal file
File diff suppressed because it is too large
Load diff
67
packages/frontend/src/scripts/emojiKitchen/emojiMixer.ts
Normal file
67
packages/frontend/src/scripts/emojiKitchen/emojiMixer.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
import * as data from './emojiData';
|
||||
|
||||
const mixEmojiUrl = (r, c) => {
|
||||
let padZeros = r < 20220500; // Revisions before 0522 had preceding zeros
|
||||
c[0] = c[0].split(/-/g).map(s => padZeros ? s.padStart(4, "0") : s).join("-u");
|
||||
c[1] = c[1].split(/-/g).map(s => padZeros ? s.padStart(4, "0") : s).join("-u");
|
||||
return `https://www.gstatic.com/android/keyboard/emojikitchen/${r}/u${c[0]}/u${c[0]}_u${c[1]}.png`;
|
||||
};
|
||||
|
||||
const convertBase = (value, from_base, to_base) => {
|
||||
value = value.toString();
|
||||
var range = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ+/'.split('');
|
||||
var from_range = range.slice(0, from_base);
|
||||
var to_range = range.slice(0, to_base);
|
||||
|
||||
var dec_value = value.split('').reverse().reduce(function (carry, digit, index) {
|
||||
if (from_range.indexOf(digit) === -1) throw new Error('Invalid digit `' + digit + '` for base ' + from_base + '.');
|
||||
return carry += from_range.indexOf(digit) * (Math.pow(from_base, index));
|
||||
}, 0);
|
||||
|
||||
var new_value = '';
|
||||
while (dec_value > 0) {
|
||||
new_value = to_range[dec_value % to_base] + new_value;
|
||||
dec_value = (dec_value - (dec_value % to_base)) / to_base;
|
||||
}
|
||||
return new_value || '0';
|
||||
};
|
||||
|
||||
const emojiSplit = String.fromCodePoint(0x200d);
|
||||
const hexEncodeEmoji = (chr) => {
|
||||
if (chr.length === 3) return hexEncodeEmoji(chr.slice(0, 2)) + '-' + hexEncodeEmoji(chr.slice(2, chr.length));
|
||||
else if (chr.length === 2) {
|
||||
const hi = chr.charCodeAt(0);
|
||||
const lo = chr.charCodeAt(1);
|
||||
if (0xD800 <= hi && hi < 0xDC00 && 0xDC00 <= lo && lo < 0xE000) {
|
||||
return (0x10000 + (hi - 0xD800) * 0x400 + (lo - 0xDC00)).toString(16);
|
||||
}
|
||||
return hi.toString(16) + '-' + lo.toString(16);
|
||||
}
|
||||
else if (chr.length === 1) {
|
||||
return chr.charCodeAt(0).toString(16);
|
||||
}
|
||||
else {
|
||||
const sp = chr.split(emojiSplit);
|
||||
if (sp.length !== 2) return '';
|
||||
return hexEncodeEmoji(sp[0]) + '-200d-' + hexEncodeEmoji(sp[1]);
|
||||
}
|
||||
};
|
||||
|
||||
const pairsMatchingMap = match => {
|
||||
const mv = match[0];
|
||||
let [d, c1, c2] = mv.split('.');
|
||||
c1 = data.points[convertBase(c1, 64, 10)];
|
||||
c2 = data.points[convertBase(c2, 64, 10)];
|
||||
d = data.revisions[convertBase(d, 64, 10)];
|
||||
|
||||
return mixEmojiUrl(d, [c1, c2]);
|
||||
};
|
||||
|
||||
export const mixEmoji = (emoji1, emoji2) => {
|
||||
const encordedEmoji1 = convertBase(data.points.indexOf(hexEncodeEmoji(emoji1)), 10, 64);
|
||||
const encordedEmoji2 = convertBase(data.points.indexOf(hexEncodeEmoji(emoji2)), 10, 64);
|
||||
return [
|
||||
...data.pairs.matchAll(new RegExp("^.*\\." + encordedEmoji1 + "\\." + encordedEmoji2 + "\\.$", "gm")),
|
||||
...data.pairs.matchAll(new RegExp("^.*\\." + encordedEmoji2 + "\\." + encordedEmoji1 + "\\.$", "gm"))
|
||||
].map(pairsMatchingMap).pop();
|
||||
};
|
|
@ -1,6 +1 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'font', 'blur', 'rainbow', 'sparkle', 'rotate'];
|
||||
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'skew', 'position', 'fg', 'bg', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'fgg', 'bgg', 'clip', 'move', 'mix'];
|
||||
|
|
|
@ -206,6 +206,18 @@ export const defaultStore = markRaw(new Storage('base', {
|
|||
enableDataSaverMode: {
|
||||
where: 'device',
|
||||
default: false,
|
||||
},
|
||||
enableUltimateDataSaverMode: {
|
||||
where: 'device',
|
||||
default: false,
|
||||
},
|
||||
enableCellularWithDataSaver: {
|
||||
where: 'device',
|
||||
default: false,
|
||||
},
|
||||
enableCellularWithUltimateDataSaver: {
|
||||
where: 'device',
|
||||
default: false,
|
||||
},
|
||||
disableShowingAnimatedImages: {
|
||||
where: 'device',
|
||||
|
|
|
@ -7,7 +7,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div :class="$style.root">
|
||||
<XSidebar v-if="!isMobile" :class="$style.sidebar"/>
|
||||
|
||||
<MkStickyContainer ref="contents" :class="$style.contents" style="container-type: inline-size;" @contextmenu.stop="onContextmenu">
|
||||
<MkStickyContainer ref="contents" :class="$style.contents" style="container-type: inline-size;"
|
||||
@contextmenu.stop="onContextmenu">
|
||||
<template #header>
|
||||
<div>
|
||||
<XAnnouncements v-if="$i" :class="$style.announcements"/>
|
||||
|
@ -22,14 +23,26 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<XWidgets/>
|
||||
</div>
|
||||
|
||||
<button v-if="!isDesktop && !isMobile" :class="$style.widgetButton" class="_button" @click="widgetsShowing = true"><i class="ti ti-apps"></i></button>
|
||||
<button v-if="!isDesktop && !isMobile" :class="$style.widgetButton" class="_button" @click="widgetsShowing = true">
|
||||
<i class="ti ti-apps"></i></button>
|
||||
|
||||
<div v-if="isMobile" ref="navFooter" :class="$style.nav">
|
||||
<button :class="$style.navButton" class="_button" @click="drawerMenuShowing = true"><i :class="$style.navButtonIcon" class="ti ti-menu-2"></i><span v-if="menuIndicated" :class="$style.navButtonIndicator"><i class="_indicatorCircle"></i></span></button>
|
||||
<button :class="$style.navButton" class="_button" @click="mainRouter.currentRoute.value.name === 'index' ? top() : mainRouter.push('/')"><i :class="$style.navButtonIcon" class="ti ti-home"></i></button>
|
||||
<button :class="$style.navButton" class="_button" @click="mainRouter.push('/my/notifications')"><i :class="$style.navButtonIcon" class="ti ti-bell"></i><span v-if="$i?.hasUnreadNotification" :class="$style.navButtonIndicator"><i class="_indicatorCircle"></i></span></button>
|
||||
<button :class="$style.navButton" class="_button" @click="widgetsShowing = true"><i :class="$style.navButtonIcon" class="ti ti-apps"></i></button>
|
||||
<button :class="$style.postButton" class="_button" @click="os.post()"><i :class="$style.navButtonIcon" class="ti ti-pencil"></i></button>
|
||||
<button :class="$style.navButton" class="_button" @click="drawerMenuShowing = true"><i
|
||||
:class="$style.navButtonIcon" class="ti ti-menu-2"></i><span v-if="menuIndicated"
|
||||
:class="$style.navButtonIndicator"><i
|
||||
class="_indicatorCircle"></i></span></button>
|
||||
<button :class="$style.navButton" class="_button"
|
||||
@click="mainRouter.currentRoute.value.name === 'index' ? top() : mainRouter.push('/')"><i
|
||||
:class="$style.navButtonIcon" class="ti ti-home"></i></button>
|
||||
<button :class="$style.navButton" class="_button" @click="mainRouter.push('/my/notifications')"><i
|
||||
:class="$style.navButtonIcon" class="ti ti-bell"></i><span v-if="$i?.hasUnreadNotification"
|
||||
:class="$style.navButtonIndicator"><i
|
||||
class="_indicatorCircle"></i></span></button>
|
||||
<button :class="$style.navButton" class="_button" @click="widgetsShowing = true"><i :class="$style.navButtonIcon"
|
||||
class="ti ti-apps"></i>
|
||||
</button>
|
||||
<button :class="$style.postButton" class="_button" @click="os.post()"><i :class="$style.navButtonIcon"
|
||||
class="ti ti-pencil"></i></button>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
|
@ -80,7 +93,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:leaveToClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_leaveTo : ''"
|
||||
>
|
||||
<div v-if="widgetsShowing" :class="$style.widgetsDrawer">
|
||||
<button class="_button" :class="$style.widgetsCloseButton" @click="widgetsShowing = false"><i class="ti ti-x"></i></button>
|
||||
<button class="_button" :class="$style.widgetsCloseButton" @click="widgetsShowing = false"><i
|
||||
class="ti ti-x"></i></button>
|
||||
<XWidgets/>
|
||||
</div>
|
||||
</Transition>
|
||||
|
@ -115,6 +129,33 @@ const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announce
|
|||
const DESKTOP_THRESHOLD = 1100;
|
||||
const MOBILE_THRESHOLD = 500;
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
window.navigator.connection.type === "cellular" &&
|
||||
!defaultStore.state.enableUltimateDataSaverMode &&
|
||||
defaultStore.state.enableCellularWithUltimateDataSaver
|
||||
) {
|
||||
defaultStore.state.enableDataSaverMode = true;
|
||||
defaultStore.state.enableUltimateDataSaverMode = true;
|
||||
} else if (window.navigator.connection.type !== "cellular" && window.navigator.connection.type !== "undefined" && defaultStore.state.enableDataSaverMode && defaultStore.state.enableCellularWithDataSaver) {
|
||||
defaultStore.state.enableDataSaverMode = false;
|
||||
defaultStore.state.enableUltimateDataSaverMode = true;
|
||||
}
|
||||
|
||||
if (
|
||||
window.navigator.connection.type === "cellular" &&
|
||||
!defaultStore.state.enableDataSaverMode &&
|
||||
defaultStore.state.enableCellularWithDataSaver
|
||||
) {
|
||||
defaultStore.state.enableDataSaverMode = true;
|
||||
|
||||
} else if (window.navigator.connection.type !== "cellular" && window.navigator.connection.type !== "undefined" && defaultStore.state.enableDataSaverMode && defaultStore.state.enableCellularWithDataSaver) {
|
||||
defaultStore.state.enableDataSaverMode = false;
|
||||
}
|
||||
if (defaultStore.state.enableUltimateDataSaverMode) {
|
||||
defaultStore.state.enableDataSaverMode = true;
|
||||
}
|
||||
});
|
||||
// デスクトップでウィンドウを狭くしたときモバイルUIが表示されて欲しいことはあるので deviceKind === 'desktop' の判定は行わない
|
||||
const isDesktop = ref(window.innerWidth >= DESKTOP_THRESHOLD);
|
||||
const isMobile = ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD);
|
||||
|
@ -262,6 +303,7 @@ $widgets-hide-threshold: 1090px;
|
|||
opacity: 1;
|
||||
transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
.transition_menuDrawerBg_enterFrom,
|
||||
.transition_menuDrawerBg_leaveTo {
|
||||
opacity: 0;
|
||||
|
@ -273,6 +315,7 @@ $widgets-hide-threshold: 1090px;
|
|||
transform: translateX(0);
|
||||
transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
.transition_menuDrawer_enterFrom,
|
||||
.transition_menuDrawer_leaveTo {
|
||||
opacity: 0;
|
||||
|
@ -284,6 +327,7 @@ $widgets-hide-threshold: 1090px;
|
|||
opacity: 1;
|
||||
transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
.transition_widgetsDrawerBg_enterFrom,
|
||||
.transition_widgetsDrawerBg_leaveTo {
|
||||
opacity: 0;
|
||||
|
@ -295,6 +339,7 @@ $widgets-hide-threshold: 1090px;
|
|||
transform: translateX(0);
|
||||
transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
.transition_widgetsDrawer_enterFrom,
|
||||
.transition_widgetsDrawer_leaveTo {
|
||||
opacity: 0;
|
||||
|
|
|
@ -9,6 +9,7 @@ import locales from '../../locales';
|
|||
import meta from '../../package.json';
|
||||
import pluginUnwindCssModuleClassName from './lib/rollup-plugin-unwind-css-module-class-name';
|
||||
import pluginJson5 from './vite.json5';
|
||||
import compression from "vite-plugin-compression2";
|
||||
|
||||
const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue'];
|
||||
|
||||
|
@ -44,12 +45,12 @@ function toBase62(n: number): string {
|
|||
export function getConfig(): UserConfig {
|
||||
return {
|
||||
base: '/vite/',
|
||||
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
|
||||
plugins: [
|
||||
compression({ algorithm: 'brotliCompress'}),
|
||||
pluginVue({
|
||||
reactivityTransform: true,
|
||||
}),
|
||||
|
|
|
@ -980,6 +980,9 @@ importers:
|
|||
summaly:
|
||||
specifier: github:misskey-dev/summaly
|
||||
version: github.com/misskey-dev/summaly/d2d8db49943ccb201c1b1b283e9d0a630519fac7
|
||||
vite-plugin-compression2:
|
||||
specifier: ^0.10.4
|
||||
version: 0.10.4(rollup@3.29.1)
|
||||
vite-plugin-turbosnap:
|
||||
specifier: 1.0.3
|
||||
version: 1.0.3
|
||||
|
@ -19126,6 +19129,14 @@ packages:
|
|||
- terser
|
||||
dev: true
|
||||
|
||||
/vite-plugin-compression2@0.10.4(rollup@3.29.1):
|
||||
resolution: {integrity: sha512-9YcESw0n1j8KxxY1NJKEcItlT0bLS+K/NKa/xPqZGEHW/qwgigIeRF/bCTUdZ/bn/mg2+PeERWgRmK8G1L0tyg==}
|
||||
dependencies:
|
||||
'@rollup/pluginutils': 5.0.4(rollup@3.29.1)
|
||||
transitivePeerDependencies:
|
||||
- rollup
|
||||
dev: true
|
||||
|
||||
/vite-plugin-turbosnap@1.0.3:
|
||||
resolution: {integrity: sha512-p4D8CFVhZS412SyQX125qxyzOgIFouwOcvjZWk6bQbNPR1wtaEzFT6jZxAjf1dejlGqa6fqHcuCvQea6EWUkUA==}
|
||||
dev: true
|
||||
|
|
Loading…
Reference in a new issue