Merge remote-tracking branch 'misskey-original/develop' into develop

# Conflicts:
#	package.json
#	packages/backend/src/server/api/endpoints/notes/create.ts
#	packages/frontend/src/components/MkEmojiPicker.vue
#	packages/frontend/src/components/MkTimeline.vue
#	packages/frontend/src/pages/settings/theme.vue
This commit is contained in:
mattyatea 2024-02-09 10:14:24 +09:00
commit dfd491ff85
119 changed files with 1030 additions and 478 deletions

View File

@ -24,6 +24,8 @@
- Fix: リモートユーザーのリアクション一覧がすべて見えてしまうのを修正 - Fix: リモートユーザーのリアクション一覧がすべて見えてしまうのを修正
* すべてのリモートユーザーのリアクション一覧を見えないようにします * すべてのリモートユーザーのリアクション一覧を見えないようにします
- Enhance: モデレーターはすべてのユーザーのリアクション一覧を見られるように - Enhance: モデレーターはすべてのユーザーのリアクション一覧を見られるように
- Fix: 特定のキーワードを含むノートが投稿された際、エラーに出来るような設定項目を追加 #13207
* デフォルトは空欄なので適用前と同等の動作になります
### Client ### Client
- Feat: 新しいゲームを追加 - Feat: 新しいゲームを追加
@ -49,6 +51,12 @@
- Enhance: MFMの属性でオートコンプリートが使用できるように #12735 - Enhance: MFMの属性でオートコンプリートが使用できるように #12735
- Enhance: 絵文字編集ダイアログをモーダルではなくウィンドウで表示するように - Enhance: 絵文字編集ダイアログをモーダルではなくウィンドウで表示するように
- Enhance: リモートのユーザーはメニューから直接リモートで表示できるように - Enhance: リモートのユーザーはメニューから直接リモートで表示できるように
- Enhance: リモートへの引用リノートと同一のリンクにはリンクプレビューを表示しないように
- Enhance: コードのシンタックスハイライトにテーマを適用できるように
- Enhance: リアクション権限がない場合、ハートにフォールバックするのではなくリアクションピッカーなどから打てないように
- リモートのユーザーにローカルのみのカスタム絵文字をリアクションしようとした場合
- センシティブなリアクションを認めていないユーザーにセンシティブなカスタム絵文字をリアクションしようとした場合
- ロールが必要な絵文字をリアクションしようとした場合
- Fix: ネイティブモードの絵文字がモノクロにならないように - Fix: ネイティブモードの絵文字がモノクロにならないように
- Fix: v2023.12.0で追加された「モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能」が管理画面上で正しく表示されていない問題を修正 - Fix: v2023.12.0で追加された「モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能」が管理画面上で正しく表示されていない問題を修正
- Fix: AiScriptの`readline`関数が不正な値を返すことがある問題のv2023.12.0時点での修正がPlay以外に適用されていないのを修正 - Fix: AiScriptの`readline`関数が不正な値を返すことがある問題のv2023.12.0時点での修正がPlay以外に適用されていないのを修正
@ -62,6 +70,13 @@
- Enhance: ページ遷移時にPlayerを閉じるように - Enhance: ページ遷移時にPlayerを閉じるように
- Fix: iOSで大きな画像を変換してアップロードできない問題を修正 - Fix: iOSで大きな画像を変換してアップロードできない問題を修正
- Fix: 「アニメーション画像を再生しない」もしくは「データセーバー(アイコン)」を有効にしていても、アイコンデコレーションのアニメーションが停止されない問題を修正 - Fix: 「アニメーション画像を再生しない」もしくは「データセーバー(アイコン)」を有効にしていても、アイコンデコレーションのアニメーションが停止されない問題を修正
- Fix: 画像をクロップするとクロップ後の解像度が異様に低くなる問題の修正
- Fix: 画像をクロップ時、正常に完了できない問題の修正
- Fix: キャプションが空の画像をクロップするとキャプションにnullという文字列が入ってしまう問題の修正
- Fix: プロフィールを編集してもリロードするまで反映されない問題を修正
- Fix: エラー画像URLを設定した後解除するとデフォルトの画像が表示されない問題の修正
- Fix: MkCodeEditorで行がずれていってしまう問題の修正
- Fix: Summaly proxy利用時にプレイヤーが動作しないことがあるのを修正 #13196
### Server ### Server
- Enhance: 連合先のレートリミットに引っかかった際にリトライするようになりました - Enhance: 連合先のレートリミットに引っかかった際にリトライするようになりました
@ -76,6 +91,7 @@
- Fix: properly handle cc followers - Fix: properly handle cc followers
- Fix: ジョブに関する設定の名前を修正 relashionshipJobPerSec -> relationshipJobPerSec - Fix: ジョブに関する設定の名前を修正 relashionshipJobPerSec -> relationshipJobPerSec
- Fix: コントロールパネル->モデレーション->「誰でも新規登録できるようにする」の初期値をONからOFFに変更 #13122 - Fix: コントロールパネル->モデレーション->「誰でも新規登録できるようにする」の初期値をONからOFFに変更 #13122
- Enhance: 連合向けのノート配信を軽量化 #13192
### Service Worker ### Service Worker
- Enhance: オフライン表示のデザインを改善・多言語対応 - Enhance: オフライン表示のデザインを改善・多言語対応

View File

@ -286,18 +286,17 @@ export const argTypes = {
min: 1, min: 1,
max: 4, max: 4,
}, },
},
}; };
``` ```
Also, you can use msw to mock API requests in the storybook. Creating a `MyComponent.stories.msw.ts` file to define the mock handlers. Also, you can use msw to mock API requests in the storybook. Creating a `MyComponent.stories.msw.ts` file to define the mock handlers.
```ts ```ts
import { rest } from 'msw'; import { HttpResponse, http } from 'msw';
export const handlers = [ export const handlers = [
rest.post('/api/notes/timeline', (req, res, ctx) => { http.post('/api/notes/timeline', ({ request }) => {
return res( return HttpResponse.json([]);
ctx.json([]),
);
}), }),
]; ];
``` ```

12
locales/index.d.ts vendored
View File

