1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-26 17:04:36 +02:00

Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop

This commit is contained in:
syuilo
2026-07-24 10:06:57 +09:00
18 changed files with 315 additions and 319 deletions

View File

@@ -29,7 +29,6 @@
- Fix: 幅が狭い画面で動画の再生が困難な問題を修正
- Fix: 一部の画像のみセンシティブなとき、ビューワー内で画像を切り替えるとセンシティブな画像がそのまま表示される問題を修正
- Fix: 一部の画像をビューワーで読み込んだ際に正しく表示されない問題を修正
- Enhance: タイムラインの読み込みパフォーマンスを改善
- Enhance: タブがバックグラウンドの間は必要ない定期更新処理を停止するように
- Fix: 「画像を新しいタブで開く」が機能しなくなっていた問題を修正
- Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正
@@ -70,6 +69,7 @@
- Fix: Sentry 使用環境下にて、外部送信リクエストへ `sentry-trace` / `baggage` ヘッダーが既定で付与されないように
- Fix: フォロワー限定投稿へのリプライをホーム投稿に出来る問題を修正
- Fix: ファイルをアップロードするAPIにて、処理終了後に一時ファイルが削除されないことがある問題を修正
- Fix: 初期設定で作成したアカウント以外でアカウント作成APIが使用できない問題を修正
## 2026.6.0

View File

