1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-05-02 07:25:49 +02:00

Compare commits

...

5 Commits

Author SHA1 Message Date
github-actions[bot]
6d9412b338 [skip ci] Update CHANGELOG.md (prepend template) 2026-05-02 03:30:59 +00:00
github-actions[bot]
a23a72b015 Release: 2026.5.0 2026-05-02 03:30:51 +00:00
かっこかり
93bd9d551d fix: review fixes for v2026.5.0 release (#17350)
* fix/perf: NotificationManager in NoteCreateService

* fix: treat skip as successful return in InboxProcessorService

* chore: remove comment

* fix: simplify ReactionPicker/EmojiPicker by importing components directly

* refactor: move filename parsing to setup in MkUploaderItems

* refactor
2026-05-02 10:03:34 +09:00
syuilo
35d6c20828 Update CHANGELOG.md 2026-05-01 14:22:54 +09:00
github-actions[bot]
7c9942f014 Bump version to 2026.5.0-alpha.0 2026-05-01 05:21:43 +00:00
9 changed files with 110 additions and 141 deletions

View File

@@ -1,4 +1,16 @@
## 2026.4.0 ## Unreleased
### General
-
### Client
-
### Server
-
## 2026.5.0
### General ### General
- Enhance: アバターデコレーションにカテゴリを設定できるように - Enhance: アバターデコレーションにカテゴリを設定できるように

View File

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

View File

@@ -63,10 +63,10 @@ type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
class NotificationManager { class NotificationManager {
private notifier: { id: MiUser['id']; }; private notifier: { id: MiUser['id']; };
private note: MiNote; private note: MiNote;
private queue: { private queue: Map<MiLocalUser['id'], {
target: MiLocalUser['id']; target: MiLocalUser['id'];
reason: NotificationType; reason: NotificationType;
}[]; }>;
constructor( constructor(
private mutingsRepository: MutingsRepository, private mutingsRepository: MutingsRepository,
@@ -77,7 +77,7 @@ class NotificationManager {
) { ) {
this.notifier = notifier; this.notifier = notifier;
this.note = note; this.note = note;
this.queue = []; this.queue = new Map();
} }
@bindThis @bindThis
@@ -85,7 +85,7 @@ class NotificationManager {
// 自分自身へは通知しない // 自分自身へは通知しない
if (this.notifier.id === notifiee) return; if (this.notifier.id === notifiee) return;
const exist = this.queue.find(x => x.target === notifiee); const exist = this.queue.get(notifiee);
if (exist) { if (exist) {
// 「メンションされているかつ返信されている」場合は、メンションとしての通知ではなく返信としての通知にする // 「メンションされているかつ返信されている」場合は、メンションとしての通知ではなく返信としての通知にする
@@ -93,7 +93,7 @@ class NotificationManager {
exist.reason = reason; exist.reason = reason;
} }
} else { } else {
this.queue.push({ this.queue.set(notifiee, {
reason: reason, reason: reason,
target: notifiee, target: notifiee,
}); });
@@ -102,25 +102,25 @@ class NotificationManager {
@bindThis @bindThis
public async notify() { public async notify() {
if (this.queue.length === 0) { if (this.queue.size === 0) {
return; return;
} }
const targetUserIds = this.queue.map(x => x.target); let visibleUserIds: Set<MiUser['id']> | null;
let visibleUserIds: Set<string>;
switch (this.note.visibility) { switch (this.note.visibility) {
case 'public': case 'public':
case 'home': case 'home':
visibleUserIds = new Set(targetUserIds); visibleUserIds = null;
break; break;
case 'specified': case 'specified':
visibleUserIds = new Set(this.note.visibleUserIds.filter(id => targetUserIds.includes(id))); visibleUserIds = new Set(this.note.visibleUserIds);
break; break;
// TODO: フォロワー限定ノートにフォロワーではない人がメンションされた場合通知されるのが正しい挙動なのか確認(一部に挙動の不一致がありそう)。現状は通知されるためフィルタしない // TODO: フォロワー限定ノートにフォロワーではない人がメンションされた場合通知されるのが正しい挙動なのか確認(一部に挙動の不一致がありそう)。現状は通知されるためフィルタしない
// case 'followers': { // case 'followers': {
// const targetUserIds = this.queue.map(x => x.target);
// const followers = await this.followingsRepository.find({ // const followers = await this.followingsRepository.find({
// where: { // where: {
// followeeId: this.note.userId, // followeeId: this.note.userId,
@@ -138,8 +138,10 @@ class NotificationManager {
break; break;
} }
for (const x of this.queue) { for (const x of this.queue.values()) {
if (!visibleUserIds.has(x.target)) { const isVisibleToTarget = visibleUserIds === null || visibleUserIds.has(x.target);
if (!isVisibleToTarget) {
continue; continue;
} }

View File

@@ -96,7 +96,7 @@ export class InboxProcessorService implements OnApplicationShutdown {
if (userExistenceCheckApId != null) { if (userExistenceCheckApId != null) {
const user = await this.apDbResolverService.getUserFromApId(userExistenceCheckApId); const user = await this.apDbResolverService.getUserFromApId(userExistenceCheckApId);
if (user == null) { if (user == null) {
throw new Bull.UnrecoverableError(`skip: user not found for delete activity. ${getApId(userExistenceCheckApId)}`); return `skip: user not found for delete activity. ${getApId(userExistenceCheckApId)}`;
} }
} }
} }

View File

@@ -6,42 +6,42 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div :class="$style.root" class="_gaps_s"> <div :class="$style.root" class="_gaps_s">
<div <div
v-for="item in props.items" v-for="displayItem in displayItems"
:key="item.id" :key="displayItem.item.id"
v-panel v-panel
:class="[$style.item, { [$style.itemWaiting]: item.preprocessing, [$style.itemCompleted]: item.uploaded, [$style.itemFailed]: item.uploadFailed }]" :class="[$style.item, { [$style.itemWaiting]: displayItem.item.preprocessing, [$style.itemCompleted]: displayItem.item.uploaded, [$style.itemFailed]: displayItem.item.uploadFailed }]"
:style="{ :style="{
'--p': item.progress != null ? `${item.progress.value / item.progress.max * 100}%` : '0%', '--p': displayItem.item.progress != null ? `${displayItem.item.progress.value / displayItem.item.progress.max * 100}%` : '0%',
'--pp': item.preprocessProgress != null ? `${item.preprocessProgress * 100}%` : '100%', '--pp': displayItem.item.preprocessProgress != null ? `${displayItem.item.preprocessProgress * 100}%` : '100%',
}" }"
@contextmenu.prevent.stop="onContextmenu(item, $event)" @contextmenu.prevent.stop="onContextmenu(displayItem.item, $event)"
> >
<div :class="$style.itemInner"> <div :class="$style.itemInner">
<div :class="$style.itemActionWrapper"> <div :class="$style.itemActionWrapper">
<MkButton :iconOnly="true" rounded @click="emit('showMenu', item, $event)"><i class="ti ti-dots"></i></MkButton> <MkButton :iconOnly="true" rounded @click="emit('showMenu', displayItem.item, $event)"><i class="ti ti-dots"></i></MkButton>
</div> </div>
<div :class="$style.itemThumbnail" :style="{ backgroundImage: `url(${ item.thumbnail })` }" @click="onThumbnailClick(item, $event)"></div> <div :class="$style.itemThumbnail" :style="{ backgroundImage: `url(${ displayItem.item.thumbnail })` }" @click="onThumbnailClick(displayItem.item, $event)"></div>
<div :class="$style.itemBody"> <div :class="$style.itemBody">
<div> <div>
<i v-if="item.isSensitive" style="color: var(--MI_THEME-warn); margin-right: 0.5em;" class="ti ti-eye-exclamation"></i> <i v-if="displayItem.item.isSensitive" style="color: var(--MI_THEME-warn); margin-right: 0.5em;" class="ti ti-eye-exclamation"></i>
<MkCondensedLine :minScale="2 / 3"> <MkCondensedLine :minScale="2 / 3">
<span>{{ getUploadName(item).lastIndexOf('.') != -1 ? getUploadName(item).substring(0, getUploadName(item).lastIndexOf('.')) : getUploadName(item) }}</span> <span>{{ displayItem.nameParts.baseName }}</span>
<span v-if="getUploadName(item).lastIndexOf('.') != -1" style="opacity: 0.5;">{{ getUploadName(item).substring(getUploadName(item).lastIndexOf('.')) }}</span> <span v-if="displayItem.nameParts.extension != null" style="opacity: 0.5;">{{ displayItem.nameParts.extension }}</span>
</MkCondensedLine> </MkCondensedLine>
</div> </div>
<div :class="$style.itemInfo"> <div :class="$style.itemInfo">
<span>{{ item.file.type }}</span> <span>{{ displayItem.item.file.type }}</span>
<span v-if="item.compressedSize">({{ i18n.tsx._uploader.compressedToX({ x: bytes(item.compressedSize) }) }} = {{ i18n.tsx._uploader.savedXPercent({ x: Math.round((1 - item.compressedSize / item.file.size) * 100) }) }})</span> <span v-if="displayItem.item.compressedSize">({{ i18n.tsx._uploader.compressedToX({ x: bytes(displayItem.item.compressedSize) }) }} = {{ i18n.tsx._uploader.savedXPercent({ x: Math.round((1 - displayItem.item.compressedSize / displayItem.item.file.size) * 100) }) }})</span>
<span v-else>{{ bytes(item.file.size) }}</span> <span v-else>{{ bytes(displayItem.item.file.size) }}</span>
<span v-if="item.preprocessing">{{ i18n.ts.preprocessing }}<MkLoading inline em style="margin-left: 0.5em;"/></span> <span v-if="displayItem.item.preprocessing">{{ i18n.ts.preprocessing }}<MkLoading inline em style="margin-left: 0.5em;"/></span>
</div> </div>
<div> <div>
</div> </div>
</div> </div>
<div :class="$style.itemIconWrapper"> <div :class="$style.itemIconWrapper">
<MkSystemIcon v-if="item.uploading" :class="$style.itemIcon" type="waiting"/> <MkSystemIcon v-if="displayItem.item.uploading" :class="$style.itemIcon" type="waiting"/>
<MkSystemIcon v-else-if="item.uploaded" :class="$style.itemIcon" type="success"/> <MkSystemIcon v-else-if="displayItem.item.uploaded" :class="$style.itemIcon" type="success"/>
<MkSystemIcon v-else-if="item.uploadFailed" :class="$style.itemIcon" type="error"/> <MkSystemIcon v-else-if="displayItem.item.uploadFailed" :class="$style.itemIcon" type="error"/>
</div> </div>
</div> </div>
</div> </div>
@@ -49,6 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue';
import { isLink } from '@@/js/is-link.js'; import { isLink } from '@@/js/is-link.js';
import { getUploadName } from '@/composables/use-uploader.js'; import { getUploadName } from '@/composables/use-uploader.js';
import type { UploaderItem } from '@/composables/use-uploader.js'; import type { UploaderItem } from '@/composables/use-uploader.js';
@@ -60,11 +61,36 @@ const props = defineProps<{
items: UploaderItem[]; items: UploaderItem[];
}>(); }>();
const displayItems = computed(() => props.items.map(item => ({
item,
nameParts: getUploadNameParts(item),
})));
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'showMenu', item: UploaderItem, event: PointerEvent): void; (ev: 'showMenu', item: UploaderItem, event: PointerEvent): void;
(ev: 'showMenuViaContextmenu', item: UploaderItem, event: PointerEvent): void; (ev: 'showMenuViaContextmenu', item: UploaderItem, event: PointerEvent): void;
}>(); }>();
function getUploadNameParts(item: UploaderItem): {
baseName: string;
extension: string | null;
} {
const name = getUploadName(item);
const extensionIndex = name.lastIndexOf('.');
if (extensionIndex === -1) {
return {
baseName: name,
extension: null,
};
}
return {
baseName: name.substring(0, extensionIndex),
extension: name.substring(extensionIndex),
};
}
function onContextmenu(item: UploaderItem, ev: PointerEvent) { function onContextmenu(item: UploaderItem, ev: PointerEvent) {
if (ev.target && isLink(ev.target as HTMLElement)) return; if (ev.target && isLink(ev.target as HTMLElement)) return;
if (window.getSelection()?.toString() !== '') return; if (window.getSelection()?.toString() !== '') return;

View File

@@ -194,7 +194,6 @@ export function popup<T extends Component>(
const id = ++popupIdCount; const id = ++popupIdCount;
const dispose = () => { const dispose = () => {
// このsetTimeoutが無いと挙動がおかしくなる(autocompleteが閉じなくなる)。Vueのバグ
nextTick(() => { nextTick(() => {
popups.value = popups.value.filter(p => p.id !== id); popups.value = popups.value.filter(p => p.id !== id);
}); });

View File

@@ -3,9 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { markRaw, shallowRef, ref, watch } from 'vue'; import { shallowRef, ref, watch } from 'vue';
import type MkEmojiPickerDialog__TypeReferenceOnly from '@/components/MkEmojiPickerDialog.vue'; import MkEmojiPickerDialog from '@/components/MkEmojiPickerDialog.vue';
import { popup, popupAsyncWithDialog } from '@/os.js'; import { popup } from '@/os.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
/** /**
@@ -15,7 +15,6 @@ import { prefer } from '@/preferences.js';
* 一度表示したダイアログを連続で使用できることが望ましいシーンでの利用が想定される。 * 一度表示したダイアログを連続で使用できることが望ましいシーンでの利用が想定される。
*/ */
class EmojiPicker { class EmojiPicker {
private loadedComponent: typeof MkEmojiPickerDialog__TypeReferenceOnly | null = null;
private emojisRef = ref<string[]>([]); private emojisRef = ref<string[]>([]);
constructor() { constructor() {
@@ -23,15 +22,6 @@ class EmojiPicker {
} }
public init() { public init() {
// コンポーネントをプリロードしてキャッシュしておく。
// iOS PWA では await を挟むとユーザーアクティベーションが失われfocusが効かなくなるため、
// show() 呼び出し時には同期的に popup() できるよう事前にコンポーネントを解決しておく。
import('@/components/MkEmojiPickerDialog.vue').then(m => {
this.loadedComponent = markRaw(m.default);
}).catch(err => {
console.error('[EmojiPicker] Failed to preload MkEmojiPickerDialog:', err);
});
watch([prefer.r.emojiPaletteForMain, prefer.r.emojiPalettes], () => { watch([prefer.r.emojiPaletteForMain, prefer.r.emojiPalettes], () => {
this.emojisRef.value = prefer.s.emojiPaletteForMain == null ? prefer.s.emojiPalettes[0].emojis : prefer.s.emojiPalettes.find(palette => palette.id === prefer.s.emojiPaletteForMain)?.emojis ?? []; this.emojisRef.value = prefer.s.emojiPaletteForMain == null ? prefer.s.emojiPalettes[0].emojis : prefer.s.emojiPalettes.find(palette => palette.id === prefer.s.emojiPaletteForMain)?.emojis ?? [];
}, { }, {
@@ -46,10 +36,8 @@ class EmojiPicker {
) { ) {
const anchorRef = shallowRef(anchorElement); const anchorRef = shallowRef(anchorElement);
if (this.loadedComponent) { // defineAsyncComponentはiOS等でユーザーアクティベーションが失われてfocusが効かなくなるため使用不可
// コンポーネント解決済みのため同期的に popup() できる。 const { dispose } = popup(MkEmojiPickerDialog, {
// ユーザーアクティベーションコンテキストが維持されiOSでもfocusが機能する。
const { dispose } = popup(this.loadedComponent, {
anchorElement: anchorRef, anchorElement: anchorRef,
pinnedEmojis: this.emojisRef, pinnedEmojis: this.emojisRef,
asReactionPicker: false, asReactionPicker: false,
@@ -63,29 +51,6 @@ class EmojiPicker {
dispose(); dispose();
}, },
}); });
} else {
// フォールバック: 初回タップがプリロード完了前
popupAsyncWithDialog(
import('@/components/MkEmojiPickerDialog.vue').then(m => {
this.loadedComponent = markRaw(m.default);
return this.loadedComponent;
}),
{
anchorElement: anchorRef,
pinnedEmojis: this.emojisRef,
asReactionPicker: false,
choseAndClose: false,
},
{
done: (emoji: string) => {
if (onChosen) onChosen(emoji);
},
closed: () => {
if (onClosed) onClosed();
},
},
);
}
} }
} }

View File

@@ -4,13 +4,12 @@
*/ */
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { markRaw, shallowRef, ref, watch } from 'vue'; import { shallowRef, ref, watch } from 'vue';
import type MkEmojiPickerDialog__TypeReferenceOnly from '@/components/MkEmojiPickerDialog.vue'; import MkEmojiPickerDialog from '@/components/MkEmojiPickerDialog.vue';
import { popup, popupAsyncWithDialog } from '@/os.js'; import { popup } from '@/os.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
class ReactionPicker { class ReactionPicker {
private loadedComponent: typeof MkEmojiPickerDialog__TypeReferenceOnly | null = null;
private reactionsRef = ref<string[]>([]); private reactionsRef = ref<string[]>([]);
constructor() { constructor() {
@@ -18,15 +17,6 @@ class ReactionPicker {
} }
public init() { public init() {
// コンポーネントをプリロードしてキャッシュしておく。
// iOS PWA では await を挟むとユーザーアクティベーションが失われfocusが効かなくなるため、
// show() 呼び出し時には同期的に popup() できるよう事前にコンポーネントを解決しておく。
import('@/components/MkEmojiPickerDialog.vue').then(m => {
this.loadedComponent = markRaw(m.default);
}).catch(err => {
console.error('[ReactionPicker] Failed to preload MkEmojiPickerDialog:', err);
});
watch([prefer.r.emojiPaletteForReaction, prefer.r.emojiPalettes], () => { watch([prefer.r.emojiPaletteForReaction, prefer.r.emojiPalettes], () => {
this.reactionsRef.value = prefer.s.emojiPaletteForReaction == null ? prefer.s.emojiPalettes[0].emojis : prefer.s.emojiPalettes.find(palette => palette.id === prefer.s.emojiPaletteForReaction)?.emojis ?? []; this.reactionsRef.value = prefer.s.emojiPaletteForReaction == null ? prefer.s.emojiPalettes[0].emojis : prefer.s.emojiPalettes.find(palette => palette.id === prefer.s.emojiPaletteForReaction)?.emojis ?? [];
}, { }, {
@@ -43,10 +33,8 @@ class ReactionPicker {
const anchorRef = shallowRef(anchorElement); const anchorRef = shallowRef(anchorElement);
const targetNoteRef = ref(targetNote); const targetNoteRef = ref(targetNote);
if (this.loadedComponent) { // defineAsyncComponentはiOS等でユーザーアクティベーションが失われてfocusが効かなくなるため使用不可
// 通常パス: コンポーネント解決済みのため同期的に popup() できる。 const { dispose } = popup(MkEmojiPickerDialog, {
// ユーザーアクティベーションコンテキストが維持されiOSでもfocusが機能する。
const { dispose } = popup(this.loadedComponent, {
anchorElement: anchorRef, anchorElement: anchorRef,
pinnedEmojis: this.reactionsRef, pinnedEmojis: this.reactionsRef,
asReactionPicker: true, asReactionPicker: true,
@@ -60,29 +48,6 @@ class ReactionPicker {
dispose(); dispose();
}, },
}); });
} else {
// フォールバック: 初回タップがプリロード完了前
popupAsyncWithDialog(
import('@/components/MkEmojiPickerDialog.vue').then(m => {
this.loadedComponent = markRaw(m.default);
return this.loadedComponent;
}),
{
anchorElement: anchorRef,
pinnedEmojis: this.reactionsRef,
asReactionPicker: true,
targetNote: targetNoteRef,
},
{
done: (reaction: string) => {
if (onChosen) onChosen(reaction);
},
closed: () => {
if (onClosed) onClosed();
},
},
);
}
} }
} }

View File

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