@ -4376,6 +4376,18 @@ export interface Locale extends ILocale {
* AND指定になり * AND指定になり
*/ */
"sensitiveWordsDescription2": string; "sensitiveWordsDescription2": string;
/**
*
*/
"prohibitedWords": string;
/**
* 稿
*/
"prohibitedWordsDescription": string;
/**
* AND指定になり
*/
"prohibitedWordsDescription2": string;
/** /**
* *
*/ */

View File

@ -1090,6 +1090,9 @@ resetPasswordConfirm: "パスワードリセットしますか?"
sensitiveWords: "センシティブワード" sensitiveWords: "センシティブワード"
sensitiveWordsDescription: "設定したワードが含まれるノートの公開範囲をホームにします。改行で区切って複数設定できます。" sensitiveWordsDescription: "設定したワードが含まれるノートの公開範囲をホームにします。改行で区切って複数設定できます。"
sensitiveWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。" sensitiveWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。"
prohibitedWords: "禁止ワード"
prohibitedWordsDescription: "設定したワードが含まれるノートを投稿しようとした際、エラーとなるようにします。改行で区切って複数設定できます。"
prohibitedWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。"
hiddenTags: "非表示ハッシュタグ" hiddenTags: "非表示ハッシュタグ"
hiddenTagsDescription: "設定したタグをトレンドに表示させないようにします。改行で区切って複数設定できます。" hiddenTagsDescription: "設定したタグをトレンドに表示させないようにします。改行で区切って複数設定できます。"
notesSearchNotAvailable: "ノート検索は利用できません。" notesSearchNotAvailable: "ノート検索は利用できません。"

View File

@ -1,12 +1,12 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2024.2.0-beta.9-PrisMisskey.3", "version": "2024.2.0-beta.10-PrisMisskey.1",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/misskey-dev/misskey.git" "url": "https://github.com/misskey-dev/misskey.git"
}, },
"packageManager": "pnpm@8.12.1", "packageManager": "pnpm@8.15.1",
"workspaces": [ "workspaces": [
"packages/frontend", "packages/frontend",
"packages/backend", "packages/backend",

View File

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class prohibitedWords1707429690000 {
name = 'prohibitedWords1707429690000'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "prohibitedWords" character varying(1024) array NOT NULL DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "prohibitedWords"`);
}
}

View File

@ -460,7 +460,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
*/ */
@bindThis @bindThis
public checkDuplicate(name: string): Promise<boolean> { public checkDuplicate(name: string): Promise<boolean> {
return this.emojisRepository.exist({ where: { name, host: IsNull() } }); return this.emojisRepository.exists({ where: { name, host: IsNull() } });
} }
@bindThis @bindThis

View File

@ -163,7 +163,7 @@ export class HashtagService {
const instance = await this.metaService.fetch(); const instance = await this.metaService.fetch();
const hiddenTags = instance.hiddenTags.map(t => normalizeForSearch(t)); const hiddenTags = instance.hiddenTags.map(t => normalizeForSearch(t));
if (hiddenTags.includes(hashtag)) return; if (hiddenTags.includes(hashtag)) return;
if (this.utilityService.isSensitiveWordIncluded(hashtag, instance.sensitiveWords)) return; if (this.utilityService.isKeyWordIncluded(hashtag, instance.sensitiveWords)) return;
// YYYYMMDDHHmm (10分間隔) // YYYYMMDDHHmm (10分間隔)
const now = new Date(); const now = new Date();

View File

@ -419,6 +419,10 @@ export class MfmService {
}, },
text: (node) => { text: (node) => {
if (!node.props.text.match(/[\r\n]/)) {
return doc.createTextNode(node.props.text);
}
const el = doc.createElement('span'); const el = doc.createElement('span');
const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x)); const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x));

View File

@ -119,6 +119,8 @@ class NotificationManager {
export class NoteCreateService implements OnApplicationShutdown { export class NoteCreateService implements OnApplicationShutdown {
#shutdownController = new AbortController(); #shutdownController = new AbortController();
public static ContainsProhibitedWordsError = class extends Error {};
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
private config: Config, private config: Config,
@ -223,13 +225,19 @@ export class NoteCreateService implements OnApplicationShutdown {
if (data.visibility === 'public' && data.channel == null) { if (data.visibility === 'public' && data.channel == null) {
const sensitiveWords = meta.sensitiveWords; const sensitiveWords = meta.sensitiveWords;
if (this.utilityService.isSensitiveWordIncluded(data.cw ?? data.text ?? '', sensitiveWords)) { if (this.utilityService.isKeyWordIncluded(data.cw ?? data.text ?? '', sensitiveWords)) {
data.visibility = 'home'; data.visibility = 'home';
} else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) { } else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) {
data.visibility = 'home'; data.visibility = 'home';
} }
} }
if (!user.host) {
if (this.utilityService.isKeyWordIncluded(data.cw ?? data.text ?? '', meta.prohibitedWords)) {
throw new NoteCreateService.ContainsProhibitedWordsError();
}
}
const inSilencedInstance = this.utilityService.isSilencedHost(meta.silencedHosts, user.host); const inSilencedInstance = this.utilityService.isSilencedHost(meta.silencedHosts, user.host);
if (data.visibility === 'public' && inSilencedInstance && user.host !== null) { if (data.visibility === 'public' && inSilencedInstance && user.host !== null) {
@ -571,7 +579,7 @@ export class NoteCreateService implements OnApplicationShutdown {
if (data.reply) { if (data.reply) {
// 通知 // 通知
if (data.reply.userHost === null) { if (data.reply.userHost === null) {
const isThreadMuted = await this.noteThreadMutingsRepository.exist({ const isThreadMuted = await this.noteThreadMutingsRepository.exists({
where: { where: {
userId: data.reply.userId, userId: data.reply.userId,
threadId: data.reply.threadId ?? data.reply.id, threadId: data.reply.threadId ?? data.reply.id,
@ -709,7 +717,7 @@ export class NoteCreateService implements OnApplicationShutdown {
@bindThis @bindThis
private async createMentionedEvents(mentionedUsers: MinimumUser[], note: MiNote, nm: NotificationManager) { private async createMentionedEvents(mentionedUsers: MinimumUser[], note: MiNote, nm: NotificationManager) {
for (const u of mentionedUsers.filter(u => this.userEntityService.isLocalUser(u))) { for (const u of mentionedUsers.filter(u => this.userEntityService.isLocalUser(u))) {
const isThreadMuted = await this.noteThreadMutingsRepository.exist({ const isThreadMuted = await this.noteThreadMutingsRepository.exists({
where: { where: {
userId: u.id, userId: u.id,
threadId: note.threadId ?? note.id, threadId: note.threadId ?? note.id,

View File

@ -49,7 +49,7 @@ export class NoteReadService implements OnApplicationShutdown {
//#endregion //#endregion
// スレッドミュート // スレッドミュート
const isThreadMuted = await this.noteThreadMutingsRepository.exist({ const isThreadMuted = await this.noteThreadMutingsRepository.exists({
where: { where: {
userId: userId, userId: userId,
threadId: note.threadId ?? note.id, threadId: note.threadId ?? note.id,
@ -70,7 +70,7 @@ export class NoteReadService implements OnApplicationShutdown {
// 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する // 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する
setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => { setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => {
const exist = await this.noteUnreadsRepository.exist({ where: { id: unread.id } }); const exist = await this.noteUnreadsRepository.exists({ where: { id: unread.id } });
if (!exist) return; if (!exist) return;

View File

@ -74,12 +74,12 @@ export class SignupService {
const secret = generateUserToken(); const secret = generateUserToken();
// Check username duplication // Check username duplication
if (await this.usersRepository.exist({ where: { usernameLower: username.toLowerCase(), host: IsNull() } })) { if (await this.usersRepository.exists({ where: { usernameLower: username.toLowerCase(), host: IsNull() } })) {
throw new Error('DUPLICATED_USERNAME'); throw new Error('DUPLICATED_USERNAME');
} }
// Check deleted username duplication // Check deleted username duplication
if (await this.usedUsernamesRepository.exist({ where: { username: username.toLowerCase() } })) { if (await this.usedUsernamesRepository.exists({ where: { username: username.toLowerCase() } })) {
throw new Error('USED_USERNAME'); throw new Error('USED_USERNAME');
} }

View File

@ -144,7 +144,7 @@ export class UserFollowingService implements OnModuleInit {
let autoAccept = false; let autoAccept = false;
// 鍵アカウントであっても、既にフォローされていた場合はスルー // 鍵アカウントであっても、既にフォローされていた場合はスルー
const isFollowing = await this.followingsRepository.exist({ const isFollowing = await this.followingsRepository.exists({
where: { where: {
followerId: follower.id, followerId: follower.id,
followeeId: followee.id, followeeId: followee.id,
@ -156,7 +156,7 @@ export class UserFollowingService implements OnModuleInit {
// フォローしているユーザーは自動承認オプション // フォローしているユーザーは自動承認オプション
if (!autoAccept && (this.userEntityService.isLocalUser(followee) && followeeProfile.autoAcceptFollowed)) { if (!autoAccept && (this.userEntityService.isLocalUser(followee) && followeeProfile.autoAcceptFollowed)) {
const isFollowed = await this.followingsRepository.exist({ const isFollowed = await this.followingsRepository.exists({
where: { where: {
followerId: followee.id, followerId: followee.id,
followeeId: follower.id, followeeId: follower.id,
@ -170,7 +170,7 @@ export class UserFollowingService implements OnModuleInit {
if (followee.isLocked && !autoAccept) { if (followee.isLocked && !autoAccept) {
autoAccept = !!(await this.accountMoveService.validateAlsoKnownAs( autoAccept = !!(await this.accountMoveService.validateAlsoKnownAs(
follower, follower,
(oldSrc, newSrc) => this.followingsRepository.exist({ (oldSrc, newSrc) => this.followingsRepository.exists({
where: { where: {
followeeId: followee.id, followeeId: followee.id,
followerId: newSrc.id, followerId: newSrc.id,
@ -233,7 +233,7 @@ export class UserFollowingService implements OnModuleInit {
this.cacheService.userFollowingsCache.refresh(follower.id); this.cacheService.userFollowingsCache.refresh(follower.id);
const requestExist = await this.followRequestsRepository.exist({ const requestExist = await this.followRequestsRepository.exists({
where: { where: {
followeeId: followee.id, followeeId: followee.id,
followerId: follower.id, followerId: follower.id,
@ -531,7 +531,7 @@ export class UserFollowingService implements OnModuleInit {
} }
} }
const requestExist = await this.followRequestsRepository.exist({ const requestExist = await this.followRequestsRepository.exists({
where: { where: {
followeeId: followee.id, followeeId: followee.id,
followerId: follower.id, followerId: follower.id,

View File

@ -43,13 +43,13 @@ export class UtilityService {
} }
@bindThis @bindThis
public isSensitiveWordIncluded(text: string, sensitiveWords: string[]): boolean { public isKeyWordIncluded(text: string, keyWords: string[]): boolean {
if (sensitiveWords.length === 0) return false; if (keyWords.length === 0) return false;
if (text === '') return false; if (text === '') return false;
const regexpregexp = /^\/(.+)\/(.*)$/; const regexpregexp = /^\/(.+)\/(.*)$/;
const matched = sensitiveWords.some(filter => { const matched = keyWords.some(filter => {
// represents RegExp // represents RegExp
const regexp = filter.match(regexpregexp); const regexp = filter.match(regexpregexp);
// This should never happen due to input sanitisation. // This should never happen due to input sanitisation.

View File

@ -629,7 +629,7 @@ export class ApInboxService {
return 'skip: follower not found'; return 'skip: follower not found';
} }
const isFollowing = await this.followingsRepository.exist({ const isFollowing = await this.followingsRepository.exists({
where: { where: {
followerId: follower.id, followerId: follower.id,
followeeId: actor.id, followeeId: actor.id,
@ -686,14 +686,14 @@ export class ApInboxService {
return 'skip: フォロー解除しようとしているユーザーはローカルユーザーではありません'; return 'skip: フォロー解除しようとしているユーザーはローカルユーザーではありません';
} }
const requestExist = await this.followRequestsRepository.exist({ const requestExist = await this.followRequestsRepository.exists({
where: { where: {
followerId: actor.id, followerId: actor.id,
followeeId: followee.id, followeeId: followee.id,
}, },
}); });
const isFollowing = await this.followingsRepository.exist({ const isFollowing = await this.followingsRepository.exists({
where: { where: {
followerId: actor.id, followerId: actor.id,
followeeId: followee.id, followeeId: followee.id,

View File

@ -25,8 +25,21 @@ export class ApMfmService {
} }
@bindThis @bindThis
public getNoteHtml(note: MiNote): string | null { public getNoteHtml(note: MiNote, apAppend?: string) {
if (!note.text) return ''; let noMisskeyContent = false;
return this.mfmService.toHtml(mfm.parse(note.text), JSON.parse(note.mentionedRemoteUsers)); const srcMfm = (note.text ?? '') + (apAppend ?? '');
const parsed = mfm.parse(srcMfm);
if (!apAppend && parsed?.every(n => ['text', 'unicodeEmoji', 'emojiCode', 'mention', 'hashtag', 'url'].includes(n.type))) {
noMisskeyContent = true;
}
const content = this.mfmService.toHtml(parsed, JSON.parse(note.mentionedRemoteUsers));
return {
content,
noMisskeyContent,
};
} }
} }

View File

@ -325,7 +325,7 @@ export class ApRendererService {
inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId }); inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId });
if (inReplyToNote != null) { if (inReplyToNote != null) {
const inReplyToUserExist = await this.usersRepository.exist({ where: { id: inReplyToNote.userId } }); const inReplyToUserExist = await this.usersRepository.exists({ where: { id: inReplyToNote.userId } });
if (inReplyToUserExist) { if (inReplyToUserExist) {
if (inReplyToNote.uri) { if (inReplyToNote.uri) {
@ -389,17 +389,15 @@ export class ApRendererService {
poll = await this.pollsRepository.findOneBy({ noteId: note.id }); poll = await this.pollsRepository.findOneBy({ noteId: note.id });
} }
let apText = text; let apAppend = '';
if (quote) { if (quote) {
apText += `\n\nRE: ${quote}`; apAppend += `\n\nRE: ${quote}`;
} }
const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
const content = this.apMfmService.getNoteHtml(Object.assign({}, note, { const { content, noMisskeyContent } = this.apMfmService.getNoteHtml(note, apAppend);
text: apText,
}));
const emojis = await this.getEmojis(note.emojis); const emojis = await this.getEmojis(note.emojis);
const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji)); const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji));
@ -412,9 +410,6 @@ export class ApRendererService {
const asPoll = poll ? { const asPoll = poll ? {
type: 'Question', type: 'Question',
content: this.apMfmService.getNoteHtml(Object.assign({}, note, {
text: text,
})),
[poll.expiresAt && poll.expiresAt < new Date() ? 'closed' : 'endTime']: poll.expiresAt, [poll.expiresAt && poll.expiresAt < new Date() ? 'closed' : 'endTime']: poll.expiresAt,
[poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({ [poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({
type: 'Note', type: 'Note',
@ -432,11 +427,13 @@ export class ApRendererService {
attributedTo, attributedTo,
summary: summary ?? undefined, summary: summary ?? undefined,
content: content ?? undefined, content: content ?? undefined,
_misskey_content: text, ...(noMisskeyContent ? {} : {
source: { _misskey_content: text,
content: text, source: {
mediaType: 'text/x.misskeymarkdown', content: text,
}, mediaType: 'text/x.misskeymarkdown',
},
}),
_misskey_quote: quote, _misskey_quote: quote,
quoteUrl: quote, quoteUrl: quote,
published: this.idService.parse(note.id).date.toISOString(), published: this.idService.parse(note.id).date.toISOString(),
@ -625,6 +622,7 @@ export class ApRendererService {
'https://www.w3.org/ns/activitystreams', 'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1', 'https://w3id.org/security/v1',
{ {
Key: 'sec:Key',
// as non-standards // as non-standards
manuallyApprovesFollowers: 'as:manuallyApprovesFollowers', manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
sensitive: 'as:sensitive', sensitive: 'as:sensitive',

View File

@ -51,14 +51,14 @@ export class ChannelEntityService {
const banner = channel.bannerId ? await this.driveFilesRepository.findOneBy({ id: channel.bannerId }) : null; const banner = channel.bannerId ? await this.driveFilesRepository.findOneBy({ id: channel.bannerId }) : null;
const isFollowing = meId ? await this.channelFollowingsRepository.exist({ const isFollowing = meId ? await this.channelFollowingsRepository.exists({
where: { where: {
followerId: meId, followerId: meId,
followeeId: channel.id, followeeId: channel.id,
}, },
}) : false; }) : false;
const isFavorited = meId ? await this.channelFavoritesRepository.exist({ const isFavorited = meId ? await this.channelFavoritesRepository.exists({
where: { where: {
userId: meId, userId: meId,
channelId: channel.id, channelId: channel.id,

View File

@ -46,7 +46,7 @@ export class ClipEntityService {
description: clip.description, description: clip.description,
isPublic: clip.isPublic, isPublic: clip.isPublic,
favoritedCount: await this.clipFavoritesRepository.countBy({ clipId: clip.id }), favoritedCount: await this.clipFavoritesRepository.countBy({ clipId: clip.id }),
isFavorited: meId ? await this.clipFavoritesRepository.exist({ where: { clipId: clip.id, userId: meId } }) : undefined, isFavorited: meId ? await this.clipFavoritesRepository.exists({ where: { clipId: clip.id, userId: meId } }) : undefined,
}); });
} }

View File

@ -31,6 +31,7 @@ export class EmojiEntityService {
category: emoji.category, category: emoji.category,
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ) // || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
url: emoji.publicUrl || emoji.originalUrl, url: emoji.publicUrl || emoji.originalUrl,
localOnly: emoji.localOnly ? true : undefined,
isSensitive: emoji.isSensitive ? true : undefined, isSensitive: emoji.isSensitive ? true : undefined,
roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0 ? emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : undefined, roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0 ? emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : undefined,
draft: emoji.draft, draft: emoji.draft,

View File

@ -47,7 +47,7 @@ export class FlashEntityService {
summary: flash.summary, summary: flash.summary,
script: flash.script, script: flash.script,
likedCount: flash.likedCount, likedCount: flash.likedCount,
isLiked: meId ? await this.flashLikesRepository.exist({ where: { flashId: flash.id, userId: meId } }) : undefined, isLiked: meId ? await this.flashLikesRepository.exists({ where: { flashId: flash.id, userId: meId } }) : undefined,
}); });
} }

View File

@ -53,7 +53,7 @@ export class GalleryPostEntityService {
tags: post.tags.length > 0 ? post.tags : undefined, tags: post.tags.length > 0 ? post.tags : undefined,
isSensitive: post.isSensitive, isSensitive: post.isSensitive,
likedCount: post.likedCount, likedCount: post.likedCount,
isLiked: meId ? await this.galleryLikesRepository.exist({ where: { postId: post.id, userId: meId } }) : undefined, isLiked: meId ? await this.galleryLikesRepository.exists({ where: { postId: post.id, userId: meId } }) : undefined,
}); });
} }

View File

@ -108,7 +108,7 @@ export class NoteEntityService implements OnModuleInit {
hide = false; hide = false;
} else { } else {
// フォロワーかどうか // フォロワーかどうか
const isFollowing = await this.followingsRepository.exist({ const isFollowing = await this.followingsRepository.exists({
where: { where: {
followeeId: packedNote.userId, followeeId: packedNote.userId,
followerId: meId, followerId: meId,

View File

@ -104,7 +104,7 @@ export class PageEntityService {
eyeCatchingImage: page.eyeCatchingImageId ? await this.driveFileEntityService.pack(page.eyeCatchingImageId) : null, eyeCatchingImage: page.eyeCatchingImageId ? await this.driveFileEntityService.pack(page.eyeCatchingImageId) : null,
attachedFiles: this.driveFileEntityService.packMany((await Promise.all(attachedFiles)).filter((x): x is MiDriveFile => x != null)), attachedFiles: this.driveFileEntityService.packMany((await Promise.all(attachedFiles)).filter((x): x is MiDriveFile => x != null)),
likedCount: page.likedCount, likedCount: page.likedCount,
isLiked: meId ? await this.pageLikesRepository.exist({ where: { pageId: page.id, userId: meId } }) : undefined, isLiked: meId ? await this.pageLikesRepository.exists({ where: { pageId: page.id, userId: meId } }) : undefined,
}); });
} }

View File

@ -152,43 +152,43 @@ export class UserEntityService implements OnModuleInit {
followerId: me, followerId: me,
followeeId: target, followeeId: target,
}), }),
this.followingsRepository.exist({ this.followingsRepository.exists({
where: { where: {
followerId: target, followerId: target,
followeeId: me, followeeId: me,
}, },
}), }),
this.followRequestsRepository.exist({ this.followRequestsRepository.exists({
where: { where: {
followerId: me, followerId: me,
followeeId: target, followeeId: target,
}, },
}), }),
this.followRequestsRepository.exist({ this.followRequestsRepository.exists({
where: { where: {
followerId: target, followerId: target,
followeeId: me, followeeId: me,
}, },
}), }),
this.blockingsRepository.exist({ this.blockingsRepository.exists({
where: { where: {
blockerId: me, blockerId: me,
blockeeId: target, blockeeId: target,
}, },
}), }),
this.blockingsRepository.exist({ this.blockingsRepository.exists({
where: { where: {
blockerId: target, blockerId: target,
blockeeId: me, blockeeId: me,
}, },
}), }),
this.mutingsRepository.exist({ this.mutingsRepository.exists({
where: { where: {
muterId: me, muterId: me,
muteeId: target, muteeId: target,
}, },
}), }),
this.renoteMutingsRepository.exist({ this.renoteMutingsRepository.exists({
where: { where: {
muterId: me, muterId: me,
muteeId: target, muteeId: target,
@ -215,7 +215,7 @@ export class UserEntityService implements OnModuleInit {
/* /*
const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId); const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId);
const isUnread = (myAntennas.length > 0 ? await this.antennaNotesRepository.exist({ const isUnread = (myAntennas.length > 0 ? await this.antennaNotesRepository.exists({
where: { where: {
antennaId: In(myAntennas.map(x => x.id)), antennaId: In(myAntennas.map(x => x.id)),
read: false, read: false,

View File

@ -76,6 +76,11 @@ export class MiMeta {
}) })
public sensitiveWords: string[]; public sensitiveWords: string[];
@Column('varchar', {
length: 1024, array: true, default: '{}',
})
public prohibitedWords: string[];
@Column('varchar', { @Column('varchar', {
length: 1024, array: true, default: '{}', length: 1024, array: true, default: '{}',
}) })

View File

@ -27,6 +27,10 @@ export const packedEmojiSimpleSchema = {
type: 'string', type: 'string',
optional: false, nullable: false, optional: false, nullable: false,
}, },
localOnly: {
type: 'boolean',
optional: true, nullable: false,
},
isSensitive: { isSensitive: {
type: 'boolean', type: 'boolean',
optional: true, nullable: false, optional: true, nullable: false,

View File

@ -163,12 +163,12 @@ export class SignupApiService {
} }
if (instance.emailRequiredForSignup) { if (instance.emailRequiredForSignup) {
if (await this.usersRepository.exist({ where: { usernameLower: username.toLowerCase(), host: IsNull() } })) { if (await this.usersRepository.exists({ where: { usernameLower: username.toLowerCase(), host: IsNull() } })) {
throw new FastifyReplyError(400, 'DUPLICATED_USERNAME'); throw new FastifyReplyError(400, 'DUPLICATED_USERNAME');
} }
// Check deleted username duplication // Check deleted username duplication
if (await this.usedUsernamesRepository.exist({ where: { username: username.toLowerCase() } })) { if (await this.usedUsernamesRepository.exists({ where: { username: username.toLowerCase() } })) {
throw new FastifyReplyError(400, 'USED_USERNAME'); throw new FastifyReplyError(400, 'USED_USERNAME');
} }

View File

@ -156,6 +156,13 @@ export const meta = {
type: 'string', type: 'string',
}, },
}, },
prohibitedWords: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
},
},
bannedEmailDomains: { bannedEmailDomains: {
type: 'array', type: 'array',
optional: true, nullable: false, optional: true, nullable: false,
@ -524,6 +531,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
blockedHosts: instance.blockedHosts, blockedHosts: instance.blockedHosts,
silencedHosts: instance.silencedHosts, silencedHosts: instance.silencedHosts,
sensitiveWords: instance.sensitiveWords, sensitiveWords: instance.sensitiveWords,
prohibitedWords: instance.prohibitedWords,
preservedUsernames: instance.preservedUsernames, preservedUsernames: instance.preservedUsernames,
hcaptchaSecretKey: instance.hcaptchaSecretKey, hcaptchaSecretKey: instance.hcaptchaSecretKey,
mcaptchaSecretKey: instance.mcaptchaSecretKey, mcaptchaSecretKey: instance.mcaptchaSecretKey,

View File

@ -55,7 +55,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw e; throw e;
}); });
const exist = await this.promoNotesRepository.exist({ where: { noteId: note.id } }); const exist = await this.promoNotesRepository.exists({ where: { noteId: note.id } });
if (exist) { if (exist) {
throw new ApiError(meta.errors.alreadyPromoted); throw new ApiError(meta.errors.alreadyPromoted);

View File

@ -41,6 +41,11 @@ export const paramDef = {
type: 'string', type: 'string',
}, },
}, },
prohibitedWords: {
type: 'array', nullable: true, items: {
type: 'string',
},
},
themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' }, themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' },
mascotImageUrl: { type: 'string', nullable: true }, mascotImageUrl: { type: 'string', nullable: true },
bannerUrl: { type: 'string', nullable: true }, bannerUrl: { type: 'string', nullable: true },
@ -182,6 +187,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (Array.isArray(ps.sensitiveWords)) { if (Array.isArray(ps.sensitiveWords)) {
set.sensitiveWords = ps.sensitiveWords.filter(Boolean); set.sensitiveWords = ps.sensitiveWords.filter(Boolean);
} }
if (Array.isArray(ps.prohibitedWords)) {
set.prohibitedWords = ps.prohibitedWords.filter(Boolean);
}
if (Array.isArray(ps.silencedHosts)) { if (Array.isArray(ps.silencedHosts)) {
let lastValue = ''; let lastValue = '';
set.silencedHosts = ps.silencedHosts.sort().filter((h) => { set.silencedHosts = ps.silencedHosts.sort().filter((h) => {

View File

@ -62,7 +62,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const accessToken = secureRndstr(32); const accessToken = secureRndstr(32);
// Fetch exist access token // Fetch exist access token
const exist = await this.accessTokensRepository.exist({ const exist = await this.accessTokensRepository.exists({
where: { where: {
appId: session.appId, appId: session.appId,
userId: me.id, userId: me.id,

View File

@ -88,7 +88,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}); });
// Check if already blocking // Check if already blocking
const exist = await this.blockingsRepository.exist({ const exist = await this.blockingsRepository.exists({
where: { where: {
blockerId: blocker.id, blockerId: blocker.id,
blockeeId: blockee.id, blockeeId: blockee.id,

View File

@ -88,7 +88,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}); });
// Check not blocking // Check not blocking
const exist = await this.blockingsRepository.exist({ const exist = await this.blockingsRepository.exists({
where: { where: {
blockerId: blocker.id, blockerId: blocker.id,
blockeeId: blockee.id, blockeeId: blockee.id,

View File

@ -62,7 +62,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchClip); throw new ApiError(meta.errors.noSuchClip);
} }
const exist = await this.clipFavoritesRepository.exist({ const exist = await this.clipFavoritesRepository.exists({
where: { where: {
clipId: clip.id, clipId: clip.id,
userId: me.id, userId: me.id,

View File

@ -38,7 +38,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private driveFilesRepository: DriveFilesRepository, private driveFilesRepository: DriveFilesRepository,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const exist = await this.driveFilesRepository.exist({ const exist = await this.driveFilesRepository.exists({
where: { where: {
md5: ps.md5, md5: ps.md5,
userId: me.id, userId: me.id,

View File

@ -70,7 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
// if already liked // if already liked
const exist = await this.flashLikesRepository.exist({ const exist = await this.flashLikesRepository.exists({
where: { where: {
flashId: flash.id, flashId: flash.id,
userId: me.id, userId: me.id,

View File

@ -101,7 +101,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}); });
// Check if already following // Check if already following
const exist = await this.followingsRepository.exist({ const exist = await this.followingsRepository.exists({
where: { where: {
followerId: follower.id, followerId: follower.id,
followeeId: followee.id, followeeId: followee.id,

View File

@ -85,7 +85,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}); });
// Check not following // Check not following
const exist = await this.followingsRepository.exist({ const exist = await this.followingsRepository.exists({
where: { where: {
followerId: follower.id, followerId: follower.id,
followeeId: followee.id, followeeId: followee.id,

View File

@ -72,7 +72,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
// if already liked // if already liked
const exist = await this.galleryLikesRepository.exist({ const exist = await this.galleryLikesRepository.exists({
where: { where: {
postId: post.id, postId: post.id,
userId: me.id, userId: me.id,

View File

@ -71,7 +71,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private downloadService: DownloadService, private downloadService: DownloadService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const userExist = await this.usersRepository.exist({ where: { id: me.id } }); const userExist = await this.usersRepository.exists({ where: { id: me.id } });
if (!userExist) throw new ApiError(meta.errors.noSuchUser); if (!userExist) throw new ApiError(meta.errors.noSuchUser);
const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
if (file === null) throw new ApiError(meta.errors.noSuchFile); if (file === null) throw new ApiError(meta.errors.noSuchFile);

View File

@ -34,7 +34,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
if (ps.tokenId) { if (ps.tokenId) {
const tokenExist = await this.accessTokensRepository.exist({ where: { id: ps.tokenId } }); const tokenExist = await this.accessTokensRepository.exists({ where: { id: ps.tokenId } });
if (tokenExist) { if (tokenExist) {
await this.accessTokensRepository.delete({ await this.accessTokensRepository.delete({
@ -43,7 +43,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}); });
} }
} else if (ps.token) { } else if (ps.token) {
const tokenExist = await this.accessTokensRepository.exist({ where: { token: ps.token } }); const tokenExist = await this.accessTokensRepository.exists({ where: { token: ps.token } });
if (tokenExist) { if (tokenExist) {
await this.accessTokensRepository.delete({ await this.accessTokensRepository.delete({

View File

@ -83,7 +83,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}); });
// Check if already muting // Check if already muting
const exist = await this.mutingsRepository.exist({ const exist = await this.mutingsRepository.exists({
where: { where: {
muterId: muter.id, muterId: muter.id,
muteeId: mutee.id, muteeId: mutee.id,

View File

@ -21,6 +21,8 @@ import { IdService } from '@/core/IdService.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { isPureRenote } from '@/misc/is-pure-renote.js'; import { isPureRenote } from '@/misc/is-pure-renote.js';
import { MetaService } from '@/core/MetaService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -145,6 +147,11 @@ export const meta = {
kind: 'permission', kind: 'permission',
id: '7f86f06f-7e15-4057-8561-f4b6d4ac755a', id: '7f86f06f-7e15-4057-8561-f4b6d4ac755a',
}, },
containsProhibitedWords: {
message: 'Cannot post because it contains prohibited words.',
code: 'CONTAINS_PROHIBITED_WORDS',
id: 'aa6e01d3-a85c-669d-758a-76aab43af334',
},
}, },
} as const; } as const;
@ -311,7 +318,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// Check blocking // Check blocking
if (renote.userId !== me.id) { if (renote.userId !== me.id) {
const blockExist = await this.blockingsRepository.exist({ const blockExist = await this.blockingsRepository.exists({
where: { where: {
blockerId: renote.userId, blockerId: renote.userId,
blockeeId: me.id, blockeeId: me.id,
@ -359,7 +366,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// Check blocking // Check blocking
if (reply.userId !== me.id) { if (reply.userId !== me.id) {
const blockExist = await this.blockingsRepository.exist({ const blockExist = await this.blockingsRepository.exists({
where: { where: {
blockerId: reply.userId, blockerId: reply.userId,
blockeeId: me.id, blockeeId: me.id,
@ -390,70 +397,80 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
} }
const note: MiNoteCreateOption = { // 投稿を作成
createdAt: new Date(), try {
files: files, const note : MiNoteCreateOption = {
poll: ps.poll ? { createdAt: new Date(),
choices: ps.poll.choices, files: files,
multiple: ps.poll.multiple ?? false, poll: ps.poll ? {
expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, choices: ps.poll.choices,
} : undefined, multiple: ps.poll.multiple ?? false,
text: ps.text ?? undefined, expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
reply, } : undefined,
renote, text: ps.text ?? undefined,
cw: ps.cw, reply,
localOnly: ps.localOnly, renote,
reactionAcceptance: ps.reactionAcceptance, cw: ps.cw,
visibility, localOnly: ps.localOnly,
visibleUsers, reactionAcceptance: ps.reactionAcceptance,
channel, visibility: ps.visibility,
apMentions: ps.noExtractMentions ? [] : undefined, visibleUsers,
apHashtags: ps.noExtractHashtags ? [] : undefined, channel,
apEmojis: ps.noExtractEmojis ? [] : undefined, apMentions: ps.noExtractMentions ? [] : undefined,
}; apHashtags: ps.noExtractHashtags ? [] : undefined,
apEmojis: ps.noExtractEmojis ? [] : undefined,
};
if (ps.schedule) { if (ps.schedule) {
// 予約投稿 // 予約投稿
const canCreateScheduledNote = (await this.roleService.getUserPolicies(me.id)).canScheduleNote; const canCreateScheduledNote = (await this.roleService.getUserPolicies(me.id)).canScheduleNote;
if (!canCreateScheduledNote) { if (!canCreateScheduledNote) {
throw new ApiError(meta.errors.rolePermissionDenied); throw new ApiError(meta.errors.rolePermissionDenied);
}
if (!ps.schedule.scheduledAt) {
throw new ApiError(meta.errors.specifyScheduleDate);
}
me.token = null;
const scheduledNoteId = this.idService.gen(new Date().getTime());
await this.scheduledNotesRepository.insert({
id: scheduledNoteId,
note: note,
userId: me.id,
scheduledAt: new Date(ps.schedule.scheduledAt),
});
const delay = new Date(ps.schedule.scheduledAt).getTime() - Date.now();
await this.queueService.ScheduleNotePostQueue.add(delay.toString(), {
scheduledNoteId,
}, {
jobId: scheduledNoteId,
delay,
removeOnComplete: true,
});
return {
scheduledNoteId,
scheduledNote: note,
// ↓互換性のため(微妙)
createdNote: null,
};
} else {
// 投稿を作成
const createdNoteRaw = await this.noteCreateService.create(me, note);
return {
createdNote: await this.noteEntityService.pack(createdNoteRaw, me),
};
}
} catch (e) {
// TODO: 他のErrorもここでキャッチしてエラーメッセージを当てるようにしたい
if (e instanceof NoteCreateService.ContainsProhibitedWordsError) {
throw new ApiError(meta.errors.containsProhibitedWords);
} }
if (!ps.schedule.scheduledAt) { throw e;
throw new ApiError(meta.errors.specifyScheduleDate);
}
me.token = null;
const scheduledNoteId = this.idService.gen(new Date().getTime());
await this.scheduledNotesRepository.insert({
id: scheduledNoteId,
note: note,
userId: me.id,
scheduledAt: new Date(ps.schedule.scheduledAt),
});
const delay = new Date(ps.schedule.scheduledAt).getTime() - Date.now();
await this.queueService.ScheduleNotePostQueue.add(delay.toString(), {
scheduledNoteId,
}, {
jobId: scheduledNoteId,
delay,
removeOnComplete: true,
});
return {
scheduledNoteId,
scheduledNote: note,
// ↓互換性のため(微妙)
createdNote: null,
};
} else {
// 投稿を作成
const createdNoteRaw = await this.noteCreateService.create(me, note);
return {
createdNote: await this.noteEntityService.pack(createdNoteRaw, me),
};
} }
}); });
} }

View File

@ -67,7 +67,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}); });
// if already favorited // if already favorited
const exist = await this.noteFavoritesRepository.exist({ const exist = await this.noteFavoritesRepository.exists({
where: { where: {
noteId: note.id, noteId: note.id,
userId: me.id, userId: me.id,

View File

@ -70,7 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
// if already liked // if already liked
const exist = await this.pageLikesRepository.exist({ const exist = await this.pageLikesRepository.exists({
where: { where: {
pageId: page.id, pageId: page.id,
userId: me.id, userId: me.id,

View File

@ -49,7 +49,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw err; throw err;
}); });
const exist = await this.promoReadsRepository.exist({ const exist = await this.promoReadsRepository.exists({
where: { where: {
noteId: note.id, noteId: note.id,
userId: me.id, userId: me.id,

View File

@ -101,7 +101,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (me == null) { if (me == null) {
throw new ApiError(meta.errors.forbidden); throw new ApiError(meta.errors.forbidden);
} else if (me.id !== user.id) { } else if (me.id !== user.id) {
const isFollowing = await this.followingsRepository.exist({ const isFollowing = await this.followingsRepository.exists({
where: { where: {
followeeId: user.id, followeeId: user.id,
followerId: me.id, followerId: me.id,

View File

@ -109,7 +109,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (me == null) { if (me == null) {
throw new ApiError(meta.errors.forbidden); throw new ApiError(meta.errors.forbidden);
} else if (me.id !== user.id) { } else if (me.id !== user.id) {
const isFollowing = await this.followingsRepository.exist({ const isFollowing = await this.followingsRepository.exists({
where: { where: {
followeeId: user.id, followeeId: user.id,
followerId: me.id, followerId: me.id,

View File

@ -90,7 +90,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private roleService: RoleService, private roleService: RoleService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const listExist = await this.userListsRepository.exist({ const listExist = await this.userListsRepository.exists({
where: { where: {
id: ps.listId, id: ps.listId,
isPublic: true, isPublic: true,
@ -121,7 +121,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}); });
if (currentUser.id !== me.id) { if (currentUser.id !== me.id) {
const blockExist = await this.blockingsRepository.exist({ const blockExist = await this.blockingsRepository.exists({
where: { where: {
blockerId: currentUser.id, blockerId: currentUser.id,
blockeeId: me.id, blockeeId: me.id,
@ -132,7 +132,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
} }
const exist = await this.userListMembershipsRepository.exist({ const exist = await this.userListMembershipsRepository.exists({
where: { where: {
userListId: userList.id, userListId: userList.id,
userId: currentUser.id, userId: currentUser.id,

View File

@ -47,7 +47,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private idService: IdService, private idService: IdService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const userListExist = await this.userListsRepository.exist({ const userListExist = await this.userListsRepository.exists({
where: { where: {
id: ps.listId, id: ps.listId,
isPublic: true, isPublic: true,
@ -58,7 +58,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.noSuchList); throw new ApiError(meta.errors.noSuchList);
} }
const exist = await this.userListFavoritesRepository.exist({ const exist = await this.userListFavoritesRepository.exists({
where: { where: {
userId: me.id, userId: me.id,
userListId: ps.listId, userListId: ps.listId,

View File

@ -104,7 +104,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// Check blocking // Check blocking
if (user.id !== me.id) { if (user.id !== me.id) {
const blockExist = await this.blockingsRepository.exist({ const blockExist = await this.blockingsRepository.exists({
where: { where: {
blockerId: user.id, blockerId: user.id,
blockeeId: me.id, blockeeId: me.id,
@ -115,7 +115,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
} }
const exist = await this.userListMembershipsRepository.exist({ const exist = await this.userListMembershipsRepository.exists({
where: { where: {
userListId: userList.id, userListId: userList.id,
userId: user.id, userId: user.id,

View File

@ -74,7 +74,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
userListId: ps.listId, userListId: ps.listId,
}); });
if (me !== null) { if (me !== null) {
additionalProperties.isLiked = await this.userListFavoritesRepository.exist({ additionalProperties.isLiked = await this.userListFavoritesRepository.exists({
where: { where: {
userId: me.id, userId: me.id,
userListId: ps.listId, userListId: ps.listId,

View File

@ -45,7 +45,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private userListFavoritesRepository: UserListFavoritesRepository, private userListFavoritesRepository: UserListFavoritesRepository,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const userListExist = await this.userListsRepository.exist({ const userListExist = await this.userListsRepository.exists({
where: { where: {
id: ps.listId, id: ps.listId,
isPublic: true, isPublic: true,

View File

@ -43,7 +43,7 @@ class UserListChannel extends Channel {
this.withRenotes = params.withRenotes ?? true; this.withRenotes = params.withRenotes ?? true;
// Check existence and owner // Check existence and owner
const listExist = await this.userListsRepository.exist({ const listExist = await this.userListsRepository.exists({
where: { where: {
id: this.listId, id: this.listId,
userId: this.user!.id, userId: this.user!.id,

View File

@ -16,12 +16,14 @@ describe('Note', () => {
let alice: misskey.entities.SignupResponse; let alice: misskey.entities.SignupResponse;
let bob: misskey.entities.SignupResponse; let bob: misskey.entities.SignupResponse;
let tom: misskey.entities.SignupResponse;
beforeAll(async () => { beforeAll(async () => {
const connection = await initTestDb(true); const connection = await initTestDb(true);
Notes = connection.getRepository(MiNote); Notes = connection.getRepository(MiNote);
alice = await signup({ username: 'alice' }); alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' }); bob = await signup({ username: 'bob' });
tom = await signup({ username: 'tom', host: 'example.com' });
}, 1000 * 60 * 2); }, 1000 * 60 * 2);
test('投稿できる', async () => { test('投稿できる', async () => {
@ -607,6 +609,77 @@ describe('Note', () => {
assert.strictEqual(note2.status, 200); assert.strictEqual(note2.status, 200);
assert.strictEqual(note2.body.createdNote.visibility, 'home'); assert.strictEqual(note2.body.createdNote.visibility, 'home');
}); });
test('禁止ワードを含む投稿はエラーになる (単語指定)', async () => {
const prohibited = await api('admin/update-meta', {
prohibitedWords: [
'test',
],
}, alice);
assert.strictEqual(prohibited.status, 204);
await new Promise(x => setTimeout(x, 2));
const note1 = await api('/notes/create', {
text: 'hogetesthuge',
}, alice);
assert.strictEqual(note1.status, 400);
assert.strictEqual(note1.body.error.code, 'CONTAINS_PROHIBITED_WORDS');
});
test('禁止ワードを含む投稿はエラーになる (正規表現)', async () => {
const prohibited = await api('admin/update-meta', {
prohibitedWords: [
'/Test/i',
],
}, alice);
assert.strictEqual(prohibited.status, 204);
const note2 = await api('/notes/create', {
text: 'hogetesthuge',
}, alice);
assert.strictEqual(note2.status, 400);
assert.strictEqual(note2.body.error.code, 'CONTAINS_PROHIBITED_WORDS');
});
test('禁止ワードを含む投稿はエラーになる (スペースアンド)', async () => {
const prohibited = await api('admin/update-meta', {
prohibitedWords: [
'Test hoge',
],
}, alice);
assert.strictEqual(prohibited.status, 204);
const note2 = await api('/notes/create', {
text: 'hogeTesthuge',
}, alice);
assert.strictEqual(note2.status, 400);
assert.strictEqual(note2.body.error.code, 'CONTAINS_PROHIBITED_WORDS');
});
test('禁止ワードを含んでいてもリモートノートはエラーにならない', async () => {
const prohibited = await api('admin/update-meta', {
prohibitedWords: [
'test',
],
}, alice);
assert.strictEqual(prohibited.status, 204);
await new Promise(x => setTimeout(x, 2));
const note1 = await api('/notes/create', {
text: 'hogetesthuge',
}, tom);
assert.strictEqual(note1.status, 200);
});
}); });
describe('notes/delete', () => { describe('notes/delete', () => {

View File

@ -0,0 +1,44 @@
import * as assert from 'assert';
import { Test } from '@nestjs/testing';
import { CoreModule } from '@/core/CoreModule.js';
import { ApMfmService } from '@/core/activitypub/ApMfmService.js';
import { GlobalModule } from '@/GlobalModule.js';
import { MiNote } from '@/models/Note.js';
describe('ApMfmService', () => {
let apMfmService: ApMfmService;
beforeAll(async () => {
const app = await Test.createTestingModule({
imports: [GlobalModule, CoreModule],
}).compile();
apMfmService = app.get<ApMfmService>(ApMfmService);
});
describe('getNoteHtml', () => {
test('Do not provide _misskey_content for simple text', () => {
const note: MiNote = {
text: 'テキスト #タグ @mention 🍊 :emoji: https://example.com',
mentionedRemoteUsers: '[]',
} as any;
const { content, noMisskeyContent } = apMfmService.getNoteHtml(note);
assert.equal(noMisskeyContent, true, 'noMisskeyContent');
assert.equal(content, '<p>テキスト <a href="http://misskey.local/tags/タグ" rel="tag">#タグ</a> <a href="http://misskey.local/@mention" class="u-url mention">@mention</a> 🍊 :emoji: <a href="https://example.com">https://example.com</a></p>', 'content');
});
test('Provide _misskey_content for MFM', () => {
const note: MiNote = {
text: '$[tada foo]',
mentionedRemoteUsers: '[]',
} as any;
const { content, noMisskeyContent } = apMfmService.getNoteHtml(note);
assert.equal(noMisskeyContent, false, 'noMisskeyContent');
assert.equal(content, '<p><i>foo</i></p>', 'content');
});
});
});

View File

@ -33,6 +33,12 @@ describe('MfmService', () => {
const output = '<p><span>foo<br>bar<br>baz</span></p>'; const output = '<p><span>foo<br>bar<br>baz</span></p>';
assert.equal(mfmService.toHtml(mfm.parse(input)), output); assert.equal(mfmService.toHtml(mfm.parse(input)), output);
}); });
test('Do not generate unnecessary span', () => {
const input = 'foo $[tada bar]';
const output = '<p>foo <i>bar</i></p>';
assert.equal(mfmService.toHtml(mfm.parse(input)), output);
});
}); });
describe('fromHtml', () => { describe('fromHtml', () => {

View File

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { type SharedOptions, rest } from 'msw'; import { type SharedOptions, http, HttpResponse } from 'msw';
export const onUnhandledRequest = ((req, print) => { export const onUnhandledRequest = ((req, print) => {
if (req.url.hostname !== 'localhost' || /^\/(?:client-assets\/|fluent-emojis?\/|iframe.html$|node_modules\/|src\/|sb-|static-assets\/|vite\/)/.test(req.url.pathname)) { if (req.url.hostname !== 'localhost' || /^\/(?:client-assets\/|fluent-emojis?\/|iframe.html$|node_modules\/|src\/|sb-|static-assets\/|vite\/)/.test(req.url.pathname)) {
@ -13,19 +13,31 @@ export const onUnhandledRequest = ((req, print) => {
}) satisfies SharedOptions['onUnhandledRequest']; }) satisfies SharedOptions['onUnhandledRequest'];
export const commonHandlers = [ export const commonHandlers = [
rest.get('/fluent-emoji/:codepoints.png', async (req, res, ctx) => { http.get('/fluent-emoji/:codepoints.png', async ({ params }) => {
const { codepoints } = req.params; const { codepoints } = params;
const value = await fetch(`https://raw.githubusercontent.com/misskey-dev/emojis/main/dist/${codepoints}.png`).then((response) => response.blob()); const value = await fetch(`https://raw.githubusercontent.com/misskey-dev/emojis/main/dist/${codepoints}.png`).then((response) => response.blob());
return res(ctx.set('Content-Type', 'image/png'), ctx.body(value)); return new HttpResponse(value, {
headers: {
'Content-Type': 'image/png',
},
});
}), }),
rest.get('/fluent-emojis/:codepoints.png', async (req, res, ctx) => { http.get('/fluent-emojis/:codepoints.png', async ({ params }) => {
const { codepoints } = req.params; const { codepoints } = params;
const value = await fetch(`https://raw.githubusercontent.com/misskey-dev/emojis/main/dist/${codepoints}.png`).then((response) => response.blob()); const value = await fetch(`https://raw.githubusercontent.com/misskey-dev/emojis/main/dist/${codepoints}.png`).then((response) => response.blob());
return res(ctx.set('Content-Type', 'image/png'), ctx.body(value)); return new HttpResponse(value, {
headers: {
'Content-Type': 'image/png',
},
});
}), }),
rest.get('/twemoji/:codepoints.svg', async (req, res, ctx) => { http.get('/twemoji/:codepoints.svg', async ({ params }) => {
const { codepoints } = req.params; const { codepoints } = params;
const value = await fetch(`https://unpkg.com/@discordapp/twemoji@15.0.2/dist/svg/${codepoints}.svg`).then((response) => response.blob()); const value = await fetch(`https://unpkg.com/@discordapp/twemoji@15.0.2/dist/svg/${codepoints}.svg`).then((response) => response.blob());
return res(ctx.set('Content-Type', 'image/svg+xml'), ctx.body(value)); return new HttpResponse(value, {
headers: {
'Content-Type': 'image/svg+xml',
},
});
}), }),
]; ];

