mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-05-14 00:35:52 +02:00
Merge branch 'develop' into room
This commit is contained in:
@@ -228,6 +228,6 @@
|
||||
"pid-port": "2.1.0",
|
||||
"simple-oauth2": "5.1.0",
|
||||
"supertest": "7.2.2",
|
||||
"vite": "8.0.2"
|
||||
"vite": "8.0.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,7 +376,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
isLocked: person.manuallyApprovesFollowers,
|
||||
movedToUri: person.movedTo,
|
||||
movedAt: person.movedTo ? new Date() : null,
|
||||
alsoKnownAs: person.alsoKnownAs,
|
||||
alsoKnownAs: toArray(person.alsoKnownAs),
|
||||
isExplorable: person.discoverable,
|
||||
username: person.preferredUsername,
|
||||
usernameLower: person.preferredUsername?.toLowerCase(),
|
||||
@@ -568,7 +568,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
isCat: (person as any).isCat === true,
|
||||
isLocked: person.manuallyApprovesFollowers,
|
||||
movedToUri: person.movedTo ?? null,
|
||||
alsoKnownAs: person.alsoKnownAs ?? null,
|
||||
alsoKnownAs: person.alsoKnownAs ? toArray(person.alsoKnownAs) : null,
|
||||
isExplorable: person.discoverable,
|
||||
...(await this.resolveAvatarAndBanner(exist, person.icon, person.image).catch(() => ({}))),
|
||||
} as Partial<MiRemoteUser> & Pick<MiRemoteUser, 'isBot' | 'isCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;
|
||||
|
||||
@@ -51,6 +51,7 @@ import { ChatService } from '@/core/ChatService.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
import type { NoteEntityService } from './NoteEntityService.js';
|
||||
import type { PageEntityService } from './PageEntityService.js';
|
||||
import { toArray } from '@/misc/prelude/array.js';
|
||||
|
||||
const Ajv = _Ajv.default;
|
||||
const ajv = new Ajv();
|
||||
@@ -527,10 +528,10 @@ export class UserEntityService implements OnModuleInit {
|
||||
url: profile!.url,
|
||||
uri: user.uri,
|
||||
movedTo: user.movedToUri ? this.apPersonService.resolvePerson(user.movedToUri).then(user => user.id).catch(() => null) : null,
|
||||
alsoKnownAs: user.alsoKnownAs
|
||||
? Promise.all(user.alsoKnownAs.map(uri => this.apPersonService.fetchPerson(uri).then(user => user?.id).catch(() => null)))
|
||||
.then(xs => xs.length === 0 ? null : xs.filter(x => x != null))
|
||||
: null,
|
||||
alsoKnownAs: user.alsoKnownAs ?
|
||||
Promise.all(toArray(user.alsoKnownAs).map(uri => this.apPersonService.fetchPerson(uri).then(user => user?.id).catch(() => null)))
|
||||
.then(xs => xs.length === 0 ? null : xs.filter(x => x != null))
|
||||
: null,
|
||||
createdAt: this.idService.parse(user.id).date.toISOString(),
|
||||
updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null,
|
||||
lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null,
|
||||
|
||||
@@ -224,6 +224,51 @@ describe('ActivityPub', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('alsoKnownAs field', () => {
|
||||
test('Handle alsoKnownAs as an array', async () => {
|
||||
const actor = {
|
||||
...createRandomActor(),
|
||||
alsoKnownAs: ['https://example.com/users/alice', 'https://example.com/users/alice2'],
|
||||
};
|
||||
|
||||
resolver.register(actor.id, actor);
|
||||
|
||||
const user = await personService.createPerson(actor.id, resolver);
|
||||
|
||||
assert.deepStrictEqual(user.alsoKnownAs, actor.alsoKnownAs);
|
||||
});
|
||||
|
||||
test('Handle alsoKnownAs as a string', async () => {
|
||||
const actor = {
|
||||
...createRandomActor(),
|
||||
alsoKnownAs: 'https://example.com/users/alice',
|
||||
};
|
||||
|
||||
resolver.register(actor.id, actor);
|
||||
|
||||
const user = await personService.createPerson(actor.id, resolver);
|
||||
|
||||
assert.deepStrictEqual(user.alsoKnownAs, [actor.alsoKnownAs]);
|
||||
});
|
||||
|
||||
test('Update person with alsoKnownAs as a string', async () => {
|
||||
const actor = createRandomActor();
|
||||
resolver.register(actor.id, actor);
|
||||
const user = await personService.createPerson(actor.id, resolver);
|
||||
|
||||
const updatedActor = {
|
||||
...actor,
|
||||
alsoKnownAs: 'https://example.com/users/alice',
|
||||
};
|
||||
resolver.register(actor.id, updatedActor);
|
||||
|
||||
await personService.updatePerson(actor.id, resolver, updatedActor);
|
||||
|
||||
const updatedUser = await personService.fetchPerson(actor.id);
|
||||
assert.deepStrictEqual(updatedUser?.alsoKnownAs, [updatedActor.alsoKnownAs]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Collection visibility', () => {
|
||||
test('Public following/followers', async () => {
|
||||
const actor = createRandomActor();
|
||||
|
||||
@@ -248,6 +248,16 @@ describe('UserEntityService', () => {
|
||||
expect(actual.achievements).toEqual(achievements);
|
||||
});
|
||||
|
||||
test('alsoKnownAs as string does not throw', async () => {
|
||||
const me = await createUser();
|
||||
const who = await createUser();
|
||||
|
||||
const whoWithStringAlsoKnownAs: MiUser = { ...who, alsoKnownAs: 'https://remote.example.com/users/alice' as any };
|
||||
|
||||
const actual = await service.pack(whoWithStringAlsoKnownAs, me, { schema: 'UserDetailedNotMe' }) as any;
|
||||
expect(Array.isArray(actual.alsoKnownAs)).toBe(true);
|
||||
});
|
||||
|
||||
describe('packManyによるpreloadがある時、preloadが無い時とpackの結果が同じになるか見たい', () => {
|
||||
test('no-preload', async() => {
|
||||
const me = await createUser();
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"estree-walker": "3.0.3",
|
||||
"i18n": "workspace:*",
|
||||
"magic-string": "0.30.21",
|
||||
"rolldown": "1.0.0-rc.11",
|
||||
"vite": "8.0.2"
|
||||
"rolldown": "1.0.0-rc.13",
|
||||
"vite": "8.0.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.2",
|
||||
"@typescript-eslint/parser": "8.57.2",
|
||||
"@vitest/coverage-v8": "4.1.1",
|
||||
"@vitest/coverage-v8": "4.1.2",
|
||||
"@vue/runtime-core": "3.5.30",
|
||||
"acorn": "8.16.0",
|
||||
"cross-env": "10.1.0",
|
||||
@@ -57,7 +57,7 @@
|
||||
"sass-embedded": "1.98.0",
|
||||
"start-server-and-test": "2.1.5",
|
||||
"tsx": "4.21.0",
|
||||
"vite": "8.0.2",
|
||||
"vite": "8.0.7",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vue-component-type-helpers": "3.2.6",
|
||||
"vue-eslint-parser": "10.4.0",
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
"punycode.js": "2.3.1",
|
||||
"qr-code-styling": "1.9.2",
|
||||
"qr-scanner": "1.4.2",
|
||||
"sanitize-html": "2.17.1",
|
||||
"sanitize-html": "2.17.2",
|
||||
"shiki": "3.23.0",
|
||||
"textarea-caret": "3.1.0",
|
||||
"three": "0.183.2",
|
||||
@@ -112,7 +112,7 @@
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.2",
|
||||
"@typescript-eslint/parser": "8.57.2",
|
||||
"@vitest/coverage-v8": "4.1.1",
|
||||
"@vitest/coverage-v8": "4.1.2",
|
||||
"@vue/compiler-core": "3.5.30",
|
||||
"acorn": "8.16.0",
|
||||
"astring": "1.9.0",
|
||||
@@ -131,17 +131,17 @@
|
||||
"prettier": "3.8.1",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"rolldown": "1.0.0-rc.11",
|
||||
"rolldown": "1.0.0-rc.13",
|
||||
"sass-embedded": "1.98.0",
|
||||
"seedrandom": "3.0.5",
|
||||
"start-server-and-test": "2.1.5",
|
||||
"storybook": "10.3.3",
|
||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||
"tsx": "4.21.0",
|
||||
"vite": "8.0.2",
|
||||
"vite": "8.0.7",
|
||||
"vite-plugin-glsl": "1.5.6",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vitest": "4.1.1",
|
||||
"vitest": "4.1.2",
|
||||
"vitest-fetch-mock": "0.4.5",
|
||||
"vue-component-type-helpers": "3.2.6",
|
||||
"vue-eslint-parser": "10.4.0",
|
||||
|
||||
@@ -4,14 +4,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<button
|
||||
v-if="!link"
|
||||
ref="el" class="_button"
|
||||
<component
|
||||
:is="component"
|
||||
ref="el"
|
||||
class="_button"
|
||||
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait, [$style.active]: active }]"
|
||||
:type="type"
|
||||
:name="name"
|
||||
:value="value"
|
||||
:disabled="disabled || wait"
|
||||
v-bind="cProps"
|
||||
@click="emit('click', $event)"
|
||||
@mousedown="onMousedown"
|
||||
>
|
||||
@@ -19,34 +17,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<div :class="$style.content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</button>
|
||||
<MkA
|
||||
v-else class="_button"
|
||||
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait, [$style.active]: active }]"
|
||||
:to="to ?? '#'"
|
||||
:behavior="linkBehavior"
|
||||
@mousedown="onMousedown"
|
||||
>
|
||||
<div ref="ripples" :class="$style.ripples" :data-children-class="$style.ripple"></div>
|
||||
<div :class="$style.content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</MkA>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onMounted, useTemplateRef } from 'vue';
|
||||
import type { MkABehavior } from '@/components/global/MkA.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
<script lang="ts">
|
||||
interface MkButtonBase {
|
||||
type?: string;
|
||||
primary?: boolean;
|
||||
gradate?: boolean;
|
||||
rounded?: boolean;
|
||||
inline?: boolean;
|
||||
link?: boolean;
|
||||
to?: string;
|
||||
linkBehavior?: MkABehavior;
|
||||
autofocus?: boolean;
|
||||
wait?: boolean;
|
||||
danger?: boolean;
|
||||
@@ -55,12 +35,39 @@ const props = defineProps<{
|
||||
large?: boolean;
|
||||
transparent?: boolean;
|
||||
asLike?: boolean;
|
||||
iconOnly?: boolean;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
interface MkButtonAsButton extends MkButtonBase {
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
name?: string;
|
||||
value?: string;
|
||||
disabled?: boolean;
|
||||
iconOnly?: boolean;
|
||||
active?: boolean;
|
||||
}>();
|
||||
}
|
||||
|
||||
interface MkButtonAsLink extends MkButtonBase {
|
||||
type: 'a';
|
||||
href: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
}
|
||||
|
||||
interface MkButtonAsRouterLink extends MkButtonBase {
|
||||
type: 'routerLink';
|
||||
to: string;
|
||||
linkBehavior?: MkABehavior;
|
||||
}
|
||||
|
||||
export type MkButtonProps = MkButtonAsButton | MkButtonAsLink | MkButtonAsRouterLink;
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, computed, onMounted, useTemplateRef } from 'vue';
|
||||
import MkA from '@/components/global/MkA.vue';
|
||||
import type { MkABehavior } from '@/components/global/MkA.vue';
|
||||
|
||||
const props = defineProps<MkButtonProps>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'click', payload: PointerEvent): void;
|
||||
@@ -69,6 +76,22 @@ const emit = defineEmits<{
|
||||
const el = useTemplateRef('el');
|
||||
const ripples = useTemplateRef('ripples');
|
||||
|
||||
const component = computed(() => {
|
||||
if (props.type === 'a') return 'a';
|
||||
if (props.type === 'routerLink') return MkA;
|
||||
return 'button';
|
||||
});
|
||||
const cProps = computed(() => {
|
||||
if (props.type === 'a') return { href: props.href ?? '#', target: props.target, rel: props.rel };
|
||||
if (props.type === 'routerLink') return { to: props.to!, behavior: props.linkBehavior };
|
||||
return {
|
||||
type: props.type ?? 'button',
|
||||
name: props.name,
|
||||
value: props.value,
|
||||
disabled: props.disabled || props.wait,
|
||||
};
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autofocus) {
|
||||
nextTick(() => {
|
||||
|
||||
@@ -5,7 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<template>
|
||||
<div ref="el" :class="$style.root">
|
||||
<MkMenu :items="items" :align="align" :width="width" :asDrawer="false" @close="onChildClosed"/>
|
||||
<MkMenu
|
||||
:items="items"
|
||||
:align="align"
|
||||
:width="width"
|
||||
:asDrawer="false"
|
||||
:debugDisablePredictionCone="debugDisablePredictionCone"
|
||||
:debugShowPredictionCone="debugShowPredictionCone"
|
||||
@close="onChildClosed"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -19,6 +27,8 @@ const props = defineProps<{
|
||||
anchorElement: HTMLElement;
|
||||
rootElement: HTMLElement;
|
||||
width?: number;
|
||||
debugDisablePredictionCone?: boolean;
|
||||
debugShowPredictionCone?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -80,6 +90,7 @@ onUnmounted(() => {
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
rootElement: el,
|
||||
checkHit: (ev: MouseEvent) => {
|
||||
return (ev.target === el.value || el.value?.contains(ev.target as Node));
|
||||
},
|
||||
|
||||
@@ -27,6 +27,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
}"
|
||||
@keydown.stop="() => {}"
|
||||
@contextmenu.self.prevent="() => {}"
|
||||
@mousemove.passive="onMouseMove"
|
||||
@mouseleave.passive="onMouseLeave"
|
||||
>
|
||||
<template v-for="item in (items2 ?? [])">
|
||||
<div v-if="item.type === 'divider'" role="separator" tabindex="-1" :class="$style.divider"></div>
|
||||
@@ -169,6 +171,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
tabindex="0"
|
||||
:class="['_button', $style.item, $style.parent, { [$style.active]: childShowingItem === item }]"
|
||||
@mouseenter.prevent="preferClick ? null : showChildren(item, $event)"
|
||||
@mousemove="parentMouseMove"
|
||||
@keydown.enter.prevent="preferClick ? null : showChildren(item, $event)"
|
||||
@click.prevent="!preferClick ? null : showChildren(item, $event)"
|
||||
>
|
||||
@@ -206,15 +209,30 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<span v-if="items2 == null || items2.length === 0" tabindex="-1" :class="[$style.none, $style.item]">
|
||||
<span>{{ i18n.ts.none }}</span>
|
||||
</span>
|
||||
|
||||
<div
|
||||
:class="[$style.guard, { [$style.showGuard]: debugShowPredictionCone }]"
|
||||
:style="{ clipPath: guardPolygon, top: guard.top + 'px' }"
|
||||
@mousemove="guardMouseMove"
|
||||
></div>
|
||||
</div>
|
||||
<div v-if="childMenu">
|
||||
<XChild ref="child" :items="childMenu" :anchorElement="childTarget!" :rootElement="itemsEl!" @actioned="childActioned" @closed="closeChild"/>
|
||||
</div>
|
||||
|
||||
<XChild
|
||||
v-if="childMenu" :key="childMenuKey"
|
||||
ref="child"
|
||||
:items="childMenu"
|
||||
:anchorElement="childTarget!"
|
||||
:rootElement="itemsEl!"
|
||||
:debugDisablePredictionCone="props.debugDisablePredictionCone"
|
||||
:debugShowPredictionCone="props.debugShowPredictionCone"
|
||||
@actioned="childActioned"
|
||||
@closed="closeChild"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineAsyncComponent, inject, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, unref, watch, shallowRef } from 'vue';
|
||||
import { computed, defineAsyncComponent, inject, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, unref, watch, shallowRef, reactive } from 'vue';
|
||||
import type { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio, MenuRadioOption, MenuParent } from '@/types/menu.js';
|
||||
import type { Keymap } from '@/utility/hotkey.js';
|
||||
import MkSwitchButton from '@/components/MkSwitch.button.vue';
|
||||
@@ -236,6 +254,8 @@ const props = defineProps<{
|
||||
align?: 'center' | string;
|
||||
width?: number;
|
||||
maxHeight?: number;
|
||||
debugDisablePredictionCone?: boolean;
|
||||
debugShowPredictionCone?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -292,6 +312,7 @@ watch(() => props.items, () => {
|
||||
});
|
||||
|
||||
const childMenu = ref<MenuItem[] | null>();
|
||||
const childMenuKey = ref(0);
|
||||
const childTarget = shallowRef<HTMLElement>();
|
||||
|
||||
function closeChild() {
|
||||
@@ -348,6 +369,7 @@ async function showRadioOptions(item: MenuRadio, ev: MouseEvent | PointerEvent |
|
||||
} else {
|
||||
childTarget.value = (ev.currentTarget ?? ev.target) as HTMLElement;
|
||||
childMenu.value = children;
|
||||
childMenuKey.value++;
|
||||
childShowingItem.value = item;
|
||||
}
|
||||
}
|
||||
@@ -378,6 +400,7 @@ async function showChildren(item: MenuParent, ev: MouseEvent | PointerEvent | Ke
|
||||
childTarget.value = (ev.currentTarget ?? ev.target) as HTMLElement;
|
||||
// これでもリアクティビティは保たれる
|
||||
childMenu.value = children;
|
||||
childMenuKey.value++;
|
||||
childShowingItem.value = item;
|
||||
}
|
||||
}
|
||||
@@ -472,6 +495,67 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
disposeHandlers();
|
||||
});
|
||||
|
||||
const guard = reactive({
|
||||
enabled: false,
|
||||
top: 0,
|
||||
cursorSideX: 0,
|
||||
cursorSideY: 0,
|
||||
childSideTopY: 0,
|
||||
childSideBottomY: 0,
|
||||
direction: 'toRight',
|
||||
});
|
||||
|
||||
const guardPolygon = computed(() =>
|
||||
guard.enabled
|
||||
? guard.direction === 'toRight'
|
||||
? `polygon(${guard.cursorSideX}px ${guard.cursorSideY}px, 101% ${guard.childSideTopY}px, 101% ${guard.childSideBottomY}px)` // ぴったり端に100%で覆ってもなぜか端でカーソルのイベントが後ろに貫通するので1%だけ伸ばす
|
||||
: `polygon(0% ${guard.childSideTopY}px, 0% ${guard.childSideBottomY}px, ${guard.cursorSideX}px ${guard.cursorSideY}px)`
|
||||
: 'polygon(0 0, 0 0, 0 0)',
|
||||
);
|
||||
|
||||
function parentMouseMove(ev: MouseEvent) {
|
||||
if (props.debugDisablePredictionCone) return;
|
||||
if (isTouchUsing) return;
|
||||
if (child.value == null || child.value.rootElement == null) return;
|
||||
|
||||
ev.stopPropagation();
|
||||
|
||||
const itemBounding = (ev.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const rootBounding = itemsEl.value!.getBoundingClientRect();
|
||||
const childBounding = child.value.rootElement.getBoundingClientRect();
|
||||
const isChildRight = childBounding.left > rootBounding.left;
|
||||
|
||||
const CURSOR_SIDE_X_PADDING = 3; // (px)
|
||||
const CHILD_SIDE_Y_PADDING_BASE = 70; // (px)
|
||||
const CHILD_SIDE_Y_PADDING_EXTEND = 30; // (px)
|
||||
const SCALE_FACTOR_COMPUTE_DISTANCE = 300; // コーンの広さが最大になる距離(px)
|
||||
const localMouseX = ev.clientX - itemBounding.left;
|
||||
const localMouseY = ev.clientY - rootBounding.top;
|
||||
const scaleFactor = isChildRight ? Math.min((itemBounding.width - localMouseX), SCALE_FACTOR_COMPUTE_DISTANCE) / SCALE_FACTOR_COMPUTE_DISTANCE : Math.min(localMouseX, SCALE_FACTOR_COMPUTE_DISTANCE) / SCALE_FACTOR_COMPUTE_DISTANCE;
|
||||
const cursorSideXPadding = isChildRight ? CURSOR_SIDE_X_PADDING : -CURSOR_SIDE_X_PADDING;
|
||||
const childSideYPadding = CHILD_SIDE_Y_PADDING_BASE + (CHILD_SIDE_Y_PADDING_EXTEND * scaleFactor);
|
||||
|
||||
guard.enabled = true;
|
||||
guard.top = itemsEl.value!.scrollTop;
|
||||
guard.cursorSideX = localMouseX - cursorSideXPadding;
|
||||
guard.cursorSideY = localMouseY;
|
||||
guard.childSideTopY = (childBounding.top - rootBounding.top) - childSideYPadding;
|
||||
guard.childSideBottomY = (childBounding.bottom - rootBounding.top) + childSideYPadding;
|
||||
guard.direction = isChildRight ? 'toRight' : 'toLeft';
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
guard.enabled = false;
|
||||
}
|
||||
|
||||
function onMouseMove() {
|
||||
guard.enabled = false;
|
||||
}
|
||||
|
||||
function guardMouseMove(ev: MouseEvent) {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
@@ -592,6 +676,8 @@ onBeforeUnmount(() => {
|
||||
&:focus-visible:active,
|
||||
&:focus-visible.active {
|
||||
color: var(--menuHoverFg, var(--MI_THEME-accent));
|
||||
position: relative;
|
||||
z-index: 10; // guardより上にする
|
||||
|
||||
&::before {
|
||||
background-color: var(--menuHoverBg, var(--MI_THEME-accentedBg));
|
||||
@@ -744,4 +830,20 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.guard {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
|
||||
&.showGuard {
|
||||
background: #0f04;
|
||||
|
||||
&:hover {
|
||||
background: #f004;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -263,7 +263,7 @@ const emit = defineEmits<{
|
||||
|
||||
const inTimeline = inject<boolean>('inTimeline', false);
|
||||
const tl_withSensitive = inject<Ref<boolean>>('tl_withSensitive', ref(true));
|
||||
const inChannel = inject('inChannel', null);
|
||||
const inChannel = inject(DI.inChannel, null);
|
||||
const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null);
|
||||
|
||||
let note = deepClone(props.note);
|
||||
@@ -650,23 +650,35 @@ async function showRenoteMenu() {
|
||||
};
|
||||
}
|
||||
|
||||
const renoteDetailsMenu: MenuItem = {
|
||||
const renoteDetailsMenu: MenuItem[] = [{
|
||||
type: 'link',
|
||||
text: i18n.ts.renoteDetails,
|
||||
icon: 'ti ti-info-circle',
|
||||
to: notePage(note),
|
||||
};
|
||||
}];
|
||||
|
||||
if (
|
||||
props.note.channelId != null &&
|
||||
(inChannel == null || props.note.channelId !== inChannel.value)
|
||||
) {
|
||||
renoteDetailsMenu.push({
|
||||
type: 'link',
|
||||
text: i18n.ts.viewRenotedChannel,
|
||||
icon: 'ti ti-device-tv',
|
||||
to: `/channels/${props.note.channelId}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (isMyRenote) {
|
||||
os.popupMenu([
|
||||
renoteDetailsMenu,
|
||||
...renoteDetailsMenu,
|
||||
getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote),
|
||||
{ type: 'divider' },
|
||||
getUnrenote(),
|
||||
], renoteTime.value);
|
||||
} else {
|
||||
os.popupMenu([
|
||||
renoteDetailsMenu,
|
||||
...renoteDetailsMenu,
|
||||
getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote),
|
||||
{ type: 'divider' },
|
||||
getAbuseNoteMenu(note, i18n.ts.reportAbuseRenote),
|
||||
|
||||
@@ -238,6 +238,7 @@ import { isLink } from '@@/js/is-link.js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
|
||||
import type { Keymap } from '@/utility/hotkey.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import MkNoteSub from '@/components/MkNoteSub.vue';
|
||||
import MkNoteSimple from '@/components/MkNoteSimple.vue';
|
||||
import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
|
||||
@@ -286,7 +287,7 @@ const props = withDefaults(defineProps<{
|
||||
initialTab: 'replies',
|
||||
});
|
||||
|
||||
const inChannel = inject('inChannel', null);
|
||||
const inChannel = inject(DI.inChannel, null);
|
||||
|
||||
let note = deepClone(props.note);
|
||||
|
||||
@@ -581,18 +582,36 @@ async function showRenoteMenu() {
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
os.popupMenu([{
|
||||
text: i18n.ts.unrenote,
|
||||
icon: 'ti ti-trash',
|
||||
danger: true,
|
||||
action: () => {
|
||||
misskeyApi('notes/delete', {
|
||||
noteId: note.id,
|
||||
}).then(() => {
|
||||
globalEvents.emit('noteDeleted', note.id);
|
||||
});
|
||||
},
|
||||
}], renoteTime.value);
|
||||
const menu: MenuItem[] = [];
|
||||
|
||||
if (isMyRenote) {
|
||||
menu.push({
|
||||
text: i18n.ts.unrenote,
|
||||
icon: 'ti ti-trash',
|
||||
danger: true,
|
||||
action: () => {
|
||||
misskeyApi('notes/delete', {
|
||||
noteId: note.id,
|
||||
}).then(() => {
|
||||
globalEvents.emit('noteDeleted', note.id);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
props.note.channelId != null &&
|
||||
(inChannel == null || props.note.channelId !== inChannel.value)
|
||||
) {
|
||||
menu.push({
|
||||
type: 'link',
|
||||
text: i18n.ts.viewRenotedChannel,
|
||||
icon: 'ti ti-device-tv',
|
||||
to: `/channels/${props.note.channelId}`,
|
||||
});
|
||||
}
|
||||
|
||||
os.popupMenu(menu, renoteTime.value);
|
||||
}
|
||||
|
||||
function focus() {
|
||||
|
||||
@@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, onUnmounted, provide, ref, useTemplateRef } from 'vue';
|
||||
import { computed, onMounted, onUnmounted, provide, ref, useTemplateRef, nextTick } from 'vue';
|
||||
import { url } from '@@/js/config.js';
|
||||
import type { PageMetadata } from '@/page.js';
|
||||
import RouterView from '@/components/global/RouterView.vue';
|
||||
@@ -98,6 +98,24 @@ windowRouter.addListener('replace', ctx => {
|
||||
_history_.value.push({ path: ctx.fullPath });
|
||||
});
|
||||
|
||||
windowRouter.addListener('forcePush', ctx => {
|
||||
window.open(url + ctx.fullPath, '_blank', 'noopener');
|
||||
if (ctx.onInit) {
|
||||
nextTick(() => {
|
||||
windowEl.value?.close();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
windowRouter.addListener('forceReplace', ctx => {
|
||||
window.open(url + ctx.fullPath, '_blank', 'noopener');
|
||||
if (ctx.onInit) {
|
||||
nextTick(() => {
|
||||
windowEl.value?.close();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
windowRouter.addListener('change', ctx => {
|
||||
if (_DEV_) console.log('windowRouter: change', ctx.fullPath);
|
||||
searchMarkerId.value = getSearchMarker(ctx.fullPath);
|
||||
@@ -107,7 +125,7 @@ windowRouter.addListener('change', ctx => {
|
||||
});
|
||||
});
|
||||
|
||||
windowRouter.init();
|
||||
windowRouter.init(true);
|
||||
|
||||
provide(DI.router, windowRouter);
|
||||
provide(DI.inAppSearchMarkerId, searchMarkerId);
|
||||
|
||||
@@ -4,8 +4,31 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkModal ref="modal" v-slot="{ type, maxHeight }" :manualShowing="manualShowing" :zPriority="'high'" :anchorElement="anchorElement" :transparentBg="true" :returnFocusTo="returnFocusTo" @click="click" @close="onModalClose" @closed="onModalClosed">
|
||||
<MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :asDrawer="type === 'drawer'" :returnFocusTo="returnFocusTo" :class="{ [$style.drawer]: type === 'drawer' }" @close="onMenuClose" @hide="hide"/>
|
||||
<MkModal
|
||||
ref="modal"
|
||||
v-slot="{ type, maxHeight }"
|
||||
:manualShowing="manualShowing"
|
||||
:zPriority="'high'"
|
||||
:anchorElement="anchorElement"
|
||||
:transparentBg="true"
|
||||
:returnFocusTo="returnFocusTo"
|
||||
@click="click"
|
||||
@close="onModalClose"
|
||||
@closed="onModalClosed"
|
||||
>
|
||||
<MkMenu
|
||||
:items="items"
|
||||
:align="align"
|
||||
:width="width"
|
||||
:max-height="maxHeight"
|
||||
:asDrawer="type === 'drawer'"
|
||||
:returnFocusTo="returnFocusTo"
|
||||
:debugDisablePredictionCone="debugDisablePredictionCone"
|
||||
:debugShowPredictionCone="debugShowPredictionCone"
|
||||
:class="{ [$style.drawer]: type === 'drawer' }"
|
||||
@close="onMenuClose"
|
||||
@hide="hide"
|
||||
/>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
@@ -21,6 +44,8 @@ defineProps<{
|
||||
width?: number;
|
||||
anchorElement?: HTMLElement | null;
|
||||
returnFocusTo?: HTMLElement | null;
|
||||
debugDisablePredictionCone?: boolean;
|
||||
debugShowPredictionCone?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -74,6 +74,7 @@ import { store } from '@/store.js';
|
||||
import MkNote from '@/components/MkNote.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { globalEvents, useGlobalEvent } from '@/events.js';
|
||||
import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
|
||||
import { Paginator } from '@/utility/paginator.js';
|
||||
@@ -101,7 +102,7 @@ const props = withDefaults(defineProps<{
|
||||
|
||||
provide('inTimeline', true);
|
||||
provide('tl_withSensitive', computed(() => props.withSensitive));
|
||||
provide('inChannel', computed(() => props.src === 'channel'));
|
||||
provide(DI.inChannel, computed(() => props.src === 'channel' ? props.channel ?? null : null));
|
||||
|
||||
let paginator: IPaginator<Misskey.entities.Note>;
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
<div class="_gaps_s" :class="$style.mainActions">
|
||||
<MkButton :class="$style.mainAction" full rounded gradate data-cy-signup style="margin-right: 12px;" @click="signup()">{{ i18n.ts.joinThisServer }}</MkButton>
|
||||
<MkButton :class="$style.mainAction" full rounded link to="https://misskey-hub.net/servers/">{{ i18n.ts.exploreOtherServers }}</MkButton>
|
||||
<MkButton :class="$style.mainAction" full rounded type="a" target="_blank" rel="noopener" href="https://misskey-hub.net/servers/">{{ i18n.ts.exploreOtherServers }}</MkButton>
|
||||
<MkButton :class="$style.mainAction" full rounded data-cy-signin @click="signin()">{{ i18n.ts.login }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,7 @@ const props = withDefaults(defineProps<{
|
||||
});
|
||||
|
||||
const behavior = props.behavior ?? inject<MkABehavior>('linkNavigationBehavior', null);
|
||||
const isWindow = inject<boolean>('inWindow', false);
|
||||
|
||||
const el = useTemplateRef('el');
|
||||
|
||||
@@ -92,7 +93,11 @@ function nav(ev: PointerEvent) {
|
||||
ev.preventDefault();
|
||||
|
||||
if (behavior === 'browser') {
|
||||
window.location.href = props.to;
|
||||
if (isWindow) {
|
||||
window.open(props.to, '_blank', 'noopener');
|
||||
} else {
|
||||
window.location.href = props.to;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { notificationTypes } from 'misskey-js';
|
||||
import { ref } from 'vue';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import { i18n } from './i18n.js';
|
||||
import type { BasicTimelineType } from '@/timelines.js';
|
||||
import type { SoundStore } from '@/preferences/def.js';
|
||||
@@ -14,6 +15,13 @@ import { deepClone } from '@/utility/clone.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import * as os from '@/os.js';
|
||||
|
||||
type DeckEvents = {
|
||||
'column.dragStart': () => void;
|
||||
'column.dragEnd': () => void;
|
||||
};
|
||||
|
||||
export const deckGlobalEvents = new EventEmitter<DeckEvents>();
|
||||
|
||||
export type DeckProfile = {
|
||||
name: string;
|
||||
id: string;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { InjectionKey, Ref } from 'vue';
|
||||
import type { InjectionKey, Ref, ComputedRef } from 'vue';
|
||||
import type { PageMetadata } from '@/page.js';
|
||||
import type { Router } from '@/router.js';
|
||||
|
||||
@@ -18,4 +18,5 @@ export const DI = {
|
||||
mfmEmojiReactCallback: Symbol() as InjectionKey<(emoji: string) => void>,
|
||||
inModal: Symbol() as InjectionKey<boolean>,
|
||||
inAppSearchMarkerId: Symbol() as InjectionKey<Ref<string | null>>,
|
||||
inChannel: Symbol() as InjectionKey<ComputedRef<string | null> | null>, // 現在開いているチャンネルのID
|
||||
};
|
||||
|
||||
@@ -46,20 +46,34 @@ type ParsedPath = (string | {
|
||||
})[];
|
||||
|
||||
export type RouterEvents = {
|
||||
/** ページ内遷移を検知した場合(analytics用) */
|
||||
change: (ctx: {
|
||||
beforeFullPath: string;
|
||||
fullPath: string;
|
||||
resolved: PathResolvedResult;
|
||||
}) => void;
|
||||
/** history stateのreplaceを行う場合 */
|
||||
replace: (ctx: {
|
||||
fullPath: string;
|
||||
}) => void;
|
||||
/** location.replace相当の処理が必要な場合 */
|
||||
forceReplace: (ctx: {
|
||||
onInit: boolean;
|
||||
fullPath: string;
|
||||
}) => void;
|
||||
/** history stateのpushを行う場合 */
|
||||
push: (ctx: {
|
||||
beforeFullPath: string;
|
||||
fullPath: string;
|
||||
route: RouteDef | null;
|
||||
props: Map<string, string | boolean> | null;
|
||||
}) => void;
|
||||
/** location.hrefへの代入相当の処理が必要な場合 */
|
||||
forcePush: (ctx: {
|
||||
onInit: boolean;
|
||||
fullPath: string;
|
||||
}) => void;
|
||||
/** 遷移先が現在のページと同じだった場合 */
|
||||
same: () => void;
|
||||
};
|
||||
|
||||
@@ -216,7 +230,6 @@ export class Nirax<DEF extends RouteDef[]> extends EventEmitter<RouterEvents> {
|
||||
private currentFullPath: string; // /foo/bar?baz=qux#hash
|
||||
private isLoggedIn: boolean;
|
||||
private notFoundPageComponent: Component;
|
||||
private redirectCount = 0;
|
||||
|
||||
public navHook: ((fullPath: string, flag?: RouterFlag) => boolean) | null = null;
|
||||
|
||||
@@ -232,8 +245,17 @@ export class Nirax<DEF extends RouteDef[]> extends EventEmitter<RouterEvents> {
|
||||
this.notFoundPageComponent = notFoundPageComponent;
|
||||
}
|
||||
|
||||
public init() {
|
||||
const res = this.navigate(this.currentFullPath, false);
|
||||
public init(triggerForceReplace = false) {
|
||||
const res = this.resolveForNavigation(this.currentFullPath);
|
||||
|
||||
if (triggerForceReplace && res.route.path === '/:(*)') {
|
||||
this.emit('forceReplace', {
|
||||
onInit: true,
|
||||
fullPath: res._parsedRoute.fullPath,
|
||||
});
|
||||
}
|
||||
|
||||
this.navigate(res, false);
|
||||
this.emit('replace', {
|
||||
fullPath: res._parsedRoute.fullPath,
|
||||
});
|
||||
@@ -362,17 +384,15 @@ export class Nirax<DEF extends RouteDef[]> extends EventEmitter<RouterEvents> {
|
||||
return check(this.routes, _parts);
|
||||
}
|
||||
|
||||
private navigate(fullPath: string, emitChange = true, _redirected = false): PathResolvedResult {
|
||||
const beforeFullPath = this.currentFullPath;
|
||||
this.currentFullPath = fullPath;
|
||||
|
||||
const res = this.resolve(this.currentFullPath);
|
||||
/** 通常のresolve + リダイレクト解決 */
|
||||
private resolveForNavigation(fullPath: string, _redirectCount = 0): PathResolvedResult {
|
||||
const res = this.resolve(fullPath);
|
||||
|
||||
if (res == null) {
|
||||
throw new Error('no route found for: ' + fullPath);
|
||||
}
|
||||
|
||||
for (let current: PathResolvedResult | undefined = res; current; current = current.child) {
|
||||
for (let current: PathResolvedResult | undefined = res; current != null; current = current.child) {
|
||||
if ('redirect' in current.route) {
|
||||
let redirectPath: string;
|
||||
if (typeof current.route.redirect === 'function') {
|
||||
@@ -380,14 +400,25 @@ export class Nirax<DEF extends RouteDef[]> extends EventEmitter<RouterEvents> {
|
||||
} else {
|
||||
redirectPath = current.route.redirect + (current._parsedRoute.queryString ? '?' + current._parsedRoute.queryString : '') + (current._parsedRoute.hash ? '#' + current._parsedRoute.hash : '');
|
||||
}
|
||||
if (_DEV_) console.log('Redirecting to: ', redirectPath);
|
||||
if (_redirected && this.redirectCount++ > 10) {
|
||||
if (_DEV_) console.log('Redirecting from', current._parsedRoute.fullPath, 'to', redirectPath);
|
||||
if (_redirectCount > 10) {
|
||||
throw new Error('redirect loop detected');
|
||||
}
|
||||
return this.navigate(redirectPath, emitChange, true);
|
||||
return this.resolveForNavigation(redirectPath, _redirectCount + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...res,
|
||||
redirected: _redirectCount > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** 解決された`res`に応じてrouterの状態を更新する。 */
|
||||
private navigate(res: PathResolvedResult, emitChange = true) {
|
||||
const beforeFullPath = this.currentFullPath;
|
||||
this.currentFullPath = res._parsedRoute.fullPath;
|
||||
|
||||
if (res.route.loginRequired && !this.isLoggedIn && 'component' in res.route) {
|
||||
res.route.component = this.notFoundPageComponent;
|
||||
res.props.set('showLoginPopup', true);
|
||||
@@ -400,16 +431,12 @@ export class Nirax<DEF extends RouteDef[]> extends EventEmitter<RouterEvents> {
|
||||
if (emitChange && res.route.path !== '/:(*)') {
|
||||
this.emit('change', {
|
||||
beforeFullPath,
|
||||
fullPath,
|
||||
fullPath: res._parsedRoute.fullPath,
|
||||
resolved: res,
|
||||
});
|
||||
}
|
||||
|
||||
this.redirectCount = 0;
|
||||
return {
|
||||
...res,
|
||||
redirected: _redirected,
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
public getCurrentFullPath() {
|
||||
@@ -447,10 +474,14 @@ export class Nirax<DEF extends RouteDef[]> extends EventEmitter<RouterEvents> {
|
||||
const cancel = this.navHook(fullPath, flag ?? undefined);
|
||||
if (cancel) return;
|
||||
}
|
||||
const res = this.navigate(fullPath);
|
||||
const res = this.resolveForNavigation(fullPath);
|
||||
if (res.route.path === '/:(*)') {
|
||||
window.location.href = fullPath;
|
||||
this.emit('forcePush', {
|
||||
fullPath: res._parsedRoute.fullPath,
|
||||
onInit: false,
|
||||
});
|
||||
} else {
|
||||
this.navigate(res);
|
||||
this.emit('push', {
|
||||
beforeFullPath,
|
||||
fullPath: res._parsedRoute.fullPath,
|
||||
@@ -462,10 +493,18 @@ export class Nirax<DEF extends RouteDef[]> extends EventEmitter<RouterEvents> {
|
||||
|
||||
/** どうしても必要な場合に使用(パスが確定している場合は `Nirax.replace` を使用すること) */
|
||||
public replaceByPath(fullPath: string) {
|
||||
const res = this.navigate(fullPath);
|
||||
this.emit('replace', {
|
||||
fullPath: res._parsedRoute.fullPath,
|
||||
});
|
||||
const res = this.resolveForNavigation(fullPath);
|
||||
if (res.route.path === '/:(*)') {
|
||||
this.emit('forceReplace', {
|
||||
fullPath: res._parsedRoute.fullPath,
|
||||
onInit: false,
|
||||
});
|
||||
} else {
|
||||
this.navigate(res);
|
||||
this.emit('replace', {
|
||||
fullPath: res._parsedRoute.fullPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public useListener<E extends keyof RouterEvents>(event: E, listener: EventEmitter.EventListener<RouterEvents, E>) {
|
||||
|
||||
@@ -5,10 +5,9 @@
|
||||
|
||||
// TODO: なんでもかんでもos.tsに突っ込むのやめたいのでよしなに分割する
|
||||
|
||||
import { markRaw, ref, defineAsyncComponent, nextTick } from 'vue';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import { markRaw, ref, defineAsyncComponent, nextTick, effectScope, isRef, shallowReactive, watch } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import type { Component, MaybeRef } from 'vue';
|
||||
import type { Component, MaybeRef, ShallowReactive } from 'vue';
|
||||
import type { ComponentEmit, ComponentProps as CP } from 'vue-component-type-helpers';
|
||||
import type { Form, GetFormResultType } from '@/utility/form.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
@@ -146,7 +145,7 @@ let popupIdCount = 0;
|
||||
export const popups = ref<{
|
||||
id: number;
|
||||
component: Component;
|
||||
props: Record<string, any>;
|
||||
props: ShallowReactive<Record<string, any>>;
|
||||
events: Record<string, any>;
|
||||
}[]>([]);
|
||||
|
||||
@@ -184,6 +183,32 @@ type ComponentEmitsObject<C extends Component, IE = OverloadToUnion<ComponentEmi
|
||||
: (...args: any[]) => void;
|
||||
}>;
|
||||
|
||||
// ref をそのまま保持せず popup 側の reactive props に同期するようにして、スコープをまたいでリアクティビティが切れるのを防止する
|
||||
function normalizePopupProps<T extends Record<string, any>>(props: T): {
|
||||
resolvedProps: ShallowReactive<T>;
|
||||
stopSync: () => void;
|
||||
} {
|
||||
const resolvedProps = shallowReactive<T>({} as T) as T; // shallowReactiveの返り値はreadonlyだが、実際には書き換えるので元の型で扱う
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if (isRef(value)) {
|
||||
watch(value, (resolvedValue) => {
|
||||
resolvedProps[key as keyof T] = resolvedValue as T[keyof T];
|
||||
}, { immediate: true });
|
||||
} else {
|
||||
resolvedProps[key as keyof T] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
resolvedProps: resolvedProps as ShallowReactive<T>,
|
||||
stopSync: () => scope.stop(),
|
||||
};
|
||||
}
|
||||
|
||||
// NOTE: ジェネリック型つきのコンポーネントでは、emitsの型推論がうまく働かない(型変数を取り出すことはできないため)
|
||||
// NOTE: emitsがOverloadToUnionで対応しているオーバーロードの数を超える場合は、OverloadToUnionの個数を増やせばOK
|
||||
export function popup<T extends Component>(
|
||||
@@ -194,20 +219,24 @@ export function popup<T extends Component>(
|
||||
markRaw(component);
|
||||
|
||||
const id = ++popupIdCount;
|
||||
const { resolvedProps, stopSync } = normalizePopupProps(props);
|
||||
let disposed = false;
|
||||
const dispose = () => {
|
||||
// このsetTimeoutが無いと挙動がおかしくなる(autocompleteが閉じなくなる)。Vueのバグ?
|
||||
window.setTimeout(() => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
stopSync();
|
||||
|
||||
nextTick(() => {
|
||||
popups.value = popups.value.filter(p => p.id !== id);
|
||||
}, 0);
|
||||
};
|
||||
const state = {
|
||||
component,
|
||||
props,
|
||||
events,
|
||||
id,
|
||||
});
|
||||
};
|
||||
|
||||
popups.value.push(state);
|
||||
popups.value.push({
|
||||
component,
|
||||
props: resolvedProps,
|
||||
events,
|
||||
id,
|
||||
});
|
||||
|
||||
return {
|
||||
dispose,
|
||||
@@ -242,27 +271,7 @@ export async function popupAsyncWithDialog<T extends Component>(
|
||||
window.clearTimeout(timer);
|
||||
closeWaiting();
|
||||
|
||||
markRaw(component);
|
||||
|
||||
const id = ++popupIdCount;
|
||||
const dispose = () => {
|
||||
// このsetTimeoutが無いと挙動がおかしくなる(autocompleteが閉じなくなる)。Vueのバグ?
|
||||
window.setTimeout(() => {
|
||||
popups.value = popups.value.filter(p => p.id !== id);
|
||||
}, 0);
|
||||
};
|
||||
const state = {
|
||||
component,
|
||||
props,
|
||||
events,
|
||||
id,
|
||||
};
|
||||
|
||||
popups.value.push(state);
|
||||
|
||||
return {
|
||||
dispose,
|
||||
};
|
||||
return popup(component, props, events);
|
||||
}
|
||||
|
||||
export function pageWindow(path: string) {
|
||||
@@ -640,6 +649,8 @@ export function popupMenu(items: (MenuItem | null)[], anchorElement?: HTMLElemen
|
||||
width?: number;
|
||||
onClosing?: () => void;
|
||||
onClosed?: () => void;
|
||||
debugDisablePredictionCone?: boolean;
|
||||
debugShowPredictionCone?: boolean;
|
||||
}): Promise<void> {
|
||||
if (!(anchorElement instanceof HTMLElement)) {
|
||||
anchorElement = null;
|
||||
@@ -653,6 +664,8 @@ export function popupMenu(items: (MenuItem | null)[], anchorElement?: HTMLElemen
|
||||
width: options?.width,
|
||||
align: options?.align,
|
||||
returnFocusTo,
|
||||
debugDisablePredictionCone: options?.debugDisablePredictionCone,
|
||||
debugShowPredictionCone: options?.debugShowPredictionCone,
|
||||
}, {
|
||||
closed: () => {
|
||||
resolve();
|
||||
@@ -725,8 +738,6 @@ export async function post(props: PostFormProps = {}): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
export const deckGlobalEvents = new EventEmitter();
|
||||
|
||||
/*
|
||||
export function checkExistence(fileData: ArrayBuffer): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<template>
|
||||
<div class="_gaps">
|
||||
<MkButton v-if="$i && ($i.isModerator || $i.policies.canManageCustomEmojis)" primary link to="/custom-emojis-manager">{{ i18n.ts.manageCustomEmojis }}</MkButton>
|
||||
<MkButton v-if="$i && ($i.isModerator || $i.policies.canManageCustomEmojis)" primary type="routerLink" to="/custom-emojis-manager">{{ i18n.ts.manageCustomEmojis }}</MkButton>
|
||||
|
||||
<div class="query">
|
||||
<MkInput v-model="q" class="" :placeholder="i18n.ts.search" autocapitalize="off">
|
||||
|
||||
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<div class="_spacer" style="--MI_SPACER-w: 900px;">
|
||||
<div :class="$style.root" class="_gaps">
|
||||
<div :class="$style.subMenus" class="_gaps">
|
||||
<MkButton link to="/admin/abuse-report-notification-recipient" primary>{{ i18n.ts.notificationSetting }}</MkButton>
|
||||
<MkButton type="routerLink" to="/admin/abuse-report-notification-recipient" primary>{{ i18n.ts.notificationSetting }}</MkButton>
|
||||
</div>
|
||||
|
||||
<MkTip k="abuses">
|
||||
|
||||
@@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkPagination>
|
||||
</div>
|
||||
<div v-else-if="tab === 'owned'" class="_gaps">
|
||||
<MkButton link primary rounded to="/channels/new"><i class="ti ti-plus"></i> {{ i18n.ts.createNew }}</MkButton>
|
||||
<MkButton type="routerLink" primary rounded to="/channels/new"><i class="ti ti-plus"></i> {{ i18n.ts.createNew }}</MkButton>
|
||||
<MkPagination v-slot="{items}" :paginator="ownedPaginator">
|
||||
<div :class="$style.root">
|
||||
<MkChannelPreview v-for="channel in items" :key="channel.id" :channel="channel"/>
|
||||
|
||||
@@ -7,30 +7,46 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<PageWithHeader>
|
||||
<div class="_spacer" style="--MI_SPACER-w: 600px;">
|
||||
<div class="_gaps_m">
|
||||
<MkResult v-if="resultType === 'empty'" type="empty"/>
|
||||
<MkResult v-if="resultType === 'notFound'" type="notFound"/>
|
||||
<MkResult v-if="resultType === 'error'" type="error"/>
|
||||
<MkSelect
|
||||
v-model="resultType" :items="resultTypeDef"
|
||||
></MkSelect>
|
||||
<MkFolder>
|
||||
<template #label>Icons</template>
|
||||
|
||||
<MkSystemIcon v-if="iconType === 'info'" type="info" style="width: 150px;"/>
|
||||
<MkSystemIcon v-if="iconType === 'question'" type="question" style="width: 150px;"/>
|
||||
<MkSystemIcon v-if="iconType === 'success'" type="success" style="width: 150px;"/>
|
||||
<MkSystemIcon v-if="iconType === 'warn'" type="warn" style="width: 150px;"/>
|
||||
<MkSystemIcon v-if="iconType === 'error'" type="error" style="width: 150px;"/>
|
||||
<MkSystemIcon v-if="iconType === 'waiting'" type="waiting" style="width: 150px;"/>
|
||||
<MkSelect
|
||||
v-model="iconType" :items="iconTypeDef"
|
||||
></MkSelect>
|
||||
<div class="_gaps_m">
|
||||
<MkResult v-if="resultType === 'empty'" type="empty"/>
|
||||
<MkResult v-if="resultType === 'notFound'" type="notFound"/>
|
||||
<MkResult v-if="resultType === 'error'" type="error"/>
|
||||
<MkSelect
|
||||
v-model="resultType" :items="resultTypeDef"
|
||||
></MkSelect>
|
||||
|
||||
<div class="_buttons">
|
||||
<MkButton @click="os.alert({ type: 'error', title: 'Error', text: 'error' })">Error</MkButton>
|
||||
<MkButton @click="os.alert({ type: 'warning', title: 'Warning', text: 'warning' })">Warning</MkButton>
|
||||
<MkButton @click="os.alert({ type: 'info', title: 'Info', text: 'info' })">Info</MkButton>
|
||||
<MkButton @click="os.alert({ type: 'success', title: 'Success', text: 'success' })">Success</MkButton>
|
||||
<MkButton @click="os.alert({ type: 'question', title: 'Question', text: 'question' })">Question</MkButton>
|
||||
</div>
|
||||
<MkSystemIcon v-if="iconType === 'info'" type="info" style="width: 150px;"/>
|
||||
<MkSystemIcon v-if="iconType === 'question'" type="question" style="width: 150px;"/>
|
||||
<MkSystemIcon v-if="iconType === 'success'" type="success" style="width: 150px;"/>
|
||||
<MkSystemIcon v-if="iconType === 'warn'" type="warn" style="width: 150px;"/>
|
||||
<MkSystemIcon v-if="iconType === 'error'" type="error" style="width: 150px;"/>
|
||||
<MkSystemIcon v-if="iconType === 'waiting'" type="waiting" style="width: 150px;"/>
|
||||
<MkSelect
|
||||
v-model="iconType" :items="iconTypeDef"
|
||||
></MkSelect>
|
||||
|
||||
<div class="_buttons">
|
||||
<MkButton @click="os.alert({ type: 'error', title: 'Error', text: 'error' })">Error</MkButton>
|
||||
<MkButton @click="os.alert({ type: 'warning', title: 'Warning', text: 'warning' })">Warning</MkButton>
|
||||
<MkButton @click="os.alert({ type: 'info', title: 'Info', text: 'info' })">Info</MkButton>
|
||||
<MkButton @click="os.alert({ type: 'success', title: 'Success', text: 'success' })">Success</MkButton>
|
||||
<MkButton @click="os.alert({ type: 'question', title: 'Question', text: 'question' })">Question</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder>
|
||||
<template #label>Nested menu guard (a.k.a "prediction cone")</template>
|
||||
|
||||
<div class="_buttons">
|
||||
<MkButton @click="select($event, false, false)">select without guard</MkButton>
|
||||
<MkButton @click="select($event, true, false)">select with guard</MkButton>
|
||||
<MkButton @click="select($event, true, true)">select with guard (visualize)</MkButton>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</div>
|
||||
</div>
|
||||
</PageWithHeader>
|
||||
@@ -47,6 +63,7 @@ import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { useMkSelect } from '@/composables/use-mkselect.js';
|
||||
import * as os from '@/os.js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
|
||||
const {
|
||||
model: resultType,
|
||||
@@ -74,6 +91,64 @@ const {
|
||||
initialValue: 'info',
|
||||
});
|
||||
|
||||
function select(ev: PointerEvent, enablePredictionCone: boolean, showPredictionCone: boolean) {
|
||||
os.popupMenu([
|
||||
{ type: 'parent', text: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', children: [
|
||||
{ text: 'Option', action: () => {} },
|
||||
{ text: 'Option', action: () => {} },
|
||||
{ text: 'Option', action: () => {} },
|
||||
] },
|
||||
{ type: 'parent', text: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', children: [
|
||||
{ text: 'Option', action: () => {} },
|
||||
{ text: 'Option', action: () => {} },
|
||||
{ text: 'Option', action: () => {} },
|
||||
{ text: 'Option', action: () => {} },
|
||||
{ text: 'Option', action: () => {} },
|
||||
{ text: 'Option', action: () => {} },
|
||||
{ text: 'Option', action: () => {} },
|
||||
{ text: 'Option', action: () => {} },
|
||||
{ text: 'Option', action: () => {} },
|
||||
] },
|
||||
{ type: 'parent', text: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', children: [
|
||||
{ text: 'Option', action: () => {} },
|
||||
{ text: 'Option', action: () => {} },
|
||||
{ text: 'Option', action: () => {} },
|
||||
] },
|
||||
{ text: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', action: () => {} },
|
||||
{ type: 'parent', text: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', children: [
|
||||
{ text: 'Option', action: () => {} },
|
||||
] },
|
||||
{ type: 'parent', text: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', children: [
|
||||
{ text: 'Option', action: () => {} },
|
||||
{ text: 'Option', action: () => {} },
|
||||
{ text: 'Option', action: () => {} },
|
||||
{ text: 'Option', action: () => {} },
|
||||
{ text: 'Option', action: () => {} },
|
||||
] },
|
||||
{ type: 'parent', text: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', children: [
|
||||
{ text: 'Option', action: () => {} },
|
||||
{ text: 'Option', action: () => {} },
|
||||
{ type: 'parent', text: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', children: [
|
||||
{ text: 'Option', action: () => {} },
|
||||
{ text: 'Option', action: () => {} },
|
||||
{ text: 'Option', action: () => {} },
|
||||
{ text: 'Option', action: () => {} },
|
||||
{ text: 'Option', action: () => {} },
|
||||
] },
|
||||
{ text: 'Option', action: () => {} },
|
||||
{ text: 'Option', action: () => {} },
|
||||
] },
|
||||
{ type: 'parent', text: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', children: [
|
||||
{ text: 'Option', action: () => {} },
|
||||
] },
|
||||
], ev.currentTarget ?? ev.target, {
|
||||
debugDisablePredictionCone: !enablePredictionCone,
|
||||
debugShowPredictionCone: showPredictionCone,
|
||||
}).then((value) => {
|
||||
console.log('Selected:', value);
|
||||
});
|
||||
}
|
||||
|
||||
definePage(() => ({
|
||||
title: 'DEBUG ROOM',
|
||||
icon: 'ti ti-help-circle',
|
||||
|
||||
@@ -215,7 +215,7 @@ async function deleteFile() {
|
||||
|
||||
globalEvents.emit('driveFilesDeleted', [file.value]);
|
||||
|
||||
router.push('/my/drive');
|
||||
router.replace('/my/drive');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -81,6 +81,8 @@ function menu(ev: PointerEvent) {
|
||||
text-align: left;
|
||||
background: var(--MI_THEME-panel);
|
||||
border-radius: 8px;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 66px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--MI_THEME-accent);
|
||||
|
||||
@@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<div>
|
||||
<MkResult v-if="antennas.length === 0" type="empty"/>
|
||||
|
||||
<MkButton :link="true" to="/my/antennas/create" primary :class="$style.add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
|
||||
<MkButton type="routerLink" to="/my/antennas/create" primary :class="$style.add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
|
||||
|
||||
<div v-if="antennas.length > 0" class="_gaps">
|
||||
<MkA v-for="antenna in antennas" :key="antenna.id" :class="$style.antenna" :to="`/timeline/antenna/${antenna.id}`">
|
||||
|
||||
@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
|
||||
<div class="_spacer" style="--MI_SPACER-w: 700px;">
|
||||
<div class="jqqmcavi">
|
||||
<MkButton v-if="pageId && author != null" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="ti ti-external-link"></i> {{ i18n.ts._pages.viewPage }}</MkButton>
|
||||
<MkButton v-if="pageId && author != null" class="button" inline type="routerLink" :to="`/@${ author.username }/pages/${ currentName }`"><i class="ti ti-external-link"></i> {{ i18n.ts._pages.viewPage }}</MkButton>
|
||||
<MkButton v-if="!readonly" inline primary class="button" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
|
||||
<MkButton v-if="pageId" inline class="button" @click="duplicate"><i class="ti ti-copy"></i> {{ i18n.ts.duplicate }}</MkButton>
|
||||
<MkButton v-if="pageId && !readonly" inline class="button" danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
|
||||
|
||||
@@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<div>
|
||||
<a :class="$style.qrRoot" :href="twoFactorData.url"><img :class="$style.qr" :src="twoFactorData.qr"></a>
|
||||
<!-- QRコード側にマージンが入っているので直下でOK -->
|
||||
<div><MkButton inline rounded link :to="twoFactorData.url" :linkBehavior="'browser'">{{ i18n.ts.launchApp }}</MkButton></div>
|
||||
<div><MkButton inline rounded type="routerLink" :to="twoFactorData.url" :linkBehavior="'browser'">{{ i18n.ts.launchApp }}</MkButton></div>
|
||||
</div>
|
||||
<MkKeyValue :copy="twoFactorData.url">
|
||||
<template #key>{{ i18n.ts._2fa.step2Uri }}</template>
|
||||
|
||||
@@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<SearchMarker :keywords="['avatar', 'icon', 'change']">
|
||||
<MkButton primary rounded @click="changeAvatar"><SearchLabel>{{ i18n.ts._profile.changeAvatar }}</SearchLabel></MkButton>
|
||||
</SearchMarker>
|
||||
<MkButton primary rounded link to="/settings/avatar-decoration">{{ i18n.ts.decorate }} <i class="ti ti-sparkles"></i></MkButton>
|
||||
<MkButton primary rounded type="routerLink" to="/settings/avatar-decoration">{{ i18n.ts.decorate }} <i class="ti ti-sparkles"></i></MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<div v-else key="success" class="_gaps_m" style="padding: 32px;">
|
||||
<div :class="$style.mainText">{{ i18n.ts.emailVerified }}</div>
|
||||
<div>
|
||||
<MkButton large rounded link to="/" linkBehavior="browser" style="margin: 0 auto;">
|
||||
<MkButton large rounded type="routerLink" to="/" linkBehavior="browser" style="margin: 0 auto;">
|
||||
{{ i18n.ts.goToMisskey }}
|
||||
</MkButton>
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,14 @@ mainRouter.addListener('replace', ctx => {
|
||||
window.history.replaceState({ }, '', ctx.fullPath);
|
||||
});
|
||||
|
||||
mainRouter.addListener('forceReplace', ctx => {
|
||||
window.location.replace(ctx.fullPath);
|
||||
});
|
||||
|
||||
mainRouter.addListener('forcePush', ctx => {
|
||||
window.location.href = ctx.fullPath;
|
||||
});
|
||||
|
||||
mainRouter.addListener('change', ctx => {
|
||||
if (_DEV_) console.log('mainRouter: change', ctx.fullPath);
|
||||
analytics.page({
|
||||
|
||||
@@ -46,11 +46,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
import { onBeforeUnmount, onMounted, provide, watch, useTemplateRef, ref, computed } from 'vue';
|
||||
import type { Column } from '@/deck.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn } from '@/deck.js';
|
||||
import { deckGlobalEvents, updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn } from '@/deck.js';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { checkDragDataType, getDragData, setDragData } from '@/drag-and-drop.js';
|
||||
|
||||
provide('shouldHeaderThin', true);
|
||||
@@ -79,7 +78,7 @@ const emit = defineEmits<{
|
||||
const body = useTemplateRef('body');
|
||||
|
||||
const dragging = ref(false);
|
||||
watch(dragging, v => os.deckGlobalEvents.emit(v ? 'column.dragStart' : 'column.dragEnd'));
|
||||
watch(dragging, v => deckGlobalEvents.emit(v ? 'column.dragStart' : 'column.dragEnd'));
|
||||
|
||||
const draghover = ref(false);
|
||||
const dropready = ref(false);
|
||||
@@ -88,13 +87,13 @@ const isMainColumn = computed(() => props.column.type === 'main');
|
||||
const active = computed(() => props.column.active !== false);
|
||||
|
||||
onMounted(() => {
|
||||
os.deckGlobalEvents.on('column.dragStart', onOtherDragStart);
|
||||
os.deckGlobalEvents.on('column.dragEnd', onOtherDragEnd);
|
||||
deckGlobalEvents.on('column.dragStart', onOtherDragStart);
|
||||
deckGlobalEvents.on('column.dragEnd', onOtherDragEnd);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
os.deckGlobalEvents.off('column.dragStart', onOtherDragStart);
|
||||
os.deckGlobalEvents.off('column.dragEnd', onOtherDragEnd);
|
||||
deckGlobalEvents.off('column.dragStart', onOtherDragStart);
|
||||
deckGlobalEvents.off('column.dragEnd', onOtherDragEnd);
|
||||
});
|
||||
|
||||
function onOtherDragStart() {
|
||||
@@ -306,7 +305,7 @@ function onDragleave() {
|
||||
|
||||
function onDrop(ev: DragEvent) {
|
||||
draghover.value = false;
|
||||
os.deckGlobalEvents.emit('column.dragEnd');
|
||||
deckGlobalEvents.emit('column.dragEnd');
|
||||
|
||||
const id = getDragData(ev, 'deckColumn');
|
||||
if (id != null) {
|
||||
|
||||
90
packages/frontend/test/lib/nirax/fallbacks.test.ts
Normal file
90
packages/frontend/test/lib/nirax/fallbacks.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { assert, describe, test } from 'vitest';
|
||||
import { createRouter, loginFallbackComponent } from './fixture.js';
|
||||
|
||||
describe('[NIRAX] フォールバック', () => {
|
||||
test('pushの際、ページが見つからなかったらforcePushを発火する', () => {
|
||||
const router = createRouter('/');
|
||||
const forcePushes: string[] = [];
|
||||
|
||||
router.addListener('forcePush', ctx => {
|
||||
forcePushes.push(ctx.fullPath);
|
||||
assert.strictEqual(ctx.onInit, false);
|
||||
});
|
||||
|
||||
router.init();
|
||||
|
||||
router.pushByPath('/missing');
|
||||
|
||||
assert.deepStrictEqual(forcePushes, ['/missing']);
|
||||
assert.strictEqual(router.getCurrentFullPath(), '/');
|
||||
assert.strictEqual(router.current.route.path, '/');
|
||||
});
|
||||
|
||||
test('replaceの際、ページが見つからなかったらforceReplaceを発火する', () => {
|
||||
const router = createRouter('/');
|
||||
const forceReplacements: string[] = [];
|
||||
|
||||
router.addListener('forceReplace', ctx => {
|
||||
forceReplacements.push(ctx.fullPath);
|
||||
assert.strictEqual(ctx.onInit, false);
|
||||
});
|
||||
|
||||
router.init();
|
||||
|
||||
router.replaceByPath('/also-missing');
|
||||
|
||||
assert.deepStrictEqual(forceReplacements, ['/also-missing']);
|
||||
assert.strictEqual(router.getCurrentFullPath(), '/');
|
||||
assert.strictEqual(router.current.route.path, '/');
|
||||
});
|
||||
|
||||
test('初期ページが見つからない場合でも初回はforceReplaceを発火しない', () => {
|
||||
const router = createRouter('/missing');
|
||||
const forceReplacements: string[] = [];
|
||||
|
||||
router.addListener('forceReplace', ctx => {
|
||||
forceReplacements.push(ctx.fullPath);
|
||||
assert.strictEqual(ctx.onInit, true);
|
||||
});
|
||||
|
||||
router.init();
|
||||
|
||||
assert.deepStrictEqual(forceReplacements, []); // 初回はforceReplaceを発火しない
|
||||
assert.strictEqual(router.getCurrentFullPath(), '/missing');
|
||||
assert.strictEqual(router.current.route.path, '/:(*)');
|
||||
});
|
||||
|
||||
test('初期ページが見つからない場合でも、initで明示した場合はforceReplaceを発火する', () => {
|
||||
const router = createRouter('/missing');
|
||||
const forceReplacements: string[] = [];
|
||||
|
||||
router.addListener('forceReplace', ctx => {
|
||||
forceReplacements.push(ctx.fullPath);
|
||||
assert.strictEqual(ctx.onInit, true);
|
||||
});
|
||||
|
||||
router.init(true); // forceReplaceを強制的に発火させる
|
||||
|
||||
assert.deepStrictEqual(forceReplacements, ['/missing']);
|
||||
assert.strictEqual(router.getCurrentFullPath(), '/missing');
|
||||
assert.strictEqual(router.current.route.path, '/:(*)');
|
||||
});
|
||||
|
||||
test('loginRequiredなルートではコンポーネントを差し替えてshowLoginPopupを設定する', () => {
|
||||
const router = createRouter('/', false);
|
||||
|
||||
router.init();
|
||||
|
||||
router.pushByPath('/private');
|
||||
|
||||
assert.strictEqual(router.current.route.path, '/private');
|
||||
assert.ok('component' in router.current.route);
|
||||
assert.strictEqual(router.current.route.component, loginFallbackComponent);
|
||||
assert.strictEqual(router.current.props.get('showLoginPopup'), true);
|
||||
});
|
||||
});
|
||||
80
packages/frontend/test/lib/nirax/fixture.ts
Normal file
80
packages/frontend/test/lib/nirax/fixture.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Component } from 'vue';
|
||||
import { Nirax } from '@/lib/nirax.js';
|
||||
import type { RouteDef } from '@/lib/nirax.js';
|
||||
|
||||
export const homeComponent = { name: 'home-page' } as Component;
|
||||
export const postComponent = { name: 'post-page' } as Component;
|
||||
export const fileComponent = { name: 'file-page' } as Component;
|
||||
export const optionalComponent = { name: 'optional-page' } as Component;
|
||||
export const userComponent = { name: 'user-page' } as Component;
|
||||
export const followersComponent = { name: 'followers-page' } as Component;
|
||||
export const privateComponent = { name: 'private-page' } as Component;
|
||||
export const notFoundRouteComponent = { name: 'not-found-route' } as Component;
|
||||
export const loginFallbackComponent = { name: 'login-fallback' } as Component;
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
path: '/',
|
||||
component: homeComponent,
|
||||
},
|
||||
{
|
||||
path: '/posts/:postId',
|
||||
component: postComponent,
|
||||
query: {
|
||||
from: 'source',
|
||||
},
|
||||
hash: 'section',
|
||||
},
|
||||
{
|
||||
path: '/files/:path(*)',
|
||||
component: fileComponent,
|
||||
},
|
||||
{
|
||||
path: '/optional/:slug?',
|
||||
component: optionalComponent,
|
||||
},
|
||||
{
|
||||
path: '/old',
|
||||
redirect: '/posts/redirected',
|
||||
},
|
||||
{
|
||||
path: '/legacy/:postId',
|
||||
redirect: props => `/posts/${props.get('postId')}`,
|
||||
},
|
||||
{
|
||||
path: '/loop-a',
|
||||
redirect: '/loop-b',
|
||||
},
|
||||
{
|
||||
path: '/loop-b',
|
||||
redirect: '/loop-a',
|
||||
},
|
||||
{
|
||||
path: '/user/:id',
|
||||
component: userComponent,
|
||||
children: [
|
||||
{
|
||||
path: '/followers',
|
||||
component: followersComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/private',
|
||||
component: privateComponent,
|
||||
loginRequired: true,
|
||||
},
|
||||
{
|
||||
path: '/:(*)',
|
||||
component: notFoundRouteComponent,
|
||||
},
|
||||
] as const satisfies RouteDef[];
|
||||
|
||||
export function createRouter(currentFullPath = '/', isLoggedIn = true) {
|
||||
return new Nirax(routes, currentFullPath, isLoggedIn, loginFallbackComponent);
|
||||
}
|
||||
105
packages/frontend/test/lib/nirax/navigation.test.ts
Normal file
105
packages/frontend/test/lib/nirax/navigation.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { assert, describe, test } from 'vitest';
|
||||
import { createRouter } from './fixture.js';
|
||||
|
||||
describe('[NIRAX] ナビゲーションイベント', () => {
|
||||
test('init時にリダイレクトを解決してreplaceを発火する', () => {
|
||||
const router = createRouter('/old?from=legacy#intro');
|
||||
const changes: string[] = [];
|
||||
const replacements: string[] = [];
|
||||
|
||||
router.addListener('change', ctx => {
|
||||
changes.push(ctx.fullPath);
|
||||
});
|
||||
router.addListener('replace', ctx => {
|
||||
replacements.push(ctx.fullPath);
|
||||
});
|
||||
|
||||
router.init();
|
||||
|
||||
assert.strictEqual(router.getCurrentFullPath(), '/posts/redirected?from=legacy#intro');
|
||||
assert.strictEqual(router.current.redirected, true);
|
||||
assert.deepStrictEqual(changes, []); // 初回はchangeを発火しない
|
||||
assert.deepStrictEqual(replacements, ['/posts/redirected?from=legacy#intro']);
|
||||
});
|
||||
|
||||
test('push時に動的リダイレクトを解決してpushとchangeを発火する', () => {
|
||||
const router = createRouter('/');
|
||||
const pushed: string[] = [];
|
||||
const changed: string[] = [];
|
||||
|
||||
router.addListener('push', ctx => {
|
||||
pushed.push(ctx.fullPath);
|
||||
assert.strictEqual(ctx.route?.path, '/posts/:postId');
|
||||
assert.strictEqual(ctx.props?.get('postId'), 'abc123');
|
||||
});
|
||||
router.addListener('change', ctx => {
|
||||
changed.push(ctx.fullPath);
|
||||
});
|
||||
|
||||
router.init();
|
||||
|
||||
router.pushByPath('/legacy/abc123');
|
||||
|
||||
assert.strictEqual(router.getCurrentFullPath(), '/posts/abc123');
|
||||
assert.deepStrictEqual(pushed, ['/posts/abc123']);
|
||||
assert.deepStrictEqual(changed, ['/posts/abc123']);
|
||||
});
|
||||
|
||||
test('無限リダイレクトはエラーになる', () => {
|
||||
const router = createRouter('/');
|
||||
|
||||
router.init();
|
||||
|
||||
assert.throws(() => {
|
||||
router.pushByPath('/loop-a');
|
||||
}, /redirect loop detected/);
|
||||
assert.strictEqual(router.getCurrentFullPath(), '/');
|
||||
});
|
||||
|
||||
test('同じパスへの遷移ではsameを発火する', () => {
|
||||
const router = createRouter('/posts/123');
|
||||
let sameCount = 0;
|
||||
let pushCount = 0;
|
||||
|
||||
router.addListener('same', () => {
|
||||
sameCount++;
|
||||
});
|
||||
router.addListener('push', () => {
|
||||
pushCount++;
|
||||
});
|
||||
|
||||
router.init();
|
||||
|
||||
router.pushByPath('/posts/123');
|
||||
|
||||
assert.strictEqual(sameCount, 1);
|
||||
assert.strictEqual(pushCount, 0); // sameのときはpushを発火しない
|
||||
});
|
||||
|
||||
test('navHookでナビゲーションをキャンセルできる', () => {
|
||||
const router = createRouter('/posts/123');
|
||||
const navHookCalls: string[] = [];
|
||||
let pushCount = 0;
|
||||
|
||||
router.addListener('push', () => {
|
||||
pushCount++;
|
||||
});
|
||||
router.navHook = fullPath => {
|
||||
navHookCalls.push(fullPath);
|
||||
return true;
|
||||
};
|
||||
|
||||
router.init();
|
||||
|
||||
router.pushByPath('/posts/456');
|
||||
|
||||
assert.deepStrictEqual(navHookCalls, ['/posts/456']);
|
||||
assert.strictEqual(pushCount, 0);
|
||||
assert.strictEqual(router.getCurrentFullPath(), '/posts/123');
|
||||
});
|
||||
});
|
||||
73
packages/frontend/test/lib/nirax/resolve.test.ts
Normal file
73
packages/frontend/test/lib/nirax/resolve.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { assert, describe, test } from 'vitest';
|
||||
import { createRouter } from './fixture.js';
|
||||
|
||||
describe('[NIRAX] resolve', () => {
|
||||
test('staticなルートを解決できる', () => {
|
||||
const router = createRouter();
|
||||
const resolved = router.resolve('/');
|
||||
|
||||
assert.ok(resolved);
|
||||
assert.strictEqual(resolved.route.path, '/');
|
||||
assert.strictEqual(resolved.props.size, 0);
|
||||
});
|
||||
|
||||
test('パスパラメータ付きルートを解決できる', () => {
|
||||
const router = createRouter();
|
||||
const resolved = router.resolve('/posts/abc%2Fdef');
|
||||
|
||||
assert.ok(resolved);
|
||||
assert.strictEqual(resolved.route.path, '/posts/:postId');
|
||||
assert.strictEqual(resolved.props.get('postId'), 'abc/def');
|
||||
});
|
||||
|
||||
test('queryとhashのエイリアスを解決できる', () => {
|
||||
const router = createRouter();
|
||||
const resolved = router.resolve('/posts/abc?from=timeline#thread');
|
||||
|
||||
assert.ok(resolved);
|
||||
assert.strictEqual(resolved.props.get('source'), 'timeline');
|
||||
assert.strictEqual(resolved.props.get('section'), 'thread');
|
||||
});
|
||||
|
||||
test('wildcardルートのパラメータを解決できる', () => {
|
||||
const router = createRouter();
|
||||
const resolved = router.resolve('/files/images/icons/logo%20mark.svg');
|
||||
|
||||
assert.ok(resolved);
|
||||
assert.strictEqual(resolved.route.path, '/files/:path(*)');
|
||||
assert.strictEqual(resolved.props.get('path'), 'images/icons/logo mark.svg');
|
||||
});
|
||||
|
||||
test('optionalなパスパラメータが省略されたルートを解決できる', () => {
|
||||
const router = createRouter();
|
||||
const resolved = router.resolve('/optional');
|
||||
|
||||
assert.ok(resolved);
|
||||
assert.strictEqual(resolved.route.path, '/optional/:slug?');
|
||||
assert.strictEqual(resolved.props.has('slug'), false);
|
||||
});
|
||||
|
||||
test('optionalなパスパラメータが存在するルートを解決できる', () => {
|
||||
const router = createRouter();
|
||||
const resolved = router.resolve('/optional/topic');
|
||||
|
||||
assert.ok(resolved);
|
||||
assert.strictEqual(resolved.props.get('slug'), 'topic');
|
||||
});
|
||||
|
||||
test('ネストされたルートを解決できる', () => {
|
||||
const router = createRouter();
|
||||
const resolved = router.resolve('/user/alice/followers');
|
||||
|
||||
assert.ok(resolved);
|
||||
assert.strictEqual(resolved.route.path, '/user/:id');
|
||||
assert.strictEqual(resolved.props.get('id'), 'alice');
|
||||
assert.ok(resolved.child);
|
||||
assert.strictEqual(resolved.child.route.path, '/followers');
|
||||
});
|
||||
});
|
||||
@@ -5647,6 +5647,10 @@ export interface Locale extends ILocale {
|
||||
* 設定項目はありません
|
||||
*/
|
||||
"nothingToConfigure": string;
|
||||
/**
|
||||
* リノート先のチャンネルを見る
|
||||
*/
|
||||
"viewRenotedChannel": string;
|
||||
"_imageEditing": {
|
||||
"_vars": {
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "misskey-js",
|
||||
"version": "2026.3.2",
|
||||
"version": "2026.4.0-alpha.2",
|
||||
"description": "Misskey SDK for JavaScript",
|
||||
"license": "MIT",
|
||||
"main": "./built/index.js",
|
||||
@@ -41,13 +41,13 @@
|
||||
"@types/node": "24.12.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.2",
|
||||
"@typescript-eslint/parser": "8.57.2",
|
||||
"@vitest/coverage-v8": "4.1.1",
|
||||
"@vitest/coverage-v8": "4.1.2",
|
||||
"esbuild": "0.27.4",
|
||||
"execa": "9.6.1",
|
||||
"ncp": "2.0.0",
|
||||
"nodemon": "3.1.14",
|
||||
"tsd": "0.33.0",
|
||||
"vitest": "4.1.1",
|
||||
"vitest": "4.1.2",
|
||||
"vitest-websocket-mock": "0.5.0"
|
||||
},
|
||||
"files": [
|
||||
|
||||
Reference in New Issue
Block a user