@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2026.7.0-beta.4",
"version": "2026.7.0-beta.5",
"codename": "nasubi",
"repository": {
"type": "git",

View File

@@ -5,9 +5,12 @@
import { bindThis } from '@/decorators.js';
import { logManager } from './logging/logging-runtime.js';
import type { LogLevel, LoggerContext, LogWriteInput } from './logging/types.js';
import type { LogEntryInput, LogLevel, LoggerContext, LogWriteInput } from './logging/types.js';
import type { Keyword } from 'color-convert';
// 旧APIのdataは表示用の任意値を受け取り、Errorや配列も既存呼び出しで使用されています。
type LegacyData = Record<string, any> | null;
/**
* ロガー名の階層と従来の公開APIを提供する薄い窓口です。
* 出力条件の判断や整形はLogManagerとLogBackendへ委譲します。
@@ -52,6 +55,15 @@ export default class Logger {
});
}
/** level別メソッドの構造化入力にlevelとLoggerのcontextを付けて渡します。 */
@bindThis
private logStructured(level: LogLevel, input: LogEntryInput): void {
this.write({
...input,
level,
});
}
/** 構造化ログをLoggerのcontext付きでLogManagerへ渡します。 */
@bindThis
public write(input: LogWriteInput): void {
@@ -62,24 +74,35 @@ export default class Logger {
}
/** 処理を継続できない状況を記録します。 */
public error(input: LogEntryInput): void;
public error(error: Error, data?: LegacyData, important?: boolean): void;
public error(message: string, data?: LegacyData, important?: boolean): void;
public error(errorOrMessage: string | Error, data?: LegacyData, important?: boolean): void;
@bindThis
public error(x: string | Error, data?: Record<string, any> | null, important = false): void {
public error(x: LogEntryInput | string | Error, data?: LegacyData, important = false): void {
if (x instanceof Error) {
// エラー本体も第2引数へ残し、従来どおりスタックなどを確認できるようにします。
data = data ?? {};
data.e = x;
this.log('error', x.toString(), data, important, undefined, x);
} else if (typeof x === 'object') {
this.log('error', `${(x as any).message ?? (x as any).name ?? x}`, data, important);
} else if (typeof x === 'string') {
this.log('error', x, data, important);
} else {
this.log('error', `${x}`, data, important);
this.logStructured('error', x);
}
}
/** 処理は継続できるものの、改善が必要な状況を記録します。 */
public warn(input: LogEntryInput): void;
public warn(message: string): void;
public warn(message: string, data?: LegacyData, important?: boolean): void;
@bindThis
public warn(message: string, data?: Record<string, any> | null, important = false): void {
this.log('warn', message, data, important);
public warn(inputOrMessage: LogEntryInput | string, data?: LegacyData, important = false): void {
if (typeof inputOrMessage === 'string') {
this.log('warn', inputOrMessage, data, important);
} else {
this.logStructured('warn', inputOrMessage);
}
}
/** 処理が成功したことを、従来のDONE表示で記録します。 */
@@ -89,14 +112,40 @@ export default class Logger {
}
/** 開発者向けの調査情報を記録します。 */
public debug(input: LogEntryInput): void;
public debug(message: string): void;
public debug(message: string, data?: LegacyData, important?: boolean): void;
@bindThis
public debug(message: string, data?: Record<string, any> | null, important = false): void {
this.log('debug', message, data, important);
public debug(inputOrMessage: LogEntryInput | string, data?: LegacyData, important = false): void {
if (typeof inputOrMessage === 'string') {
this.log('debug', inputOrMessage, data, important);
} else {
this.logStructured('debug', inputOrMessage);
}
}
/** 通常の動作状況を記録します。 */
public info(input: LogEntryInput): void;
public info(message: string): void;
public info(message: string, data?: LegacyData, important?: boolean): void;
@bindThis
public info(message: string, data?: Record<string, any> | null, important = false): void {
this.log('info', message, data, important);
public info(inputOrMessage: LogEntryInput | string, data?: LegacyData, important = false): void {
if (typeof inputOrMessage === 'string') {
this.log('info', inputOrMessage, data, important);
} else {
this.logStructured('info', inputOrMessage);
}
}
/** 致命的な状況を構造化ログとして記録します。 */
public fatal(input: LogEntryInput): void;
public fatal(message: string): void;
@bindThis
public fatal(inputOrMessage: LogEntryInput | string): void {
if (typeof inputOrMessage === 'string') {
this.logStructured('fatal', { message: inputOrMessage });
} else {
this.logStructured('fatal', inputOrMessage);
}
}
}

View File

@@ -52,15 +52,19 @@ export type LogTraceContext = {
/** LogManagerが出力直前に呼び出すTrace Context取得処理です。 */
export type LogTraceContextProvider = () => LogTraceContext | undefined;
/** ロガーの呼び出し側が構造化ログとして指定する入力です。 */
export type LogWriteInput = {
readonly level: LogLevel;
/** level別のLoggerメソッドが受け取る構造化ログの共通入力です。 */
export type LogEntryInput = {
readonly message: string;
readonly eventName?: string;
readonly attributes?: Readonly<Record<string, unknown>>;
readonly error?: unknown;
};
/** 動的なlevelを含めてLogManagerへ渡す構造化ログの入力です。 */
export type LogWriteInput = LogEntryInput & {
readonly level: LogLevel;
};
/**
* ロガー名を構成する一要素です。
* 色は見やすい形式での表示だけに使い、ログの意味には影響させません。

View File

@@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { MiMeta, UsersRepository } from '@/models/_.js';
import { SignupService } from '@/core/SignupService.js';
import { RoleService } from '@/core/RoleService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { localUsernameSchema, passwordSchema } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
@@ -77,6 +78,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private userEntityService: UserEntityService,
private signupService: SignupService,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, _me, token) => {
const me = _me ? await this.usersRepository.findOneByOrFail({ id: _me.id }) : null;
@@ -93,7 +95,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// 初期パスワードが設定されていないのに初期パスワードが入力された場合
throw new ApiError(meta.errors.wrongInitialPassword);
}
} else if ((this.serverSettings.rootUserId != null && (this.serverSettings.rootUserId !== me?.id)) || token !== null) {
} else if (token !== null || !(await this.roleService.isAdministrator(me))) {
// 初回セットアップではなく、管理者でない場合 or 外部トークンを使用している場合
throw new ApiError(meta.errors.accessDenied);
}

View File

@@ -76,6 +76,91 @@ describe('Logger', () => {
}));
});
test('supports structured input through every level-specific method', () => {
const logger = new Logger('root').createSubLogger('child');
const error = new Error('broken');
const input = {
eventName: 'example.failed',
message: 'failed',
attributes: { id: 'id' },
error,
};
logger.debug(input);
logger.info(input);
logger.warn(input);
logger.error(input);
logger.fatal(input);
expect(mocks.write.mock.calls.map(([entry]) => entry.level)).toEqual([
'debug',
'info',
'warn',
'error',
'fatal',
]);
for (const [entry] of mocks.write.mock.calls) {
expect(entry).toMatchObject({
...input,
context: [
{ name: 'root', color: undefined },
{ name: 'child', color: undefined },
],
});
expect(entry).not.toHaveProperty('compatibility');
}
});
test('level-specific methods own the level even for runtime-invalid input', () => {
const logger = new Logger('root');
// @ts-expect-error level is selected by the method rather than the input object
logger.warn({ level: 'error', message: 'warning' });
expect(mocks.write.mock.calls[0][0]).toMatchObject({
level: 'warn',
message: 'warning',
});
});
test('preserves the legacy string signatures for non-error levels', () => {
const logger = new Logger('root');
logger.debug('debug', { source: 'debug' }, true);
logger.info('info', null, true);
logger.warn('warn', { source: 'warn' });
expect(mocks.write.mock.calls.map(([entry]) => entry.compatibility)).toEqual([
{ legacyLevel: undefined, important: true, data: { source: 'debug' } },
{ legacyLevel: undefined, important: true, data: null },
{ legacyLevel: undefined, important: false, data: { source: 'warn' } },
]);
});
test('records a fatal string through the structured API', () => {
new Logger('root').fatal('fatal message');
expect(mocks.write).toHaveBeenCalledWith({
level: 'fatal',
message: 'fatal message',
context: [{ name: 'root', color: undefined }],
});
});
test('preserves the legacy error string signature', () => {
new Logger('root').error('failed', { requestId: 'request' }, true);
expect(mocks.write).toHaveBeenCalledWith({
level: 'error',
message: 'failed',
context: [{ name: 'root', color: undefined }],
compatibility: {
legacyLevel: undefined,
important: true,
data: { requestId: 'request' },
},
});
});
test('uses Error.toString and adds the Error to existing data', () => {
const logger = new Logger('root');
const error = new TypeError('broken');

View File

@@ -13,8 +13,3 @@ declare const _PERF_PREFIX_: string;
// for dev-mode
declare const _LANGS_FULL_: string[][];
// TagCanvas
interface Window {
TagCanvas: any;
}

View File

@@ -14,9 +14,3 @@ declare const _PERF_PREFIX_: string;
// for dev-mode
declare const _LANGS_FULL_: string[][];
// TagCanvas
interface Window {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TagCanvas: any;
}

View File

@@ -13,8 +13,3 @@ declare const _PERF_PREFIX_: string;
// for dev-mode
declare const _LANGS_FULL_: string[][];
// TagCanvas
interface Window {
TagCanvas: any;
}

File diff suppressed because one or more lines are too long

View File

@@ -22,6 +22,7 @@
"@mcaptcha/core-glue": "0.1.0-alpha-5",
"@misskey-dev/browser-image-resizer": "2024.1.0",
"@misskey-dev/emoji-data": "17.0.3",
"@misskey-dev/tagcanvas-es": "0.1.2",
"@sentry/vue": "10.65.0",
"@simplewebauthn/browser": "13.3.0",
"@syuilo/aiscript": "1.2.1",

View File

@@ -19,15 +19,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.newBg2"></div>
<button class="_button" :class="$style.newButton" @click="releaseQueue()"><i class="ti ti-circle-arrow-up"></i> {{ i18n.ts.newNote }}</button>
</div>
<div :class="$style.notes">
<component
:is="prefer.s.animation ? MkStreamingTimelineItem : 'div'"
v-for="(note, i) in paginator.items.value"
v-bind="prefer.s.animation ? { animatingIn: note._shouldAnimateIn_, animatingOut: note._shouldAnimateOut_ } : {}"
:key="note.id"
:data-scroll-anchor="note.id"
>
<div v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i -1].createdAt, note.createdAt) && paginator.items.value[i -1]._shouldAnimateOut_ !== true">
<component
:is="prefer.s.animation ? TransitionGroup : 'div'"
:class="$style.notes"
:enterActiveClass="$style.transition_x_enterActive"
:leaveActiveClass="$style.transition_x_leaveActive"
:enterFromClass="$style.transition_x_enterFrom"
:leaveToClass="$style.transition_x_leaveTo"
:moveClass="$style.transition_x_move"
tag="div"
>
<template v-for="(note, i) in paginator.items.value" :key="note.id">
<div v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i -1].createdAt, note.createdAt)" :data-scroll-anchor="note.id">
<div :class="$style.date">
<span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i -1].createdAt, note.createdAt)?.prevText }}</span>
<span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span>
@@ -35,15 +38,15 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkNote :class="$style.note" :note="note" :withHardMute="true"/>
</div>
<div v-else-if="note._shouldInsertAd_">
<div v-else-if="note._shouldInsertAd_" :data-scroll-anchor="note.id">
<MkNote :class="$style.note" :note="note" :withHardMute="true"/>
<div :class="$style.ad">
<MkAd :preferForms="['horizontal', 'horizontal-big']"/>
</div>
</div>
<MkNote v-else :class="$style.note" :note="note" :withHardMute="true"/>
</component>
</div>
<MkNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/>
</template>
</component>
<button v-show="paginator.canFetchOlder.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder">
<div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div>
<MkLoading v-else :inline="true"/>
@@ -53,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, watch, onUnmounted, provide, useTemplateRef, onMounted, markRaw } from 'vue';
import { computed, watch, onUnmounted, provide, useTemplateRef, TransitionGroup, onMounted, shallowRef, ref, markRaw } from 'vue';
import * as Misskey from 'misskey-js';
import { useInterval } from '@@/js/use-interval.js';
import { useDocumentVisibility } from '@@/js/use-document-visibility.js';
@@ -69,10 +72,10 @@ import { instance } from '@/instance.js';
import { prefer } from '@/preferences.js';
import { store } from '@/store.js';
import MkNote from '@/components/MkNote.vue';
import MkStreamingTimelineItem, { ITEM_REMOVAL_MS } from '@/components/MkStreamingTimelineItem.vue';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
import { DI } from '@/di.js';
import { useGlobalEvent } from '@/events.js';
import { globalEvents, useGlobalEvent } from '@/events.js';
import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
import { Paginator } from '@/utility/paginator.js';
@@ -101,8 +104,6 @@ provide('inTimeline', true);
provide('tl_withSensitive', computed(() => props.withSensitive));
provide(DI.inChannel, computed(() => props.src === 'channel' ? props.channel ?? null : null));
const itemRemovalDelay = prefer.s.animation ? ITEM_REMOVAL_MS : false;
let paginator: IPaginator<Misskey.entities.Note>;
if (props.src === 'antenna') {
@@ -111,7 +112,6 @@ if (props.src === 'antenna') {
antennaId: props.antenna!,
})),
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'home') {
paginator = markRaw(new Paginator('notes/timeline', {
@@ -120,7 +120,6 @@ if (props.src === 'antenna') {
withFiles: props.onlyFiles ? true : undefined,
})),
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'local') {
paginator = markRaw(new Paginator('notes/local-timeline', {
@@ -130,7 +129,6 @@ if (props.src === 'antenna') {
withFiles: props.onlyFiles ? true : undefined,
})),
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'social') {
paginator = markRaw(new Paginator('notes/hybrid-timeline', {
@@ -140,7 +138,6 @@ if (props.src === 'antenna') {
withFiles: props.onlyFiles ? true : undefined,
})),
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'global') {
paginator = markRaw(new Paginator('notes/global-timeline', {
@@ -149,12 +146,10 @@ if (props.src === 'antenna') {
withFiles: props.onlyFiles ? true : undefined,
})),
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'mentions') {
paginator = markRaw(new Paginator('notes/mentions', {
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'directs') {
paginator = markRaw(new Paginator('notes/mentions', {
@@ -162,7 +157,6 @@ if (props.src === 'antenna') {
visibility: 'specified',
},
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'list') {
paginator = markRaw(new Paginator('notes/user-list-timeline', {
@@ -172,7 +166,6 @@ if (props.src === 'antenna') {
listId: props.list!,
})),
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'channel') {
paginator = markRaw(new Paginator('channels/timeline', {
@@ -180,7 +173,6 @@ if (props.src === 'antenna') {
channelId: props.channel!,
})),
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'role') {
paginator = markRaw(new Paginator('roles/notes', {
@@ -188,7 +180,6 @@ if (props.src === 'antenna') {
roleId: props.role!,
})),
useShallowRef: true,
itemRemovalDelay,
}));
} else {
throw new Error('Unrecognized timeline type: ' + props.src);
@@ -437,16 +428,45 @@ defineExpose({
</script>
<style lang="scss" module>
.transition_x_move {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}
.transition_x_enterActive {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
&.note,
.note {
/* Skip Note Rendering有効時、TransitionGroupでnoteを追加するときに一瞬がくっとなる問題を抑制する */
content-visibility: visible !important;
}
}
.transition_x_leaveActive {
transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1);
}
.transition_x_enterFrom {
opacity: 0;
transform: translateY(max(-64px, -100%));
}
@supports (interpolate-size: allow-keywords) {
.transition_x_leaveTo {
interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要
height: 0;
}
}
.transition_x_leaveTo {
opacity: 0;
}
.notes {
container-type: inline-size;
background: var(--MI_THEME-panel);
}
.date,
.note {
box-sizing: border-box;
}
.note:not(:empty) {
border-bottom: solid 0.5px var(--MI_THEME-divider);
}

View File

@@ -14,15 +14,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div v-else ref="rootEl">
<div :class="$style.notifications">
<component
:is="prefer.s.animation ? MkStreamingTimelineItem : 'div'"
v-for="(notification, i) in paginator.items.value"
v-bind="prefer.s.animation ? { animatingIn: notification._shouldAnimateIn_, animatingOut: notification._shouldAnimateOut_ } : {}"
:key="notification.id"
:data-scroll-anchor="notification.id"
:class="$style.item"
>
<component
:is="prefer.s.animation ? TransitionGroup : 'div'" :class="[$style.notifications]"
:enterActiveClass="$style.transition_x_enterActive"
:leaveActiveClass="$style.transition_x_leaveActive"
:enterFromClass="$style.transition_x_enterFrom"
:leaveToClass="$style.transition_x_leaveTo"
:moveClass="$style.transition_x_move"
tag="div"
>
<div v-for="(notification, i) in paginator.items.value" :key="notification.id" :data-scroll-anchor="notification.id" :class="$style.item">
<div v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i -1].createdAt, notification.createdAt)" :class="$style.date">
<span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i -1].createdAt, notification.createdAt)?.prevText }}</span>
<span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span>
@@ -30,8 +31,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type) && 'note' in notification" :class="$style.content" :note="notification.note" :withHardMute="true"/>
<XNotification v-else :class="$style.content" :notification="notification" :withTime="true" :full="true"/>
</component>
</div>
</div>
</component>
<button v-show="paginator.canFetchOlder.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder">
<div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div>
<MkLoading v-else/>
@@ -41,7 +42,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { onUnmounted, onMounted, computed, useTemplateRef, markRaw, watch } from 'vue';
import { onUnmounted, onMounted, computed, useTemplateRef, TransitionGroup, markRaw, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { notificationTypes } from 'misskey-js';
import { useInterval } from '@@/js/use-interval.js';
@@ -52,7 +53,6 @@ import MkNote from '@/components/MkNote.vue';
import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import MkStreamingTimelineItem, { ITEM_REMOVAL_MS } from '@/components/MkStreamingTimelineItem.vue';
import { prefer } from '@/preferences.js';
import { store } from '@/store.js';
import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
@@ -64,20 +64,16 @@ const props = defineProps<{
const rootEl = useTemplateRef('rootEl');
const itemRemovalDelay = prefer.s.animation ? ITEM_REMOVAL_MS : false;
const paginator = prefer.s.useGroupedNotifications ? markRaw(new Paginator('i/notifications-grouped', {
limit: 20,
computedParams: computed(() => ({
excludeTypes: props.excludeTypes ?? undefined,
})),
itemRemovalDelay,
})) : markRaw(new Paginator('i/notifications', {
limit: 20,
computedParams: computed(() => ({
excludeTypes: props.excludeTypes ?? undefined,
})),
itemRemovalDelay,
}));
const MIN_POLLING_INTERVAL = 1000 * 10;
@@ -193,8 +189,38 @@ defineExpose({
</script>
<style lang="scss" module>
.item {
border-bottom: solid 0.5px var(--MI_THEME-divider);
.transition_x_move {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}
.transition_x_enterActive {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
&.content,
.content {
/* Skip Note Rendering有効時、TransitionGroupで通知を追加するときに一瞬がくっとなる問題を抑制する */
content-visibility: visible !important;
}
}
.transition_x_leaveActive {
transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1);
}
.transition_x_enterFrom {
opacity: 0;
transform: translateY(max(-64px, -100%));
}
@supports (interpolate-size: allow-keywords) {
.transition_x_enterFrom {
interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要
height: 0;
}
}
.transition_x_leaveTo {
opacity: 0;
}
.notifications {
@@ -202,6 +228,10 @@ defineExpose({
background: var(--MI_THEME-panel);
}
.item {
border-bottom: solid 0.5px var(--MI_THEME-divider);
}
.date {
display: flex;
font-size: 85%;

View File

@@ -1,127 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div
ref="rootEl"
:class="[$style.root, {
[$style.enter]: animatingIn && animating && !animatingOut,
[$style.leave]: animatingOut,
[$style.animating]: animating,
}]"
@animationend.self="onAnimationEnd"
@animationcancel.self="onAnimationEnd"
>
<div ref="innerEl">
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
export const ITEM_REMOVAL_MS = 200;
const supportsInterpolateSize = CSS.supports('interpolate-size: allow-keywords');
let resizeObserver: ResizeObserver | null = null;
if (!supportsInterpolateSize) {
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const target = entry.target as HTMLElement;
const root = target.parentElement;
if (root != null) {
root.style.setProperty('--child-height', `${entry.contentRect.height}px`);
}
}
});
}
</script>
<script setup lang="ts">
import { useTemplateRef, onMounted, onBeforeUnmount, ref, watch } from 'vue';
const props = defineProps<{
animatingIn?: boolean;
animatingOut?: boolean;
}>();
const rootEl = useTemplateRef('rootEl');
const innerEl = useTemplateRef('innerEl');
const animating = ref(false);
watch([() => props.animatingIn, () => props.animatingOut], ([animatingIn, animatingOut]) => {
if (animatingIn === true || animatingOut === true) animating.value = true;
}, { immediate: true });
function onAnimationEnd() {
// 削除アニメーション中enterから差し替わった場合を含むは、要素が消えるまで再生中扱いのままにする
if (props.animatingOut === true) return;
animating.value = false;
}
onMounted(() => {
if (resizeObserver != null && rootEl.value != null && innerEl.value != null) {
resizeObserver.observe(innerEl.value);
rootEl.value.style.setProperty('--child-height', `${innerEl.value.getBoundingClientRect().height}px`);
}
});
onBeforeUnmount(() => {
if (resizeObserver != null && innerEl.value != null) {
resizeObserver.unobserve(innerEl.value);
}
});
</script>
<style module lang="scss">
.animating {
overflow: clip;
}
.inner {
display: flow-root;
}
.enter {
animation: enterAnim 0.7s cubic-bezier(0.23, 1, 0.32, 1) both;
}
.leave {
animation: leaveAnim 0.2s cubic-bezier(0,.5,.5,1) both;
}
@supports (interpolate-size: allow-keywords) {
.root {
interpolate-size: allow-keywords;
}
}
@keyframes enterAnim {
from {
height: 0;
opacity: 0;
transform: translateY(max(-64px, -100%));
}
to {
height: var(--child-height, auto);
opacity: 1;
transform: translateY(0);
}
}
@keyframes leaveAnim {
from {
height: var(--child-height, auto);
opacity: 1;
}
to {
height: 0;
opacity: 0;
}
}
</style>

View File

@@ -5,8 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div ref="rootEl" :class="$style.root">
<canvas :id="idForCanvas" ref="canvasEl" style="display: block;" :width="width" height="300" @contextmenu.prevent="() => {}"></canvas>
<div :id="idForTags" ref="tagsEl" :class="$style.tags">
<canvas ref="canvasEl" style="display: block;" :width="width" height="300" @contextmenu.prevent="() => {}"></canvas>
<div ref="tagsEl" :class="$style.tags">
<ul>
<slot></slot>
</ul>
@@ -15,62 +15,68 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { onMounted, watch, onBeforeUnmount, ref, useTemplateRef } from 'vue';
import { onMounted, onBeforeUnmount, nextTick, ref, shallowRef, useTemplateRef } from 'vue';
import { themeManager } from '@/theme.js';
import tinycolor from 'tinycolor2';
import { TagCanvas } from '@misskey-dev/tagcanvas-es';
const loaded = !!window.TagCanvas;
const SAFE_FOR_HTML_ID = 'abcdefghijklmnopqrstuvwxyz';
const idForCanvas = Array.from({ length: 16 }, () => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join('');
const idForTags = Array.from({ length: 16 }, () => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join('');
const available = ref(false);
const rootEl = useTemplateRef('rootEl');
const canvasEl = useTemplateRef('canvasEl');
const tagsEl = useTemplateRef('tagsEl');
const tagCanvas = shallowRef<TagCanvas | null>(null);
const width = ref(300);
watch(available, () => {
try {
window.TagCanvas.Start(idForCanvas, idForTags, {
textColour: '#ffffff',
outlineColour: tinycolor(themeManager.currentCompiledTheme!.accent).toHexString(),
outlineRadius: 10,
initial: [-0.030, -0.010],
frontSelect: true,
imageRadius: 8,
//dragControl: true,
dragThreshold: 3,
wheelZoom: false,
reverse: true,
depth: 0.5,
maxSpeed: 0.2,
minSpeed: 0.003,
stretchX: 0.8,
stretchY: 0.8,
});
} catch (err) {}
});
function createTagCanvas() {
if (tagCanvas.value) {
tagCanvas.value.destroy();
tagCanvas.value = null;
}
if (tagsEl.value == null || canvasEl.value == null) return;
if (tagsEl.value.children[0].children.length === 0) return;
tagCanvas.value = new TagCanvas(canvasEl.value, {
tagContainer: tagsEl.value,
textColor: '#ffffff',
outlineColor: tinycolor(themeManager.currentCompiledTheme!.accent).toHexString(),
outlineRadius: 10,
initial: [-0.030, -0.010],
frontSelect: true,
imageRadius: 8,
// dragControl: true,
dragThreshold: 3,
wheelZoom: false,
reverse: true,
depth: 0.5,
maxSpeed: 0.2,
minSpeed: 0.003,
stretchX: 0.8,
stretchY: 0.8,
});
}
onMounted(() => {
if (rootEl.value) width.value = rootEl.value.offsetWidth;
if (loaded) {
available.value = true;
} else {
window.document.head.appendChild(Object.assign(window.document.createElement('script'), {
async: true,
src: '/client-assets/tagcanvas.min.js',
})).addEventListener('load', () => available.value = true);
}
nextTick(() => {
createTagCanvas();
});
});
onBeforeUnmount(() => {
if (window.TagCanvas) window.TagCanvas.Delete(idForCanvas);
if (tagCanvas.value) {
tagCanvas.value.destroy();
tagCanvas.value = null;
}
});
defineExpose({
update: () => {
window.TagCanvas.Update(idForCanvas);
if (tagCanvas.value) {
tagCanvas.value.update();
} else {
createTagCanvas();
}
},
});
</script>

View File

@@ -17,8 +17,6 @@ export type MisskeyEntity = {
id: string;
createdAt: string;
_shouldInsertAd_?: boolean;
_shouldAnimateIn_?: boolean;
_shouldAnimateOut_?: boolean;
};
type AbsEndpointType = {
@@ -109,8 +107,6 @@ export class Paginator<
private canFetchDetection: 'safe' | 'limit' | null = null;
private aheadQueue: T[] = [];
private useShallowRef: SRef;
private itemRemovalDelay: number | false;
private removalTimers = new Map<string, number>();
// 配列内の要素をどのような順序で並べるか
// newest: 新しいものが先頭 (default)
@@ -142,9 +138,6 @@ export class Paginator<
useShallowRef?: SRef;
// アイテム削除時にアニメーションを待つ時間 (ms)
itemRemovalDelay?: number | false;
canSearch?: boolean;
searchParamName?: keyof E['req'];
}) {
@@ -167,7 +160,6 @@ export class Paginator<
this.noPaging = props.noPaging ?? false;
this.offsetMode = props.offsetMode ?? false;
this.canSearch = props.canSearch ?? false;
this.itemRemovalDelay = props.itemRemovalDelay ?? false;
this.searchParamName = props.searchParamName ?? 'search';
this.getNewestId = this.getNewestId.bind(this);
@@ -199,7 +191,6 @@ export class Paginator<
}
public async init(): Promise<void> {
this.clearRemovalTimers();
this.items.value = [];
this.aheadQueue = [];
this.queuedAheadItemsCount.value = 0;
@@ -393,8 +384,6 @@ export class Paginator<
public prepend(item: T): void {
if (this.items.value.some(x => x.id === item.id)) return;
item._shouldAnimateIn_ = true;
item._shouldAnimateOut_ = false;
this.items.value.unshift(item);
this.trim(false);
if (this.useShallowRef) triggerRef(this.items);
@@ -410,10 +399,6 @@ export class Paginator<
public releaseQueue(): void {
if (this.aheadQueue.length === 0) return; // これやらないと余計なre-renderが走る
for (const item of this.aheadQueue) {
item._shouldAnimateIn_ = false; // 一気に入るときは挿入アニメーションさせない
item._shouldAnimateOut_ = false;
}
this.unshiftItems(this.aheadQueue);
this.aheadQueue = [];
this.queuedAheadItemsCount.value = 0;
@@ -422,43 +407,13 @@ export class Paginator<
public removeItem(id: string): void {
// TODO: queueからも消す
if (this.itemRemovalDelay === false) {
const index = this.items.value.findIndex(x => x.id === id);
if (index !== -1) {
this.items.value.splice(index, 1);
if (this.useShallowRef) triggerRef(this.items);
}
return;
}
const index = this.items.value.findIndex(x => x.id === id);
if (index !== -1) {
this.items.value.splice(index, 1);
if (this.useShallowRef) triggerRef(this.items);
const item = this.items.value[index]!;
item._shouldAnimateOut_ = true;
if (this.useShallowRef) triggerRef(this.items);
if (this.removalTimers.has(id)) return;
this.removalTimers.set(id, window.setTimeout(() => {
this.removalTimers.delete(id);
const currentIndex = this.items.value.findIndex(x => x.id === id);
if (currentIndex !== -1) {
this.items.value.splice(currentIndex, 1);
if (this.useShallowRef) triggerRef(this.items);
}
}, this.itemRemovalDelay + 20)); // アニメーション終了からやや余裕をもたせる
}
}
private clearRemovalTimers(): void {
for (const timer of this.removalTimers.values()) {
window.clearTimeout(timer);
}
this.removalTimers.clear();
}
public updateItem(id: string, updater: (item: T) => T): void {
// TODO: queueのも更新

View File

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

8
pnpm-lock.yaml generated
View File

@@ -673,6 +673,9 @@ importers:
'@misskey-dev/emoji-data':
specifier: 17.0.3
version: 17.0.3
'@misskey-dev/tagcanvas-es':
specifier: 0.1.2
version: 0.1.2
'@sentry/vue':
specifier: 10.65.0
version: 10.65.0(vue@3.5.39(typescript@6.0.2))
@@ -2447,6 +2450,9 @@ packages:
'@misskey-dev/summaly@5.5.1':
resolution: {integrity: sha512-CfpVtmFgltjNS1FAS5+bOluKAF/irZuQnUxAw66ydnp/uesiNNu62ltf9gEgwutT5S+itGtlnP87AOx6DDn/1A==}
'@misskey-dev/tagcanvas-es@0.1.2':
resolution: {integrity: sha512-SGqyu30/CCQyIC95Zcmj3k1vYXwfX66/arsxpl1UUulUqvlRpTpuhB7kEAAps1lqXin0rQzXknb31EeveNKsaw==}
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==}
cpu: [arm64]
@@ -10505,6 +10511,8 @@ snapshots:
optionalDependencies:
fastify: 5.8.5
'@misskey-dev/tagcanvas-es@0.1.2': {}
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
optional: true