View File

@ -29,7 +29,7 @@
"@twemoji/parser": "15.0.0", "@twemoji/parser": "15.0.0",
"@vitejs/plugin-vue": "5.0.3", "@vitejs/plugin-vue": "5.0.3",
"@vue/compiler-sfc": "3.4.15", "@vue/compiler-sfc": "3.4.15",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.0.6", "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.2",
"astring": "1.8.6", "astring": "1.8.6",
"broadcast-channel": "7.0.0", "broadcast-channel": "7.0.0",
"buraha": "0.0.1", "buraha": "0.0.1",
@ -71,7 +71,7 @@
"typescript": "5.3.3", "typescript": "5.3.3",
"uuid": "9.0.1", "uuid": "9.0.1",
"v-code-diff": "1.7.2", "v-code-diff": "1.7.2",
"vite": "5.0.12", "vite": "5.1.0",
"vue": "3.4.15", "vue": "3.4.15",
"vuedraggable": "next" "vuedraggable": "next"
}, },
@ -122,8 +122,8 @@
"happy-dom": "10.0.3", "happy-dom": "10.0.3",
"intersection-observer": "0.12.2", "intersection-observer": "0.12.2",
"micromatch": "4.0.5", "micromatch": "4.0.5",
"msw": "2.1.2", "msw": "2.1.7",
"msw-storybook-addon": "1.10.0", "msw-storybook-addon": "2.0.0-beta.1",
"nodemon": "3.0.3", "nodemon": "3.0.3",
"prettier": "3.2.4", "prettier": "3.2.4",
"react": "18.2.0", "react": "18.2.0",

