Compare commits

...

12 Commits

Author SHA1 Message Date
kakkokari-gtyih d8cd69e055 refactor: 処理共通化 2025-05-25 23:50:05 +09:00
kakkokari-gtyih efb50f03d9 Merge branch 'develop' into deps-update-openapits 2025-05-25 23:43:12 +09:00
zyoshoka ed3a844f5d
fix(backend): add response schema for `notes/show-partial-bulk` endpoint (#16093) 2025-05-25 08:38:45 +09:00
かっこかり 0504d4399c
fix(frontend): リアクション削除イベントのコンディションが誤っていたのを修正 (#16097) 2025-05-25 08:38:29 +09:00
かっこかり fab9db405c
fix(frontend): タブ間同期が行われるとテーマが切り替わり続ける問題を修正 (#16094) 2025-05-25 08:37:39 +09:00
github-actions[bot] 02b37b7adf Bump version to 2025.5.1-beta.2 2025-05-24 09:42:21 +00:00
かっこかり 02041344bd
fix(frontend); カスタム絵文字のリアクションが二重で表示されることがある問題を修正 (#16092)
* fix(frontend): カスタム絵文字のリアクションが二重で表示されることがある問題を修正

* fix: improve event locking mechanism

* fix: remove unused console log

* fix: unused import

* fix: その場で書き換えることで再レンダリングを最小限に抑える(かも)

* refactor: reactive note data を composable内で生成するように
2025-05-24 18:31:55 +09:00
github-actions[bot] fe1b2b00f5 Bump version to 2025.5.1-beta.1 2025-05-24 05:32:52 +00:00
syuilo 4fcb80bcf2 enhance(backend): ファイル種別が判定できない場合、拡張子を参照するように 2025-05-24 14:28:49 +09:00
syuilo 836ed98c54 Update CHANGELOG.md 2025-05-24 14:06:54 +09:00
syuilo 64791a7160 fix(frontend): ユーザに表示されるファイル種別と、実際のファイル種別が異なり、ファイルをアップロードすることに失敗することがある問題を修正
Fix #16091
2025-05-24 13:59:26 +09:00
syuilo 90e39d22d2 fix(backend): unallowedFileTypeエラーがハンドリングされていない問題を修正 2025-05-24 13:55:34 +09:00
20 changed files with 230 additions and 167 deletions

View File

@ -14,6 +14,8 @@
- デフォルト値は「ローカルのコンテンツだけ公開」になっています - デフォルト値は「ローカルのコンテンツだけ公開」になっています
- Feat: ロールでアップロード可能なファイル種別を設定可能になりました - Feat: ロールでアップロード可能なファイル種別を設定可能になりました
- デフォルトは**テキスト、JSON、画像、動画、音声ファイル**になっています。zipなど、その他の種別のファイルは含まれていないため、必要に応じて設定を変更してください。 - デフォルトは**テキスト、JSON、画像、動画、音声ファイル**になっています。zipなど、その他の種別のファイルは含まれていないため、必要に応じて設定を変更してください。
- 場合によってはファイル種別を正しく検出できないことがあります(特にテキストフォーマット)。その場合、ファイル種別は application/octet-stream と見做されます。
- したがって、それらの種別不明ファイルを許可したい場合は application/octet-stream を指定に追加してください。
- Enhance: UIのアイコンデータの読み込みを軽量化 - Enhance: UIのアイコンデータの読み込みを軽量化
### Client ### Client

4
locales/index.d.ts vendored
View File

@ -7753,6 +7753,10 @@ export interface Locale extends ILocale {
* MIMEタイプを指定します(*)(: image/*) * MIMEタイプを指定します(*)(: image/*)
*/ */
"uploadableFileTypes_caption": string; "uploadableFileTypes_caption": string;
/**
* {x}
*/
"uploadableFileTypes_caption2": ParameterizedString<"x">;
}; };
"_condition": { "_condition": {
/** /**

View File

@ -2007,6 +2007,7 @@ _role:
chatAvailability: "チャットを許可" chatAvailability: "チャットを許可"
uploadableFileTypes: "アップロード可能なファイル種別" uploadableFileTypes: "アップロード可能なファイル種別"
uploadableFileTypes_caption: "MIMEタイプを指定します。改行で区切って複数指定できるほか、アスタリスク(*)でワイルドカード指定できます。(例: image/*)" uploadableFileTypes_caption: "MIMEタイプを指定します。改行で区切って複数指定できるほか、アスタリスク(*)でワイルドカード指定できます。(例: image/*)"
uploadableFileTypes_caption2: "ファイルによっては種別を判定できないことがあります。そのようなファイルを許可する場合は {x} を指定に追加してください。"
_condition: _condition:
roleAssignedTo: "マニュアルロールにアサイン済み" roleAssignedTo: "マニュアルロールにアサイン済み"
isLocal: "ローカルユーザー" isLocal: "ローカルユーザー"

View File

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2025.5.1-beta.0", "version": "2025.5.1-beta.2",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -469,13 +469,14 @@ export class DriveService {
if (user && this.meta.sensitiveMediaDetection === 'remote' && this.userEntityService.isLocalUser(user)) skipNsfwCheck = true; if (user && this.meta.sensitiveMediaDetection === 'remote' && this.userEntityService.isLocalUser(user)) skipNsfwCheck = true;
const info = await this.fileInfoService.getFileInfo(path, { const info = await this.fileInfoService.getFileInfo(path, {
fileName: name,
skipSensitiveDetection: skipNsfwCheck, skipSensitiveDetection: skipNsfwCheck,
sensitiveThreshold: // 感度が高いほどしきい値は低くすることになる sensitiveThreshold: // 感度が高いほどしきい値は低くすることになる
this.meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 0.1 : this.meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 0.1 :
this.meta.sensitiveMediaDetectionSensitivity === 'high' ? 0.3 : this.meta.sensitiveMediaDetectionSensitivity === 'high' ? 0.3 :
this.meta.sensitiveMediaDetectionSensitivity === 'low' ? 0.7 : this.meta.sensitiveMediaDetectionSensitivity === 'low' ? 0.7 :
this.meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0.9 : this.meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0.9 :
0.5, 0.5,
sensitiveThresholdForPorn: 0.75, sensitiveThresholdForPorn: 0.75,
enableSensitiveMediaDetectionForVideos: this.meta.enableSensitiveMediaDetectionForVideos, enableSensitiveMediaDetectionForVideos: this.meta.enableSensitiveMediaDetectionForVideos,
}); });

View File

@ -64,6 +64,7 @@ export class FileInfoService {
*/ */
@bindThis @bindThis
public async getFileInfo(path: string, opts: { public async getFileInfo(path: string, opts: {
fileName?: string | null;
skipSensitiveDetection: boolean; skipSensitiveDetection: boolean;
sensitiveThreshold?: number; sensitiveThreshold?: number;
sensitiveThresholdForPorn?: number; sensitiveThresholdForPorn?: number;
@ -76,6 +77,26 @@ export class FileInfoService {
let type = await this.detectType(path); let type = await this.detectType(path);
if (type.mime === TYPE_OCTET_STREAM.mime && opts.fileName != null) {
const ext = opts.fileName.split('.').pop();
if (ext === 'txt') {
type = {
mime: 'text/plain',
ext: 'txt',
};
} else if (ext === 'csv') {
type = {
mime: 'text/csv',
ext: 'csv',
};
} else if (ext === 'json') {
type = {
mime: 'application/json',
ext: 'json',
};
}
}
// image dimensions // image dimensions
let width: number | undefined; let width: number | undefined;
let height: number | undefined; let height: number | undefined;
@ -438,12 +459,12 @@ export class FileInfoService {
*/ */
@bindThis @bindThis
private async detectImageSize(path: string): Promise<{ private async detectImageSize(path: string): Promise<{
width: number; width: number;
height: number; height: number;
wUnits: string; wUnits: string;
hUnits: string; hUnits: string;
orientation?: number; orientation?: number;
}> { }> {
const readable = fs.createReadStream(path); const readable = fs.createReadStream(path);
const imageSize = await probeImageSize(readable); const imageSize = await probeImageSize(readable);
readable.destroy(); readable.destroy();

View File

@ -63,6 +63,12 @@ export const meta = {
id: 'b9d8c348-33f0-4673-b9a9-5d4da058977a', id: 'b9d8c348-33f0-4673-b9a9-5d4da058977a',
httpStatusCode: 413, httpStatusCode: 413,
}, },
unallowedFileType: {
message: 'Cannot upload the file because it is an unallowed file type.',
code: 'UNALLOWED_FILE_TYPE',
id: '4becd248-7f2c-48c4-a9f0-75edc4f9a1ea',
},
}, },
} as const; } as const;
@ -123,6 +129,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (err.id === '282f77bf-5816-4f72-9264-aa14d8261a21') throw new ApiError(meta.errors.inappropriate); if (err.id === '282f77bf-5816-4f72-9264-aa14d8261a21') throw new ApiError(meta.errors.inappropriate);
if (err.id === 'c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6') throw new ApiError(meta.errors.noFreeSpace); if (err.id === 'c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6') throw new ApiError(meta.errors.noFreeSpace);
if (err.id === 'f9e4e5f3-4df4-40b5-b400-f236945f7073') throw new ApiError(meta.errors.maxFileSizeExceeded); if (err.id === 'f9e4e5f3-4df4-40b5-b400-f236945f7073') throw new ApiError(meta.errors.maxFileSizeExceeded);
if (err.id === 'bd71c601-f9b0-4808-9137-a330647ced9b') throw new ApiError(meta.errors.unallowedFileType);
} }
throw new ApiError(); throw new ApiError();
} finally { } finally {

View File

@ -19,7 +19,26 @@ export const meta = {
optional: false, nullable: false, optional: false, nullable: false,
items: { items: {
type: 'object', type: 'object',
optional: false, nullable: false, properties: {
id: {
type: 'string',
optional: false, nullable: false,
},
reactions: {
type: 'object',
optional: false, nullable: false,
additionalProperties: {
type: 'number',
},
},
reactionEmojis: {
type: 'object',
optional: false, nullable: false,
additionalProperties: {
type: 'string',
},
},
},
}, },
}, },

View File

@ -193,7 +193,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, inject, onMounted, ref, useTemplateRef, watch, provide, shallowRef, reactive } from 'vue'; import { computed, inject, onMounted, ref, useTemplateRef, provide } from 'vue';
import * as mfm from 'mfm-js'; import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js'; import { isLink } from '@@/js/is-link.js';
@ -283,12 +283,10 @@ if (noteViewInterruptors.length > 0) {
const isRenote = Misskey.note.isPureRenote(note); const isRenote = Misskey.note.isPureRenote(note);
const appearNote = getAppearNote(note); const appearNote = getAppearNote(note);
const $appearNote = reactive({ const { $note: $appearNote, subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({
reactions: appearNote.reactions, note: appearNote,
reactionCount: appearNote.reactionCount, parentNote: note,
reactionEmojis: appearNote.reactionEmojis, mock: props.mock,
myReaction: appearNote.myReaction,
pollChoices: appearNote.poll?.choices,
}); });
const rootEl = useTemplateRef('rootEl'); const rootEl = useTemplateRef('rootEl');
@ -410,17 +408,6 @@ provide(DI.mfmEmojiReactCallback, (reaction) => {
}); });
}); });
let subscribeManuallyToNoteCapture: () => void = () => { };
if (!props.mock) {
const { subscribe } = useNoteCapture({
note: appearNote,
parentNote: note,
$note: $appearNote,
});
subscribeManuallyToNoteCapture = subscribe;
}
if (!props.mock) { if (!props.mock) {
useTooltip(renoteButton, async (showing) => { useTooltip(renoteButton, async (showing) => {
const renotes = await misskeyApi('notes/renotes', { const renotes = await misskeyApi('notes/renotes', {

View File

@ -228,7 +228,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, inject, onMounted, provide, reactive, ref, useTemplateRef } from 'vue'; import { computed, inject, onMounted, provide, ref, useTemplateRef } from 'vue';
import * as mfm from 'mfm-js'; import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js'; import { isLink } from '@@/js/is-link.js';
@ -304,12 +304,9 @@ if (noteViewInterruptors.length > 0) {
const isRenote = Misskey.note.isPureRenote(note); const isRenote = Misskey.note.isPureRenote(note);
const appearNote = getAppearNote(note); const appearNote = getAppearNote(note);
const $appearNote = reactive({ const { $note: $appearNote, subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({
reactions: appearNote.reactions, note: appearNote,
reactionCount: appearNote.reactionCount, parentNote: note,
reactionEmojis: appearNote.reactionEmojis,
myReaction: appearNote.myReaction,
pollChoices: appearNote.poll?.choices,
}); });
const rootEl = useTemplateRef('rootEl'); const rootEl = useTemplateRef('rootEl');
@ -397,12 +394,6 @@ const reactionsPagination = computed(() => ({
}, },
})); }));
const { subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({
note: appearNote,
parentNote: note,
$note: $appearNote,
});
useTooltip(renoteButton, async (showing) => { useTooltip(renoteButton, async (showing) => {
const renotes = await misskeyApi('notes/renotes', { const renotes = await misskeyApi('notes/renotes', {
noteId: appearNote.id, noteId: appearNote.id,

View File

@ -73,7 +73,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSelect> </MkSelect>
<div>{{ i18n.tsx._uploader.maxFileSizeIsX({ x: $i.policies.maxFileSizeMb + 'MB' }) }}</div> <div>{{ i18n.tsx._uploader.maxFileSizeIsX({ x: $i.policies.maxFileSizeMb + 'MB' }) }}</div>
<div>{{ i18n.ts._uploader.allowedTypes }}: {{ $i.policies.uploadableFileTypes.join(', ') }}</div>
<!-- クライアントで検出するMIME typeとサーバーで検出するMIME typeが異なる場合があり混乱の元になるのでとりあえず隠しとく -->
<!-- https://github.com/misskey-dev/misskey/issues/16091 -->
<!--<div>{{ i18n.ts._uploader.allowedTypes }}: {{ $i.policies.uploadableFileTypes.join(', ') }}</div>-->
</div> </div>
</div> </div>

View File

@ -3,10 +3,10 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { onUnmounted } from 'vue'; import { onUnmounted, reactive } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { EventEmitter } from 'eventemitter3'; import { EventEmitter } from 'eventemitter3';
import type { Reactive, Ref } from 'vue'; import type { Reactive } from 'vue';
import { useStream } from '@/stream.js'; import { useStream } from '@/stream.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import { store } from '@/store.js'; import { store } from '@/store.js';
@ -179,60 +179,83 @@ function realtimeSubscribe(props: {
}); });
} }
type ReactiveNoteData = Reactive<{ export type ReactiveNoteData = {
reactions: Misskey.entities.Note['reactions']; reactions: Misskey.entities.Note['reactions'];
reactionCount: Misskey.entities.Note['reactionCount']; reactionCount: Misskey.entities.Note['reactionCount'];
reactionEmojis: Misskey.entities.Note['reactionEmojis']; reactionEmojis: Misskey.entities.Note['reactionEmojis'];
myReaction: Misskey.entities.Note['myReaction']; myReaction: Misskey.entities.Note['myReaction'];
pollChoices: NonNullable<Misskey.entities.Note['poll']>['choices']; pollChoices: NonNullable<Misskey.entities.Note['poll']>['choices'];
}>; };
const noReaction = Symbol();
export function useNoteCapture(props: { export function useNoteCapture(props: {
note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>; note: Misskey.entities.Note;
parentNote: Misskey.entities.Note | null; parentNote: Misskey.entities.Note | null;
$note: ReactiveNoteData; mock?: boolean;
}): { }): {
$note: Reactive<ReactiveNoteData>;
subscribe: () => void; subscribe: () => void;
} { } {
const { note, parentNote, $note } = props; const { note, parentNote, mock } = props;
const $note = reactive<ReactiveNoteData>({
reactions: Object.entries(note.reactions).reduce((acc, [name, count]) => {
// Normalize reactions
const normalizedName = name.replace(/^:(\w+):$/, ':$1@.:');
if (acc[normalizedName] == null) {
acc[normalizedName] = count;
} else {
acc[normalizedName] += count;
}
return acc;
}, {} as Misskey.entities.Note['reactions']),
reactionCount: note.reactionCount,
reactionEmojis: note.reactionEmojis,
myReaction: note.myReaction,
pollChoices: note.poll?.choices ?? [],
});
noteEvents.on(`reacted:${note.id}`, onReacted); noteEvents.on(`reacted:${note.id}`, onReacted);
noteEvents.on(`unreacted:${note.id}`, onUnreacted); noteEvents.on(`unreacted:${note.id}`, onUnreacted);
noteEvents.on(`pollVoted:${note.id}`, onPollVoted); noteEvents.on(`pollVoted:${note.id}`, onPollVoted);
let latestReactedKey: string | null = null; // 操作がダブっていないかどうかを簡易的に記録するためのMap
let latestUnreactedKey: string | null = null; const reactionUserMap = new Map<Misskey.entities.User['id'], string | typeof noReaction>();
let latestPollVotedKey: string | null = null; let latestPollVotedKey: string | null = null;
function onReacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }): void { function onReacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }): void {
const newReactedKey = `${ctx.userId}:${ctx.reaction}`; const normalizedName = ctx.reaction.replace(/^:(\w+):$/, ':$1@.:');
if (newReactedKey === latestReactedKey) return;
latestReactedKey = newReactedKey; if (reactionUserMap.has(ctx.userId) && reactionUserMap.get(ctx.userId) === normalizedName) return;
reactionUserMap.set(ctx.userId, normalizedName);
if (ctx.emoji && !(ctx.emoji.name in $note.reactionEmojis)) { if (ctx.emoji && !(ctx.emoji.name in $note.reactionEmojis)) {
$note.reactionEmojis[ctx.emoji.name] = ctx.emoji.url; $note.reactionEmojis[ctx.emoji.name] = ctx.emoji.url;
} }
const currentCount = $note.reactions[ctx.reaction] || 0; const currentCount = $note.reactions[normalizedName] || 0;
$note.reactions[ctx.reaction] = currentCount + 1; $note.reactions[normalizedName] = currentCount + 1;
$note.reactionCount += 1; $note.reactionCount += 1;
if ($i && (ctx.userId === $i.id)) { if ($i && (ctx.userId === $i.id)) {
$note.myReaction = ctx.reaction; $note.myReaction = normalizedName;
} }
} }
function onUnreacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }): void { function onUnreacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }): void {
const newUnreactedKey = `${ctx.userId}:${ctx.reaction}`; const normalizedName = ctx.reaction.replace(/^:(\w+):$/, ':$1@.:');
if (newUnreactedKey === latestUnreactedKey) return;
latestUnreactedKey = newUnreactedKey;
const currentCount = $note.reactions[ctx.reaction] || 0; // 確実に一度リアクションされて取り消されている場合のみ処理をとめるAPIで初回読み込み→Streamでアップデート等の場合、reactionUserMapに情報がないため
if (reactionUserMap.has(ctx.userId) && reactionUserMap.get(ctx.userId) === noReaction) return;
reactionUserMap.set(ctx.userId, noReaction);
$note.reactions[ctx.reaction] = Math.max(0, currentCount - 1); const currentCount = $note.reactions[normalizedName] || 0;
$note.reactions[normalizedName] = Math.max(0, currentCount - 1);
$note.reactionCount = Math.max(0, $note.reactionCount - 1); $note.reactionCount = Math.max(0, $note.reactionCount - 1);
if ($note.reactions[ctx.reaction] === 0) delete $note.reactions[ctx.reaction]; if ($note.reactions[normalizedName] === 0) delete $note.reactions[normalizedName];
if ($i && (ctx.userId === $i.id)) { if ($i && (ctx.userId === $i.id)) {
$note.myReaction = null; $note.myReaction = null;
@ -257,10 +280,20 @@ export function useNoteCapture(props: {
} }
function subscribe() { function subscribe() {
if (mock) {
// モックモードでは購読しない
return;
}
if ($i && store.s.realtimeMode) { if ($i && store.s.realtimeMode) {
realtimeSubscribe(props); realtimeSubscribe({
note,
});
} else { } else {
pollingSubscribe(props); pollingSubscribe({
note,
$note,
});
} }
} }
@ -277,6 +310,7 @@ export function useNoteCapture(props: {
if ((Date.now() - new Date(note.createdAt).getTime()) > 1000 * 60 * 5) { // 5min if ((Date.now() - new Date(note.createdAt).getTime()) > 1000 * 60 * 5) { // 5min
// リノートで表示されているノートでもないし、投稿からある程度経過しているので自動で購読しない // リノートで表示されているノートでもないし、投稿からある程度経過しているので自動で購読しない
return { return {
$note,
subscribe: () => { subscribe: () => {
subscribe(); subscribe();
}, },
@ -286,6 +320,7 @@ export function useNoteCapture(props: {
if ((Date.now() - new Date(parentNote.createdAt).getTime()) > 1000 * 60 * 5) { // 5min if ((Date.now() - new Date(parentNote.createdAt).getTime()) > 1000 * 60 * 5) { // 5min
// リノートで表示されているノートだが、リノートされてからある程度経過しているので自動で購読しない // リノートで表示されているノートだが、リノートされてからある程度経過しているので自動で購読しない
return { return {
$note,
subscribe: () => { subscribe: () => {
subscribe(); subscribe();
}, },
@ -296,6 +331,7 @@ export function useNoteCapture(props: {
subscribe(); subscribe();
return { return {
$note,
subscribe: () => { subscribe: () => {
// すでに購読しているので何もしない // すでに購読しているので何もしない
}, },

View File

@ -418,7 +418,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts._role.useBaseValue }}</template> <template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch> </MkSwitch>
<MkTextarea :modelValue="role.policies.uploadableFileTypes.value.join('\n')" :disabled="role.policies.uploadableFileTypes.useDefault" :readonly="readonly" @update:modelValue="role.policies.uploadableFileTypes.value = $event.split('\n')"> <MkTextarea :modelValue="role.policies.uploadableFileTypes.value.join('\n')" :disabled="role.policies.uploadableFileTypes.useDefault" :readonly="readonly" @update:modelValue="role.policies.uploadableFileTypes.value = $event.split('\n')">
<template #caption>{{ i18n.ts._role._options.uploadableFileTypes_caption }}</template> <template #caption>
<div>{{ i18n.ts._role._options.uploadableFileTypes_caption }}</div>
<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.tsx._role._options.uploadableFileTypes_caption2({ x: 'application/octet-stream' }) }}</div>
</template>
</MkTextarea> </MkTextarea>
<MkRange v-model="role.policies.uploadableFileTypes.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> <MkRange v-model="role.policies.uploadableFileTypes.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template> <template #label>{{ i18n.ts._role.priority }}</template>

View File

@ -150,6 +150,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts._role._options.uploadableFileTypes }}</template> <template #label>{{ i18n.ts._role._options.uploadableFileTypes }}</template>
<template #suffix>...</template> <template #suffix>...</template>
<MkTextarea :modelValue="policies.uploadableFileTypes.join('\n')"> <MkTextarea :modelValue="policies.uploadableFileTypes.join('\n')">
<template #caption>
<div>{{ i18n.ts._role._options.uploadableFileTypes_caption }}</div>
<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.tsx._role._options.uploadableFileTypes_caption2({ x: 'application/octet-stream' }) }}</div>
</template>
</MkTextarea> </MkTextarea>
</MkFolder> </MkFolder>

View File

@ -101,27 +101,15 @@ html._themeChanging_ {
} }
html::view-transition-new(theme-changing) { html::view-transition-new(theme-changing) {
z-index: 4000001; z-index: 4000000;
animation: themeChangingNew 0.5s ease;
animation-fill-mode: forwards;
} }
html::view-transition-old(theme-changing) { html::view-transition-old(theme-changing) {
z-index: 4000000; z-index: 4000001;
animation: themeChangingOld 0.5s ease; animation: themeChangingOld 0.5s ease;
animation-fill-mode: forwards; animation-fill-mode: forwards;
} }
@keyframes themeChangingNew {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes themeChangingOld { @keyframes themeChangingOld {
0% { 0% {
opacity: 1; opacity: 1;

View File

@ -15,6 +15,7 @@ import { globalEvents } from '@/events.js';
import { miLocalStorage } from '@/local-storage.js'; import { miLocalStorage } from '@/local-storage.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { deepEqual } from '@/utility/deep-equal.js';
export type Theme = { export type Theme = {
id: string; id: string;
@ -127,6 +128,7 @@ function applyThemeInternal(theme: Theme, persist: boolean) {
} }
let timeout: number | null = null; let timeout: number | null = null;
let currentTheme: Theme | null = null;
export function applyTheme(theme: Theme, persist = true) { export function applyTheme(theme: Theme, persist = true) {
if (timeout) { if (timeout) {
@ -134,6 +136,9 @@ export function applyTheme(theme: Theme, persist = true) {
timeout = null; timeout = null;
} }
if (deepEqual(currentTheme, theme)) return;
currentTheme = theme;
if (window.document.startViewTransition != null && prefer.s.animation) { if (window.document.startViewTransition != null && prefer.s.animation) {
window.document.documentElement.classList.add('_themeChanging_'); window.document.documentElement.classList.add('_themeChanging_');
window.document.startViewTransition(async () => { window.document.startViewTransition(async () => {

View File

@ -39,20 +39,22 @@ export function uploadFile(file: File | Blob, options: {
const filePromise = new Promise<Misskey.entities.DriveFile>((resolve, reject) => { const filePromise = new Promise<Misskey.entities.DriveFile>((resolve, reject) => {
if ($i == null) return reject(); if ($i == null) return reject();
const allowedMimeTypes = $i.policies.uploadableFileTypes; // こっち側で検出するMIME typeとサーバーで検出するMIME typeは異なる場合があるため、こっち側ではやらないことにする
const isAllowedMimeType = allowedMimeTypes.some(mimeType => { // https://github.com/misskey-dev/misskey/issues/16091
if (mimeType === '*' || mimeType === '*/*') return true; //const allowedMimeTypes = $i.policies.uploadableFileTypes;
if (mimeType.endsWith('/*')) return file.type.startsWith(mimeType.slice(0, -1)); //const isAllowedMimeType = allowedMimeTypes.some(mimeType => {
return file.type === mimeType; // if (mimeType === '*' || mimeType === '*/*') return true;
}); // if (mimeType.endsWith('/*')) return file.type.startsWith(mimeType.slice(0, -1));
if (!isAllowedMimeType) { // return file.type === mimeType;
os.alert({ //});
type: 'error', //if (!isAllowedMimeType) {
title: i18n.ts.failedToUpload, // os.alert({
text: i18n.ts.cannotUploadBecauseUnallowedFileType, // type: 'error',
}); // title: i18n.ts.failedToUpload,
return reject(); // text: i18n.ts.cannotUploadBecauseUnallowedFileType,
} // });
// return reject();
//}
if ((file.size > instance.maxFileSize) || (file.size > ($i.policies.maxFileSizeMb * 1024 * 1024))) { if ((file.size > instance.maxFileSize) || (file.size > ($i.policies.maxFileSizeMb * 1024 * 1024))) {
os.alert({ os.alert({

View File

@ -13,88 +13,69 @@ import * as ts from 'typescript';
export function removeNeverPropertiesFromAST(astNodes: readonly ts.Node[]): ts.Node[] { export function removeNeverPropertiesFromAST(astNodes: readonly ts.Node[]): ts.Node[] {
const factory = ts.factory; const factory = ts.factory;
const typeNodeRecursiveVisitor = (node: ts.Node): ts.Node | undefined => { /**
if (ts.isTypeLiteralNode(node)) { * TypeLiteralNodeやInterfaceDeclarationのmembersからneverプロパティを除去し
const newMembers: ts.TypeElement[] = []; */
let hasTypeLiteralChanged = false; function removeNeverPropertiesFromMembers(
members: readonly ts.TypeElement[],
visitType: (node: ts.Node) => ts.Node | undefined,
): { newMembers: ts.TypeElement[]; hasChanged: boolean } {
const newMembers: ts.TypeElement[] = [];
let hasChanged = false;
for (const member of node.members) { for (const member of members) {
if (ts.isPropertySignature(member)) { if (ts.isPropertySignature(member)) {
if (member.type && member.type.kind === ts.SyntaxKind.NeverKeyword) { if (member.type && member.type.kind === ts.SyntaxKind.NeverKeyword) {
hasTypeLiteralChanged = true; hasChanged = true;
continue;
}
let updatedPropertySignature = member;
if (member.type) {
const visitedMemberType = ts.visitNode(member.type, visitType);
if (visitedMemberType && visitedMemberType !== member.type) {
updatedPropertySignature = factory.updatePropertySignature(
member,
member.modifiers,
member.name,
member.questionToken,
visitedMemberType as ts.TypeNode,
);
hasChanged = true;
} else if (visitedMemberType === undefined) {
// 子の型が消された場合、このプロパティも消す
hasChanged = true;
continue; continue;
} }
let updatedPropertySignature = member;
if (member.type) {
const visitedMemberType = ts.visitNode(member.type, typeNodeRecursiveVisitor);
if (visitedMemberType && visitedMemberType !== member.type) {
updatedPropertySignature = factory.updatePropertySignature(
member,
member.modifiers,
member.name,
member.questionToken,
visitedMemberType as ts.TypeNode
);
hasTypeLiteralChanged = true;
} else if (visitedMemberType === undefined) {
// 子の型が消された場合、このプロパティも消す
hasTypeLiteralChanged = true;
continue;
}
}
newMembers.push(updatedPropertySignature);
} else {
newMembers.push(member);
} }
newMembers.push(updatedPropertySignature);
} else {
newMembers.push(member);
} }
}
return { newMembers, hasChanged };
}
function typeNodeRecursiveVisitor(node: ts.Node): ts.Node | undefined {
if (ts.isTypeLiteralNode(node)) {
const { newMembers, hasChanged } = removeNeverPropertiesFromMembers(node.members, typeNodeRecursiveVisitor);
if (newMembers.length === 0) { if (newMembers.length === 0) {
// すべてのプロパティがneverで消された場合、このTypeLiteralNode自体も消す // すべてのプロパティがneverで消された場合、このTypeLiteralNode自体も消す
return undefined; return undefined;
} }
if (hasTypeLiteralChanged) { if (hasChanged) {
return factory.updateTypeLiteralNode(node, factory.createNodeArray(newMembers)); return factory.updateTypeLiteralNode(node, factory.createNodeArray(newMembers));
} }
return node; return node;
} }
return ts.visitEachChild(node, typeNodeRecursiveVisitor, undefined); return ts.visitEachChild(node, typeNodeRecursiveVisitor, undefined);
}; }
const interfaceRecursiveVisitor = (node: ts.Node): ts.Node | undefined => { function interfaceRecursiveVisitor(node: ts.Node): ts.Node | undefined {
if (ts.isInterfaceDeclaration(node)) { if (ts.isInterfaceDeclaration(node)) {
const newMembers: ts.TypeElement[] = []; const { newMembers, hasChanged } = removeNeverPropertiesFromMembers(node.members, typeNodeRecursiveVisitor);
let hasChanged = false;
for (const member of node.members) {
if (ts.isPropertySignature(member)) {
if (member.type && member.type.kind === ts.SyntaxKind.NeverKeyword) {
hasChanged = true;
continue;
}
let updatedPropertySignature = member;
if (member.type) {
const visitedMemberType = ts.visitNode(member.type, typeNodeRecursiveVisitor);
if (visitedMemberType && visitedMemberType !== member.type) {
updatedPropertySignature = factory.updatePropertySignature(
member,
member.modifiers,
member.name,
member.questionToken,
visitedMemberType as ts.TypeNode
);
hasChanged = true;
} else if (visitedMemberType === undefined) {
hasChanged = true;
continue;
}
}
newMembers.push(updatedPropertySignature);
} else {
newMembers.push(member);
}
}
if (newMembers.length === 0) { if (newMembers.length === 0) {
return undefined; return undefined;
@ -107,15 +88,15 @@ export function removeNeverPropertiesFromAST(astNodes: readonly ts.Node[]): ts.N
node.name, node.name,
node.typeParameters, node.typeParameters,
node.heritageClauses, node.heritageClauses,
newMembers newMembers,
); );
} }
return node; return node;
} }
return ts.visitEachChild(node, interfaceRecursiveVisitor, undefined); return ts.visitEachChild(node, interfaceRecursiveVisitor, undefined);
}; }
const topLevelVisitor = (node: ts.Node): ts.Node | undefined => { function topLevelVisitor(node: ts.Node): ts.Node | undefined {
if (ts.isTypeAliasDeclaration(node) && node.name.escapedText === 'paths') { if (ts.isTypeAliasDeclaration(node) && node.name.escapedText === 'paths') {
const newType = ts.visitNode(node.type, typeNodeRecursiveVisitor); const newType = ts.visitNode(node.type, typeNodeRecursiveVisitor);
if (newType && newType !== node.type) { if (newType && newType !== node.type) {
@ -124,7 +105,7 @@ export function removeNeverPropertiesFromAST(astNodes: readonly ts.Node[]): ts.N
node.modifiers, node.modifiers,
node.name, node.name,
node.typeParameters, node.typeParameters,
newType as ts.TypeNode newType as ts.TypeNode,
); );
} else if (newType === undefined) { } else if (newType === undefined) {
return undefined; return undefined;
@ -135,7 +116,7 @@ export function removeNeverPropertiesFromAST(astNodes: readonly ts.Node[]): ts.N
return result; return result;
} }
return ts.visitEachChild(node, topLevelVisitor, undefined); return ts.visitEachChild(node, topLevelVisitor, undefined);
}; }
const transformedNodes: ts.Node[] = []; const transformedNodes: ts.Node[] = [];
for (const astNode of astNodes) { for (const astNode of astNodes) {

View File

@ -1,7 +1,7 @@
{ {
"type": "module", "type": "module",
"name": "misskey-js", "name": "misskey-js",
"version": "2025.5.1-beta.0", "version": "2025.5.1-beta.2",
"description": "Misskey SDK for JavaScript", "description": "Misskey SDK for JavaScript",
"license": "MIT", "license": "MIT",
"main": "./built/index.js", "main": "./built/index.js",

View File

@ -29772,7 +29772,15 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
'application/json': Record<string, never>[]; 'application/json': {
id: string;
reactions: {
[key: string]: number;
};
reactionEmojis: {
[key: string]: string;
};
}[];
}; };
}; };
/** @description Client error */ /** @description Client error */