View File

@ -6,7 +6,7 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable @typescript-eslint/explicit-function-return-type */
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { StoryObj } from '@storybook/vue3'; import { StoryObj } from '@storybook/vue3';
import { rest } from 'msw'; import { HttpResponse, http } from 'msw';
import { abuseUserReport } from '../../.storybook/fakes.js'; import { abuseUserReport } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js'; import { commonHandlers } from '../../.storybook/mocks.js';
import MkAbuseReport from './MkAbuseReport.vue'; import MkAbuseReport from './MkAbuseReport.vue';
@ -44,9 +44,9 @@ export const Default = {
msw: { msw: {
handlers: [ handlers: [
...commonHandlers, ...commonHandlers,
rest.post('/api/admin/resolve-abuse-user-report', async (req, res, ctx) => { http.post('/api/admin/resolve-abuse-user-report', async ({ request }) => {
action('POST /api/admin/resolve-abuse-user-report')(await req.json()); action('POST /api/admin/resolve-abuse-user-report')(await request.json());
return res(ctx.json({})); return HttpResponse.json({});
}), }),
], ],
}, },

View File

@ -6,7 +6,7 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable @typescript-eslint/explicit-function-return-type */
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { StoryObj } from '@storybook/vue3'; import { StoryObj } from '@storybook/vue3';
import { rest } from 'msw'; import { HttpResponse, http } from 'msw';
import { userDetailed } from '../../.storybook/fakes.js'; import { userDetailed } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js'; import { commonHandlers } from '../../.storybook/mocks.js';
import MkAbuseReportWindow from './MkAbuseReportWindow.vue'; import MkAbuseReportWindow from './MkAbuseReportWindow.vue';
@ -44,9 +44,9 @@ export const Default = {
msw: { msw: {
handlers: [ handlers: [
...commonHandlers, ...commonHandlers,
rest.post('/api/users/report-abuse', async (req, res, ctx) => { http.post('/api/users/report-abuse', async ({ request }) => {
action('POST /api/users/report-abuse')(await req.json()); action('POST /api/users/report-abuse')(await request.json());
return res(ctx.json({})); return HttpResponse.json({});
}), }),
], ],
}, },

View File

@ -5,7 +5,7 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable @typescript-eslint/explicit-function-return-type */
import { StoryObj } from '@storybook/vue3'; import { StoryObj } from '@storybook/vue3';
import { rest } from 'msw'; import { HttpResponse, http } from 'msw';
import { userDetailed } from '../../.storybook/fakes.js'; import { userDetailed } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js'; import { commonHandlers } from '../../.storybook/mocks.js';
import MkAchievements from './MkAchievements.vue'; import MkAchievements from './MkAchievements.vue';
@ -39,8 +39,8 @@ export const Empty = {
msw: { msw: {
handlers: [ handlers: [
...commonHandlers, ...commonHandlers,
rest.post('/api/users/achievements', (req, res, ctx) => { http.post('/api/users/achievements', () => {
return res(ctx.json([])); return HttpResponse.json([]);
}), }),
], ],
}, },
@ -52,8 +52,8 @@ export const All = {
msw: { msw: {
handlers: [ handlers: [
...commonHandlers, ...commonHandlers,
rest.post('/api/users/achievements', (req, res, ctx) => { http.post('/api/users/achievements', () => {
return res(ctx.json(ACHIEVEMENT_TYPES.map((name) => ({ name, unlockedAt: 0 })))); return HttpResponse.json(ACHIEVEMENT_TYPES.map((name) => ({ name, unlockedAt: 0 })));
}), }),
], ],
}, },

View File

@ -8,7 +8,7 @@ import { action } from '@storybook/addon-actions';
import { expect } from '@storybook/jest'; import { expect } from '@storybook/jest';
import { userEvent, waitFor, within } from '@storybook/testing-library'; import { userEvent, waitFor, within } from '@storybook/testing-library';
import { StoryObj } from '@storybook/vue3'; import { StoryObj } from '@storybook/vue3';
import { rest } from 'msw'; import { HttpResponse, http } from 'msw';
import { userDetailed } from '../../.storybook/fakes.js'; import { userDetailed } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js'; import { commonHandlers } from '../../.storybook/mocks.js';
import MkAutocomplete from './MkAutocomplete.vue'; import MkAutocomplete from './MkAutocomplete.vue';
@ -99,11 +99,11 @@ export const User = {
msw: { msw: {
handlers: [ handlers: [
...commonHandlers, ...commonHandlers,
rest.post('/api/users/search-by-username-and-host', (req, res, ctx) => { http.post('/api/users/search-by-username-and-host', () => {
return res(ctx.json([ return HttpResponse.json([
userDetailed('44', 'mizuki', 'misskey-hub.net', 'Mizuki'), userDetailed('44', 'mizuki', 'misskey-hub.net', 'Mizuki'),
userDetailed('49', 'momoko', 'misskey-hub.net', 'Momoko'), userDetailed('49', 'momoko', 'misskey-hub.net', 'Momoko'),
])); ]);
}), }),
], ],
}, },
@ -132,12 +132,12 @@ export const Hashtag = {
msw: { msw: {
handlers: [ handlers: [
...commonHandlers, ...commonHandlers,
rest.post('/api/hashtags/search', (req, res, ctx) => { http.post('/api/hashtags/search', () => {
return res(ctx.json([ return HttpResponse.json([
'気象警報注意報', '気象警報注意報',
'気象警報', '気象警報',
'気象情報', '気象情報',
])); ]);
}), }),
], ],
}, },

View File

@ -5,7 +5,7 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable @typescript-eslint/explicit-function-return-type */
import { StoryObj } from '@storybook/vue3'; import { StoryObj } from '@storybook/vue3';
import { rest } from 'msw'; import { HttpResponse, http } from 'msw';
import { userDetailed } from '../../.storybook/fakes.js'; import { userDetailed } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js'; import { commonHandlers } from '../../.storybook/mocks.js';
import MkAvatars from './MkAvatars.vue'; import MkAvatars from './MkAvatars.vue';
@ -38,12 +38,12 @@ export const Default = {
msw: { msw: {
handlers: [ handlers: [
...commonHandlers, ...commonHandlers,
rest.post('/api/users/show', (req, res, ctx) => { http.post('/api/users/show', () => {
return res(ctx.json([ return HttpResponse.json([
userDetailed('17'), userDetailed('17'),
userDetailed('20'), userDetailed('20'),
userDetailed('18'), userDetailed('18'),
])); ]);
}), }),
], ],
}, },

View File

@ -5,14 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
<template> <template>
<div :class="[$style.codeBlockRoot, { [$style.codeEditor]: codeEditor }]" v-html="html"></div> <div :class="[$style.codeBlockRoot, { [$style.codeEditor]: codeEditor }, (darkMode ? $style.dark : $style.light)]" v-html="html"></div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed, watch } from 'vue'; import { ref, computed, watch } from 'vue';
import { bundledLanguagesInfo } from 'shiki'; import { bundledLanguagesInfo } from 'shiki';
import type { BuiltinLanguage } from 'shiki'; import type { BuiltinLanguage } from 'shiki';
import { getHighlighter } from '@/scripts/code-highlighter.js'; import { getHighlighter, getTheme } from '@/scripts/code-highlighter.js';
import { defaultStore } from '@/store.js';
const props = defineProps<{ const props = defineProps<{
code: string; code: string;
@ -21,11 +22,23 @@ const props = defineProps<{
}>(); }>();
const highlighter = await getHighlighter(); const highlighter = await getHighlighter();
const darkMode = defaultStore.reactiveState.darkMode;
const codeLang = ref<BuiltinLanguage | 'aiscript'>('js'); const codeLang = ref<BuiltinLanguage | 'aiscript'>('js');
const [lightThemeName, darkThemeName] = await Promise.all([
getTheme('light', true),
getTheme('dark', true),
]);
const html = computed(() => highlighter.codeToHtml(props.code, { const html = computed(() => highlighter.codeToHtml(props.code, {
lang: codeLang.value, lang: codeLang.value,
theme: 'dark-plus', themes: {
fallback: 'dark-plus',
light: lightThemeName,
dark: darkThemeName,
},
defaultColor: false,
cssVariablePrefix: '--shiki-',
})); }));
async function fetchLanguage(to: string): Promise<void> { async function fetchLanguage(to: string): Promise<void> {
@ -64,6 +77,16 @@ watch(() => props.lang, (to) => {
margin: .5em 0; margin: .5em 0;
overflow: auto; overflow: auto;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--divider);
font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
color: var(--shiki-fallback);
background-color: var(--shiki-fallback-bg);
& span {
color: var(--shiki-fallback);
background-color: var(--shiki-fallback-bg);
}
& pre, & pre,
& code { & code {
@ -71,6 +94,26 @@ watch(() => props.lang, (to) => {
} }
} }
.light.codeBlockRoot :global(.shiki) {
color: var(--shiki-light);
background-color: var(--shiki-light-bg);
& span {
color: var(--shiki-light);
background-color: var(--shiki-light-bg);
}
}
.dark.codeBlockRoot :global(.shiki) {
color: var(--shiki-dark);
background-color: var(--shiki-dark-bg);
& span {
color: var(--shiki-dark);
background-color: var(--shiki-dark-bg);
}
}
.codeBlockRoot.codeEditor { .codeBlockRoot.codeEditor {
min-width: 100%; min-width: 100%;
height: 100%; height: 100%;
@ -79,6 +122,7 @@ watch(() => props.lang, (to) => {
padding: 12px; padding: 12px;
margin: 0; margin: 0;
border-radius: 6px; border-radius: 6px;
border: none;
min-height: 130px; min-height: 130px;
pointer-events: none; pointer-events: none;
min-width: calc(100% - 24px); min-width: calc(100% - 24px);
@ -90,6 +134,11 @@ watch(() => props.lang, (to) => {
text-rendering: inherit; text-rendering: inherit;
text-transform: inherit; text-transform: inherit;
white-space: pre; white-space: pre;
& span {
display: inline-block;
min-height: 1em;
}
} }
} }
</style> </style>

View File

@ -53,7 +53,6 @@ function copy() {
} }
.codeBlockCopyButton { .codeBlockCopyButton {
color: #D4D4D4;
position: absolute; position: absolute;
top: 8px; top: 8px;
right: 8px; right: 8px;
@ -67,8 +66,7 @@ function copy() {
.codeBlockFallbackRoot { .codeBlockFallbackRoot {
display: block; display: block;
overflow-wrap: anywhere; overflow-wrap: anywhere;
color: #D4D4D4; background: var(--bg);
background: #1E1E1E;
padding: 1em; padding: 1em;
margin: .5em 0; margin: .5em 0;
overflow: auto; overflow: auto;
@ -93,8 +91,8 @@ function copy() {
border-radius: 8px; border-radius: 8px;
padding: 24px; padding: 24px;
margin-top: 4px; margin-top: 4px;
color: #D4D4D4; color: var(--fg);
background: #1E1E1E; background: var(--bg);
} }
.codePlaceholderContainer { .codePlaceholderContainer {

View File

@ -196,10 +196,11 @@ watch(v, newValue => {
resize: none; resize: none;
text-align: left; text-align: left;
color: transparent; color: transparent;
caret-color: rgb(225, 228, 232); caret-color: var(--fg);
background-color: transparent; background-color: transparent;
border: 0; border: 0;
border-radius: 6px; border-radius: 6px;
box-sizing: border-box;
outline: 0; outline: 0;
min-width: calc(100% - 24px); min-width: calc(100% - 24px);
height: 100%; height: 100%;
@ -210,6 +211,6 @@ watch(v, newValue => {
} }
.textarea::selection { .textarea::selection {
color: #fff; color: var(--bg);
} }
</style> </style>

View File

@ -18,8 +18,7 @@ const props = defineProps<{
display: inline-block; display: inline-block;
font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace; font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
overflow-wrap: anywhere; overflow-wrap: anywhere;
color: #D4D4D4; background: var(--bg);
background: #1E1E1E;
padding: .1em; padding: .1em;
border-radius: .3em; border-radius: .3em;
} }

View File

@ -63,18 +63,25 @@ const loading = ref(true);
const ok = async () => { const ok = async () => {
const promise = new Promise<Misskey.entities.DriveFile>(async (res) => { const promise = new Promise<Misskey.entities.DriveFile>(async (res) => {
const croppedCanvas = await cropper?.getCropperSelection()?.$toCanvas(); const croppedImage = await cropper?.getCropperImage();
const croppedSection = await cropper?.getCropperSelection();
// ()
const zoomedRate = croppedImage.getBoundingClientRect().width / croppedImage.clientWidth;
const widthToRender = croppedSection.getBoundingClientRect().width / zoomedRate;
const croppedCanvas = await croppedSection?.$toCanvas({ width: widthToRender });
croppedCanvas?.toBlob(blob => { croppedCanvas?.toBlob(blob => {
if (!blob) return; if (!blob) return;
const formData = new FormData(); const formData = new FormData();
formData.append('file', blob); formData.append('file', blob);
formData.append('name', `cropped_${props.file.name}`); formData.append('name', `cropped_${props.file.name}`);
formData.append('isSensitive', props.file.isSensitive ? 'true' : 'false'); formData.append('isSensitive', props.file.isSensitive ? 'true' : 'false');
formData.append('comment', props.file.comment ?? 'null'); if (props.file.comment) { formData.append('comment', props.file.comment);}
formData.append('i', $i!.token); formData.append('i', $i!.token);
if (props.uploadFolder || props.uploadFolder === null) { if (props.uploadFolder) {
formData.append('folderId', props.uploadFolder ?? 'null'); formData.append('folderId', props.uploadFolder);
} else if (defaultStore.state.uploadFolder) { } else if (props.uploadFolder !== null && defaultStore.state.uploadFolder) {
formData.append('folderId', defaultStore.state.uploadFolder); formData.append('folderId', defaultStore.state.uploadFolder);
} }

View File

@ -123,7 +123,7 @@ import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { customEmojiCategories, customEmojis, customEmojisMap } from '@/custom-emojis.js'; import { customEmojiCategories, customEmojis, customEmojisMap } from '@/custom-emojis.js';
import { signinRequired } from '@/account.js'; import { signinRequired } from '@/account.js';
import MkButton from '@/components/MkButton.vue'; import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js';
import { deepClone } from '@/scripts/clone.js'; import { deepClone } from '@/scripts/clone.js';
const $i = signinRequired(); const $i = signinRequired();
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
@ -133,6 +133,7 @@ const props = withDefaults(defineProps<{
asDrawer?: boolean; asDrawer?: boolean;
asWindow?: boolean; asWindow?: boolean;
asReactionPicker?: boolean; // 使使 asReactionPicker?: boolean; // 使使
targetNote?: Misskey.entities.Note;
}>(), { }>(), {
showPinned: true, showPinned: true,
}); });
@ -349,7 +350,7 @@ watch(q, () => {
}); });
function filterAvailable(emoji: Misskey.entities.EmojiSimple): boolean { function filterAvailable(emoji: Misskey.entities.EmojiSimple): boolean {
return (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction == null || emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0) || ($i && $i.roles.some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.includes(r.id))); return !props.targetNote || checkReactionPermissions($i!, props.targetNote, emoji);
} }
function focus() { function focus() {

View File

@ -24,6 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:showPinned="showPinned" :showPinned="showPinned"
:pinnedEmojis="pinnedEmojis" :pinnedEmojis="pinnedEmojis"
:asReactionPicker="asReactionPicker" :asReactionPicker="asReactionPicker"
:targetNote="targetNote"
:asDrawer="type === 'drawer'" :asDrawer="type === 'drawer'"
:max-height="maxHeight" :max-height="maxHeight"
@chosen="chosen" @chosen="chosen"
@ -32,6 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import * as Misskey from 'misskey-js';
import { shallowRef } from 'vue'; import { shallowRef } from 'vue';
import MkModal from '@/components/MkModal.vue'; import MkModal from '@/components/MkModal.vue';
import MkEmojiPicker from '@/components/MkEmojiPicker.vue'; import MkEmojiPicker from '@/components/MkEmojiPicker.vue';
@ -43,6 +45,7 @@ const props = withDefaults(defineProps<{
showPinned?: boolean; showPinned?: boolean;
pinnedEmojis?: string[], pinnedEmojis?: string[],
asReactionPicker?: boolean; asReactionPicker?: boolean;
targetNote?: Misskey.entities.Note;
choseAndClose?: boolean; choseAndClose?: boolean;
}>(), { }>(), {
manualShowing: null, manualShowing: null,

View File

@ -13,12 +13,13 @@ SPDX-License-Identifier: AGPL-3.0-only
:front="true" :front="true"
@closed="emit('closed')" @closed="emit('closed')"
> >
<MkEmojiPicker :showPinned="showPinned" :asReactionPicker="asReactionPicker" asWindow :class="$style.picker" @chosen="chosen"/> <MkEmojiPicker :showPinned="showPinned" :asReactionPicker="asReactionPicker" :targetNote="targetNote" asWindow :class="$style.picker" @chosen="chosen"/>
</MkWindow> </MkWindow>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import { } from 'vue';
import * as Misskey from 'misskey-js';
import MkWindow from '@/components/MkWindow.vue'; import MkWindow from '@/components/MkWindow.vue';
import MkEmojiPicker from '@/components/MkEmojiPicker.vue'; import MkEmojiPicker from '@/components/MkEmojiPicker.vue';
@ -26,6 +27,7 @@ withDefaults(defineProps<{
src?: HTMLElement; src?: HTMLElement;
showPinned?: boolean; showPinned?: boolean;
asReactionPicker?: boolean; asReactionPicker?: boolean;
targetNote?: Misskey.entities.Note
}>(), { }>(), {
showPinned: true, showPinned: true,
}); });

View File

@ -25,11 +25,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</Transition> </Transition>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, shallowRef, computed, nextTick, watch } from 'vue'; import { ref, shallowRef, computed, nextTick, watch } from 'vue';
import type { Tab } from '@/components/global/MkPageHeader.tabs.vue'; import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { isHorizontalSwipeSwiping as isSwiping } from '@/scripts/touch.js';
const rootEl = shallowRef<HTMLDivElement>(); const rootEl = shallowRef<HTMLDivElement>();
@ -49,16 +49,16 @@ const shouldAnimate = computed(() => defaultStore.reactiveState.enableHorizontal
// // // //
// //
const MIN_SWIPE_DISTANCE = 50; const MIN_SWIPE_DISTANCE = 20;
// //
const SWIPE_DISTANCE_THRESHOLD = 125; const SWIPE_DISTANCE_THRESHOLD = 70;
// Y // Y
const SWIPE_ABORT_Y_THRESHOLD = 75; const SWIPE_ABORT_Y_THRESHOLD = 75;
// //
const MAX_SWIPE_DISTANCE = 150; const MAX_SWIPE_DISTANCE = 120;
// // // //
@ -68,7 +68,6 @@ let startScreenY: number | null = null;
const currentTabIndex = computed(() => props.tabs.findIndex(tab => tab.key === tabModel.value)); const currentTabIndex = computed(() => props.tabs.findIndex(tab => tab.key === tabModel.value));
const pullDistance = ref(0); const pullDistance = ref(0);
const isSwiping = ref(false);
const isSwipingForClass = ref(false); const isSwipingForClass = ref(false);
let swipeAborted = false; let swipeAborted = false;
@ -77,6 +76,8 @@ function touchStart(event: TouchEvent) {
if (event.touches.length !== 1) return; if (event.touches.length !== 1) return;
if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return;
startScreenX = event.touches[0].screenX; startScreenX = event.touches[0].screenX;
startScreenY = event.touches[0].screenY; startScreenY = event.touches[0].screenY;
} }
@ -90,6 +91,8 @@ function touchMove(event: TouchEvent) {
if (swipeAborted) return; if (swipeAborted) return;
if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return;
let distanceX = event.touches[0].screenX - startScreenX; let distanceX = event.touches[0].screenX - startScreenX;
let distanceY = event.touches[0].screenY - startScreenY; let distanceY = event.touches[0].screenY - startScreenY;
@ -139,6 +142,8 @@ function touchEnd(event: TouchEvent) {
if (!isSwiping.value) return; if (!isSwiping.value) return;
if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return;
const distance = event.changedTouches[0].screenX - startScreenX; const distance = event.changedTouches[0].screenX - startScreenX;
if (Math.abs(distance) > SWIPE_DISTANCE_THRESHOLD) { if (Math.abs(distance) > SWIPE_DISTANCE_THRESHOLD) {
@ -162,6 +167,24 @@ function touchEnd(event: TouchEvent) {
}, 400); }, 400);
} }
/** 横スワイプに関与する可能性のある要素を調べる */
function hasSomethingToDoWithXSwipe(el: HTMLElement) {
if (['INPUT', 'TEXTAREA'].includes(el.tagName)) return true;
if (el.isContentEditable) return true;
if (el.scrollWidth > el.clientWidth) return true;
const style = window.getComputedStyle(el);
if (['absolute', 'fixed', 'sticky'].includes(style.position)) return true;
if (['scroll', 'auto'].includes(style.overflowX)) return true;
if (style.touchAction === 'pan-x') return true;
if (el.parentElement && el.parentElement !== rootEl.value) {
return hasSomethingToDoWithXSwipe(el.parentElement);
} else {
return false;
}
}
const transitionName = ref<'swipeAnimationLeft' | 'swipeAnimationRight' | undefined>(undefined); const transitionName = ref<'swipeAnimationLeft' | 'swipeAnimationRight' | undefined>(undefined);
watch(tabModel, (newTab, oldTab) => { watch(tabModel, (newTab, oldTab) => {
@ -182,6 +205,7 @@ watch(tabModel, (newTab, oldTab) => {
<style lang="scss" module> <style lang="scss" module>
.transitionRoot { .transitionRoot {
touch-action: pan-y pinch-zoom;
display: grid; display: grid;
grid-template-columns: 100%; grid-template-columns: 100%;
overflow: clip; overflow: clip;

View File

@ -5,7 +5,7 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable @typescript-eslint/explicit-function-return-type */
import { StoryObj } from '@storybook/vue3'; import { StoryObj } from '@storybook/vue3';
import { rest } from 'msw'; import { HttpResponse, http } from 'msw';
import { userDetailed, inviteCode } from '../../.storybook/fakes.js'; import { userDetailed, inviteCode } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js'; import { commonHandlers } from '../../.storybook/mocks.js';
import MkInviteCode from './MkInviteCode.vue'; import MkInviteCode from './MkInviteCode.vue';
@ -39,8 +39,8 @@ export const Default = {
msw: { msw: {
handlers: [ handlers: [
...commonHandlers, ...commonHandlers,
rest.post('/api/users/show', (req, res, ctx) => { http.post('/api/users/show', ({ params }) => {
return res(ctx.json(userDetailed(req.params.userId as string))); return HttpResponse.json(userDetailed(params.userId as string));
}), }),
], ],
}, },

View File

@ -137,6 +137,7 @@ function close() {
margin-top: 12px; margin-top: 12px;
font-size: 0.8em; font-size: 0.8em;
line-height: 1.5em; line-height: 1.5em;
text-align: center;
} }
> .indicatorWithValue { > .indicatorWithValue {

View File

@ -260,7 +260,7 @@ const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entiti
const isMyRenote = $i && ($i.id === note.value.userId); const isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(false); const showContent = ref(false);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value) : null); const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null);
const isLong = shouldCollapsed(appearNote.value, urls.value ?? []); const isLong = shouldCollapsed(appearNote.value, urls.value ?? []);
const collapsed = ref(appearNote.value.cw == null && isLong); const collapsed = ref(appearNote.value.cw == null && isLong);
const isDeleted = ref(false); const isDeleted = ref(false);
@ -392,7 +392,7 @@ function react(viaKeyboard = false): void {
} }
} else { } else {
blur(); blur();
reactionPicker.show(reactButton.value ?? null, reaction => { reactionPicker.show(reactButton.value ?? null, note.value, reaction => {
sound.playMisskeySfx('reaction'); sound.playMisskeySfx('reaction');
if (props.mock) { if (props.mock) {

View File

@ -303,7 +303,7 @@ const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : fals
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
const translating = ref(false); const translating = ref(false);
const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null; const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null;
const urls = parsed ? extractUrlFromMfm(parsed) : null; const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null;
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance);
const conversation = ref<Misskey.entities.Note[]>([]); const conversation = ref<Misskey.entities.Note[]>([]);
const replies = ref<Misskey.entities.Note[]>([]); const replies = ref<Misskey.entities.Note[]>([]);
@ -411,7 +411,7 @@ function react(viaKeyboard = false): void {
} }
} else { } else {
blur(); blur();
reactionPicker.show(reactButton.value ?? null, reaction => { reactionPicker.show(reactButton.value ?? null, note.value, reaction => {
sound.playMisskeySfx('reaction'); sound.playMisskeySfx('reaction');
misskeyApi('notes/reactions/create', { misskeyApi('notes/reactions/create', {

View File

@ -26,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { onMounted, onUnmounted, ref, shallowRef } from 'vue'; import { onMounted, onUnmounted, ref, shallowRef } from 'vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { getScrollContainer } from '@/scripts/scroll.js'; import { getScrollContainer } from '@/scripts/scroll.js';
import { isHorizontalSwipeSwiping } from '@/scripts/touch.js';
const SCROLL_STOP = 10; const SCROLL_STOP = 10;
const MAX_PULL_DISTANCE = Infinity; const MAX_PULL_DISTANCE = Infinity;
@ -129,7 +130,7 @@ function moveEnd() {
function moving(event: TouchEvent | PointerEvent) { function moving(event: TouchEvent | PointerEvent) {
if (!isPullStart.value || isRefreshing.value || disabled) return; if (!isPullStart.value || isRefreshing.value || disabled) return;
if ((scrollEl?.scrollTop ?? 0) > (supportPointerDesktop ? SCROLL_STOP : SCROLL_STOP + pullDistance.value)) { if ((scrollEl?.scrollTop ?? 0) > (supportPointerDesktop ? SCROLL_STOP : SCROLL_STOP + pullDistance.value) || isHorizontalSwipeSwiping.value) {
pullDistance.value = 0; pullDistance.value = 0;
isPullEnd.value = false; isPullEnd.value = false;
moveEnd(); moveEnd();
@ -148,6 +149,10 @@ function moving(event: TouchEvent | PointerEvent) {
if (event.cancelable) event.preventDefault(); if (event.cancelable) event.preventDefault();
} }
if (pullDistance.value > SCROLL_STOP) {
event.stopPropagation();
}
isPullEnd.value = pullDistance.value >= FIRE_THRESHOLD; isPullEnd.value = pullDistance.value >= FIRE_THRESHOLD;
} }

View File

@ -32,6 +32,8 @@ import { claimAchievement } from '@/scripts/achievements.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import * as sound from '@/scripts/sound.js'; import * as sound from '@/scripts/sound.js';
import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js';
import { customEmojis } from '@/custom-emojis.js';
let gamingType = computed(defaultStore.makeGetterSetter('gamingType')); let gamingType = computed(defaultStore.makeGetterSetter('gamingType'));
@ -50,13 +52,19 @@ const emit = defineEmits<{
const buttonEl = shallowRef<HTMLElement>(); const buttonEl = shallowRef<HTMLElement>();
const canToggle = computed(() => !props.reaction.match(/@\w/) && $i); const isCustomEmoji = computed(() => props.reaction.includes(':'));
const emoji = computed(() => isCustomEmoji.value ? customEmojis.value.find(emoji => emoji.name === props.reaction.replace(/:/g, '').replace(/@\./, '')) : null);
const canToggle = computed(() => {
return !props.reaction.match(/@\w/) && $i
&& (emoji.value && checkReactionPermissions($i, props.note, emoji.value))
|| !isCustomEmoji.value;
});
const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':'));
async function toggleReaction() { async function toggleReaction() {
if (!canToggle.value) return; if (!canToggle.value) return;
// TODO: 使
const oldReaction = props.note.myReaction; const oldReaction = props.note.myReaction;
if (oldReaction) { if (oldReaction) {
const confirm = await os.confirm({ const confirm = await os.confirm({
@ -103,8 +111,8 @@ async function toggleReaction() {
} }
async function menu(ev) { async function menu(ev) {
if (!canToggle.value) return; if (!canGetInfo.value) return;
if (!props.reaction.includes(':')) return;
os.popupMenu([{ os.popupMenu([{
text: i18n.ts.info, text: i18n.ts.info,
icon: 'ti ti-info-circle', icon: 'ti ti-info-circle',

View File

@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<div :class="$style.caption"><slot name="caption"></slot></div> <div :class="$style.caption"><slot name="caption"></slot></div>
<MkButton v-if="manualSave && changed" primary @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> <MkButton v-if="manualSave && changed" primary :class="$style.save" @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</div> </div>
</template> </template>
@ -138,6 +138,7 @@ function show() {
active: computed(() => v.value === option.props?.value), active: computed(() => v.value === option.props?.value),
action: () => { action: () => {
v.value = option.props?.value; v.value = option.props?.value;
changed.value = true;
emit('changeByUser', v.value); emit('changeByUser', v.value);
}, },
}); });
@ -288,6 +289,10 @@ function show() {
padding-left: 6px; padding-left: 6px;
} }
.save {
margin: 8px 0 0 0;
}
.chevron { .chevron {
transition: transform 0.1s ease-out; transition: transform 0.1s ease-out;
} }

View File

@ -18,8 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { computed, watch, onUnmounted, provide, shallowRef } from 'vue'; import { computed, watch, onUnmounted, provide, shallowRef } from 'vue';
import Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { Connection } from 'misskey-js/built/streaming.js';
import MkNotes from '@/components/MkNotes.vue'; import MkNotes from '@/components/MkNotes.vue';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import { useStream } from '@/stream.js'; import { useStream } from '@/stream.js';
@ -87,8 +86,8 @@ function prepend(note) {
} }
} }
let connection: Connection; let connection: Misskey.ChannelConnection | null = null;
let connection2: Connection; let connection2: Misskey.ChannelConnection | null = null;
let paginationQuery: Paging | null = null; let paginationQuery: Paging | null = null;
const stream = useStream(); const stream = useStream();
@ -161,7 +160,7 @@ function connectChannel() {
if (props.src.startsWith('custom-timeline')) { if (props.src.startsWith('custom-timeline')) {
return; return;
} }
if (props.src !== 'directs' && props.src !== 'mentions') connection.on('note', prepend); if (props.src !== 'directs' && props.src !== 'mentions') connection?.on('note', prepend);
} }
function disconnectChannel() { function disconnectChannel() {

View File

@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
v-if="player.url.startsWith('http://') || player.url.startsWith('https://')" v-if="player.url.startsWith('http://') || player.url.startsWith('https://')"
sandbox="allow-popups allow-scripts allow-storage-access-by-user-activation allow-same-origin" sandbox="allow-popups allow-scripts allow-storage-access-by-user-activation allow-same-origin"
scrolling="no" scrolling="no"
:allow="player.allow.join(';')" :allow="player.allow == null ? 'autoplay;encrypted-media;fullscreen' : player.allow.filter(x => ['autoplay', 'clipboard-write', 'fullscreen', 'encrypted-media', 'picture-in-picture', 'web-share'].includes(x)).join(';')"
:class="$style.playerIframe" :class="$style.playerIframe"
:src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')"
:style="{ border: 0 }" :style="{ border: 0 }"

View File

@ -5,7 +5,7 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable @typescript-eslint/explicit-function-return-type */
import { StoryObj } from '@storybook/vue3'; import { StoryObj } from '@storybook/vue3';
import { rest } from 'msw'; import { HttpResponse, http } from 'msw';
import { commonHandlers } from '../../.storybook/mocks.js'; import { commonHandlers } from '../../.storybook/mocks.js';
import { userDetailed } from '../../.storybook/fakes.js'; import { userDetailed } from '../../.storybook/fakes.js';
import MkUserSetupDialog_Follow from './MkUserSetupDialog.Follow.vue'; import MkUserSetupDialog_Follow from './MkUserSetupDialog.Follow.vue';
@ -38,17 +38,17 @@ export const Default = {
msw: { msw: {
handlers: [ handlers: [
...commonHandlers, ...commonHandlers,
rest.post('/api/users', (req, res, ctx) => { http.post('/api/users', () => {
return res(ctx.json([ return HttpResponse.json([
userDetailed('44'), userDetailed('44'),
userDetailed('49'), userDetailed('49'),
])); ]);
}), }),
rest.post('/api/pinned-users', (req, res, ctx) => { http.post('/api/pinned-users', () => {
return res(ctx.json([ return HttpResponse.json([
userDetailed('44'), userDetailed('44'),
userDetailed('49'), userDetailed('49'),
])); ]);
}), }),
], ],
}, },

View File

@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
import XUser from '@/components/MkUserSetupDialog.User.vue'; import XUser from '@/components/MkUserSetupDialog.User.vue';

View File

@ -5,7 +5,7 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable @typescript-eslint/explicit-function-return-type */
import { StoryObj } from '@storybook/vue3'; import { StoryObj } from '@storybook/vue3';
import { rest } from 'msw'; import { HttpResponse, http } from 'msw';
import { commonHandlers } from '../../.storybook/mocks.js'; import { commonHandlers } from '../../.storybook/mocks.js';
import { userDetailed } from '../../.storybook/fakes.js'; import { userDetailed } from '../../.storybook/fakes.js';
import MkUserSetupDialog from './MkUserSetupDialog.vue'; import MkUserSetupDialog from './MkUserSetupDialog.vue';
@ -38,17 +38,17 @@ export const Default = {
msw: { msw: {
handlers: [ handlers: [
...commonHandlers, ...commonHandlers,
rest.post('/api/users', (req, res, ctx) => { http.post('/api/users', () => {
return res(ctx.json([ return HttpResponse.json([
userDetailed('44'), userDetailed('44'),
userDetailed('49'), userDetailed('49'),
])); ]);
}), }),
rest.post('/api/pinned-users', (req, res, ctx) => { http.post('/api/pinned-users', () => {
return res(ctx.json([ return HttpResponse.json([
userDetailed('44'), userDetailed('44'),
userDetailed('49'), userDetailed('49'),
])); ]);
}), }),
], ],
}, },

View File

@ -7,7 +7,7 @@
import { expect } from '@storybook/jest'; import { expect } from '@storybook/jest';
import { userEvent, waitFor, within } from '@storybook/testing-library'; import { userEvent, waitFor, within } from '@storybook/testing-library';
import { StoryObj } from '@storybook/vue3'; import { StoryObj } from '@storybook/vue3';
import { rest } from 'msw'; import { HttpResponse, http } from 'msw';
import { commonHandlers } from '../../../.storybook/mocks.js'; import { commonHandlers } from '../../../.storybook/mocks.js';
import MkUrl from './MkUrl.vue'; import MkUrl from './MkUrl.vue';
export const Default = { export const Default = {
@ -59,8 +59,8 @@ export const Default = {
msw: { msw: {
handlers: [ handlers: [
...commonHandlers, ...commonHandlers,
rest.get('/url', (req, res, ctx) => { http.get('/url', () => {
return res(ctx.json({ return HttpResponse.json({
title: 'Misskey Hub', title: 'Misskey Hub',
icon: 'https://misskey-hub.net/favicon.ico', icon: 'https://misskey-hub.net/favicon.ico',
description: 'Misskeyはオープンソースの分散型ソーシャルネットワーキングプラットフォームです。', description: 'Misskeyはオープンソースの分散型ソーシャルネットワーキングプラットフォームです。',
@ -74,7 +74,7 @@ export const Default = {
sitename: 'misskey-hub.net', sitename: 'misskey-hub.net',
sensitive: false, sensitive: false,
url: 'https://misskey-hub.net/', url: 'https://misskey-hub.net/',
})); });
}), }),
], ],
}, },

View File

@ -4,7 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<KeepAlive :max="defaultStore.state.numberOfPageCache"> <KeepAlive
:max="defaultStore.state.numberOfPageCache"
:exclude="pageCacheController"
>
<Suspense :timeout="0"> <Suspense :timeout="0">
<component :is="currentPageComponent" :key="key" v-bind="Object.fromEntries(currentPageProps)"/> <component :is="currentPageComponent" :key="key" v-bind="Object.fromEntries(currentPageProps)"/>
@ -16,9 +19,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { inject, onBeforeUnmount, provide, ref, shallowRef } from 'vue'; import { inject, onBeforeUnmount, provide, ref, shallowRef, computed, nextTick } from 'vue';
import { IRouter, Resolved } from '@/nirax.js'; import { IRouter, Resolved, RouteDef } from '@/nirax.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { globalEvents } from '@/events.js';
import MkLoadingPage from '@/pages/_loading_.vue';
const props = defineProps<{ const props = defineProps<{
router?: IRouter; router?: IRouter;
@ -46,20 +51,47 @@ function resolveNested(current: Resolved, d = 0): Resolved | null {
} }
const current = resolveNested(router.current)!; const current = resolveNested(router.current)!;
const currentPageComponent = shallowRef(current.route.component); const currentPageComponent = shallowRef('component' in current.route ? current.route.component : MkLoadingPage);
const currentPageProps = ref(current.props); const currentPageProps = ref(current.props);
const key = ref(current.route.path + JSON.stringify(Object.fromEntries(current.props))); const key = ref(current.route.path + JSON.stringify(Object.fromEntries(current.props)));
function onChange({ resolved, key: newKey }) { function onChange({ resolved, key: newKey }) {
const current = resolveNested(resolved); const current = resolveNested(resolved);
if (current == null) return; if (current == null || 'redirect' in current.route) return;
currentPageComponent.value = current.route.component; currentPageComponent.value = current.route.component;
currentPageProps.value = current.props; currentPageProps.value = current.props;
key.value = current.route.path + JSON.stringify(Object.fromEntries(current.props)); key.value = current.route.path + JSON.stringify(Object.fromEntries(current.props));
nextTick(() => {
//
if (clearCacheRequested.value) {
clearCacheRequested.value = false;
}
});
} }
router.addListener('change', onChange); router.addListener('change', onChange);
// #region
/**
* キャッシュクリアが有効になったら全キャッシュをクリアする
*
* keepAlive側にwatcherがあるのですぐ消えるとはおもうけど念のためページ遷移完了まではキャッシュを無効化しておく
* キャッシュ有効時向けにexcludeを使いたい場合はpageCacheControllerに並列に突っ込むのではなく下に追記すること
*/
const pageCacheController = computed(() => clearCacheRequested.value ? /.*/ : undefined);
const clearCacheRequested = ref(false);
globalEvents.on('requestClearPageCache', () => {
if (_DEV_) console.log('clear page cache requested');
if (!clearCacheRequested.value) {
clearCacheRequested.value = true;
}
});
// #endregion
onBeforeUnmount(() => { onBeforeUnmount(() => {
router.removeListener('change', onChange); router.removeListener('change', onChange);
}); });

View File

@ -4,6 +4,10 @@
*/ */
import { EventEmitter } from 'eventemitter3'; import { EventEmitter } from 'eventemitter3';
import * as Misskey from 'misskey-js';
// TODO: 型付け export const globalEvents = new EventEmitter<{
export const globalEvents = new EventEmitter(); themeChanged: () => void;
clientNotification: (notification: Misskey.entities.Notification) => void;
requestClearPageCache: () => void;
}>();

View File

@ -129,9 +129,10 @@ export function promiseDialog<T extends Promise<any>>(
let popupIdCount = 0; let popupIdCount = 0;
export const popups = ref([]) as Ref<{ export const popups = ref([]) as Ref<{
id: any; id: number;
component: any; component: Component;
props: Record<string, any>; props: Record<string, any>;
events: Record<string, any>;
}[]>; }[]>;
const zIndexes = { const zIndexes = {
@ -145,7 +146,18 @@ export function claimZIndex(priority: keyof typeof zIndexes = 'low'): number {
return zIndexes[priority]; return zIndexes[priority];
} }
export async function popup<T extends Component>(component: T, props: ComponentProps<T>, events = {}, disposeEvent?: string) { // InstanceType<typeof Component>['$emit'] だとインターセクション型が返ってきて
// 使い物にならないので、代わりに ['$props'] から色々省くことで emit の型を生成する
// FIXME: 何故か *.ts ファイルからだと型がうまく取れない?ことがあるのをなんとかしたい
type ComponentEmit<T> = T extends new () => { $props: infer Props }
? EmitsExtractor<Props>
: never;
type EmitsExtractor<T> = {
[K in keyof T as K extends `onVnode${string}` ? never : K extends `on${infer E}` ? Uncapitalize<E> : K extends string ? never : K]: T[K];
};
export async function popup<T extends Component>(component: T, props: ComponentProps<T>, events: ComponentEmit<T> = {} as ComponentEmit<T>, disposeEvent?: keyof ComponentEmit<T>) {
markRaw(component); markRaw(component);
const id = ++popupIdCount; const id = ++popupIdCount;

View File

@ -217,13 +217,13 @@ const patronsWithIcon = [{
icon: 'https://assets.misskey-hub.net/patrons/302dce2898dd457ba03c3f7dc037900b.jpg', icon: 'https://assets.misskey-hub.net/patrons/302dce2898dd457ba03c3f7dc037900b.jpg',
}, { }, {
name: 'taichan', name: 'taichan',
icon: 'https://assets.misskey-hub.net/patrons/f981ab0159fb4e2c998e05f7263e1cd9.png', icon: 'https://assets.misskey-hub.net/patrons/f981ab0159fb4e2c998e05f7263e1cd9.jpg',
}, { }, {
name: '猫吉よりお', name: '猫吉よりお',
icon: 'https://assets.misskey-hub.net/patrons/a11518b3b34b4536a4bdd7178ba76a7b.png', icon: 'https://assets.misskey-hub.net/patrons/a11518b3b34b4536a4bdd7178ba76a7b.jpg',
}, { }, {
name: '有栖かずみ', name: '有栖かずみ',
icon: 'https://assets.misskey-hub.net/patrons/9240e8e0ba294a8884143e99ac7ed6a0.png', icon: 'https://assets.misskey-hub.net/patrons/9240e8e0ba294a8884143e99ac7ed6a0.jpg',
}]; }];
const patrons = [ const patrons = [

View File

@ -148,9 +148,9 @@ function save() {
themeColor: themeColor.value === '' ? null : themeColor.value, themeColor: themeColor.value === '' ? null : themeColor.value,
defaultLightTheme: defaultLightTheme.value === '' ? null : defaultLightTheme.value, defaultLightTheme: defaultLightTheme.value === '' ? null : defaultLightTheme.value,
defaultDarkTheme: defaultDarkTheme.value === '' ? null : defaultDarkTheme.value, defaultDarkTheme: defaultDarkTheme.value === '' ? null : defaultDarkTheme.value,
infoImageUrl: infoImageUrl.value, infoImageUrl: infoImageUrl.value === '' ? null : infoImageUrl.value,
notFoundImageUrl: notFoundImageUrl.value, notFoundImageUrl: notFoundImageUrl.value === '' ? null : notFoundImageUrl.value,
serverErrorImageUrl: serverErrorImageUrl.value, serverErrorImageUrl: serverErrorImageUrl.value === '' ? null : serverErrorImageUrl.value,
manifestJsonOverride: manifestJsonOverride.value === '' ? '{}' : JSON.stringify(JSON5.parse(manifestJsonOverride.value)), manifestJsonOverride: manifestJsonOverride.value === '' ? '{}' : JSON.stringify(JSON5.parse(manifestJsonOverride.value)),
}).then(() => { }).then(() => {
fetchInstance(); fetchInstance();

View File

@ -44,6 +44,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template> <template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template>
</MkTextarea> </MkTextarea>
<MkTextarea v-model="prohibitedWords">
<template #label>{{ i18n.ts.prohibitedWords }}</template>
<template #caption>{{ i18n.ts.prohibitedWordsDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template>
</MkTextarea>
<MkTextarea v-model="hiddenTags"> <MkTextarea v-model="hiddenTags">
<template #label>{{ i18n.ts.hiddenTags }}</template> <template #label>{{ i18n.ts.hiddenTags }}</template>
<template #caption>{{ i18n.ts.hiddenTagsDescription }}</template> <template #caption>{{ i18n.ts.hiddenTagsDescription }}</template>
@ -80,6 +85,7 @@ import FormLink from '@/components/form/link.vue';
const enableRegistration = ref<boolean>(false); const enableRegistration = ref<boolean>(false);
const emailRequiredForSignup = ref<boolean>(false); const emailRequiredForSignup = ref<boolean>(false);
const sensitiveWords = ref<string>(''); const sensitiveWords = ref<string>('');
const prohibitedWords = ref<string>('');
const hiddenTags = ref<string>(''); const hiddenTags = ref<string>('');
const preservedUsernames = ref<string>(''); const preservedUsernames = ref<string>('');
const tosUrl = ref<string | null>(null); const tosUrl = ref<string | null>(null);
@ -91,6 +97,7 @@ async function init() {
enableRegistration.value = !meta.disableRegistration; enableRegistration.value = !meta.disableRegistration;
emailRequiredForSignup.value = meta.emailRequiredForSignup; emailRequiredForSignup.value = meta.emailRequiredForSignup;
sensitiveWords.value = meta.sensitiveWords.join('\n'); sensitiveWords.value = meta.sensitiveWords.join('\n');
prohibitedWords.value = meta.prohibitedWords.join('\n');
hiddenTags.value = meta.hiddenTags.join('\n'); hiddenTags.value = meta.hiddenTags.join('\n');
preservedUsernames.value = meta.preservedUsernames.join('\n'); preservedUsernames.value = meta.preservedUsernames.join('\n');
tosUrl.value = meta.tosUrl; tosUrl.value = meta.tosUrl;
@ -105,6 +112,7 @@ function save() {
tosUrl: tosUrl.value, tosUrl: tosUrl.value,
privacyPolicyUrl: privacyPolicyUrl.value, privacyPolicyUrl: privacyPolicyUrl.value,
sensitiveWords: sensitiveWords.value.split('\n'), sensitiveWords: sensitiveWords.value.split('\n'),
prohibitedWords: prohibitedWords.value.split('\n'),
hiddenTags: hiddenTags.value.split('\n'), hiddenTags: hiddenTags.value.split('\n'),
preservedUsernames: preservedUsernames.value.split('\n'), preservedUsernames: preservedUsernames.value.split('\n'),
enableGDPRMode: enableGDPRMode.value, enableGDPRMode: enableGDPRMode.value,

View File

@ -967,7 +967,6 @@ function getGameImageDriveFile() {
formData.append('file', blob); formData.append('file', blob);
formData.append('name', `bubble-game-${Date.now()}.png`); formData.append('name', `bubble-game-${Date.now()}.png`);
formData.append('isSensitive', 'false'); formData.append('isSensitive', 'false');
formData.append('comment', 'null');
formData.append('i', $i.token); formData.append('i', $i.token);
if (defaultStore.state.uploadFolder) { if (defaultStore.state.uploadFolder) {
formData.append('folderId', defaultStore.state.uploadFolder); formData.append('folderId', defaultStore.state.uploadFolder);

View File

@ -184,7 +184,7 @@ watch(nowProfileId, () => {
}); });
function previewReaction(ev: MouseEvent) { function previewReaction(ev: MouseEvent) {
reactionPicker.show(getHTMLElement(ev)); reactionPicker.show(getHTMLElement(ev), null);
} }
function previewEmoji(ev: MouseEvent) { function previewEmoji(ev: MouseEvent) {

View File

@ -126,6 +126,7 @@ import { langmap } from '@/scripts/langmap.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
import { claimAchievement } from '@/scripts/achievements.js'; import { claimAchievement } from '@/scripts/achievements.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { globalEvents } from '@/events.js';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
import MkTextarea from '@/components/MkTextarea.vue'; import MkTextarea from '@/components/MkTextarea.vue';
@ -175,6 +176,7 @@ function saveFields() {
os.apiWithDialog('i/update', { os.apiWithDialog('i/update', {
fields: fields.value.filter(field => field.name !== '' && field.value !== '').map(field => ({ name: field.name, value: field.value })), fields: fields.value.filter(field => field.name !== '' && field.value !== '').map(field => ({ name: field.name, value: field.value })),
}); });
globalEvents.emit('requestClearPageCache');
} }
function save() { function save() {
@ -194,6 +196,7 @@ function save() {
isCat: !!profile.isCat, isCat: !!profile.isCat,
isGorilla: !!profile.isGorilla, isGorilla: !!profile.isGorilla,
}); });
globalEvents.emit('requestClearPageCache');
claimAchievement('profileFilled'); claimAchievement('profileFilled');
if (profile.name === 'syuilo' || profile.name === 'しゅいろ') { if (profile.name === 'syuilo' || profile.name === 'しゅいろ') {
claimAchievement('setNameToSyuilo'); claimAchievement('setNameToSyuilo');
@ -228,6 +231,7 @@ function changeAvatar(ev) {
}); });
$i.avatarId = i.avatarId; $i.avatarId = i.avatarId;
$i.avatarUrl = i.avatarUrl; $i.avatarUrl = i.avatarUrl;
globalEvents.emit('requestClearPageCache');
claimAchievement('profileFilled'); claimAchievement('profileFilled');
}); });
} }
@ -254,6 +258,7 @@ function changeBanner(ev) {
}); });
$i.bannerId = i.bannerId; $i.bannerId = i.bannerId;
$i.bannerUrl = i.bannerUrl; $i.bannerUrl = i.bannerUrl;
globalEvents.emit('requestClearPageCache');
}); });
} }

View File

@ -105,6 +105,18 @@ import {uniqueBy} from '@/scripts/array';
import {fetchThemes, getThemes} from '@/theme-store.js'; import {fetchThemes, getThemes} from '@/theme-store.js';
import {definePageMetadata} from '@/scripts/page-metadata'; import {definePageMetadata} from '@/scripts/page-metadata';
import {miLocalStorage} from '@/local-storage'; import {miLocalStorage} from '@/local-storage';
import { unisonReload } from '@/scripts/unison-reload.js';
import * as os from '@/os.js';
async function reloadAsk() {
const { canceled } = await os.confirm({
type: 'info',
text: i18n.ts.reloadToApplySetting,
});
if (canceled) return;
unisonReload();
}
const installedThemes = ref(getThemes()); const installedThemes = ref(getThemes());
const builtinThemes = getBuiltinThemesRef(); const builtinThemes = getBuiltinThemesRef();
@ -141,6 +153,7 @@ const lightThemeId = computed({
} }
}, },
}); });
const darkMode = computed(defaultStore.makeGetterSetter('darkMode')); const darkMode = computed(defaultStore.makeGetterSetter('darkMode'));
const syncDeviceDarkMode = computed(ColdDeviceStorage.makeGetterSetter('syncDeviceDarkMode')); const syncDeviceDarkMode = computed(ColdDeviceStorage.makeGetterSetter('syncDeviceDarkMode'));
const wallpaper = ref(miLocalStorage.getItem('wallpaper')); const wallpaper = ref(miLocalStorage.getItem('wallpaper'));
@ -170,7 +183,7 @@ watch(wallpaper, () => {
} else { } else {
miLocalStorage.setItem('wallpaper', wallpaper.value); miLocalStorage.setItem('wallpaper', wallpaper.value);
} }
location.reload(); reloadAsk();
}); });
onActivated(() => { onActivated(() => {

View File

@ -5,7 +5,7 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable @typescript-eslint/explicit-function-return-type */
import { StoryObj } from '@storybook/vue3'; import { StoryObj } from '@storybook/vue3';
import { rest } from 'msw'; import { HttpResponse, http } from 'msw';
import { userDetailed } from '../../../.storybook/fakes.js'; import { userDetailed } from '../../../.storybook/fakes.js';
import { commonHandlers } from '../../../.storybook/mocks.js'; import { commonHandlers } from '../../../.storybook/mocks.js';
import home_ from './home.vue'; import home_ from './home.vue';
@ -39,12 +39,13 @@ export const Default = {
msw: { msw: {
handlers: [ handlers: [
...commonHandlers, ...commonHandlers,
rest.post('/api/users/notes', (req, res, ctx) => { http.post('/api/users/notes', () => {
return res(ctx.json([])); return HttpResponse.json([]);
}), }),
rest.get('/api/charts/user/notes', (req, res, ctx) => { http.get('/api/charts/user/notes', ({ request }) => {
const length = Math.max(Math.min(parseInt(req.url.searchParams.get('limit') ?? '30', 10), 1), 300); const url = new URL(request.url);
return res(ctx.json({ const length = Math.max(Math.min(parseInt(url.searchParams.get('limit') ?? '30', 10), 1), 300);
return HttpResponse.json({
total: Array.from({ length }, () => 0), total: Array.from({ length }, () => 0),
inc: Array.from({ length }, () => 0), inc: Array.from({ length }, () => 0),
dec: Array.from({ length }, () => 0), dec: Array.from({ length }, () => 0),
@ -54,11 +55,12 @@ export const Default = {
renote: Array.from({ length }, () => 0), renote: Array.from({ length }, () => 0),
withFile: Array.from({ length }, () => 0), withFile: Array.from({ length }, () => 0),
}, },
})); });
}), }),
rest.get('/api/charts/user/pv', (req, res, ctx) => { http.get('/api/charts/user/pv', ({ request }) => {
const length = Math.max(Math.min(parseInt(req.url.searchParams.get('limit') ?? '30', 10), 1), 300); const url = new URL(request.url);
return res(ctx.json({ const length = Math.max(Math.min(parseInt(url.searchParams.get('limit') ?? '30', 10), 1), 300);
return HttpResponse.json({
upv: { upv: {
user: Array.from({ length }, () => 0), user: Array.from({ length }, () => 0),
visitor: Array.from({ length }, () => 0), visitor: Array.from({ length }, () => 0),
@ -67,7 +69,7 @@ export const Default = {
user: Array.from({ length }, () => 0), user: Array.from({ length }, () => 0),
visitor: Array.from({ length }, () => 0), visitor: Array.from({ length }, () => 0),
}, },
})); });
}), }),
], ],
}, },

View File

@ -13,6 +13,7 @@ import { get, set } from '@/scripts/idb-proxy.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { useStream } from '@/stream.js'; import { useStream } from '@/stream.js';
import { deepClone } from '@/scripts/clone.js'; import { deepClone } from '@/scripts/clone.js';
import { deepMerge } from '@/scripts/merge.js';
type StateDef = Record<string, { type StateDef = Record<string, {
where: 'account' | 'device' | 'deviceAccount'; where: 'account' | 'device' | 'deviceAccount';
@ -84,29 +85,9 @@ export class Storage<T extends StateDef> {
return typeof value === 'object' && value !== null && !Array.isArray(value); return typeof value === 'object' && value !== null && !Array.isArray(value);
} }
/**
* valueにないキーをdefからもらう\
* nullはそのままundefinedはdefの値
**/
private mergeObject<X>(value: X, def: X): X {
if (this.isPureObject(value) && this.isPureObject(def)) {
const result = structuredClone(value) as X;
for (const [k, v] of Object.entries(def) as [keyof X, X[keyof X]][]) {
if (!Object.prototype.hasOwnProperty.call(value, k) || value[k] === undefined) {
result[k] = v;
} else if (this.isPureObject(v) && this.isPureObject(result[k])) {
const child = structuredClone(result[k]) as X[keyof X] & Record<string | number | symbol, unknown>;
result[k] = this.mergeObject<typeof v>(child, v);
}
}
return result;
}
return value;
}
private mergeState<X>(value: X, def: X): X { private mergeState<X>(value: X, def: X): X {
if (this.isPureObject(value) && this.isPureObject(def)) { if (this.isPureObject(value) && this.isPureObject(def)) {
const merged = this.mergeObject(value, def); const merged = deepMerge(value, def);
if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def, ' Result: ', merged); if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def, ' Result: ', merged);
@ -258,7 +239,7 @@ export class Storage<T extends StateDef> {
/** /**
* getter/setterを作ります * getter/setterを作ります
* vueで設定コントロールのmodelとして使う用 * vueで設定コントロールのmodelとして使う用
*/ */
public makeGetterSetter<K extends keyof T>(key: K, getter?: (v: T[K]) => unknown, setter?: (v: unknown) => T[K]): { public makeGetterSetter<K extends keyof T>(key: K, getter?: (v: T[K]) => unknown, setter?: (v: unknown) => T[K]): {
get: () => T[K]['default']; get: () => T[K]['default'];

View File

@ -80,6 +80,10 @@ class MainRouterProxy implements IRouter {
return this.supplier().resolve(path); return this.supplier().resolve(path);
} }
init(): void {
this.supplier().init();
}
eventNames(): Array<EventEmitter.EventNames<RouterEvent>> { eventNames(): Array<EventEmitter.EventNames<RouterEvent>> {
return this.supplier().eventNames(); return this.supplier().eventNames();
} }

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