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

# Conflicts:
#	.config/docker_example.env
#	.config/docker_example.yml
#	CHANGELOG.md
#	locales/index.d.ts
#	locales/ja-JP.yml
#	packages/backend/package.json
#	packages/backend/src/core/entities/UserEntityService.ts
#	packages/backend/src/server/api/endpoints/i/update.ts
#	packages/frontend/package.json
#	packages/frontend/src/components/MkChannelFollowButton.vue
#	packages/frontend/src/components/MkFollowButton.vue
#	packages/frontend/src/components/MkMediaAudio.vue
#	packages/frontend/src/components/MkMenu.vue
#	packages/frontend/src/components/MkNote.vue
#	packages/frontend/src/components/MkNoteDetailed.vue
#	packages/frontend/src/components/MkPostForm.vue
#	packages/frontend/src/components/MkRadio.vue
#	packages/frontend/src/components/MkSuperMenu.vue
#	packages/frontend/src/components/MkSwitch.vue
#	packages/frontend/src/components/MkVisitorDashboard.vue
#	packages/frontend/src/os.ts
#	packages/frontend/src/pages/about.vue
#	packages/frontend/src/pages/admin/roles.editor.vue
#	packages/frontend/src/pages/admin/roles.vue
#	packages/frontend/src/pages/custom-emojis-manager.vue
#	packages/frontend/src/pages/follow.vue
#	packages/frontend/src/pages/timeline.vue
#	packages/frontend/src/pages/user/home.vue
#	packages/frontend/src/scripts/get-note-menu.ts
#	packages/frontend/src/scripts/keycode.ts
#	packages/frontend/src/ui/_common_/navbar-for-mobile.vue
#	packages/frontend/src/ui/_common_/navbar.vue
#	pnpm-lock.yaml
This commit is contained in:
mattyatea 2024-07-15 20:13:51 +09:00
commit 15005c4266
263 changed files with 9874 additions and 6414 deletions

View file

@ -1,82 +0,0 @@
module.exports = {
root: true,
env: {
'node': false,
},
parser: 'vue-eslint-parser',
parserOptions: {
'parser': '@typescript-eslint/parser',
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
extraFileExtensions: ['.vue'],
},
extends: [
'../shared/.eslintrc.js',
'plugin:vue/vue3-recommended',
],
rules: {
'@typescript-eslint/no-empty-interface': [
'error',
{
'allowSingleExtends': true,
},
],
// window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため
// e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため
'id-denylist': ['error', 'window', 'e'],
'no-shadow': ['warn'],
'vue/attributes-order': ['error', {
'alphabetical': false,
}],
'vue/no-use-v-if-with-v-for': ['error', {
'allowUsingIterationVar': false,
}],
'vue/no-ref-as-operand': 'error',
'vue/no-multi-spaces': ['error', {
'ignoreProperties': false,
}],
'vue/no-v-html': 'warn',
'vue/order-in-components': 'error',
'vue/html-indent': ['warn', 'tab', {
'attribute': 1,
'baseIndent': 0,
'closeBracket': 0,
'alignAttributesVertically': true,
'ignores': [],
}],
'vue/html-closing-bracket-spacing': ['warn', {
'startTag': 'never',
'endTag': 'never',
'selfClosingTag': 'never',
}],
'vue/multi-word-component-names': 'warn',
'vue/require-v-for-key': 'warn',
'vue/no-unused-components': 'warn',
'vue/no-unused-vars': 'warn',
'vue/no-dupe-keys': 'warn',
'vue/valid-v-for': 'warn',
'vue/return-in-computed-property': 'warn',
'vue/no-setup-props-reactivity-loss': 'warn',
'vue/max-attributes-per-line': 'off',
'vue/html-self-closing': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/v-on-event-hyphenation': ['error', 'never', { autofix: true }],
'vue/attribute-hyphenation': ['error', 'never'],
},
globals: {
// Node.js
'module': false,
'require': false,
'__dirname': false,
// Misskey
'_DEV_': false,
'_LANGS_': false,
'_VERSION_': false,
'_ENV_': false,
'_PERF_PREFIX_': false,
'_DATA_TRANSFER_DRIVE_FILE_': false,
'_DATA_TRANSFER_DRIVE_FOLDER_': false,
'_DATA_TRANSFER_DECK_COLUMN_': false,
},
};

View file

@ -47,7 +47,6 @@ await fs.readFile(
)
)
.map((path) => path.replace(/(?:(?<=\.stories)\.(?:impl|meta)|\.msw)(?=\.ts$)/g, ''))
.map((path) => (path.startsWith('.') ? path : `./${path}`))
);
if (
micromatch(Array.from(modules), [

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { FORCE_REMOUNT } from '@storybook/core-events';
import { FORCE_RE_RENDER, FORCE_REMOUNT } from '@storybook/core-events';
import { addons } from '@storybook/preview-api';
import { type Preview, setup } from '@storybook/vue3';
import isChromatic from 'chromatic/isChromatic';
@ -16,7 +16,7 @@ import '../src/style.scss';
const appInitialized = Symbol();
let lastStory = null;
let lastStory: string | null = null;
let moduleInitialized = false;
let unobserve = () => {};
let misskeyOS = null;
@ -110,7 +110,7 @@ const preview = {
}).catch(() => {});
Promise.all([resetIndexedDBPromise, resetDefaultStorePromise]).then(() => {
initLocalStorage();
channel.emit(FORCE_REMOUNT, { storyId: context.id });
channel.emit(FORCE_RE_RENDER, { storyId: context.id });
});
}
const story = Story();

View file

@ -0,0 +1,95 @@
import globals from 'globals';
import tsParser from '@typescript-eslint/parser';
import parser from 'vue-eslint-parser';
import pluginVue from 'eslint-plugin-vue';
import pluginMisskey from '@misskey-dev/eslint-plugin';
import sharedConfig from '../shared/eslint.config.js';
export default [
...sharedConfig,
{
files: ['src/**/*.vue'],
...pluginMisskey.configs.typescript,
},
...pluginVue.configs['flat/recommended'],
{
files: ['src/**/*.{ts,vue}'],
languageOptions: {
globals: {
...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])),
...globals.browser,
// Node.js
module: false,
require: false,
__dirname: false,
// Misskey
_DEV_: false,
_LANGS_: false,
_VERSION_: false,
_ENV_: false,
_PERF_PREFIX_: false,
_DATA_TRANSFER_DRIVE_FILE_: false,
_DATA_TRANSFER_DRIVE_FOLDER_: false,
_DATA_TRANSFER_DECK_COLUMN_: false,
},
parser,
parserOptions: {
extraFileExtensions: ['.vue'],
parser: tsParser,
project: ['./tsconfig.json'],
sourceType: 'module',
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/no-empty-interface': ['error', {
allowSingleExtends: true,
}],
// window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため
// e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため
'id-denylist': ['error', 'window', 'e'],
'no-shadow': ['warn'],
'vue/attributes-order': ['error', {
alphabetical: false,
}],
'vue/no-use-v-if-with-v-for': ['error', {
allowUsingIterationVar: false,
}],
'vue/no-ref-as-operand': 'error',
'vue/no-multi-spaces': ['error', {
ignoreProperties: false,
}],
'vue/no-v-html': 'warn',
'vue/order-in-components': 'error',
'vue/html-indent': ['warn', 'tab', {
attribute: 1,
baseIndent: 0,
closeBracket: 0,
alignAttributesVertically: true,
ignores: [],
}],
'vue/html-closing-bracket-spacing': ['warn', {
startTag: 'never',
endTag: 'never',
selfClosingTag: 'never',
}],
'vue/multi-word-component-names': 'warn',
'vue/require-v-for-key': 'warn',
'vue/no-unused-components': 'warn',
'vue/no-unused-vars': 'warn',
'vue/no-dupe-keys': 'warn',
'vue/valid-v-for': 'warn',
'vue/return-in-computed-property': 'warn',
'vue/no-setup-props-reactivity-loss': 'warn',
'vue/max-attributes-per-line': 'off',
'vue/html-self-closing': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/v-on-event-hyphenation': ['error', 'never', {
autofix: true,
}],
'vue/attribute-hyphenation': ['error', 'never'],
},
},
];

View file

@ -23,24 +23,24 @@
"@meersagor/wavesurfer-vue": "^0.1.0",
"@misskey-dev/browser-image-resizer": "2024.1.0",
"@rollup/plugin-json": "6.1.0",
"@rollup/plugin-replace": "5.0.5",
"@rollup/plugin-replace": "5.0.7",
"@rollup/pluginutils": "5.1.0",
"@syuilo/aiscript": "0.18.0",
"@tabler/icons-webfont": "3.3.0",
"@twemoji/parser": "15.1.1",
"@vitejs/plugin-vue": "5.0.4",
"@vue/compiler-sfc": "3.4.26",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.9",
"@vitejs/plugin-vue": "5.0.5",
"@vue/compiler-sfc": "3.4.31",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.11",
"astring": "1.8.6",
"broadcast-channel": "7.0.0",
"buraha": "0.0.1",
"canvas-confetti": "1.9.3",
"chart.js": "4.4.2",
"chart.js": "4.4.3",
"chartjs-adapter-date-fns": "3.0.0",
"chartjs-chart-matrix": "2.0.1",
"chartjs-plugin-gradient": "0.6.1",
"chartjs-plugin-zoom": "2.0.1",
"chromatic": "11.3.0",
"chromatic": "11.5.4",
"compare-versions": "6.1.0",
"cropperjs": "2.0.0-beta.5",
"date-fns": "2.30.0",
@ -56,90 +56,88 @@
"misskey-bubble-game": "workspace:*",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
"photoswipe": "5.4.3",
"photoswipe": "5.4.4",
"punycode": "2.3.1",
"rollup": "4.17.2",
"rollup": "4.18.0",
"sanitize-html": "2.13.0",
"sass": "1.76.0",
"shiki": "1.4.0",
"sass": "1.77.6",
"shiki": "1.10.0",
"strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0",
"three": "0.164.1",
"throttle-debounce": "5.0.0",
"three": "0.165.0",
"throttle-debounce": "5.0.2",
"tinycolor2": "1.6.0",
"tsc-alias": "1.8.8",
"tsc-alias": "1.8.10",
"tsconfig-paths": "4.2.0",
"typescript": "5.5.2",
"uuid": "9.0.1",
"v-code-diff": "1.11.0",
"vite": "5.2.11",
"vue": "3.4.26",
"typescript": "5.5.3",
"uuid": "10.0.0",
"v-code-diff": "1.12.0",
"vite": "5.3.2",
"vue": "3.4.31",
"vuedraggable": "next",
"wavesurfer.js": "^7.7.14"
},
"devDependencies": {
"@misskey-dev/eslint-plugin": "1.0.0",
"@misskey-dev/summaly": "5.1.0",
"@storybook/addon-actions": "8.0.9",
"@storybook/addon-essentials": "8.0.9",
"@storybook/addon-interactions": "8.0.9",
"@storybook/addon-links": "8.0.9",
"@storybook/addon-mdx-gfm": "8.0.9",
"@storybook/addon-storysource": "8.0.9",
"@storybook/blocks": "8.0.9",
"@storybook/components": "8.0.9",
"@storybook/core-events": "8.0.9",
"@storybook/manager-api": "8.0.9",
"@storybook/preview-api": "8.0.9",
"@storybook/react": "8.0.9",
"@storybook/react-vite": "8.0.9",
"@storybook/test": "8.0.9",
"@storybook/theming": "8.0.9",
"@storybook/types": "8.0.9",
"@storybook/vue3": "8.0.9",
"@storybook/vue3-vite": "8.0.9",
"@testing-library/vue": "8.0.3",
"@storybook/addon-actions": "8.1.11",
"@storybook/addon-essentials": "8.1.11",
"@storybook/addon-interactions": "8.1.11",
"@storybook/addon-links": "8.1.11",
"@storybook/addon-mdx-gfm": "8.1.11",
"@storybook/addon-storysource": "8.1.11",
"@storybook/blocks": "8.1.11",
"@storybook/components": "8.1.11",
"@storybook/core-events": "8.1.11",
"@storybook/manager-api": "8.1.11",
"@storybook/preview-api": "8.1.11",
"@storybook/react": "8.1.11",
"@storybook/react-vite": "8.1.11",
"@storybook/test": "8.1.11",
"@storybook/theming": "8.1.11",
"@storybook/types": "8.1.11",
"@storybook/vue3": "8.1.11",
"@storybook/vue3-vite": "8.1.11",
"@testing-library/vue": "8.1.0",
"@types/escape-regexp": "0.0.3",
"@types/estree": "1.0.5",
"@types/matter-js": "0.19.6",
"@types/micromatch": "4.0.7",
"@types/node": "20.12.7",
"@types/micromatch": "4.0.9",
"@types/node": "20.14.9",
"@types/punycode": "2.1.4",
"@types/sanitize-html": "2.11.0",
"@types/seedrandom": "3.0.8",
"@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6",
"@types/uuid": "9.0.8",
"@types/uuid": "10.0.0",
"@types/ws": "8.5.10",
"@typescript-eslint/eslint-plugin": "7.7.1",
"@typescript-eslint/parser": "7.7.1",
"@vitest/coverage-v8": "0.34.6",
"@vue/runtime-core": "3.4.26",
"acorn": "8.11.3",
"@typescript-eslint/eslint-plugin": "7.15.0",
"@typescript-eslint/parser": "7.15.0",
"@vitest/coverage-v8": "1.6.0",
"@vue/runtime-core": "3.4.31",
"acorn": "8.12.0",
"cross-env": "7.0.3",
"cypress": "13.8.1",
"eslint": "8.57.0",
"cypress": "13.13.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-vue": "9.25.0",
"eslint-plugin-vue": "9.26.0",
"fast-glob": "3.3.2",
"happy-dom": "10.0.3",
"intersection-observer": "0.12.2",
"micromatch": "4.0.5",
"msw": "2.2.14",
"msw-storybook-addon": "2.0.1",
"nodemon": "3.1.0",
"prettier": "3.2.5",
"micromatch": "4.0.7",
"msw": "2.3.1",
"msw-storybook-addon": "2.0.2",
"nodemon": "3.1.4",
"prettier": "3.3.2",
"react": "18.3.1",
"react-dom": "18.3.1",
"seedrandom": "3.0.5",
"start-server-and-test": "2.0.3",
"storybook": "8.0.9",
"start-server-and-test": "2.0.4",
"storybook": "8.1.11",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"vite-plugin-turbosnap": "1.0.3",
"vitest": "0.34.6",
"vitest": "1.6.0",
"vitest-fetch-mock": "0.2.2",
"vue-component-type-helpers": "2.0.16",
"vue-eslint-parser": "9.4.2",
"vue-tsc": "2.0.16"
"vue-component-type-helpers": "2.0.24",
"vue-eslint-parser": "9.4.3",
"vue-tsc": "2.0.24"
}
}

View file

@ -184,10 +184,12 @@ export async function refreshAccount() {
export async function login(token: Account['token'], redirect?: string) {
const showing = ref(true);
popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), {
success: false,
showing: showing,
}, {}, 'closed');
}, {
closed: () => dispose(),
});
if (_DEV_) console.log('logging as token ', token);
const me = await fetchAccount(token, undefined, true)
.catch(reason => {
@ -223,21 +225,23 @@ export async function openAccountMenu(opts: {
if (!$i) return;
function showSigninDialog() {
popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
done: res => {
addAccount(res.id, res.i);
success();
},
}, 'closed');
closed: () => dispose(),
});
}
function createAccount() {
popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
done: res => {
addAccount(res.id, res.i);
switchAccountWithToken(res.i);
},
}, 'closed');
closed: () => dispose(),
});
}
async function switchAccount(account: Misskey.entities.UserDetailed) {

View file

@ -13,7 +13,6 @@ import * as sound from '@/scripts/sound.js';
import { $i, signout, updateAccount } from '@/account.js';
import { instance } from '@/instance.js';
import { ColdDeviceStorage, defaultStore } from '@/store.js';
import { makeHotkey } from '@/scripts/hotkey.js';
import { reactionPicker } from '@/scripts/reaction-picker.js';
import { miLocalStorage } from '@/local-storage.js';
import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js';
@ -21,6 +20,7 @@ import { initializeSw } from '@/scripts/initialize-sw.js';
import { deckStore } from '@/ui/deck/deck-store.js';
import { emojiPicker } from '@/scripts/emoji-picker.js';
import { mainRouter } from '@/router/main.js';
import { type Keymap, makeHotkey } from '@/scripts/hotkey.js';
export async function mainBoot() {
const { isClientUpdated } = await common(() => createApp(
@ -36,7 +36,9 @@ export async function mainBoot() {
emojiPicker.init();
if (isClientUpdated && $i) {
popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {}, 'closed');
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {
closed: () => dispose(),
});
}
const stream = useStream();
@ -68,14 +70,6 @@ export async function mainBoot() {
});
}
const hotkeys = {
'd': (): void => {
defaultStore.set('darkMode', !defaultStore.state.darkMode);
},
's': (): void => {
mainRouter.push('/search');
},
};
try {
if (defaultStore.state.enableSeasonalScreenEffect) {
const month = new Date().getMonth() + 1;
@ -104,27 +98,30 @@ export async function mainBoot() {
}
if ($i) {
// only add post shortcuts if logged in
hotkeys['p|n'] = post;
defaultStore.loaded.then(() => {
if (defaultStore.state.accountSetupWizard !== -1) {
popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, {}, 'closed');
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, {
closed: () => dispose(),
});
}
});
for (const announcement of ($i.unreadAnnouncements ?? []).filter(x => x.display === 'dialog')) {
popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), {
announcement,
}, {}, 'closed');
}, {
closed: () => dispose(),
});
}
stream.on('announcementCreated', (ev) => {
const announcement = ev.announcement;
if (announcement.display === 'dialog') {
popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), {
announcement,
}, {}, 'closed');
}, {
closed: () => dispose(),
});
}
});
@ -248,13 +245,17 @@ export async function mainBoot() {
const neverShowDonationInfo = miLocalStorage.getItem('neverShowDonationInfo');
if (neverShowDonationInfo !== 'true' && (createdAt.getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3))) && !location.pathname.startsWith('/miauth')) {
if (latestDonationInfoShownAt == null || (new Date(latestDonationInfoShownAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 30)))) {
popup(defineAsyncComponent(() => import('@/components/MkDonation.vue')), {}, {}, 'closed');
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkDonation.vue')), {}, {
closed: () => dispose(),
});
}
}
const modifiedVersionMustProminentlyOfferInAgplV3Section13Read = miLocalStorage.getItem('modifiedVersionMustProminentlyOfferInAgplV3Section13Read');
if (modifiedVersionMustProminentlyOfferInAgplV3Section13Read !== 'true' && instance.repositoryUrl !== 'https://github.com/misskey-dev/misskey') {
popup(defineAsyncComponent(() => import('@/components/MkSourceCodeAvailablePopup.vue')), {}, {}, 'closed');
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSourceCodeAvailablePopup.vue')), {}, {
closed: () => dispose(),
});
}
if ('Notification' in window) {
@ -323,7 +324,19 @@ export async function mainBoot() {
}
// shortcut
document.addEventListener('keydown', makeHotkey(hotkeys));
const keymap = {
'p|n': () => {
if ($i == null) return;
post();
},
'd': () => {
defaultStore.set('darkMode', !defaultStore.state.darkMode);
},
's': () => {
mainRouter.push('/search');
},
} as const satisfies Keymap;
document.addEventListener('keydown', makeHotkey(keymap), { passive: false });
initializeSw();
}

View file

@ -153,7 +153,7 @@ onMounted(() => {
background: linear-gradient(0deg, #ffee20, #eb7018);
}
&:before {
&::before {
content: "";
display: block;
position: absolute;
@ -173,7 +173,7 @@ onMounted(() => {
background: linear-gradient(0deg, #e1e1e1, #7c7c7c);
}
&:before {
&::before {
content: "";
display: block;
position: absolute;

View file

@ -423,7 +423,6 @@ function onMousedown(evt: MouseEvent): void {
}
&:focus-visible {
outline: solid 2px var(--focus);
outline-offset: 2px;
}

View file

@ -12,14 +12,12 @@ import { expect, userEvent, within } from '@storybook/test';
import { channel } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkChannelFollowButton from './MkChannelFollowButton.vue';
import { semaphore } from '@/scripts/test-utils.js';
import { i18n } from '@/i18n.js';
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
const s = semaphore();
export const Default = {
render(args) {
return {
@ -46,17 +44,13 @@ export const Default = {
full: true,
},
async play({ canvasElement }) {
await s.acquire();
await sleep(1000);
const canvas = within(canvasElement);
const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
await expect(buttonElement).toHaveTextContent(i18n.ts.follow);
await userEvent.click(buttonElement);
await sleep(1000);
await expect(buttonElement).toHaveTextContent(i18n.ts.unfollow);
await sleep(100);
await userEvent.click(buttonElement);
s.release();
},
parameters: {
layout: 'centered',

View file

@ -1,5 +1,6 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License-Identifier: AGPL-3.0-only
SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
@ -117,17 +118,7 @@ async function onClick() {
}
&:focus-visible {
&:after {
content: "";
pointer-events: none;
position: absolute;
top: -5px;
right: -5px;
bottom: -5px;
left: -5px;
border: 2px solid var(--focus);
border-radius: 32px;
}
outline-offset: 2px;
}
&:hover {

View file

@ -4,7 +4,7 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
<template>
<div style="position: relative;">
<MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1" @click="updateLastReadedAt">
<MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" @click="updateLastReadedAt">
<div class="banner" :style="bannerStyle">
<div class="fade"></div>
<div class="name"><i class="ti ti-device-tv"></i> {{ channel.name }}</div>
@ -79,6 +79,7 @@ const bannerStyle = computed(() => {
<style lang="scss" scoped>
.eftoefju {
display: block;
position: relative;
overflow: hidden;
width: 100%;
@ -86,6 +87,22 @@ const bannerStyle = computed(() => {
text-decoration: none;
}
&:focus-within {
outline: none;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: inherit;
pointer-events: none;
box-shadow: inset 0 0 0 2px var(--focus);
}
}
> .banner {
position: relative;
width: 100%;

View file

@ -8,7 +8,7 @@
import { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { action } from '@storybook/addon-actions';
import { expect, within } from '@storybook/test';
import { expect, userEvent, within } from '@storybook/test';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkClickerGame from './MkClickerGame.vue';
@ -41,12 +41,10 @@ export const Default = {
await sleep(1000);
const canvas = within(canvasElement);
const count = canvas.getByTestId('count');
// NOTE: flaky なので N/A も通しておく
await expect(count).toHaveTextContent(/^(0|N\/A)$/);
// FIXME: flaky
// const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
// await userEvent.click(buttonElement);
// await expect(count).toHaveTextContent('1');
await expect(count).toHaveTextContent('0');
const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
await userEvent.click(buttonElement);
await expect(count).toHaveTextContent('1');
},
parameters: {
layout: 'centered',

View file

@ -45,7 +45,9 @@ const prevCookies = ref(0);
function onClick(ev: MouseEvent) {
const x = ev.clientX;
const y = ev.clientY;
os.popup(MkPlusOneEffect, { x, y }, {}, 'end');
const { dispose } = os.popup(MkPlusOneEffect, { x, y }, {
end: () => dispose(),
});
saveData.value!.cookies++;
saveData.value!.totalCookies++;

View file

@ -39,6 +39,14 @@ const remaining = computed(() => {
.link {
display: block;
&:focus-visible {
outline: none;
.root {
box-shadow: inset 0 0 0 2px var(--focus);
}
}
&:hover {
text-decoration: none;
color: var(--accent);

View file

@ -11,7 +11,7 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
:leaveToClass="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''"
>
<div ref="rootEl" :class="$style.root" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}">
<MkMenu :items="items" :align="'left'" @close="$emit('closed')"/>
<MkMenu :items="items" :align="'left'" @close="emit('closed')"/>
</div>
</Transition>
</template>

View file

@ -11,13 +11,6 @@ import { expect, userEvent, within } from '@storybook/test';
import { file } from '../../.storybook/fakes.js';
import MkCwButton from './MkCwButton.vue';
import { i18n } from '@/i18n.js';
import { semaphore } from '@/scripts/test-utils.js';
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
const s = semaphore();
export const Default = {
render(args) {
@ -54,8 +47,6 @@ export const Default = {
text: 'Some CW content',
},
async play({ canvasElement }) {
await s.acquire();
await sleep(1000);
const canvas = within(canvasElement);
const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
await expect(buttonElement).toHaveTextContent(i18n.ts._cw.show);
@ -63,7 +54,6 @@ export const Default = {
await userEvent.click(buttonElement);
await expect(buttonElement).toHaveTextContent(i18n.ts._cw.hide);
await userEvent.click(buttonElement);
s.release();
},
parameters: {
chromatic: {

View file

@ -45,11 +45,11 @@ function toggle() {
.label {
margin-left: 4px;
&:before {
&::before {
content: '(';
}
&:after {
&::after {
content: ')';
}
}

View file

@ -3,7 +3,7 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
-->
<template>
<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="done(true)" @closed="emit('closed')">
<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="done(true)" @closed="emit('closed')" @esc="cancel()">
<div :class="$style.root">
<div v-if="icon" :class="$style.icon">
<i :class="icon"></i>
@ -51,7 +51,7 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
</template>
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref, shallowRef, computed } from 'vue';
import { ref, shallowRef, computed } from 'vue';
import MkModal from '@/components/MkModal.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
@ -159,10 +159,6 @@ function onBgClick() {
if (props.cancelableByBgClick) cancel();
}
*/
function onKeydown(evt: KeyboardEvent) {
if (evt.key === 'Escape') cancel();
}
function onInputKeydown(evt: KeyboardEvent) {
if (evt.key === 'Enter' && okButtonDisabledReason.value === null) {
evt.preventDefault();
@ -170,14 +166,6 @@ function onInputKeydown(evt: KeyboardEvent) {
ok();
}
}
onMounted(() => {
document.addEventListener('keydown', onKeydown);
});
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeydown);
});
</script>
<style lang="scss" module>

View file

@ -135,14 +135,14 @@ function onDragend() {
background: rgba(#000, 0.05);
> .label {
&:before,
&:after {
&::before,
&::after {
background: #0b65a5;
}
&.red {
&:before,
&:after {
&::before,
&::after {
background: #c12113;
}
}
@ -153,14 +153,14 @@ function onDragend() {
background: rgba(#000, 0.1);
> .label {
&:before,
&:after {
&::before,
&::after {
background: #0b588c;
}
&.red {
&:before,
&:after {
&::before,
&::after {
background: #ce2212;
}
}
@ -179,8 +179,8 @@ function onDragend() {
}
> .label {
&:before,
&:after {
&::before,
&::after {
display: none;
}
}
@ -201,8 +201,8 @@ function onDragend() {
left: 0;
pointer-events: none;
&:before,
&:after {
&::before,
&::after {
content: "";
display: block;
position: absolute;
@ -210,14 +210,14 @@ function onDragend() {
background: #0c7ac9;
}
&:before {
&::before {
top: 0;
left: 57px;
width: 28px;
height: 8px;
}
&:after {
&::after {
top: 57px;
left: 0;
width: 8px;
@ -225,8 +225,8 @@ function onDragend() {
}
&.red {
&:before,
&:after {
&::before,
&::after {
background: #c12113;
}
}

View file

@ -348,10 +348,11 @@ function onContextmenu(ev: MouseEvent) {
text: i18n.ts.openInWindow,
icon: 'ti ti-app-window',
action: () => {
os.popup(defineAsyncComponent(() => import('@/components/MkDriveWindow.vue')), {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkDriveWindow.vue')), {
initialFolder: props.folder,
}, {
}, 'closed');
closed: () => dispose(),
});
},
}, { type: 'divider' }, {
text: i18n.ts.rename,
@ -386,7 +387,7 @@ function onContextmenu(ev: MouseEvent) {
cursor: pointer;
&.draghover {
&:after {
&::after {
content: "";
pointer-events: none;
position: absolute;

View file

@ -4,7 +4,19 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
<template>
<div class="omfetrab" :class="['s' + size, 'w' + width, 'h' + height, { asDrawer, asWindow }]" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }">
<input ref="searchEl" :value="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.ts.search" type="search" autocapitalize="off" @input="input()" @paste.stop="paste" @keydown.stop.prevent.enter="onEnter">
<input
ref="searchEl"
:value="q"
class="search"
data-prevent-emoji-insert
:class="{ filled: q != null && q != '' }"
:placeholder="i18n.ts.search"
type="search"
autocapitalize="off"
@input="input()"
@paste.stop="paste"
@keydown="onKeydown"
>
<!-- FirefoxのTabフォーカスが想定外の挙動となるためtabindex="-1"を追加 https://github.com/misskey-dev/misskey/issues/10744 -->
<div ref="emojisEl" class="emojis" tabindex="-1">
<section class="result">
@ -146,6 +158,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
(ev: 'chosen', v: string): void;
(ev: 'esc'): void;
}>();
const profileMax = $i.policies.emojiPickerProfileLimit;
const searchEl = shallowRef<HTMLInputElement>();
@ -411,7 +424,9 @@ function chosen(emoji: any, ev?: MouseEvent) {
const rect = el.getBoundingClientRect();
const x = rect.left + (el.offsetWidth / 2);
const y = rect.top + (el.offsetHeight / 2);
os.popup(MkRippleEffect, { x, y }, {}, 'end');
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
end: () => dispose(),
});
}
const key = getKey(emoji);
@ -440,9 +455,18 @@ function paste(event: ClipboardEvent): void {
}
}
function onEnter(ev: KeyboardEvent) {
function onKeydown(ev: KeyboardEvent) {
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return;
done();
if (ev.key === 'Enter') {
ev.preventDefault();
ev.stopPropagation();
done();
}
if (ev.key === 'Escape') {
ev.preventDefault();
ev.stopPropagation();
emit('esc');
}
}
const activeIndex = ref(defaultStore.state.pickerProfileDefault);
@ -718,11 +742,6 @@ left: 0;*/
border-radius: 4px;
font-size: 24px;
&:focus-visible {
outline: solid 2px var(--focus);
z-index: 1;
}
&:hover {
background: rgba(0, 0, 0, 0.05);
}

View file

@ -8,10 +8,12 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
v-slot="{ type, maxHeight }"
:zPriority="'middle'"
:preferType="defaultStore.state.emojiPickerUseDrawerForMobile === false ? 'popup' : 'auto'"
:hasInteractionWithOtherFocusTrappedEls="true"
:transparentBg="true"
:manualShowing="manualShowing"
:src="src"
@click="modal?.close()"
@esc="modal?.close()"
@opening="opening"
@close="emit('close')"
@closed="emit('closed')"
@ -27,6 +29,7 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
:asDrawer="type === 'drawer'"
:max-height="maxHeight"
@chosen="chosen"
@esc="modal?.close()"
/>
</MkModal>
</template>

View file

@ -3,7 +3,7 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
-->
<template>
<MkA :to="`/play/${flash.id}`" class="vhpxefrk _panel" tabindex="-1">
<MkA :to="`/play/${flash.id}`" class="vhpxefrk _panel">
<article>
<header>
<h1 :title="flash.title">{{ flash.title }}</h1>
@ -38,6 +38,10 @@ const props = defineProps<{
color: var(--accent);
}
&:focus-visible {
outline-offset: -2px;
}
> article {
padding: 16px;

View file

@ -6,10 +6,10 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
<div ref="rootEl" :class="$style.root" role="group" :aria-expanded="opened">
<MkStickyContainer>
<template #header>
<div :class="[$style.header, { [$style.opened]: opened }]" class="_button" role="button" data-cy-folder-header @click="toggle">
<button :class="[$style.header, { [$style.opened]: opened }]" class="_button" role="button" data-cy-folder-header @click="toggle">
<div :class="$style.headerIcon"><slot name="icon"></slot></div>
<div :class="$style.headerText">
<div>
<div :class="$style.headerTextMain">
<MkCondensedLine :minScale="2 / 3"><slot name="label"></slot></MkCondensedLine>
</div>
<div :class="$style.headerTextSub">
@ -21,7 +21,7 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
<i v-if="opened" class="ti ti-chevron-up icon"></i>
<i v-else class="ti ti-chevron-down icon"></i>
</div>
</div>
</button>
</template>
<div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : undefined, overflow: maxHeight ? `auto` : undefined }" :aria-hidden="!opened">
@ -146,6 +146,10 @@ onMounted(() => {
background: var(--buttonHoverBg);
}
&:focus-within {
outline-offset: 2px;
}
&.active {
color: var(--accent);
background: var(--buttonHoverBg);
@ -189,6 +193,12 @@ onMounted(() => {
padding-right: 12px;
}
.headerTextMain,
.headerTextSub {
width: fit-content;
max-width: 100%;
}
.headerTextSub {
color: var(--fgTransparentWeak);
font-size: .85em;

View file

@ -62,6 +62,8 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
import {useStream} from '@/stream.js';
import {i18n} from '@/i18n.js';
import {claimAchievement} from '@/scripts/achievements.js';
import {pleaseLogin } from '@/scripts/please-login.js';
import { host } from '@/config.js';
import {$i} from '@/account.js';
import {defaultStore} from '@/store.js';
@ -85,7 +87,7 @@ const hasPendingFollowRequestFromYou = ref(props.user.hasPendingFollowRequestFro
const wait = ref(false);
const connection = useStream().useChannel('main');
if (props.user.isFollowing == null) {
if (props.user.isFollowing == null && $i) {
misskeyApi('users/show', {
userId: props.user.id,
})
@ -100,7 +102,9 @@ function onFollowChange(user: Misskey.entities.UserDetailed) {
}
async function onClick() {
wait.value = true;
pleaseLogin(undefined, { type: 'web', path: `/@${props.user.username}@${props.user.host ?? host}` });
wait.value = true;
try {
if (isFollowing.value) {
@ -246,17 +250,7 @@ onBeforeUnmount(() => {
}
&:focus-visible {
&:after {
content: "";
pointer-events: none;
position: absolute;
top: -5px;
right: -5px;
bottom: -5px;
left: -5px;
border: 2px solid var(--focus);
border-radius: 32px;
}
outline-offset: 2px;
}
&:hover {

View file

@ -83,7 +83,7 @@ function leaveHover(): void {
> article {
> footer {
&:before {
&::before {
opacity: 1;
}
}
@ -139,7 +139,7 @@ function leaveHover(): void {
text-shadow: 0 0 8px #000;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
&:before {
&::before {
content: "";
display: block;
position: absolute;

View file

@ -14,8 +14,8 @@ SPDX-License-Identifier: AGPL-3.0-only
:enterToClass="defaultStore.state.animation && props.transition?.enterToClass || undefined"
:leaveFromClass="defaultStore.state.animation && props.transition?.leaveFromClass || undefined"
>
<canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined"/>
<img v-show="!hide" key="img" ref="img" :height="imgHeight" :width="imgWidth" :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined" loading="eager" decoding="async"/>
<canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined" tabindex="-1"/>
<img v-show="!hide" key="img" ref="img" :height="imgHeight ?? undefined" :width="imgWidth ?? undefined" :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined" loading="eager" decoding="async" tabindex="-1"/>
</TransitionGroup>
</div>
</template>

View file

@ -3,7 +3,7 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
-->
<template>
<MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="true" :src="src" @click="modal?.close()" @closed="emit('closed')">
<MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="true" :src="src" @click="modal?.close()" @closed="emit('closed')" @esc="modal?.close()">
<div class="szkkfdyq _popup _shadow" :class="{ asDrawer: type === 'drawer' }" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }">
<div class="main">
<template v-for="item in items" :key="item.text">

View file

@ -36,11 +36,13 @@ const el = ref<HTMLElement | { $el: HTMLElement }>();
if (isEnabledUrlPreview.value) {
useTooltip(el, (showing) => {
os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
showing,
url: props.url,
source: el.value instanceof HTMLElement ? el.value : el.value?.$el,
}, {}, 'closed');
}, {
closed: () => dispose(),
});
});
}
</script>

View file

@ -1,5 +1,6 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License-Identifier: AGPL-3.0-only
SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
@ -39,23 +40,37 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
<audio
ref="audioEl"
preload="metadata"
@keydown.prevent="() => {}"
:src="audio.url"
>
</audio>
<div :class="[$style.controlsChild, $style.controlsLeft]">
<button class="_button" :class="$style.controlButton" @click="togglePlayPause">
<button
:class="['_button', $style.controlButton]"
tabindex="-1"
@click.stop="togglePlayPause"
>
<i v-if="isPlaying" class="ti ti-player-pause-filled"></i>
<i v-else class="ti ti-player-play-filled"></i>
</button>
</div>
<div :class="[$style.controlsChild, $style.controlsRight]">
<button class="_button" :class="$style.controlButton" @click="showMenu">
<button
:class="['_button', $style.controlButton]"
tabindex="-1"
@click.stop="() => {}"
@mousedown.prevent.stop="showMenu"
>
<i class="ti ti-settings"></i>
</button>
</div>
<div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }}</div>
<div :class="[$style.controlsChild, $style.controlsVolume]">
<button class="_button" :class="$style.controlButton" @click="toggleMute">
<button
:class="['_button', $style.controlButton]"
tabindex="-1"
@click.stop="toggleMute"
>
<i v-if="volume === 0" class="ti ti-volume-3"></i>
<i v-else class="ti ti-volume"></i>
</button>
@ -97,6 +112,7 @@ import type { MenuItem } from '@/types/menu.js';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { type Keymap } from '@/scripts/hotkey.js';
import bytes from '@/filters/bytes.js';
import { hms } from '@/filters/hms.js';
import MkMediaRange from '@/components/MkMediaRange.vue';
@ -106,32 +122,44 @@ const props = defineProps<{
}>();
const keymap = {
'up': () => {
if (hasFocus() && audioEl.value) {
volume.value = Math.min(volume.value + 0.1, 1);
}
'up': {
allowRepeat: true,
callback: () => {
if (hasFocus() && audioEl.value) {
volume.value = Math.min(volume.value + 0.1, 1);
}
},
},
'down': () => {
if (hasFocus() && audioEl.value) {
volume.value = Math.max(volume.value - 0.1, 0);
}
'down': {
allowRepeat: true,
callback: () => {
if (hasFocus() && audioEl.value) {
volume.value = Math.max(volume.value - 0.1, 0);
}
},
},
'left': () => {
if (hasFocus() && audioEl.value) {
audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0);
}
'left': {
allowRepeat: true,
callback: () => {
if (hasFocus() && audioEl.value) {
audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0);
}
},
},
'right': () => {
if (hasFocus() && audioEl.value) {
audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, audioEl.value.duration);
}
'right': {
allowRepeat: true,
callback: () => {
if (hasFocus() && audioEl.value) {
audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, audioEl.value.duration);
}
},
},
'space': () => {
if (hasFocus()) {
togglePlayPause();
}
},
};
} as const satisfies Keymap;
// PlayerEl
function hasFocus() {
@ -373,7 +401,7 @@ onDeactivated(() => {
border-radius: var(--radius);
overflow: clip;
&:focus {
&:focus-visible {
outline: none;
}
}
@ -439,6 +467,10 @@ onDeactivated(() => {
color: var(--accent);
background-color: var(--accentedBg);
}
&:focus-visible {
outline: none;
}
}
}

View file

@ -39,6 +39,7 @@ import XVideo from '@/components/MkMediaVideo.vue';
import * as os from '@/os.js';
import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
import { defaultStore } from '@/store.js';
import { focusParent } from '@/scripts/focus.js';
const props = defineProps<{
mediaList: Misskey.entities.DriveFile[];
@ -49,7 +50,9 @@ const gallery = shallowRef<HTMLDivElement>();
const pswpZIndex = os.claimZIndex('middle');
document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
const count = computed(() => props.mediaList.filter(media => previewable(media)).length);
let lightbox: PhotoSwipeLightbox | null;
let lightbox: PhotoSwipeLightbox | null = null;
let activeEl: HTMLElement | null = null;
const popstateHandler = (): void => {
if (lightbox?.pswp && lightbox.pswp.isOpen === true) {
@ -60,7 +63,7 @@ const popstateHandler = (): void => {
async function calcAspectRatio() {
if (!gallery.value) return;
let img = props.mediaList[0];
const img = props.mediaList[0];
if (props.mediaList.length !== 1 || !(img.properties.width && img.properties.height)) {
gallery.value.style.aspectRatio = '';
@ -131,6 +134,7 @@ onMounted(() => {
bgOpacity: 1,
showAnimationDuration: 100,
hideAnimationDuration: 100,
returnFocus: false,
pswpModule: PhotoSwipe,
});
@ -159,39 +163,47 @@ onMounted(() => {
lightbox.on('uiRegister', () => {
lightbox?.pswp?.ui?.registerElement({
name: 'altText',
className: 'pwsp__alt-text-container',
className: 'pswp__alt-text-container',
appendTo: 'wrapper',
onInit: (el, pwsp) => {
let textBox = document.createElement('p');
textBox.className = 'pwsp__alt-text _acrylic';
onInit: (el, pswp) => {
const textBox = document.createElement('p');
textBox.className = 'pswp__alt-text _acrylic';
el.appendChild(textBox);
pwsp.on('change', () => {
textBox.textContent = pwsp.currSlide?.data.comment;
pswp.on('change', () => {
textBox.textContent = pswp.currSlide?.data.comment;
});
},
});
});
lightbox.init();
window.addEventListener('popstate', popstateHandler);
lightbox.on('beforeOpen', () => {
lightbox.on('afterInit', () => {
activeEl = document.activeElement instanceof HTMLElement ? document.activeElement : null;
focusParent(activeEl, true, true);
lightbox?.pswp?.element?.focus({
preventScroll: true,
});
history.pushState(null, '', '#pswp');
});
lightbox.on('close', () => {
lightbox.on('destroy', () => {
focusParent(activeEl, true, false);
activeEl = null;
if (window.location.hash === '#pswp') {
history.back();
}
});
window.addEventListener('popstate', popstateHandler);
lightbox.init();
});
onUnmounted(() => {
window.removeEventListener('popstate', popstateHandler);
lightbox?.destroy();
lightbox = null;
activeEl = null;
});
const previewable = (file: Misskey.entities.DriveFile): boolean => {
@ -199,6 +211,16 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => {
// FILE_TYPE_BROWSERSAFE
return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type);
};
const openGallery = () => {
if (props.mediaList.filter(media => previewable(media)).length > 0) {
lightbox?.loadAndOpen(0);
}
};
defineExpose({
openGallery,
});
</script>
<style lang="scss" module>
@ -298,7 +320,7 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => {
backdrop-filter: var(--modalBgFilter);
}
.pwsp__alt-text-container {
.pswp__alt-text-container {
display: flex;
flex-direction: row;
align-items: center;
@ -312,7 +334,7 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => {
max-width: 800px;
}
.pwsp__alt-text {
.pswp__alt-text {
color: var(--fg);
margin: 0 auto;
text-align: center;

View file

@ -111,6 +111,7 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
import { ref, shallowRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue';
import * as Misskey from 'misskey-js';
import type { MenuItem } from '@/types/menu.js';
import { type Keymap } from '@/scripts/hotkey.js';
import bytes from '@/filters/bytes.js';
import { hms } from '@/filters/hms.js';
import { defaultStore } from '@/store.js';
@ -126,32 +127,44 @@ const props = defineProps<{
}>();
const keymap = {
'up': () => {
if (hasFocus() && videoEl.value) {
volume.value = Math.min(volume.value + 0.1, 1);
}
'up': {
allowRepeat: true,
callback: () => {
if (hasFocus() && videoEl.value) {
volume.value = Math.min(volume.value + 0.1, 1);
}
},
},
'down': () => {
if (hasFocus() && videoEl.value) {
volume.value = Math.max(volume.value - 0.1, 0);
}
'down': {
allowRepeat: true,
callback: () => {
if (hasFocus() && videoEl.value) {
volume.value = Math.max(volume.value - 0.1, 0);
}
},
},
'left': () => {
if (hasFocus() && videoEl.value) {
videoEl.value.currentTime = Math.max(videoEl.value.currentTime - 5, 0);
}
'left': {
allowRepeat: true,
callback: () => {
if (hasFocus() && videoEl.value) {
videoEl.value.currentTime = Math.max(videoEl.value.currentTime - 5, 0);
}
},
},
'right': () => {
if (hasFocus() && videoEl.value) {
videoEl.value.currentTime = Math.min(videoEl.value.currentTime + 5, videoEl.value.duration);
}
'right': {
allowRepeat: true,
callback: () => {
if (hasFocus() && videoEl.value) {
videoEl.value.currentTime = Math.min(videoEl.value.currentTime + 5, videoEl.value.duration);
}
},
},
'space': () => {
if (hasFocus()) {
togglePlayPause();
}
},
};
} as const satisfies Keymap;
// PlayerEl
function hasFocus() {
@ -467,7 +480,7 @@ onDeactivated(() => {
position: relative;
overflow: clip;
&:focus {
&:focus-visible {
outline: none;
}
}
@ -574,6 +587,10 @@ onDeactivated(() => {
border-radius: 99rem;
font-size: 1.1rem;
&:focus-visible {
outline: none;
}
}
.videoLoading {
@ -637,6 +654,10 @@ onDeactivated(() => {
&:hover {
background-color: var(--accent);
}
&:focus-visible {
outline: none;
}
}
}

View file

@ -9,7 +9,7 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
</template>
<script lang="ts" setup>
import { nextTick, onMounted, onUnmounted, shallowRef, watch } from 'vue';
import { nextTick, onMounted, onUnmounted, provide, shallowRef, watch } from 'vue';
import MkMenu from './MkMenu.vue';
import { MenuItem } from '@/types/menu.js';
@ -18,7 +18,6 @@ const props = defineProps<{
targetElement: HTMLElement;
rootElement: HTMLElement;
width?: number;
viaKeyboard?: boolean;
}>();
const emit = defineEmits<{
@ -26,6 +25,8 @@ const emit = defineEmits<{
(ev: 'actioned'): void;
}>();
provide('isNestingMenu', true);
const el = shallowRef<HTMLElement>();
const align = 'left';

View file

@ -1,25 +1,41 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License-Identifier: AGPL-3.0-only
SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div role="menu">
<div role="menu" @focusin.passive.stop="() => {}">
<div
ref="itemsEl" v-hotkey="keymap"
ref="itemsEl"
v-hotkey="keymap"
tabindex="0"
class="_popup _shadow"
:class="[$style.root, { [$style.center]: align === 'center', [$style.asDrawer]: asDrawer },{[$style.gamingDark]: gamingType === 'dark',[$style.gamingLight]: gamingType === 'light' }]"
:style="{ width: (width && !asDrawer) ? width + 'px' : '', maxHeight: maxHeight ? maxHeight + 'px' : '' }"
@contextmenu.self="e => e.preventDefault()"
:style="{
width: (width && !asDrawer) ? `${width}px` : '',
maxHeight: maxHeight ? `${maxHeight}px` : '',
}"
@keydown.stop="() => {}"
@contextmenu.self.prevent="() => {}"
>
<template v-for="(item, i) in (items2 ?? [])">
<div v-if="item.type === 'divider'" role="separator" :class="$style.divider"></div>
<span v-else-if="item.type === 'label'" role="menuitem" :class="[$style.label, $style.item,{[$style.gamingDark]: gamingType === 'dark',[$style.gamingLight]: gamingType === 'light' }]">
<template v-for="item in (items2 ?? [])">
<div v-if="item.type === 'divider'" role="separator" tabindex="-1" :class="$style.divider"></div>
<span v-else-if="item.type === 'label'" role="menuitem" tabindex="-1" :class="[$style.label, $style.item,{[$style.gamingDark]: gamingType === 'dark',[$style.gamingLight]: gamingType === 'light' }]">
<span style="opacity: 0.7;">{{ item.text }}</span>
</span>
<span v-else-if="item.type === 'pending'" role="menuitem" :tabindex="i" :class="[$style.pending, $style.item,{[$style.gamingDark]: gamingType === 'dark',[$style.gamingLight]: gamingType === 'light' }]">
<span v-else-if="item.type === 'pending'" role="menuitem" tabindex="0" :class="[$style.pending, $style.item,{[$style.gamingDark]: gamingType === 'dark',[$style.gamingLight]: gamingType === 'light' }]">
<span><MkEllipsis/></span>
</span>
<MkA v-else-if="item.type === 'link'" role="menuitem" :to="item.to" :tabindex="i" class="_button" :class="[$style.item,{[$style.gamingDark]: gamingType === 'dark',[$style.gamingLight]: gamingType === 'light' }]" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<MkA
v-else-if="item.type === 'link'"
role="menuitem"
:to="item.to"
:tabindex="i"
class="_button"
:class="[$style.item,{[$style.gamingDark]: gamingType === 'dark',[$style.gamingLight]: gamingType === 'light' }]"
@click.passive="close(true)"
@mouseenter.passive="onItemMouseEnter"
@mouseleave.passive="onItemMouseLeave">
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
<div :class="$style.item_content">
@ -27,20 +43,48 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
</div>
</MkA>
<a v-else-if="item.type === 'a'" role="menuitem" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button" :class="[$style.item,{[$style.gamingDark]: gamingType === 'dark',[$style.gamingLight]: gamingType === 'light' }]" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<a
v-else-if="item.type === 'a'"
role="menuitem"
:href="item.href"
:target="item.target"
:download="item.download"
:tabindex="i"
class="_button"
:class="[$style.item,{[$style.gamingDark]: gamingType === 'dark',[$style.gamingLight]: gamingType === 'light' }]"
@click.passive="close(true)"
@mouseenter.passive="onItemMouseEnter"
@mouseleave.passive="onItemMouseLeave">
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<div :class="$style.item_content">
<span :class="$style.item_content_text">{{ item.text }}</span>
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
</div>
</a>
<button v-else-if="item.type === 'user'" role="menuitem" :tabindex="i" class="_button" :class="[$style.item, { [$style.active]: item.active },{[$style.gamingDark]: gamingType === 'dark',[$style.gamingLight]: gamingType === 'light' }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<button
v-else-if="item.type === 'user'"
role="menuitem"
tabindex="0"
:class="['_button', $style.item, { [$style.active]: item.active },{[$style.gamingDark]: gamingType === 'dark',[$style.gamingLight]: gamingType === 'light' }]"
@click.prevent="item.active ? close(false) : clicked(item.action, $event)"
@mouseenter.passive="onItemMouseEnter"
@mouseleave.passive="onItemMouseLeave"
>
<MkAvatar :user="item.user" :class="$style.avatar"/><MkUserName :user="item.user"/>
<div v-if="item.indicate" :class="$style.item_content">
<span :class="$style.indicator"><i class="_indicatorCircle"></i></span>
</div>
</button>
<button v-else-if="item.type === 'switch'" role="menuitemcheckbox" :tabindex="i" class="_button" :class="[$style.item, $style.switch, { [$style.switchDisabled]: item.disabled } , { [$style.gamingDark]: gamingType === 'dark',[$style.gamingLight]: gamingType === 'light' }]" @click="switchItem(item)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<button
v-else-if="item.type === 'switch'"
role="menuitemcheckbox"
:tabindex="i"
class="_button"
:class="[$style.item, $style.switch, { [$style.switchDisabled]: item.disabled } , { [$style.gamingDark]: gamingType === 'dark',[$style.gamingLight]: gamingType === 'light' }]"
:disabled="unref(item.disabled)"
@click.prevent="switchItem(item)"
@mouseenter.passive="onItemMouseEnter"
@mouseleave.passive="onItemMouseLeave">
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<MkSwitchButton v-else :class="$style.switchButton" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)" model-value/>
<div :class="$style.item_content">
@ -48,29 +92,61 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
<MkSwitchButton v-if="item.icon" :class="[$style.switchButton, $style.caret]" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/>
</div>
</button>
<button v-else-if="item.type === 'radio'" class="_button" role="menuitem" :tabindex="i" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="preferClick ? null : showRadioOptions(item, $event)" @click="!preferClick ? null : showRadioOptions(item, $event)">
<button
v-else-if="item.type === 'radio'"
role="menuitem"
tabindex="0"
:class="['_button', $style.item, $style.parent, { [$style.active]: childShowingItem === item }]"
:disabled="unref(item.disabled)"
@mouseenter.prevent="preferClick ? null : showRadioOptions(item, $event)"
@keydown.enter.prevent="preferClick ? null : showRadioOptions(item, $event)"
@click.prevent="!preferClick ? null : showRadioOptions(item, $event)"
>
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i>
<div :class="$style.item_content">
<span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span>
<span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span>
</div>
</button>
<button v-else-if="item.type === 'radioOption'" :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.radioActive]: item.active }]" @click="clicked(item.action, $event, false)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<button
v-else-if="item.type === 'radioOption'"
role="menuitemradio"
tabindex="0"
:class="['_button', $style.item, $style.radio, { [$style.active]: unref(item.active) }]"
@click.prevent="unref(item.active) ? null : clicked(item.action, $event, false)"
@mouseenter.passive="onItemMouseEnter"
@mouseleave.passive="onItemMouseLeave"
>
<div :class="$style.icon">
<span :class="[$style.radio, { [$style.radioChecked]: item.active }]"></span>
<span :class="[$style.radioIcon, { [$style.radioChecked]: unref(item.active) }]"></span>
</div>
<div :class="$style.item_content">
<span :class="$style.item_content_text">{{ item.text }}</span>
</div>
</button>
<button v-else-if="item.type === 'parent'" class="_button" role="menuitem" :tabindex="i" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item } , { [$style.gamingDark]: gamingType === 'dark',[$style.gamingLight]: gamingType === 'light' }]" @mouseenter="preferClick ? null : showChildren(item, $event)" @click="!preferClick ? null : showChildren(item, $event)">
<button
v-else-if="item.type === 'parent'"
role="menuitem"
tabindex="0"
:class="['_button', $style.item, $style.parent, { [$style.active]: childShowingItem === item } , { [$style.gamingDark]: gamingType === 'dark',[$style.gamingLight]: gamingType === 'light' }]"
@mouseenter.prevent="preferClick ? null : showChildren(item, $event)"
@keydown.enter.prevent="preferClick ? null : showChildren(item, $event)"
@click.prevent="!preferClick ? null : showChildren(item, $event)"
>
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i>
<div :class="$style.item_content">
<span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span>
<span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span>
</div>
</button>
<button v-else :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: getValue(item.active) }, { [$style.gamingDark]: gamingType === 'dark',[$style.gamingLight]: gamingType === 'light' }]" :disabled="getValue(item.active)" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<button
v-else role="menuitem"
tabindex="0"
:class="['_button', $style.item, { [$style.danger]: item.danger, [$style.active]: unref(item.active) }, { [$style.gamingDark]: gamingType === 'dark',[$style.gamingLight]: gamingType === 'light' }]"
@click.prevent="unref(item.active) ? close(false) : clicked(item.action, $event)"
@mouseenter.passive="onItemMouseEnter"
@mouseleave.passive="onItemMouseLeave"
>
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon, { [$style.gamingDark]: gamingType === 'dark',[$style.gamingLight]: gamingType === 'light' }]"></i>
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
<div :class="$style.item_content">
@ -79,18 +155,18 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
</div>
</button>
</template>
<span v-if="items2 == null || items2.length === 0" :class="[$style.none, $style.item]">
<span v-if="items2 == null || items2.length === 0" tabindex="-1" :class="[$style.none, $style.item]">
<span>{{ i18n.ts.none }}</span>
</span>
</div>
<div v-if="childMenu">
<XChild ref="child" :items="childMenu" :targetElement="childTarget!" :rootElement="itemsEl!" showing @actioned="childActioned" @close="close(false)"/>
<XChild ref="child" :items="childMenu" :targetElement="childTarget!" :rootElement="itemsEl!" @actioned="childActioned" @closed="closeChild"/>
</div>
</div>
</template>
<script lang="ts">
import { ComputedRef, computed, defineAsyncComponent, isRef, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
import { inject, computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch, unref } from 'vue';
import { focusPrev, focusNext } from '@/scripts/focus.js';
import { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio, MenuRadioOption, MenuParent } from '@/types/menu.js';
import * as os from '@/os.js';
@ -99,6 +175,9 @@ import { isTouchUsing } from '@/scripts/touch.js';
import {defaultStore} from '@/store.js'
import MkSwitchButton from '@/components/MkSwitch.button.vue';
let gamingType = computed(defaultStore.makeGetterSetter('gamingType'));
import { type Keymap } from '@/scripts/hotkey.js';
import { isFocusable } from '@/scripts/focus.js';
import { getNodeOrNull } from '@/scripts/get-dom-node-or-null.js';
const childrenCache = new WeakMap<MenuParent, MenuItem[]>();
</script>
@ -108,7 +187,6 @@ const XChild = defineAsyncComponent(() => import('./MkMenu.child.vue'));
const props = defineProps<{
items: MenuItem[];
viaKeyboard?: boolean;
asDrawer?: boolean;
align?: 'center' | string;
width?: number;
@ -120,17 +198,28 @@ const emit = defineEmits<{
(ev: 'hide'): void;
}>();
const itemsEl = shallowRef<HTMLDivElement>();
const isNestingMenu = inject<boolean>('isNestingMenu', false);
const itemsEl = shallowRef<HTMLElement>();
const items2 = ref<InnerMenuItem[]>();
const child = shallowRef<InstanceType<typeof XChild>>();
const keymap = computed(() => ({
'up|k|shift+tab': focusUp,
'down|j|tab': focusDown,
'esc': close,
}));
const keymap = {
'up|k|shift+tab': {
allowRepeat: true,
callback: () => focusUp(),
},
'down|j|tab': {
allowRepeat: true,
callback: () => focusDown(),
},
'esc': {
allowRepeat: true,
callback: () => close(false),
},
} as const satisfies Keymap;
const childShowingItem = ref<MenuItem | null>();
@ -168,25 +257,19 @@ function childActioned() {
close(true);
}
const onGlobalMousedown = (event: MouseEvent) => {
if (childTarget.value && (event.target === childTarget.value || childTarget.value.contains(event.target as Node))) return;
if (child.value && child.value.checkHit(event)) return;
closeChild();
};
let childCloseTimer: null | number = null;
function onItemMouseEnter(item) {
function onItemMouseEnter() {
childCloseTimer = window.setTimeout(() => {
closeChild();
}, 300);
}
function onItemMouseLeave(item) {
function onItemMouseLeave() {
if (childCloseTimer) window.clearTimeout(childCloseTimer);
}
async function showRadioOptions(item: MenuRadio, ev: MouseEvent) {
async function showRadioOptions(item: MenuRadio, ev: Event) {
const children: MenuItem[] = Object.keys(item.options).map<MenuRadioOption>(key => {
const value = item.options[key];
return {
@ -201,7 +284,7 @@ async function showRadioOptions(item: MenuRadio, ev: MouseEvent) {
if (props.asDrawer) {
os.popupMenu(children, ev.currentTarget ?? ev.target).finally(() => {
emit('close');
close(false);
});
emit('hide');
} else {
@ -211,7 +294,7 @@ async function showRadioOptions(item: MenuRadio, ev: MouseEvent) {
}
}
async function showChildren(item: MenuParent, ev: MouseEvent) {
async function showChildren(item: MenuParent, ev: Event) {
const children: MenuItem[] = await (async () => {
if (childrenCache.has(item)) {
return childrenCache.get(item)!;
@ -228,7 +311,7 @@ async function showChildren(item: MenuParent, ev: MouseEvent) {
if (props.asDrawer) {
os.popupMenu(children, ev.currentTarget ?? ev.target).finally(() => {
emit('close');
close(false);
});
emit('hide');
} else {
@ -247,15 +330,11 @@ function clicked(fn: MenuAction, ev: MouseEvent, doClose = true) {
}
function close(actioned = false) {
emit('close', actioned);
}
function focusUp() {
focusPrev(document.activeElement);
}
function focusDown() {
focusNext(document.activeElement);
disposeHandlers();
nextTick(() => {
closeChild();
emit('close', actioned);
});
}
function switchItem(item: MenuSwitch & { ref: any }) {
@ -263,25 +342,75 @@ function switchItem(item: MenuSwitch & { ref: any }) {
item.ref = !item.ref;
}
function getValue<T>(item?: ComputedRef<T> | T) {
return isRef(item) ? item.value : item;
function focusUp() {
if (disposed) return;
if (!itemsEl.value?.contains(document.activeElement)) return;
const focusableElements = Array.from(itemsEl.value.children).filter(isFocusable);
const activeIndex = focusableElements.findIndex(el => el === document.activeElement);
const targetIndex = (activeIndex !== -1 && activeIndex !== 0) ? (activeIndex - 1) : (focusableElements.length - 1);
const targetElement = focusableElements.at(targetIndex) ?? itemsEl.value;
targetElement.focus();
}
onMounted(() => {
if (props.viaKeyboard) {
nextTick(() => {
if (itemsEl.value) focusNext(itemsEl.value.children[0], true, false);
});
function focusDown() {
if (disposed) return;
if (!itemsEl.value?.contains(document.activeElement)) return;
const focusableElements = Array.from(itemsEl.value.children).filter(isFocusable);
const activeIndex = focusableElements.findIndex(el => el === document.activeElement);
const targetIndex = (activeIndex !== -1 && activeIndex !== (focusableElements.length - 1)) ? (activeIndex + 1) : 0;
const targetElement = focusableElements.at(targetIndex) ?? itemsEl.value;
targetElement.focus();
}
const onGlobalFocusin = (ev: FocusEvent) => {
if (disposed) return;
if (itemsEl.value?.parentElement?.contains(getNodeOrNull(ev.target))) return;
nextTick(() => {
if (itemsEl.value != null && isFocusable(itemsEl.value)) {
itemsEl.value.focus({ preventScroll: true });
nextTick(() => focusDown());
}
});
};
const onGlobalMousedown = (ev: MouseEvent) => {
if (disposed) return;
if (childTarget.value?.contains(getNodeOrNull(ev.target))) return;
if (child.value?.checkHit(ev)) return;
closeChild();
};
const setupHandlers = () => {
if (!isNestingMenu) {
document.addEventListener('focusin', onGlobalFocusin, { passive: true });
}
// TODO:
//itemsEl.scrollTo();
document.addEventListener('mousedown', onGlobalMousedown, { passive: true });
};
let disposed = false;
const disposeHandlers = () => {
disposed = true;
if (!isNestingMenu) {
document.removeEventListener('focusin', onGlobalFocusin);
}
document.removeEventListener('mousedown', onGlobalMousedown);
};
onMounted(() => {
setupHandlers();
if (!isNestingMenu) {
nextTick(() => itemsEl.value?.focus({ preventScroll: true }));
}
});
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onGlobalMousedown);
disposeHandlers();
});
</script>
@ -294,6 +423,10 @@ onBeforeUnmount(() => {
overflow: auto;
overscroll-behavior: contain;
&:focus-visible {
outline: none;
}
&.center {
> .item {
text-align: center;
@ -311,7 +444,7 @@ onBeforeUnmount(() => {
font-size: 1em;
padding: 12px 24px;
&:before {
&::before {
width: calc(100% - 24px);
border-radius: 12px;
}
@ -341,8 +474,10 @@ onBeforeUnmount(() => {
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
text-decoration: none !important;
color: var(--menuFg, var(--fg));
&:before {
&::before {
content: "";
display: block;
position: absolute;
@ -385,26 +520,46 @@ onBeforeUnmount(() => {
}
&:focus-visible {
outline: none;
&:not(:hover):not(:active)::before {
outline: var(--focus) solid 2px;
outline-offset: -2px;
}
}
&:not(:disabled) {
&:hover,
&:focus-visible:active,
&:focus-visible.active {
color: var(--menuHoverFg, var(--accent));
&::before {
background-color: var(--menuHoverBg, var(--accentedBg));
}
}
&:not(:focus-visible):active,
&:not(:focus-visible).active {
color: var(--menuActiveFg, var(--fgOnAccent));
&::before {
background-color: var(--menuActiveBg, var(--accent));
}
}
}
&:disabled {
cursor: not-allowed;
}
&.danger {
color: #ff2a2a;
&:hover {
color: #fff;
&:before {
background: #ff4242;
}
}
&:active {
color: #fff;
&:before {
background: #d42e2e !important;
}
}
--menuFg: #ff2a2a;
--menuHoverFg: #fff;
--menuHoverBg: #ff4242;
--menuActiveFg: #fff;
--menuActiveBg: #d42e2e;
}
&:active,
@ -435,19 +590,14 @@ onBeforeUnmount(() => {
-moz-animation: AnimationLight var(--gamingspeed) cubic-bezier(0, 0.2, 0.90, 1) infinite !important;
animation: AnimationLight var(--gamingspeed) cubic-bezier(0, 0.2, 0.90, 1) infinite !important;
}
&.radio {
--menuActiveFg: var(--accent);
--menuActiveBg: var(--accentedBg);
}
&.radioActive {
color: var(--accent) !important;
opacity: 1;
&:before {
background-color: var(--accentedBg) !important;
}
}
&:not(:active):focus-visible {
box-shadow: 0 0 0 2px var(--focus) inset;
&.parent {
--menuActiveFg: var(--accent);
--menuActiveBg: var(--accentedBg);
}
&.label {
@ -522,18 +672,6 @@ onBeforeUnmount(() => {
overflow: hidden;
}
.switch {
position: relative;
display: flex;
transition: all 0.2s ease;
user-select: none;
cursor: pointer;
}
.switchDisabled {
cursor: not-allowed;
}
.switchButton {
margin-left: -2px;
--height: 1.35em;
@ -545,14 +683,6 @@ onBeforeUnmount(() => {
text-overflow: ellipsis;
}
.switchInput {
position: absolute;
width: 0;
height: 0;
opacity: 0;
margin: 0;
}
.icon {
margin-right: 8px;
line-height: 1;
@ -582,12 +712,12 @@ onBeforeUnmount(() => {
border-top: solid 0.5px var(--divider);
}
.radio {
.radioIcon {
display: inline-block;
position: relative;
width: 1em;
height: 1em;
vertical-align: -.125em;
vertical-align: -0.125em;
border-radius: 50%;
border: solid 2px var(--divider);
background-color: var(--panel);

View file

@ -29,9 +29,9 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
[$style.transition_modal_leaveTo]: transitionName === 'modal',
[$style.transition_send_leaveTo]: transitionName === 'send',
})"
:duration="transitionDuration" appear @afterLeave="emit('closed')" @enter="emit('opening')" @afterEnter="onOpened"
:duration="transitionDuration" appear @afterLeave="onClosed" @enter="emit('opening')" @afterEnter="onOpened"
>
<div v-show="manualShowing != null ? manualShowing : showing" v-hotkey.global="keymap" :class="[$style.root, { [$style.drawer]: type === 'drawer', [$style.dialog]: type === 'dialog', [$style.popup]: type === 'popup' }]" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
<div v-show="manualShowing != null ? manualShowing : showing" ref="modalRootEl" v-hotkey.global="keymap" :class="[$style.root, { [$style.drawer]: type === 'drawer', [$style.dialog]: type === 'dialog', [$style.popup]: type === 'popup' }]" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
<div data-cy-bg :data-cy-transparent="isEnableBgTransparent" class="_modalBg" :class="[$style.bg, { [$style.bgTransparent]: isEnableBgTransparent }]" :style="{ zIndex }" @click="onBgClick" @mousedown="onBgClick" @contextmenu.prevent.stop="() => {}"></div>
<div ref="content" :class="[$style.content, { [$style.fixed]: fixed }]" :style="{ zIndex }" @click.self="onBgClick">
<slot :max-height="maxHeight" :type="type"></slot>
@ -46,6 +46,9 @@ import * as os from '@/os.js';
import { isTouchUsing } from '@/scripts/touch.js';
import { defaultStore } from '@/store.js';
import { deviceKind } from '@/scripts/device-kind.js';
import { type Keymap } from '@/scripts/hotkey.js';
import { focusTrap } from '@/scripts/focus-trap.js';
import { focusParent } from '@/scripts/focus.js';
function getFixedContainer(el: Element | null): Element | null {
if (el == null || el.tagName === 'BODY') return null;
@ -67,6 +70,8 @@ const props = withDefaults(defineProps<{
zPriority?: 'low' | 'middle' | 'high';
noOverlap?: boolean;
transparentBg?: boolean;
hasInteractionWithOtherFocusTrappedEls?: boolean;
returnFocusTo?: HTMLElement | null;
}>(), {
manualShowing: null,
src: null,
@ -75,6 +80,8 @@ const props = withDefaults(defineProps<{
zPriority: 'low',
noOverlap: true,
transparentBg: false,
hasInteractionWithOtherFocusTrappedEls: false,
returnFocusTo: null,
});
const emit = defineEmits<{
@ -92,6 +99,7 @@ const maxHeight = ref<number>();
const fixed = ref(false);
const transformOrigin = ref('center');
const showing = ref(true);
const modalRootEl = shallowRef<HTMLElement>();
const content = shallowRef<HTMLElement>();
const zIndex = os.claimZIndex(props.zPriority);
const useSendAnime = ref(false);
@ -130,6 +138,7 @@ const transitionDuration = computed((() =>
: 0
));
let releaseFocusTrap: (() => void) | null = null;
let contentClicking = false;
function close(opts: { useSendAnimation?: boolean } = {}) {
@ -153,8 +162,11 @@ if (type.value === 'drawer') {
}
const keymap = {
'esc': () => emit('esc'),
};
'esc': {
allowRepeat: true,
callback: () => emit('esc'),
},
} as const satisfies Keymap;
const MARGIN = 16;
const SCROLLBAR_THICKNESS = 16;
@ -291,6 +303,10 @@ const onOpened = () => {
}, { passive: true });
};
const onClosed = () => {
emit('closed');
};
const alignObserver = new ResizeObserver((entries, observer) => {
align();
});
@ -308,6 +324,20 @@ onMounted(() => {
align();
}, { immediate: true });
watch([showing, () => props.manualShowing], ([showing, manualShowing]) => {
if (manualShowing === true || (manualShowing == null && showing === true)) {
if (modalRootEl.value != null) {
const { release } = focusTrap(modalRootEl.value, props.hasInteractionWithOtherFocusTrappedEls);
releaseFocusTrap = release;
modalRootEl.value.focus();
}
} else {
releaseFocusTrap?.();
focusParent(props.returnFocusTo ?? props.src, true, false);
}
}, { immediate: true });
nextTick(() => {
alignObserver.observe(content.value!);
});

View file

@ -3,8 +3,8 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
-->
<template>
<MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="$emit('closed')">
<div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }" @keydown="onKeydown">
<MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="emit('closed')" @esc="emit('esc')">
<div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }">
<div ref="headerEl" :class="$style.header">
<button v-if="withOkButton" :class="$style.headerButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button>
<span :class="$style.title">
@ -41,6 +41,7 @@ const emit = defineEmits<{
(event: 'close'): void;
(event: 'closed'): void;
(event: 'ok'): void;
(event: 'esc'): void;
}>();
const modal = shallowRef<InstanceType<typeof MkModal>>();
@ -57,14 +58,6 @@ const onBgClick = () => {
emit('click');
};
const onKeydown = (evt) => {
if (evt.which === 27) { // Esc
evt.preventDefault();
evt.stopPropagation();
close();
}
};
const ro = new ResizeObserver((entries, observer) => {
if (rootEl.value == null || headerEl.value == null) return;
bodyWidth.value = rootEl.value.offsetWidth;

View file

@ -1,5 +1,6 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License-Identifier: AGPL-3.0-only
SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
@ -16,7 +17,7 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
},{[$style.localonly] : defaultStore.state.showVisibilityColor && note.localOnly }
]"
:tabindex="!isDeleted ? '-1' : undefined"
:tabindex="isDeleted ? '-1' : '0'"
>
<MkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/>
<div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div>
@ -34,7 +35,7 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
</template>
</I18n>
<div :class="$style.renoteInfo">
<button ref="renoteTime" :class="$style.renoteTime" class="_button" @click="showRenoteMenu()">
<button ref="renoteTime" :class="$style.renoteTime" class="_button" @mousedown.prevent="showRenoteMenu()">
<i class="ti ti-dots" :class="$style.renoteMenu"></i>
<MkTime :time="note.createdAt"/>
</button>
@ -88,7 +89,7 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
</div>
</div>
<div v-if="appearNote.files && appearNote.files.length > 0">
<MkMediaList :mediaList="appearNote.files"/>
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
</div>
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
<div v-if="isEnabledUrlPreview">
@ -119,7 +120,7 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
ref="renoteButton"
:class="$style.footerButton"
class="_button"
@mousedown="renote()"
@mousedown.prevent="renote()"
>
<i class="ti ti-repeat"></i>
<p v-if="appearNote.renoteCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.renoteCount) }}</p>
@ -134,10 +135,10 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
<i v-else class="ti ti-plus"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p>
</button>
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown="clip()">
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()">
<i class="ti ti-paperclip"></i>
</button>
<button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown="showMenu()">
<button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown.prevent="showMenu()">
<i class="ti ti-dots"></i>
</button>
</footer>
@ -183,8 +184,7 @@ import MkPoll from '@/components/MkPoll.vue';
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import { pleaseLogin } from '@/scripts/please-login.js';
import { focusPrev, focusNext } from '@/scripts/focus.js';
import { pleaseLogin, type OpenOnRemoteOptions } from '@/scripts/please-login.js';
import { checkWordMute } from '@/scripts/check-word-mute.js';
import { userPage } from '@/filters/user.js';
import number from '@/filters/number.js';
@ -206,7 +206,10 @@ import { MenuItem } from '@/types/menu.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
import { shouldCollapsed } from '@/scripts/collapsed.js';
import { host } from '@/config.js';
import { isEnabledUrlPreview } from '@/instance.js';
import { type Keymap } from '@/scripts/hotkey.js';
import { focusPrev, focusNext } from '@/scripts/focus.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note & {myReactions: string[]};
@ -264,6 +267,7 @@ const renoteTime = shallowRef<HTMLElement>();
const reactButton = shallowRef<HTMLElement>();
const clipButton = shallowRef<HTMLElement>();
const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value);
const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>();
const isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(false);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
@ -284,6 +288,11 @@ const renoteCollapsed = ref(
),
);
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: `https://${host}/notes/${appearNote.value.id}`,
}));
/* Overload FunctionLint
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): boolean | 'sensitiveMute';
@ -302,15 +311,53 @@ function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string
}
const keymap = {
'r': () => reply(true),
'e|a|plus': () => react(true),
'q': () => renote(true),
'up|k|shift+tab': focusBefore,
'down|j|tab': focusAfter,
'esc': blur,
'm|o': () => showMenu(true),
's': () => showContent.value !== showContent.value,
};
'r': () => {
if (renoteCollapsed.value) return;
reply();
},
'e|a|plus': () => {
if (renoteCollapsed.value) return;
react();
},
'q': () => {
if (renoteCollapsed.value) return;
renote();
},
'm': () => {
if (renoteCollapsed.value) return;
showMenu();
},
'c': () => {
if (renoteCollapsed.value) return;
if (!defaultStore.state.showClipButtonInNoteFooter) return;
clip();
},
'o': () => {
if (renoteCollapsed.value) return;
galleryEl.value?.openGallery();
},
'v|enter': () => {
if (renoteCollapsed.value) {
renoteCollapsed.value = false;
} else if (appearNote.value.cw != null) {
showContent.value = !showContent.value;
} else if (isLong) {
collapsed.value = !collapsed.value;
}
},
'esc': {
allowRepeat: true,
callback: () => blur(),
},
'up|k|shift+tab': {
allowRepeat: true,
callback: () => focusBefore(),
},
'down|j|tab': {
allowRepeat: true,
callback: () => focusAfter(),
},
} as const satisfies Keymap;
provide('react', (reaction: string) => {
misskeyApi('notes/reactions/create', {
@ -343,12 +390,14 @@ if (!props.mock) {
if (users.length < 1) return;
os.popup(MkUsersTooltip, {
const { dispose } = os.popup(MkUsersTooltip, {
showing,
users,
count: appearNote.value.renoteCount,
targetElement: renoteButton.value,
}, {}, 'closed');
}, {
closed: () => dispose(),
});
});
if (appearNote.value.reactionAcceptance === 'likeOnly') {
@ -363,19 +412,21 @@ if (!props.mock) {
if (users.length < 1) return;
os.popup(MkReactionsViewerDetails, {
const { dispose } = os.popup(MkReactionsViewerDetails, {
showing,
reaction: '❤️',
users,
count: appearNote.value.reactionCount,
targetElement: reactButton.value!,
}, {}, 'closed');
}, {
closed: () => dispose(),
});
});
}
}
function renote(viaKeyboard = false) {
pleaseLogin();
pleaseLogin(undefined, pleaseLoginContext.value);
showMovedDialog();
const { menu } = getRenoteMenu({ note: note.value, renoteButton, mock: props.mock });
@ -384,22 +435,21 @@ function renote(viaKeyboard = false) {
});
}
function reply(viaKeyboard = false): void {
pleaseLogin();
function reply(): void {
pleaseLogin(undefined, pleaseLoginContext.value);
if (props.mock) {
return;
}
os.post({
reply: appearNote.value,
channel: appearNote.value.channel,
animation: !viaKeyboard,
}).then(() => {
focus();
});
}
function react(viaKeyboard = false): void {
pleaseLogin();
function react(): void {
pleaseLogin(undefined, pleaseLoginContext.value);
showMovedDialog();
if (appearNote.value.reactionAcceptance === 'likeOnly') {
sound.playMisskeySfx('reaction');
@ -417,7 +467,9 @@ function react(viaKeyboard = false): void {
const rect = el.getBoundingClientRect();
const x = rect.left + (el.offsetWidth / 2);
const y = rect.top + (el.offsetHeight / 2);
os.popup(MkRippleEffect, { x, y }, {}, 'end');
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
end: () => dispose(),
});
}
} else {
blur();
@ -482,7 +534,7 @@ function onContextmenu(ev: MouseEvent): void {
}
}
function showMenu(viaKeyboard = false): void {
function showMenu(): void {
if (props.mock) {
return;
}
@ -495,12 +547,10 @@ function showMenu(viaKeyboard = false): void {
isDeleted,
currentClip: currentClip?.value,
});
os.popupMenu(menu, menuButton.value, {
viaKeyboard,
}).then(focus).finally(cleanup);
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
}
async function clip() {
async function clip(): Promise<void> {
if (props.mock) {
return;
}
@ -512,7 +562,7 @@ async function clip() {
}), clipButton.value).then(focus);
}
function showRenoteMenu(viaKeyboard = false): void {
function showRenoteMenu(): void {
if (props.mock) {
return;
}
@ -532,23 +582,19 @@ function showRenoteMenu(viaKeyboard = false): void {
}
if (isMyRenote) {
pleaseLogin();
pleaseLogin(undefined, pleaseLoginContext.value);
os.popupMenu([
getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
{ type: 'divider' },
getUnrenote(),
], renoteTime.value, {
viaKeyboard: viaKeyboard,
});
], renoteTime.value);
} else {
os.popupMenu([
getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
{ type: 'divider' },
getAbuseNoteMenu(note.value, i18n.ts.reportAbuseRenote),
($i?.isModerator || $i?.isAdmin) ? getUnrenote() : undefined,
], renoteTime.value, {
viaKeyboard: viaKeyboard,
});
], renoteTime.value);
}
}
@ -561,11 +607,11 @@ function blur() {
}
function focusBefore() {
focusPrev(rootEl.value ?? null);
focusPrev(rootEl.value);
}
function focusAfter() {
focusNext(rootEl.value ?? null);
focusNext(rootEl.value);
}
function emitUpdReaction(emoji: string, delta: number) {
@ -610,7 +656,7 @@ function emitUpdReaction(emoji: string, delta: number) {
&:focus-visible {
outline: none;
&:after {
&::after {
content: "";
pointer-events: none;
display: block;
@ -623,7 +669,7 @@ function emitUpdReaction(emoji: string, delta: number) {
margin: auto;
width: calc(100% - 8px);
height: calc(100% - 8px);
border: dashed 1px var(--focus);
border: dashed 2px var(--focus);
border-radius: var(--radius);
box-sizing: border-box;
}

View file

@ -1,5 +1,6 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License-Identifier: AGPL-3.0-only
SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
@ -9,6 +10,7 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
ref="rootEl"
v-hotkey="keymap"
:class="$style.root"
:tabindex="isDeleted ? '-1' : '0'"
>
<div v-if="appearNote.reply && appearNote.reply.replyId">
<div v-if="!conversationLoaded" style="padding: 16px">
@ -30,7 +32,7 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
</I18n>
</span>
<div :class="$style.renoteInfo">
<button ref="renoteTime" class="_button" :class="$style.renoteTime" @click="showRenoteMenu()">
<button ref="renoteTime" class="_button" :class="$style.renoteTime" @mousedown.prevent="showRenoteMenu()">
<i v-if="isMyRenote" class="ti ti-dots" style="margin-right: 4px;"></i>
<MkTime :time="note.createdAt"/>
</button>
@ -90,7 +92,7 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
</div>
</div>
<div v-if="appearNote.files && appearNote.files.length > 0">
<MkMediaList :mediaList="appearNote.files"/>
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
</div>
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
<div v-if="isEnabledUrlPreview">
@ -119,7 +121,7 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
ref="renoteButton"
class="_button"
:class="$style.noteFooterButton"
@mousedown="renote()"
@mousedown.prevent="renote()"
>
<i class="ti ti-repeat"></i>
<p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.renoteCount) }}</p>
@ -134,10 +136,10 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
<i v-else class="ti ti-plus"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p>
</button>
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown="clip()">
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()">
<i class="ti ti-paperclip"></i>
</button>
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown="showMenu()">
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()">
<i class="ti ti-dots"></i>
</button>
</footer>
@ -231,7 +233,7 @@ import MkPoll from '@/components/MkPoll.vue';
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import { pleaseLogin } from '@/scripts/please-login.js';
import { pleaseLogin, type OpenOnRemoteOptions } from '@/scripts/please-login.js';
import { checkWordMute } from '@/scripts/check-word-mute.js';
import { userPage } from '@/filters/user.js';
import { notePage } from '@/filters/note.js';
@ -244,6 +246,7 @@ import { reactionPicker } from '@/scripts/reaction-picker.js';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
import { $i } from '@/account.js';
import { i18n } from '@/i18n.js';
import { host } from '@/config.js';
import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/scripts/get-note-menu.js';
import { useNoteCapture } from '@/scripts/use-note-capture.js';
import { deepClone } from '@/scripts/clone.js';
@ -255,12 +258,8 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue';
import MkPagination, { type Paging } from '@/components/MkPagination.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
import MkButton from '@/components/MkButton.vue';
import { miLocalStorage } from '@/local-storage.js';
import { infoImageUrl, instance, isEnabledUrlPreview } from '@/instance.js';
import { deviceKind } from '@/scripts/device-kind.js';
const MOBILE_THRESHOLD = 500;
import { infoImageUrl, isEnabledUrlPreview } from '@/instance.js';
import { type Keymap } from '@/scripts/hotkey.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
initialTab: string;
@ -338,6 +337,7 @@ const renoteTime = shallowRef<HTMLElement>();
const reactButton = shallowRef<HTMLElement>();
const clipButton = shallowRef<HTMLElement>();
const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value);
const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>();
const isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(false);
const isDeleted = ref(false);
@ -351,14 +351,31 @@ const conversation = ref<Misskey.entities.Note[]>([]);
const replies = ref<Misskey.entities.Note[]>([]);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id);
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: `https://${host}/notes/${appearNote.value.id}`,
}));
const keymap = {
'r': () => reply(true),
'e|a|plus': () => react(true),
'q': () => renote(true),
'esc': blur,
'm|o': () => showMenu(true),
's': () => showContent.value !== showContent.value,
};
'r': () => reply(),
'e|a|plus': () => react(),
'q': () => renote(),
'm': () => showMenu(),
'c': () => {
if (!defaultStore.state.showClipButtonInNoteFooter) return;
clip();
},
'o': () => galleryEl.value?.openGallery(),
'v|enter': () => {
if (appearNote.value.cw != null) {
showContent.value = !showContent.value;
}
},
'esc': {
allowRepeat: true,
callback: () => blur(),
},
} as const satisfies Keymap;
provide('react', (reaction: string) => {
misskeyApi('notes/reactions/create', {
@ -404,12 +421,14 @@ useTooltip(renoteButton, async (showing) => {
if (users.length < 1) return;
os.popup(MkUsersTooltip, {
const { dispose } = os.popup(MkUsersTooltip, {
showing,
users,
count: appearNote.value.renoteCount,
targetElement: renoteButton.value,
}, {}, 'closed');
}, {
closed: () => dispose(),
});
});
if (appearNote.value.reactionAcceptance === 'likeOnly') {
@ -424,40 +443,39 @@ if (appearNote.value.reactionAcceptance === 'likeOnly') {
if (users.length < 1) return;
os.popup(MkReactionsViewerDetails, {
const { dispose } = os.popup(MkReactionsViewerDetails, {
showing,
reaction: '❤️',
users,
count: appearNote.value.reactionCount,
targetElement: reactButton.value!,
}, {}, 'closed');
}, {
closed: () => dispose(),
});
});
}
function renote(viaKeyboard = false) {
pleaseLogin();
function renote() {
pleaseLogin(undefined, pleaseLoginContext.value);
showMovedDialog();
const { menu } = getRenoteMenu({ note: note.value, renoteButton });
os.popupMenu(menu, renoteButton.value, {
viaKeyboard,
});
os.popupMenu(menu, renoteButton.value);
}
function reply(viaKeyboard = false): void {
pleaseLogin();
function reply(): void {
pleaseLogin(undefined, pleaseLoginContext.value);
showMovedDialog();
os.post({
reply: appearNote.value,
channel: appearNote.value.channel,
animation: !viaKeyboard,
}).then(() => {
focus();
});
}
function react(viaKeyboard = false): void {
pleaseLogin();
function react(): void {
pleaseLogin(undefined, pleaseLoginContext.value);
showMovedDialog();
if (appearNote.value.reactionAcceptance === 'likeOnly') {
sound.playMisskeySfx('reaction');
@ -466,12 +484,14 @@ function react(viaKeyboard = false): void {
noteId: appearNote.value.id,
reaction: '❤️',
});
const el = reactButton.value as HTMLElement | null | undefined;
const el = reactButton.value;
if (el) {
const rect = el.getBoundingClientRect();
const x = rect.left + (el.offsetWidth / 2);
const y = rect.top + (el.offsetHeight / 2);
os.popup(MkRippleEffect, { x, y }, {}, 'end');
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
end: () => dispose(),
});
}
} else {
blur();
@ -526,20 +546,18 @@ function onContextmenu(ev: MouseEvent): void {
}
}
function showMenu(viaKeyboard = false): void {
function showMenu(): void {
const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted });
os.popupMenu(menu, menuButton.value, {
viaKeyboard,
}).then(focus).finally(cleanup);
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
}
async function clip() {
async function clip(): Promise<void> {
os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted }), clipButton.value).then(focus);
}
function showRenoteMenu(viaKeyboard = false): void {
function showRenoteMenu(): void {
if (!isMyRenote) return;
pleaseLogin();
pleaseLogin(undefined, pleaseLoginContext.value);
os.popupMenu([{
text: i18n.ts.unrenote,
icon: 'ti ti-trash',
@ -550,9 +568,7 @@ function showRenoteMenu(viaKeyboard = false): void {
});
isDeleted.value = true;
},
}], renoteTime.value, {
viaKeyboard: viaKeyboard,
});
}], renoteTime.value);
}
function focus() {
@ -594,6 +610,28 @@ function loadConversation() {
transition: box-shadow 0.1s ease;
overflow: clip;
contain: content;
&:focus-visible {
outline: none;
&::after {
content: "";
pointer-events: none;
display: block;
position: absolute;
z-index: 10;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
width: calc(100% - 8px);
height: calc(100% - 8px);
border: dashed 2px var(--focus);
border-radius: var(--radius);
box-sizing: border-box;
}
}
}
.replyTo {

View file

@ -4,7 +4,7 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
<template>
<div :class="$style.root">
<MkAvatar :class="$style.avatar" :user="user" link preview/>
<MkAvatar :class="$style.avatar" :user="user"/>
<div :class="$style.main">
<div :class="$style.header">
<MkUserName :user="user" :nowrap="true"/>

View file

@ -350,7 +350,7 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
margin-right: 4px;
position: relative;
&:before {
&::before {
position: absolute;
transform: rotate(180deg);
}

View file

@ -3,7 +3,7 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
-->
<template>
<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj" tabindex="-1">
<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj">
<div v-if="page.eyeCatchingImage" class="thumbnail">
<MediaImage
:image="page.eyeCatchingImage"
@ -49,12 +49,29 @@ const props = defineProps<{
<style lang="scss" scoped>
.vhpxefrj {
display: block;
position: relative;
&:hover {
text-decoration: none;
color: var(--accent);
}
&:focus-within {
outline: none;
&::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: var(--radius);
pointer-events: none;
box-shadow: inset 0 0 0 2px var(--focus);
}
}
> .thumbnail {
& + article {
border-radius: 0 0 var(--radius) var(--radius);

View file

@ -33,7 +33,9 @@ import { pleaseLogin } from '@/scripts/please-login.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import { host } from '@/config.js';
import { useInterval } from '@/scripts/use-interval.js';
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
const props = defineProps<{
noteId: string;
@ -59,6 +61,11 @@ const timer = computed(() => i18n.tsx._poll[
const showResult = ref(props.readOnly || isVoted.value);
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: `https://${host}/notes/${props.noteId}`,
}));
//
if (props.poll.expiresAt) {
const tick = () => {
@ -75,7 +82,7 @@ if (props.poll.expiresAt) {
}
const vote = async (id) => {
pleaseLogin();
pleaseLogin(undefined, pleaseLoginContext.value);
if (props.readOnly || closed.value || isVoted.value) return;

View file

@ -3,8 +3,8 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
-->
<template>
<MkModal ref="modal" v-slot="{ type, maxHeight }" :manualShowing="manualShowing" :zPriority="'high'" :src="src" :transparentBg="true" @click="click" @close="onModalClose" @closed="onModalClosed">
<MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :asDrawer="type === 'drawer'" :class="{ [$style.drawer]: type === 'drawer' }" @close="onMenuClose" @hide="hide"/>
<MkModal ref="modal" v-slot="{ type, maxHeight }" :manualShowing="manualShowing" :zPriority="'high'" :src="src" :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>
</template>
@ -18,8 +18,8 @@ defineProps<{
items: MenuItem[];
align?: 'center' | string;
width?: number;
viaKeyboard?: boolean;
src?: any;
returnFocusTo?: HTMLElement | null;
}>();
const emit = defineEmits<{

View file

@ -1,5 +1,6 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License-Identifier: AGPL-3.0-only
SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
@ -581,7 +582,7 @@ function setVisibility() {
return;
}
os.popup(defineAsyncComponent(() => import('@/components/MkVisibilityPicker.vue')), {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkVisibilityPicker.vue')), {
currentVisibility: visibility.value,
isSilenced: $i.isSilenced, localOnly: localOnly.value,
src: visibilityButton.value,
@ -593,7 +594,8 @@ function setVisibility() {
defaultStore.set('visibility', visibility.value);
}
},
}, 'closed');
closed: () => dispose(),
});
}
async function toggleLocalOnly() {
@ -686,6 +688,7 @@ function clear() {
function onKeydown(ev: KeyboardEvent) {
if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey) && canPost.value) post();
if (ev.key === 'Escape') emit('esc');
}
@ -739,8 +742,8 @@ async function onPaste(ev: ClipboardEvent) {
return;
}
const fileName = formatTimeString(new Date(), defaultStore.state.pastedFileName).replace(/{{number}}/g, "0");
const file = new File([paste], `${fileName}.txt`, { type: "text/plain" });
const fileName = formatTimeString(new Date(), defaultStore.state.pastedFileName).replace(/{{number}}/g, '0');
const file = new File([paste], `${fileName}.txt`, { type: 'text/plain' });
upload(file, `${fileName}.txt`);
});
}
@ -897,7 +900,9 @@ async function post(ev?: MouseEvent) {
const rect = el.getBoundingClientRect();
const x = rect.left + (el.offsetWidth / 2);
const y = rect.top + (el.offsetHeight / 2);
os.popup(MkRippleEffect, { x, y }, {}, 'end');
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
end: () => dispose(),
});
}
}
@ -1301,7 +1306,16 @@ defineExpose({
margin: 12px 12px 12px 6px;
vertical-align: bottom;
&:disabled {
&:focus-visible {
outline: none;
.submitInner {
outline: 2px solid var(--fgOnAccent);
outline-offset: -4px;
}
}
&:disabled {
opacity: 0.7;
}

View file

@ -107,7 +107,7 @@ async function rename(file) {
async function describe(file) {
if (mock) return;
os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
default: file.comment !== null ? file.comment : '',
file: file,
}, {
@ -120,7 +120,8 @@ async function describe(file) {
file.comment = comment;
});
},
}, 'closed');
closed: () => dispose(),
});
}
async function crop(file: Misskey.entities.DriveFile): Promise<void> {

View file

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModal ref="modal" :preferType="'dialog'" @click="modal?.close()" @closed="onModalClosed()">
<MkModal ref="modal" :preferType="'dialog'" @click="modal?.close()" @closed="onModalClosed()" @esc="modal?.close()">
<MkPostForm ref="form" :class="$style.form" v-bind="props" autofocus freezeAfterPosted @posted="onPosted" @cancel="modal?.close()" @esc="modal?.close()"/>
</MkModal>
</template>

View file

@ -1,5 +1,6 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License-Identifier: AGPL-3.0-only
SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
@ -8,6 +9,7 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
:class="[$style.root, { [$style.disabled]: disabled, [$style.checked]: checked ,[$style.gamingDark]: gamingType === 'dark',[$style.gamingLight]: gamingType === 'light' } ]"
:aria-checked="checked"
:aria-disabled="disabled"
role="checkbox"
@click="toggle"
>
<input
@ -71,6 +73,11 @@ function toggle(): void {
border-color: var(--inputBorderHover) !important;
}
&:focus-within {
outline: none;
box-shadow: 0 0 0 2px var(--focus);
}
&.checked {
background-color: var(--accentedBg) !important;
border-color: var(--accentedBg) !important;
@ -113,7 +120,7 @@ function toggle(): void {
transform: scale(1);
opacity: 1;
}
&:after {
&::after {
background-color: var(--accent);
transform: scale(1);
opacity: 1;
@ -140,7 +147,7 @@ function toggle(): void {
border-radius: 100%;
transition: inherit;
&:after {
&::after {
content: '';
display: block;
position: absolute;

View file

@ -104,17 +104,19 @@ const steps = computed(() => {
}
});
const onMousedown = (ev: MouseEvent | TouchEvent) => {
function onMousedown(ev: MouseEvent | TouchEvent) {
ev.preventDefault();
const tooltipShowing = ref(true);
os.popup(defineAsyncComponent(() => import('@/components/MkTooltip.vue')), {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTooltip.vue')), {
showing: tooltipShowing,
text: computed(() => {
return props.textConverter(finalValue.value);
}),
targetElement: thumbEl,
}, {}, 'closed');
}, {
closed: () => dispose(),
});
const style = document.createElement('style');
style.appendChild(document.createTextNode('* { cursor: grabbing !important; } body * { pointer-events: none !important; }'));
@ -155,7 +157,7 @@ const onMousedown = (ev: MouseEvent | TouchEvent) => {
window.addEventListener('touchmove', onDrag);
window.addEventListener('mouseup', onMouseup, { once: true });
window.addEventListener('touchend', onMouseup, { once: true });
};
}
</script>
<style lang="scss" scoped>

View file

@ -23,11 +23,13 @@ const elRef = shallowRef();
if (props.withTooltip) {
useTooltip(elRef, (showing) => {
os.popup(defineAsyncComponent(() => import('@/components/MkReactionTooltip.vue')), {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkReactionTooltip.vue')), {
showing,
reaction: props.reaction.replace(/^:(\w+):$/, ':$1@.:'),
targetElement: elRef.value.$el,
}, {}, 'closed');
}, {
closed: () => dispose(),
});
});
}
</script>

View file

@ -118,10 +118,12 @@ async function menu(ev) {
text: i18n.ts.info,
icon: 'ti ti-info-circle',
action: async () => {
os.popup(MkCustomEmojiDetailedDialog, {
const { dispose } = os.popup(MkCustomEmojiDetailedDialog, {
emoji: await misskeyApiGet('emoji', {
name: props.reaction.replace(/:/g, '').replace(/@\./, ''),
}),
}, {
closed: () => dispose(),
});
},
}], ev.currentTarget ?? ev.target);
@ -133,7 +135,9 @@ function anime() {
const rect = buttonEl.value.getBoundingClientRect();
const x = rect.left + 16;
const y = rect.top + (buttonEl.value.offsetHeight / 2);
os.popup(MkReactionEffect, { reaction: props.reaction, x, y }, {}, 'end');
const { dispose } = os.popup(MkReactionEffect, { reaction: props.reaction, x, y }, {
end: () => dispose(),
});
}
watch(() => props.count, (newCount, oldCount) => {
@ -155,13 +159,15 @@ if (!mock) {
const users = reactions.map(x => x.user);
os.popup(XDetails, {
const { dispose } = os.popup(XDetails, {
showing,
reaction: props.reaction,
users,
count: props.count,
targetElement: buttonEl.value,
}, {}, 'closed');
}, {
closed: () => dispose(),
});
}, 100);
}
</script>

View file

@ -5,20 +5,29 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
<template>
<div>
<div :class="$style.label" @click="focus"><slot name="label"></slot></div>
<div ref="container" :class="[$style.input, { [$style.inline]: inline, [$style.disabled]: disabled, [$style.focused]: focused }]" @mousedown.prevent="show">
<div
ref="container"
tabindex="0"
:class="[$style.input, { [$style.inline]: inline, [$style.disabled]: disabled, [$style.focused]: focused || opening }]"
@focus="focused = true"
@blur="focused = false"
@mousedown.prevent="show"
@keydown.space.enter="show"
>
<div ref="prefixEl" :class="$style.prefix"><slot name="prefix"></slot></div>
<select
ref="inputEl"
v-model="v"
v-adaptive-border
tabindex="-1"
:class="$style.inputCore"
:disabled="disabled"
:required="required"
:readonly="readonly"
:placeholder="placeholder"
@focus="focused = true"
@blur="focused = false"
@input="onInput"
@mousedown.prevent="() => {}"
@keydown.prevent="() => {}"
>
<slot></slot>
</select>
@ -74,7 +83,7 @@ const height =
props.large ? 39 :
36;
const focus = () => inputEl.value?.focus();
const focus = () => container.value?.focus();
const onInput = (ev) => {
changed.value = true;
};
@ -125,7 +134,9 @@ onMounted(() => {
});
function show() {
focused.value = true;
if (opening.value) return;
focus();
opening.value = true;
const menu: MenuItem[] = [];
@ -172,8 +183,6 @@ function show() {
onClosing: () => {
opening.value = false;
},
}).then(() => {
focused.value = false;
});
}
</script>
@ -224,6 +233,10 @@ function show() {
}
}
&:focus {
outline: none;
}
&:hover {
> .inputCore {
border-color: var(--inputBorderHover) !important;

View file

@ -5,10 +5,23 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
<template>
<form :class="{ signing, totpLogin }" @submit.prevent="onSubmit">
<div class="_gaps_m">
<div v-show="withAvatar" :class="$style.avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : undefined, marginBottom: message ? '1.5em' : undefined }"></div>
<div v-show="withAvatar" :class="$style.avatar" :style="{ backgroundImage: user ? `url('${user.avatarUrl}')` : undefined, marginBottom: message ? '1.5em' : undefined }"></div>
<MkInfo v-if="message">
{{ message }}
</MkInfo>
<div v-if="openOnRemote" class="_gaps_m">
<div class="_gaps_s">
<MkButton type="button" rounded primary style="margin: 0 auto;" @click="openRemote(openOnRemote)">
{{ i18n.ts.continueOnRemote }} <i class="ti ti-external-link"></i>
</MkButton>
<button type="button" class="_button" :class="$style.instanceManualSelectButton" @click="specifyHostAndOpenRemote(openOnRemote)">
{{ i18n.ts.specifyServerHost }}
</button>
</div>
<div :class="$style.orHr">
<p :class="$style.orMsg">{{ i18n.ts.or }}</p>
</div>
</div>
<div v-if="!totpLogin" class="normal-signin _gaps_m">
<MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username webauthn" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange">
<template #prefix>@</template>
@ -27,8 +40,8 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
{{ i18n.ts.retry }}
</MkButton>
</div>
<div v-if="user && user.securityKeys" class="or-hr">
<p class="or-msg">{{ i18n.ts.or }}</p>
<div v-if="user && user.securityKeys" :class="$style.orHr">
<p :class="$style.orMsg">{{ i18n.ts.or }}</p>
</div>
<div class="twofa-group totp-group _gaps">
<MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" autocomplete="current-password" :withPasswordToggle="true" required>
@ -52,6 +65,7 @@ import { defineAsyncComponent, ref } from 'vue';
import { toUnicode } from 'punycode/';
import * as Misskey from 'misskey-js';
import { supported as webAuthnSupported, get as webAuthnRequest, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill';
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
@ -59,6 +73,7 @@ import MkInfo from '@/components/MkInfo.vue';
import { host as configHost } from '@/config.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { query, extractDomain } from '@/scripts/url.js';
import { login } from '@/account.js';
import { i18n } from '@/i18n.js';
@ -77,22 +92,16 @@ const emit = defineEmits<{
(ev: 'login', v: any): void;
}>();
const props = defineProps({
withAvatar: {
type: Boolean,
required: false,
default: true,
},
autoSet: {
type: Boolean,
required: false,
default: false,
},
message: {
type: String,
required: false,
default: '',
},
const props = withDefaults(defineProps<{
withAvatar?: boolean;
autoSet?: boolean;
message?: string,
openOnRemote?: OpenOnRemoteOptions,
}>(), {
withAvatar: true,
autoSet: false,
message: '',
openOnRemote: undefined,
});
function onUsernameChange(): void {
@ -217,8 +226,65 @@ function loginFailed(err: any): void {
}
function resetPassword(): void {
os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {
}, 'closed');
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {
closed: () => dispose(),
});
}
function openRemote(options: OpenOnRemoteOptions, targetHost?: string): void {
switch (options.type) {
case 'web':
case 'lookup': {
let _path: string;
if (options.type === 'lookup') {
// TODO: v2024.7.0URL
// _path = `/lookup?uri=${encodeURIComponent(_path)}`;
_path = `/authorize-follow?acct=${encodeURIComponent(options.url)}`;
} else {
_path = options.path;
}
if (targetHost) {
window.open(`https://${targetHost}${_path}`, '_blank', 'noopener');
} else {
window.open(`https://misskey-hub.net/mi-web/?path=${encodeURIComponent(_path)}`, '_blank', 'noopener');
}
break;
}
case 'share': {
const params = query(options.params);
if (targetHost) {
window.open(`https://${targetHost}/share?${params}`, '_blank', 'noopener');
} else {
window.open(`https://misskey-hub.net/share/?${params}`, '_blank', 'noopener');
}
break;
}
}
}
async function specifyHostAndOpenRemote(options: OpenOnRemoteOptions): Promise<void> {
const { canceled, result: hostTemp } = await os.inputText({
title: i18n.ts.inputHostName,
placeholder: 'misskey.example.com',
});
if (canceled) return;
let targetHost: string | null = hostTemp;
//
targetHost = extractDomain(targetHost);
if (targetHost == null) {
os.alert({
type: 'error',
title: i18n.ts.invalidValue,
text: i18n.ts.tryAgain,
});
return;
}
openRemote(options, targetHost);
}
</script>
@ -232,4 +298,36 @@ function resetPassword(): void {
background-size: cover;
border-radius: 100%;
}
.instanceManualSelectButton {
display: block;
text-align: center;
opacity: .7;
font-size: .8em;
&:hover {
text-decoration: underline;
}
}
.orHr {
position: relative;
margin: .4em auto;
width: 100%;
height: 1px;
background: var(--divider);
}
.orMsg {
position: absolute;
top: -.6em;
display: inline-block;
padding: 0 1em;
background: var(--panel);
font-size: 0.8em;
color: var(--fgOnPanel);
margin: 0;
left: 50%;
transform: translateX(-50%);
}
</style>

View file

@ -5,21 +5,22 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
<template>
<MkModalWindow
ref="dialog"
:width="370"
:height="400"
:width="400"
:height="430"
@close="onClose"
@closed="emit('closed')"
>
<template #header>{{ i18n.ts.login }}</template>
<MkSpacer :marginMin="20" :marginMax="28">
<MkSignin :autoSet="autoSet" :message="message" @login="onLogin"/>
<MkSignin :autoSet="autoSet" :message="message" :openOnRemote="openOnRemote" @login="onLogin"/>
</MkSpacer>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { shallowRef } from 'vue';
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
import MkSignin from '@/components/MkSignin.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js';
@ -27,9 +28,11 @@ import { i18n } from '@/i18n.js';
withDefaults(defineProps<{
autoSet?: boolean;
message?: string,
openOnRemote?: OpenOnRemoteOptions,
}>(), {
autoSet: false,
message: '',
openOnRemote: undefined,
});
const emit = defineEmits<{

View file

@ -1,5 +1,6 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License-Identifier: AGPL-3.0-only
SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
@ -9,15 +10,15 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
<div class="items">
<template v-for="(item, i) in group.items">
<a v-if="item.type === 'a'" :href="item.href" :target="item.target" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active, gamingDark: gamingType === 'dark',gamingLight: gamingType === 'light' }">
<a v-if="item.type === 'a'" :href="item.href" :target="item.target" class="_button item" :class="{ danger: item.danger, active: item.active, gamingDark: gamingType === 'dark',gamingLight: gamingType === 'light' }">
<span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span>
<span class="text">{{ item.text }}</span>
</a>
<button v-else-if="item.type === 'button'" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active , gamingDark: gamingType === 'dark',gamingLight: gamingType === 'light' }" :disabled="item.active" @click="ev => item.action(ev)">
<button v-else-if="item.type === 'button'" class="_button item" :class="{ danger: item.danger, active: item.active , gamingDark: gamingType === 'dark',gamingLight: gamingType === 'light' }" :disabled="item.active" @click="ev => item.action(ev)">
<span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span>
<span class="text">{{ item.text }}</span>
</button>
<MkA v-else :to="item.to" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active , gamingDark: gamingType === 'dark',gamingLight: gamingType === 'light' }">
<MkA v-else :to="item.to" class="_button item" :class="{ danger: item.danger, active: item.active , gamingDark: gamingType === 'dark',gamingLight: gamingType === 'light' }">
<span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span>
<span class="text">{{ item.text }}</span>
</MkA>
@ -69,6 +70,10 @@ defineProps<{
background: var(--panelHighlight);
}
&:focus-visible {
outline-offset: -2px;
}
&.active {
color: var(--accent);
background: var(--accentedBg);

View file

@ -1,5 +1,6 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License-Identifier: AGPL-3.0-only
SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
@ -9,9 +10,9 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
type="checkbox"
:disabled="disabled"
:class="$style.input"
@keydown.enter="toggle"
@click="toggle"
>
<XButton :checked="checked" :disabled="disabled" @toggle="toggle"/>
<XButton :class="$style.toggle" :checked="checked" :disabled="disabled" @toggle="toggle"/>
<span v-if="!noBody" :class="$style.body,{[$style.gamingDark]: gamingType === 'dark',[$style.gamingLight]: gamingType === 'light'}">
<!-- TODO: 無名slotの方は廃止 -->
<span :class="$style.label">
@ -99,7 +100,13 @@ const toggle = () => {
height: 0;
opacity: 0;
margin: 0;
&:focus-visible ~ .toggle {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
}
.body {
margin-left: 12px;
margin-top: 2px;

View file

@ -25,15 +25,15 @@ export type MkSystemWebhookResult = {
export async function showSystemWebhookEditorDialog(props: MkSystemWebhookEditorProps): Promise<MkSystemWebhookResult | null> {
const { dispose, result } = await new Promise<{ dispose: () => void, result: MkSystemWebhookResult | null }>(async resolve => {
const res = await os.popup(
const { dispose: _dispose } = os.popup(
defineAsyncComponent(() => import('@/components/MkSystemWebhookEditor.vue')),
props,
{
submitted: (ev: MkSystemWebhookResult) => {
resolve({ dispose: res.dispose, result: ev });
resolve({ dispose: _dispose, result: ev });
},
closed: () => {
resolve({ dispose: res.dispose, result: null });
resolve({ dispose: _dispose, result: null });
},
},
);

View file

@ -104,7 +104,7 @@ const exampleCWNote = reactive<Misskey.entities.Note>({
font-weight: bold;
text-align: left;
&:before {
&::before {
content: "";
display: block;
width: calc(100% - 38px);

View file

@ -114,7 +114,7 @@ const exampleNote = reactive<Misskey.entities.Note>({
font-weight: bold;
text-align: left;
&:before {
&::before {
content: "";
display: block;
width: calc(100% - 38px);

View file

@ -55,7 +55,7 @@ import { i18n } from '@/i18n.js';
font-weight: bold;
text-align: left;
&:before {
&::before {
content: "";
display: block;
width: calc(100% - 38px);

View file

@ -202,11 +202,13 @@ function adjustTweetHeight(message: any) {
if (height) tweetHeight.value = height;
}
const openPlayer = (): void => {
os.popup(defineAsyncComponent(() => import('@/components/MkYouTubePlayer.vue')), {
function openPlayer(): void {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkYouTubePlayer.vue')), {
url: requestUrl.href,
}, {
// TODO
});
};
}
(window as any).addEventListener('message', adjustTweetHeight);

View file

@ -28,7 +28,7 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
<p :class="$style.statusItemLabel">{{ i18n.ts.followers }}</p><span :class="$style.statusItemValue">{{ number(user.followersCount) }}</span>
</div>
</div>
<MkFollowButton v-if="$i && user.id != $i.id" :class="$style.follow" :user="user" mini/>
<MkFollowButton v-if="user.id != $i?.id" :class="$style.follow" :user="user" mini/>
</div>
</template>

View file

@ -176,9 +176,11 @@ function setupComplete() {
function launchTutorial() {
setupComplete();
nextTick(() => {
os.popup(defineAsyncComponent(() => import('@/components/MkTutorialDialog.vue')), {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTutorialDialog.vue')), {
initialPage: 1,
}, {}, 'closed');
}, {
closed: () => dispose(),
});
});
}

View file

@ -3,7 +3,7 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
-->
<template>
<MkModal ref="modal" v-slot="{ type }" :zPriority="'high'" :src="src" @click="modal?.close()" @closed="emit('closed')">
<MkModal ref="modal" v-slot="{ type }" :zPriority="'high'" :src="src" @click="modal?.close()" @closed="emit('closed')" @esc="modal?.close()">
<div class="_popup" :class="{ [$style.root]: true, [$style.asDrawer]: type === 'drawer' }">
<div :class="[$style.label, $style.item]">
{{ i18n.ts.visibility }}

View file

@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div class="_gaps_s" :class="$style.mainActions">
<MkButton :class="[$style.mainAction , $style.gamingDark]" full rounded data-cy-signup style="margin-right: 12px;" @click="signup()">{{ i18n.ts.joinThisServer }}</MkButton>
<MkButton :class="$style.mainAction" full rounded @click="exploreOtherServers()">{{ i18n.ts.exploreOtherServers }}</MkButton>
<MkButton :class="$style.mainAction" full rounded link to="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>
@ -65,7 +65,8 @@ import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import MkNumber from '@/components/MkNumber.vue';
import XActiveUsersChart from '@/components/MkVisitorDashboard.ActiveUsersChart.vue';
import { openInstanceMenu } from '@/ui/_common_/common';
import { openInstanceMenu } from '@/ui/_common_/common.js';
import type { MenuItem } from '@/types/menu.js';
const stats = ref<Misskey.entities.StatsResponse | null>(null);
@ -74,24 +75,24 @@ misskeyApi('stats', {}).then((res) => {
});
function signin() {
os.popup(XSigninDialog, {
const { dispose } = os.popup(XSigninDialog, {
autoSet: true,
}, {}, 'closed');
}, {
closed: () => dispose(),
});
}
function signup() {
os.popup(XSignupDialog, {
const { dispose } = os.popup(XSignupDialog, {
autoSet: true,
}, {}, 'closed');
}, {
closed: () => dispose(),
});
}
function showMenu(ev) {
function showMenu(ev: MouseEvent) {
openInstanceMenu(ev);
}
function exploreOtherServers() {
window.open('https://misskey-hub.net/servers/', '_blank', 'noopener');
}
</script>
<style lang="scss" module>

View file

@ -35,12 +35,10 @@ export const Default = {
// FIXME: 通るけどその後落ちるのでコメントアウト
// await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
await userEvent.pointer({ keys: '[MouseRight]', target: a });
await tick();
const menu = canvas.getByRole('menu');
await expect(menu).toBeInTheDocument();
await userEvent.click(a);
a.blur();
await tick();
await expect(menu).not.toBeInTheDocument();
},
args: {

View file

@ -9,12 +9,6 @@ import { StoryObj } from '@storybook/vue3';
import MkAd from './MkAd.vue';
import { i18n } from '@/i18n.js';
let lock: Promise<undefined> | undefined;
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
const common = {
render(args) {
return {
@ -37,56 +31,41 @@ const common = {
};
},
async play({ canvasElement, args }) {
if (lock) {
console.warn('This test is unexpectedly running twice in parallel, fix it!');
console.warn('See also: https://github.com/misskey-dev/misskey/issues/11267');
await lock;
const canvas = within(canvasElement);
const a = canvas.getByRole<HTMLAnchorElement>('link');
// FIXME: 通るけどその後落ちるのでコメントアウト
// await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
const img = within(a).getByRole('img');
await expect(img).toBeInTheDocument();
let buttons = canvas.getAllByRole<HTMLButtonElement>('button');
await expect(buttons).toHaveLength(1);
const i = buttons[0];
await expect(i).toBeInTheDocument();
await userEvent.click(i);
await expect(canvasElement).toHaveTextContent(i18n.ts._ad.back);
await expect(a).not.toBeInTheDocument();
await expect(i).not.toBeInTheDocument();
buttons = canvas.getAllByRole<HTMLButtonElement>('button');
const hasReduceFrequency = args.specify?.ratio !== 0;
await expect(buttons).toHaveLength(hasReduceFrequency ? 2 : 1);
const reduce = hasReduceFrequency ? buttons[0] : null;
const back = buttons[hasReduceFrequency ? 1 : 0];
if (reduce) {
await expect(reduce).toBeInTheDocument();
await expect(reduce).toHaveTextContent(i18n.ts._ad.reduceFrequencyOfThisAd);
}
let resolve: (value?: any) => void;
lock = new Promise(r => resolve = r);
try {
// NOTE: sleep しないと何故か落ちる
await sleep(100);
const canvas = within(canvasElement);
const a = canvas.getByRole<HTMLAnchorElement>('link');
// await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
const img = within(a).getByRole('img');
await expect(img).toBeInTheDocument();
let buttons = canvas.getAllByRole<HTMLButtonElement>('button');
await expect(buttons).toHaveLength(1);
const i = buttons[0];
await expect(i).toBeInTheDocument();
await userEvent.click(i);
await expect(canvasElement).toHaveTextContent(i18n.ts._ad.back);
await expect(a).not.toBeInTheDocument();
await expect(i).not.toBeInTheDocument();
buttons = canvas.getAllByRole<HTMLButtonElement>('button');
const hasReduceFrequency = args.specify?.ratio !== 0;
await expect(buttons).toHaveLength(hasReduceFrequency ? 2 : 1);
const reduce = hasReduceFrequency ? buttons[0] : null;
const back = buttons[hasReduceFrequency ? 1 : 0];
if (reduce) {
await expect(reduce).toBeInTheDocument();
await expect(reduce).toHaveTextContent(i18n.ts._ad.reduceFrequencyOfThisAd);
}
await expect(back).toBeInTheDocument();
await expect(back).toHaveTextContent(i18n.ts._ad.back);
await userEvent.click(back);
await waitFor(() => expect(canvas.queryByRole('img')).toBeTruthy());
if (reduce) {
await expect(reduce).not.toBeInTheDocument();
}
await expect(back).not.toBeInTheDocument();
const aAgain = canvas.getByRole<HTMLAnchorElement>('link');
await expect(aAgain).toBeInTheDocument();
const imgAgain = within(aAgain).getByRole('img');
await expect(imgAgain).toBeInTheDocument();
} finally {
resolve!();
lock = undefined;
await expect(back).toBeInTheDocument();
await expect(back).toHaveTextContent(i18n.ts._ad.back);
await userEvent.click(back);
await waitFor(() => expect(canvas.queryByRole('img')).toBeTruthy());
if (reduce) {
await expect(reduce).not.toBeInTheDocument();
}
await expect(back).not.toBeInTheDocument();
const aAgain = canvas.getByRole<HTMLAnchorElement>('link');
await expect(aAgain).toBeInTheDocument();
const imgAgain = within(aAgain).getByRole('img');
await expect(imgAgain).toBeInTheDocument();
},
args: {
prefer: [],

View file

@ -113,12 +113,12 @@ function onClick(ev: MouseEvent) {
text: i18n.ts.info,
icon: 'ti ti-info-circle',
action: async () => {
os.popup(MkCustomEmojiDetailedDialog, {
const { dispose } = os.popup(MkCustomEmojiDetailedDialog, {
emoji: await misskeyApiGet('emoji', {
name: customEmojiName.value,
}),
}, {
anchor: ev.target,
closed: () => dispose(),
});
},
}], ev.currentTarget ?? ev.target);

View file

@ -100,7 +100,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
const validTime = (t: string | boolean | null | undefined) => {
if (t == null || typeof t === 'boolean') return null;
if (typeof t === 'boolean') return null;
return t.match(/^[0-9.]+s$/) ? t : null;
return t.match(/^\-?[0-9.]+s$/) ? t : null;
};
const validColor = (c: unknown): string | null => {

View file

@ -7,7 +7,11 @@ SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-projectSPDX-License
<div ref="headerEl">
<slot name="header"></slot>
</div>
<div ref="bodyEl" :data-sticky-container-header-height="headerHeight">
<div
ref="bodyEl"
:data-sticky-container-header-height="headerHeight"
:data-sticky-container-footer-height="footerHeight"
>
<slot></slot>
</div>
<div ref="footerEl">

View file

@ -49,11 +49,13 @@ const el = ref();
if (props.showUrlPreview && isEnabledUrlPreview.value) {
useTooltip(el, (showing) => {
os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
showing,
url: props.url,
source: el.value instanceof HTMLElement ? el.value : el.value?.$el,
}, {}, 'closed');
}, {
closed: () => dispose(),
});
});
}

View file

@ -59,7 +59,7 @@ function onChange({ resolved, key: newKey }) {
if (current == null || 'redirect' in current.route) return;
currentPageComponent.value = current.route.component;
currentPageProps.value = current.props;
key.value = current.route.path + JSON.stringify(Object.fromEntries(current.props));
key.value = newKey + JSON.stringify(Object.fromEntries(current.props));
nextTick(() => {
//

View file

@ -90,6 +90,7 @@ export const ROLE_POLICIES = [
'canHideAds',
'driveCapacityMb',
'alwaysMarkNsfw',
'canUpdateBioMedia',
'pinLimit',
'antennaLimit',
'wordMuteLimit',

View file

@ -4,7 +4,7 @@
*/
import { Directive } from 'vue';
import { makeHotkey } from '../scripts/hotkey.js';
import { makeHotkey } from '@/scripts/hotkey.js';
export default {
mounted(el, binding) {
@ -13,9 +13,9 @@ export default {
el._keyHandler = makeHotkey(binding.value);
if (el._hotkey_global) {
document.addEventListener('keydown', el._keyHandler);
document.addEventListener('keydown', el._keyHandler, { passive: false });
} else {
el.addEventListener('keydown', el._keyHandler);
el.addEventListener('keydown', el._keyHandler, { passive: false });
}
},

View file

@ -17,7 +17,9 @@ export default {
const x = rect.left + (el.offsetWidth / 2);
const y = rect.top + (el.offsetHeight / 2);
popup(MkRippleEffect, { x, y }, {}, 'end');
const { dispose } = popup(MkRippleEffect, { x, y }, {
end: () => dispose(),
});
});
},
};

View file

@ -51,13 +51,15 @@ export default {
if (self.text == null) return;
const showing = ref(true);
popup(defineAsyncComponent(() => import('@/components/MkTooltip.vue')), {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkTooltip.vue')), {
showing,
text: self.text,
asMfm: binding.modifiers.mfm,
direction: binding.modifiers.left ? 'left' : binding.modifiers.right ? 'right' : binding.modifiers.top ? 'top' : binding.modifiers.bottom ? 'bottom' : 'top',
targetElement: el,
}, {}, 'closed');
}, {
closed: () => dispose(),
});
self._close = () => {
showing.value = false;

View file

@ -35,7 +35,7 @@ export class UserPreview {
const showing = ref(true);
popup(defineAsyncComponent(() => import('@/components/MkUserPopup.vue')), {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUserPopup.vue')), {
showing,
q: this.user,
source: this.el,
@ -47,7 +47,8 @@ export class UserPreview {
window.clearTimeout(this.showTimer);
this.hideTimer = window.setTimeout(this.close, 500);
},
}, 'closed');
closed: () => dispose(),
});
this.promise = {
cancel: () => {

View file

@ -5,7 +5,7 @@
// TODO: なんでもかんでもos.tsに突っ込むのやめたいのでよしなに分割する
import { Component, markRaw, Ref, ref, defineAsyncComponent } from 'vue';
import { Component, markRaw, Ref, ref, defineAsyncComponent, nextTick } from 'vue';
import { EventEmitter } from 'eventemitter3';
import * as Misskey from 'misskey-js';
import type { ComponentProps as CP } from 'vue-component-type-helpers';
@ -24,10 +24,12 @@ import MkPopupMenu from '@/components/MkPopupMenu.vue';
import MkContextMenu from '@/components/MkContextMenu.vue';
import { MenuItem } from '@/types/menu.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { pleaseLogin } from '@/scripts/please-login.js';
import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js';
import { focusParent } from '@/scripts/focus.js';
import MkSwitch from '@/components/MkSwitch.vue';
import { ui } from '@/config.js';
export const openingWindowsCount = ref(0);
export const apiWithDialog = (<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req']>(
@ -119,11 +121,13 @@ export function promiseDialog<T extends Promise<any>>(
});
// NOTE: dynamic importすると挙動がおかしくなる(showingの変更が伝播しない)
popup(MkWaitingDialog, {
const { dispose } = popup(MkWaitingDialog, {
success: success,
showing: showing,
text: text,
}, {}, 'closed');
}, {
closed: () => dispose(),
});
return promise;
}
@ -169,28 +173,24 @@ type EmitsExtractor<T> = {
[K in keyof T as K extends `onVnode${string}` ? never : K extends `on${infer E}` ? Uncapitalize<E> : K extends string ? never : K]: T[K];
};
export async function popup<T extends Component>(
export function popup<T extends Component>(
component: T,
props: ComponentProps<T>,
events: ComponentEmit<T> = {} as ComponentEmit<T>,
disposeEvent?: keyof ComponentEmit<T>,
): Promise<{ dispose: () => void }> {
): { dispose: () => void } {
markRaw(component);
const id = ++popupIdCount;
const dispose = () => {
// このsetTimeoutが無いと挙動がおかしくなる(autocompleteが閉じなくなる)。Vueのバグ
window.setTimeout(() => {
popups.value = popups.value.filter(popup => popup.id !== id);
popups.value = popups.value.filter(p => p.id !== id);
}, 0);
};
const state = {
component,
props,
events: disposeEvent ? {
...events,
[disposeEvent]: dispose,
} : events,
events,
id,
};
@ -202,15 +202,19 @@ export async function popup<T extends Component>(
}
export function pageWindow(path: string) {
popup(MkPageWindow, {
const { dispose } = popup(MkPageWindow, {
initialPath: path,
}, {}, 'closed');
}, {
closed: () => dispose(),
});
}
export function toast(message: string) {
popup(MkToast, {
const { dispose } = popup(MkToast, {
message,
}, {}, 'closed');
}, {
closed: () => dispose(),
});
}
export function alert(props: {
@ -219,11 +223,12 @@ export function alert(props: {
text?: string;
}): Promise<void> {
return new Promise(resolve => {
popup(MkDialog, props, {
const { dispose } = popup(MkDialog, props, {
done: () => {
resolve();
},
}, 'closed');
closed: () => dispose(),
});
});
}
export function confirm(props: {
@ -234,14 +239,15 @@ export function confirm(props: {
cancelText?: string;
}): Promise<{ canceled: boolean }> {
return new Promise(resolve => {
popup(MkDialog, {
const { dispose } = popup(MkDialog, {
...props,
showCancelButton: true,
}, {
done: result => {
resolve(result ? result : { canceled: true });
},
}, 'closed');
closed: () => dispose(),
});
});
}
export function switch1(props: {
@ -281,7 +287,7 @@ export function actions<T extends {
canceled: false; result: T[number]['value'];
}> {
return new Promise(resolve => {
popup(MkDialog, {
const { dispose } = popup(MkDialog, {
...props,
actions: props.actions.map(a => ({
text: a.text,
@ -295,7 +301,8 @@ export function actions<T extends {
done: result => {
resolve(result ? result : { canceled: true });
},
}, 'closed');
closed: () => dispose(),
});
});
}
@ -343,7 +350,7 @@ export function inputText(props: {
canceled: false; result: string | null;
}> {
return new Promise(resolve => {
popup(MkDialog, {
const { dispose } = popup(MkDialog, {
title: props.title,
text: props.text,
input: {
@ -358,7 +365,8 @@ export function inputText(props: {
done: result => {
resolve(result ? result : { canceled: true });
},
}, 'closed');
closed: () => dispose(),
});
});
}
@ -397,7 +405,7 @@ export function inputNumber(props: {
canceled: false; result: number | null;
}> {
return new Promise(resolve => {
popup(MkDialog, {
const { dispose } = popup(MkDialog, {
title: props.title,
text: props.text,
input: {
@ -410,7 +418,8 @@ export function inputNumber(props: {
done: result => {
resolve(result ? result : { canceled: true });
},
}, 'closed');
closed: () => dispose(),
});
});
}
@ -425,7 +434,7 @@ export function inputDate(props: {
canceled: false; result: Date;
}> {
return new Promise(resolve => {
popup(MkDialog, {
const { dispose } = popup(MkDialog, {
title: props.title,
text: props.text,
input: {
@ -437,7 +446,8 @@ export function inputDate(props: {
done: result => {
resolve(result ? { result: new Date(result.result), canceled: false } : { result: undefined, canceled: true });
},
}, 'closed');
closed: () => dispose(),
});
});
}
@ -447,11 +457,12 @@ export function authenticateDialog(): Promise<{
canceled: false; result: { password: string; token: string | null; };
}> {
return new Promise(resolve => {
popup(MkPasswordDialog, {}, {
const { dispose } = popup(MkPasswordDialog, {}, {
done: result => {
resolve(result ? { canceled: false, result } : { canceled: true, result: undefined });
},
}, 'closed');
closed: () => dispose(),
});
});
}
@ -496,7 +507,7 @@ export function select<C = any>(props: {
canceled: false; result: C | null;
}> {
return new Promise(resolve => {
popup(MkDialog, {
const { dispose } = popup(MkDialog, {
title: props.title,
text: props.text,
select: {
@ -507,7 +518,8 @@ export function select<C = any>(props: {
done: result => {
resolve(result ? result : { canceled: true });
},
}, 'closed');
closed: () => dispose(),
});
});
}
@ -517,40 +529,43 @@ export function success(): Promise<void> {
window.setTimeout(() => {
showing.value = false;
}, 1000);
popup(MkWaitingDialog, {
const { dispose } = popup(MkWaitingDialog, {
success: true,
showing: showing,
}, {
done: () => resolve(),
}, 'closed');
closed: () => dispose(),
});
});
}
export function waiting(): Promise<void> {
return new Promise(resolve => {
const showing = ref(true);
popup(MkWaitingDialog, {
const { dispose } = popup(MkWaitingDialog, {
success: false,
showing: showing,
}, {
done: () => resolve(),
}, 'closed');
closed: () => dispose(),
});
});
}
export function form<F extends Form>(title: string, f: F): Promise<{ canceled: true, result?: undefined } | { canceled?: false, result: GetFormResultType<F> }> {
return new Promise(resolve => {
popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form: f }, {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form: f }, {
done: result => {
resolve(result);
},
}, 'closed');
closed: () => dispose(),
});
});
}
export async function selectUser(opts: { includeSelf?: boolean; localOnly?: boolean; multiple?: boolean; } = {}): Promise<Misskey.entities.UserDetailed> {
return new Promise(resolve => {
popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), {
includeSelf: opts.includeSelf,
localOnly: opts.localOnly,
multiple: opts.multiple,
@ -558,7 +573,8 @@ export async function selectUser(opts: { includeSelf?: boolean; localOnly?: bool
ok: user => {
resolve(user);
},
}, 'closed');
closed: () => dispose(),
});
});
}
export async function listSchedulePost() {
@ -570,7 +586,7 @@ export async function listSchedulePost() {
}
export async function selectDriveFile(multiple: boolean): Promise<Misskey.entities.DriveFile[]> {
return new Promise(resolve => {
popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
type: 'file',
multiple,
}, {
@ -579,13 +595,14 @@ export async function selectDriveFile(multiple: boolean): Promise<Misskey.entiti
resolve(files);
}
},
}, 'closed');
closed: () => dispose(),
});
});
}
export async function selectDriveFolder(multiple: boolean): Promise<Misskey.entities.DriveFolder[]> {
return new Promise(resolve => {
popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
type: 'folder',
multiple,
}, {
@ -594,20 +611,22 @@ export async function selectDriveFolder(multiple: boolean): Promise<Misskey.enti
resolve(folders);
}
},
}, 'closed');
closed: () => dispose(),
});
});
}
export async function pickEmoji(src: HTMLElement, opts: ComponentProps<typeof MkEmojiPickerDialog>): Promise<string> {
return new Promise(resolve => {
popup(MkEmojiPickerDialog, {
const { dispose } = popup(MkEmojiPickerDialog, {
src,
...opts,
}, {
done: emoji => {
resolve(emoji);
},
}, 'closed');
closed: () => dispose(),
});
});
}
@ -616,7 +635,7 @@ export async function cropImage(image: Misskey.entities.DriveFile, options: {
uploadFolder?: string | null;
}): Promise<Misskey.entities.DriveFile> {
return new Promise(resolve => {
popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), {
file: image,
aspectRatio: options.aspectRatio,
uploadFolder: options.uploadFolder,
@ -624,84 +643,91 @@ export async function cropImage(image: Misskey.entities.DriveFile, options: {
ok: x => {
resolve(x);
},
}, 'closed');
closed: () => dispose(),
});
});
}
export function popupMenu(items: MenuItem[], src?: HTMLElement | EventTarget | null, options?: {
align?: string;
width?: number;
viaKeyboard?: boolean;
onClosing?: () => void;
}): Promise<void> {
return new Promise(resolve => {
let dispose;
popup(MkPopupMenu, {
let returnFocusTo = getHTMLElementOrNull(src) ?? getHTMLElementOrNull(document.activeElement);
return new Promise(resolve => nextTick(() => {
const { dispose } = popup(MkPopupMenu, {
items,
src,
width: options?.width,
align: options?.align,
viaKeyboard: options?.viaKeyboard,
returnFocusTo,
}, {
closed: () => {
resolve();
dispose();
returnFocusTo = null;
},
closing: () => {
if (options?.onClosing) options.onClosing();
options?.onClosing?.();
},
}).then(res => {
dispose = res.dispose;
});
});
}));
}
export function contextMenu(items: MenuItem[], ev: MouseEvent): Promise<void> {
let returnFocusTo = getHTMLElementOrNull(ev.currentTarget ?? ev.target) ?? getHTMLElementOrNull(document.activeElement);
ev.preventDefault();
return new Promise(resolve => {
let dispose;
popup(MkContextMenu, {
return new Promise(resolve => nextTick(() => {
const { dispose } = popup(MkContextMenu, {
items,
ev,
}, {
closed: () => {
resolve();
dispose();
// MkModalを通していないのでここでフォーカスを戻す処理を行う
if (returnFocusTo != null) {
focusParent(returnFocusTo, true, false);
returnFocusTo = null;
}
},
}).then(res => {
dispose = res.dispose;
});
});
}));
}
export function post(props: Record<string, any> = {}): Promise<void> {
showMovedDialog();
pleaseLogin(undefined, (props.initialText || props.initialNote ? {
type: 'share',
params: {
text: props.initialText ?? props.initialNote.text,
visibility: props.initialVisibility ?? props.initialNote?.visibility,
localOnly: (props.initialLocalOnly || props.initialNote?.localOnly) ? '1' : '0',
},
} : undefined));
showMovedDialog();
return new Promise(resolve => {
// NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない
// NOTE: ただ、dynamic importしない場合、MkPostFormDialogインスタンスが使いまわされ、
// Vueが渡されたコンポーネントに内部的に__propsというプロパティを生やす影響で、
// 複数のpost formを開いたときに場合によってはエラーになる
// もちろん複数のpost formを開けること自体Misskeyサイドのバグなのだが
let dispose;
if (ui !== 'twilike') {
popup(MkPostFormDialog, props, {
const { dispose } = popup(MkPostFormDialog, props, {
closed: () => {
resolve();
dispose();
},
}).then(res => {
dispose = res.dispose;
});
})
} else {
popup(XPostFormDialog, props, {
const { dispose } = popup(XPostFormDialog, props, {
closed: () => {
resolve();
dispose();
},
}).then(res => {
dispose = res.dispose;
});
})
}
});
}

View file

@ -0,0 +1,205 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps_m">
<div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }">
<div style="overflow: clip;">
<img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" alt="" :class="$style.bannerIcon"/>
<div :class="$style.bannerName">
<b>{{ instance.name ?? host }}</b>
</div>
</div>
</div>
<MkKeyValue>
<template #key>{{ i18n.ts.description }}</template>
<template #value><div v-html="instance.description"></div></template>
</MkKeyValue>
<FormSection>
<div class="_gaps_m">
<MkKeyValue :copy="version">
<template #key>Misskey</template>
<template #value>{{ version }}</template>
</MkKeyValue>
<div v-html="i18n.tsx.poweredByMisskeyDescription({ name: instance.name ?? host })">
</div>
<FormLink to="/about-misskey">
<template #icon><i class="ti ti-info-circle"></i></template>
{{ i18n.ts.aboutMisskey }}
</FormLink>
<FormLink v-if="instance.repositoryUrl || instance.providesTarball" :to="instance.repositoryUrl || `/tarball/misskey-${version}.tar.gz`" external>
<template #icon><i class="ti ti-code"></i></template>
{{ i18n.ts.sourceCode }}
</FormLink>
<MkInfo v-else warn>
{{ i18n.ts.sourceCodeIsNotYetProvided }}
</MkInfo>
</div>
</FormSection>
<FormSection>
<div class="_gaps_m">
<FormSplit>
<MkKeyValue :copy="instance.maintainerName">
<template #key>{{ i18n.ts.administrator }}</template>
<template #value>
<template v-if="instance.maintainerName">{{ instance.maintainerName }}</template>
<span v-else style="opacity: 0.7;">({{ i18n.ts.none }})</span>
</template>
</MkKeyValue>
<MkKeyValue :copy="instance.maintainerEmail">
<template #key>{{ i18n.ts.contact }}</template>
<template #value>
<template v-if="instance.maintainerEmail">{{ instance.maintainerEmail }}</template>
<span v-else style="opacity: 0.7;">({{ i18n.ts.none }})</span>
</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.inquiry }}</template>
<template #value>
<MkLink v-if="instance.inquiryUrl" :url="instance.inquiryUrl" target="_blank">{{ instance.inquiryUrl }}</MkLink>
<span v-else style="opacity: 0.7;">({{ i18n.ts.none }})</span>
</template>
</MkKeyValue>
</FormSplit>
<div class="_gaps_s">
<FormLink v-if="instance.impressumUrl" :to="instance.impressumUrl" external>
<template #icon><i class="ti ti-user-shield"></i></template>
<template #default>{{ i18n.ts.impressum }}</template>
</FormLink>
<MkFolder v-if="instance.serverRules.length > 0">
<template #icon><i class="ti ti-checkup-list"></i></template>
<template #label>{{ i18n.ts.serverRules }}</template>
<ol class="_gaps_s" :class="$style.rules">
<li v-for="item in instance.serverRules" :key="item" :class="$style.rule">
<div :class="$style.ruleText" v-html="item"></div>
</li>
</ol>
</MkFolder>
<FormLink v-if="instance.tosUrl" :to="instance.tosUrl" external>
<template #icon><i class="ti ti-license"></i></template>
<template #default>{{ i18n.ts.termsOfService }}</template>
</FormLink>
<FormLink v-if="instance.privacyPolicyUrl" :to="instance.privacyPolicyUrl" external>
<template #icon><i class="ti ti-shield-lock"></i></template>
<template #default>{{ i18n.ts.privacyPolicy }}</template>
</FormLink>
<FormLink v-if="instance.feedbackUrl" :to="instance.feedbackUrl" external>
<template #icon><i class="ti ti-message"></i></template>
<template #default>{{ i18n.ts.feedback }}</template>
</FormLink>
</div>
</div>
</FormSection>
<FormSuspense v-slot="{ result: stats }" :p="initStats">
<FormSection>
<template #label>{{ i18n.ts.statistics }}</template>
<FormSplit>
<MkKeyValue>
<template #key>{{ i18n.ts.users }}</template>
<template #value>{{ number(stats.originalUsersCount) }}</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.notes }}</template>
<template #value>{{ number(stats.originalNotesCount) }}</template>
</MkKeyValue>
</FormSplit>
</FormSection>
</FormSuspense>
<FormSection>
<template #label>Well-known resources</template>
<div class="_gaps_s">
<FormLink to="/.well-known/host-meta" external>host-meta</FormLink>
<FormLink to="/.well-known/host-meta.json" external>host-meta.json</FormLink>
<FormLink to="/.well-known/nodeinfo" external>nodeinfo</FormLink>
<FormLink to="/robots.txt" external>robots.txt</FormLink>
<FormLink to="/manifest.json" external>manifest.json</FormLink>
</div>
</FormSection>
</div>
</template>
<script lang="ts" setup>
import { host, version } from '@/config.js';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import number from '@/filters/number.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import FormLink from '@/components/form/link.vue';
import FormSection from '@/components/form/section.vue';
import FormSplit from '@/components/form/split.vue';
import FormSuspense from '@/components/form/suspense.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import MkLink from '@/components/MkLink.vue';
const initStats = () => misskeyApi('stats', {});
</script>
<style lang="scss" module>
.banner {
text-align: center;
border-radius: 10px;
overflow: clip;
background-color: var(--panel);
background-size: cover;
background-position: center center;
}
.bannerIcon {
display: block;
margin: 16px auto 0 auto;
height: 64px;
border-radius: 8px;
}
.bannerName {
display: block;
padding: 16px;
color: #fff;
text-shadow: 0 0 8px #000;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
}
.rules {
counter-reset: item;
list-style: none;
padding: 0;
margin: 0;
}
.rule {
display: flex;
gap: 8px;
word-break: break-word;
&::before {
flex-shrink: 0;
display: flex;
position: sticky;
top: calc(var(--stickyTop, 0px) + 8px);
counter-increment: item;
content: counter(item);
width: 32px;
height: 32px;
line-height: 32px;
background-color: var(--accentedBg);
color: var(--accent);
font-size: 13px;
font-weight: bold;
align-items: center;
justify-content: center;
border-radius: 999px;
}
}
.ruleText {
padding-top: 6px;
}
</style>

View file

@ -8,111 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<MkSpacer v-if="tab === 'overview'" :contentMax="600" :marginMin="20">
<div class="_gaps_m">
<div :class="$style.banner" :style="{ backgroundImage: `url(${ bannerUrl })` }">
<div style="overflow: clip;">
<img :src="iconUrl" alt="" :class="$style.bannerIcon"/>
<div :class="$style.bannerName">
<b>{{ instance.name ?? host }}</b>
</div>
</div>
</div>
<MkKeyValue>
<template #key>{{ i18n.ts.description }}</template>
<template #value><div v-html="instance.description"></div></template>
</MkKeyValue>
<FormSection>
<div class="_gaps_m">
<MkKeyValue :copy="version">
<template #key>Misskey</template>
<template #value>{{ version }}</template>
</MkKeyValue>
<div v-html="i18n.tsx.poweredByType4nyDescription({ name: instance.name ?? host })">
</div>
<FormLink to="/about-type4ny">{{ i18n.ts.aboutType4ny }}</FormLink>
<FormLink v-if="instance.repositoryUrl || instance.providesTarball" :to="instance.repositoryUrl || `/tarball/misskey-${version}.tar.gz`" external>
<template #icon><i class="ti ti-code"></i></template>
{{ i18n.ts.sourceCode }}
</FormLink>
<MkInfo v-else warn>
{{ i18n.ts.sourceCodeIsNotYetProvided }}
</MkInfo>
ソースコード含め問い合わせは下記のメールアドレスへよろしくお願いします
</div>
</FormSection>
<FormSection>
<div class="_gaps_m">
<FormSplit>
<MkKeyValue>
<template #key>{{ i18n.ts.administrator }}</template>
<template #value>{{ instance.maintainerName }}</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.contact }}</template>
<template #value>{{ instance.maintainerEmail }}</template>
</MkKeyValue>
</FormSplit>
<FormLink v-if="instance.impressumUrl" :to="instance.impressumUrl" external>
<template #icon><i class="ti ti-user-shield"></i></template>
{{ i18n.ts.impressum }}
</FormLink>
<div class="_gaps_s">
<MkFolder v-if="instance.serverRules.length > 0">
<template #label>
<i class="ti ti-checkup-list"></i>
{{ i18n.ts.serverRules }}
</template>
<ol class="_gaps_s" :class="$style.rules">
<li v-for="(item, index) in instance.serverRules" :key="index" :class="$style.rule"><div :class="$style.ruleText" v-html="item"></div></li>
</ol>
</MkFolder>
<FormLink v-if="instance.tosUrl" :to="instance.tosUrl" external>
<template #icon><i class="ti ti-license"></i></template>
{{ i18n.ts.termsOfService }}
</FormLink>
<FormLink v-if="instance.privacyPolicyUrl" :to="instance.privacyPolicyUrl" external>
<template #icon><i class="ti ti-shield-lock"></i></template>
{{ i18n.ts.privacyPolicy }}
</FormLink>
<FormLink v-if="instance.feedbackUrl" :to="instance.feedbackUrl" external>
<template #icon><i class="ti ti-message"></i></template>
{{ i18n.ts.feedback }}
</FormLink>
</div>
</div>
</FormSection>
<FormSuspense :p="initStats">
<FormSection>
<template #label>{{ i18n.ts.statistics }}</template>
<FormSplit>
<MkKeyValue>
<template #key>{{ i18n.ts.users }}</template>
<template #value>{{ number(stats.originalUsersCount) }}</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.notes }}</template>
<template #value>{{ number(stats.originalNotesCount) }}</template>
</MkKeyValue>
</FormSplit>
</FormSection>
</FormSuspense>
<FormSection>
<template #label>Well-known resources</template>
<div class="_gaps_s">
<FormLink :to="`/.well-known/host-meta`" external>host-meta</FormLink>
<FormLink :to="`/.well-known/host-meta.json`" external>host-meta.json</FormLink>
<FormLink :to="`/.well-known/nodeinfo`" external>nodeinfo</FormLink>
<FormLink :to="`/robots.txt`" external>robots.txt</FormLink>
<FormLink :to="`/manifest.json`" external>manifest.json</FormLink>
</div>
</FormSection>
</div>
<XOverview/>
</MkSpacer>
<XEmojis v-else-if="tab === 'emojis'"/>
<MkSpacer v-else-if="tab === 'federation'" :contentMax="1000" :marginMin="20">
@ -126,26 +22,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import * as Misskey from 'misskey-js';
import XEmojis from './about.emojis.vue';
import XFederation from './about.federation.vue';
import { version, host } from '@/config.js';
import FormLink from '@/components/form/link.vue';
import FormSection from '@/components/form/section.vue';
import FormSuspense from '@/components/form/suspense.vue';
import FormSplit from '@/components/form/split.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkInstanceStats from '@/components/MkInstanceStats.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import number from '@/filters/number.js';
import { computed, defineAsyncComponent, ref, watch } from 'vue';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { claimAchievement } from '@/scripts/achievements.js';
import { instance } from '@/instance.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
const XOverview = defineAsyncComponent(() => import('@/pages/about.overview.vue'));
const XEmojis = defineAsyncComponent(() => import('@/pages/about.emojis.vue'));
const XFederation = defineAsyncComponent(() => import('@/pages/about.federation.vue'));
const MkInstanceStats = defineAsyncComponent(() => import('@/components/MkInstanceStats.vue'));
import { bannerDark, bannerLight, defaultStore, iconDark, iconLight } from '@/store.js';
const props = withDefaults(defineProps<{
@ -154,7 +40,6 @@ const props = withDefaults(defineProps<{
initialTab: 'overview',
});
const stats = ref<Misskey.entities.StatsResponse | null>(null);
const tab = ref(props.initialTab);
watch(tab, () => {
@ -184,11 +69,6 @@ watch(darkMode, () => {
}
});
const initStats = () => misskeyApi('stats', {
}).then((res) => {
stats.value = res;
});
const headerActions = computed(() => []);
const headerTabs = computed(() => [{
@ -213,64 +93,3 @@ definePageMetadata(() => ({
icon: 'ti ti-info-circle',
}));
</script>
<style lang="scss" module>
.banner {
text-align: center;
border-radius: 10px;
overflow: clip;
background-size: cover;
background-position: center center;
}
.bannerIcon {
display: block;
margin: 16px auto 0 auto;
height: 64px;
border-radius: 8px;
}
.bannerName {
display: block;
padding: 16px;
color: #fff;
text-shadow: 0 0 8px #000;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
}
.rules {
counter-reset: item;
list-style: none;
padding: 0;
margin: 0;
}
.rule {
display: flex;
gap: 8px;
word-break: break-word;
&::before {
flex-shrink: 0;
display: flex;
position: sticky;
top: calc(var(--stickyTop, 0px) + 8px);
counter-increment: item;
content: counter(item);
width: 32px;
height: 32px;
line-height: 32px;
background-color: var(--accentedBg);
color: var(--accent);
font-size: 13px;
font-weight: bold;
align-items: center;
justify-content: center;
border-radius: 999px;
}
}
.ruleText {
padding-top: 6px;
}
</style>

View file

@ -476,16 +476,20 @@ function toggleRoleItem(role) {
}
function createAnnouncement() {
os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), {
user: user.value,
}, {}, 'closed');
}, {
closed: () => dispose(),
});
}
function editAnnouncement(announcement) {
os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), {
user: user.value,
announcement,
}, {}, 'closed');
}, {
closed: () => dispose(),
});
}
watch(() => props.userId, () => {

View file

@ -109,7 +109,7 @@ async function onDeleteButtonClicked(id: string) {
async function showEditor(mode: 'create' | 'edit', id?: string) {
const { dispose, needLoad } = await new Promise<{ dispose: () => void, needLoad: boolean }>(async resolve => {
const res = await os.popup(
const { dispose: _dispose } = os.popup(
defineAsyncComponent(() => import('./notification-recipient.editor.vue')),
{
mode,
@ -117,10 +117,10 @@ async function showEditor(mode: 'create' | 'edit', id?: string) {
},
{
submitted: async () => {
resolve({ dispose: res.dispose, needLoad: true });
resolve({ dispose: _dispose, needLoad: true });
},
closed: () => {
resolve({ dispose: res.dispose, needLoad: false });
resolve({ dispose: _dispose, needLoad: false });
},
},
);

View file

@ -475,7 +475,27 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkFoldableSection>
<MkFoldableSection>
<template #header>{{ i18n.ts.avatarDecorations}}</template>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canManageAvatarDecorations, 'canManageAvatarDecorations'])" class="_margin">
<MkFolder v-if="matchQuery([i18n.ts._role._options.canUpdateBioMedia, 'canUpdateBioMedia'])">
<template #label>{{ i18n.ts._role._options.canUpdateBioMedia }}</template>
<template #suffix>
<span v-if="role.policies.canUpdateBioMedia.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canUpdateBioMedia.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canUpdateBioMedia)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canUpdateBioMedia.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canUpdateBioMedia.value" :disabled="role.policies.canUpdateBioMedia.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canUpdateBioMedia.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canManageAvatarDecorations, 'canManageAvatarDecorations'])" class="_margin">
<template #label>{{ i18n.ts._role._options.canManageAvatarDecorations }}</template>
<template #suffix>
<span v-if="role.policies.canManageAvatarDecorations.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>

View file

@ -272,9 +272,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canUpdateBioMedia, 'canUpdateBioMedia'])">
<template #label>{{ i18n.ts._role._options.canUpdateBioMedia }}</template>
<template #suffix>{{ policies.canUpdateBioMedia ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canUpdateBioMedia">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
</MkFoldableSection>
<MkButton primary rounded @click="updateBaseRole">{{ i18n.ts.save }}</MkButton>
<MkButton primary rounded @cl
ick="updateBaseRole">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
<MkButton primary rounded @click="create"><i class="ti ti-plus"></i> {{ i18n.ts._role.new }}</MkButton>

View file

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="800">
<div ref="rootEl" v-hotkey.global="keymap">
<div ref="rootEl">
<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
<div :class="$style.tl">
<MkTimeline
@ -44,9 +44,6 @@ const antenna = ref<Misskey.entities.Antenna | null>(null);
const queue = ref(0);
const rootEl = shallowRef<HTMLElement>();
const tlEl = shallowRef<InstanceType<typeof MkTimeline>>();
const keymap = computed(() => ({
't': focus,
}));
function queueUpdated(q) {
queue.value = q;

View file

@ -7,18 +7,26 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer>
<template #header><MkPageHeader/></template>
<MkSpacer :contentMax="600" :marginMin="20">
<div class="_gaps">
<MkKeyValue>
<template #key>{{ i18n.ts.inquiry }}</template>
<div class="_gaps_m">
<MkKeyValue :copy="instance.maintainerName">
<template #key>{{ i18n.ts.administrator }}</template>
<template #value>
<MkLink :url="instance.inquiryUrl" target="_blank">{{ instance.inquiryUrl }}</MkLink>
<template v-if="instance.maintainerName">{{ instance.maintainerName }}</template>
<span v-else style="opacity: 0.7;">({{ i18n.ts.none }})</span>
</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.email }}</template>
<MkKeyValue :copy="instance.maintainerEmail">
<template #key>{{ i18n.ts.contact }}</template>
<template #value>
<div>{{ instance.maintainerEmail }}</div>
<template v-if="instance.maintainerEmail">{{ instance.maintainerEmail }}</template>
<span v-else style="opacity: 0.7;">({{ i18n.ts.none }})</span>
</template>
</MkKeyValue>
<MkKeyValue :copy="instance.inquiryUrl">
<template #key>{{ i18n.ts.inquiry }}</template>
<template #value>
<MkLink v-if="instance.inquiryUrl" :url="instance.inquiryUrl" target="_blank">{{ instance.inquiryUrl }}</MkLink>
<span v-else style="opacity: 0.7;">({{ i18n.ts.none }})</span>
</template>
</MkKeyValue>
</div>
@ -28,8 +36,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { instance } from '@/instance.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkKeyValue from '@/components/MkKeyValue.vue';
import MkLink from '@/components/MkLink.vue';

View file

@ -38,16 +38,33 @@ import { definePageMetadata } from '@/scripts/page-metadata';
const tab = ref('request');
const add = async (ev: MouseEvent) => {
os.popup(defineAsyncComponent(() => import('../components/MkEmojiEditDialog.vue')), {
const { dispose } = os.popup(defineAsyncComponent(() => import('../components/MkEmojiEditDialog.vue')), {
}, {
done: result => {
//TODO: emit
// if (result.created) {
// emojisPaginationComponent.value.prepend(result.created);
// emojisPaginationComponent.value.reload();
// }
if (result.created) {
emojisPaginationComponent.value.prepend(result.created);
}
},
}, 'closed');
closed: () => dispose(),
});
};
const edit = (emoji) => {
const { dispose } = os.popup(defineAsyncComponent(() => import('../components/MkEmojiEditDialog.vue')), {
emoji: emoji,
}, {
done: result => {
if (result.updated) {
emojisPaginationComponent.value.updateItem(result.updated.id, (oldEmoji: any) => ({
...oldEmoji,
...result.updated,
}));
} else if (result.deleted) {
emojisPaginationComponent.value.removeItem(emoji.id);
}
},
closed: () => dispose(),
});
};
const menu = (ev: MouseEvent) => {

View file

@ -160,7 +160,7 @@ function rename() {
function describe() {
if (!file.value) return;
os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
default: file.value.comment ?? '',
file: file.value,
}, {
@ -172,7 +172,8 @@ function describe() {
await fetch();
});
},
}, 'closed');
closed: () => dispose(),
});
}
async function deleteFile() {
@ -233,6 +234,7 @@ onMounted(async () => {
background-color: var(--accentedBg);
color: var(--accent);
text-decoration: none;
outline: none;
}
&.danger {

View file

@ -1082,8 +1082,18 @@ function attachGameEvents() {
const domX = rect.left + (x * viewScale);
const domY = rect.top + (y * viewScale);
const scoreUnit = getScoreUnit(props.gameMode);
os.popup(MkRippleEffect, { x: domX, y: domY }, {}, 'end');
os.popup(MkPlusOneEffect, { x: domX, y: domY, value: scoreDelta + (scoreUnit === 'pt' ? '' : scoreUnit) }, {}, 'end');
{
const { dispose } = os.popup(MkRippleEffect, { x: domX, y: domY }, {
end: () => dispose(),
});
}
{
const { dispose } = os.popup(MkPlusOneEffect, { x: domX, y: domY, value: scoreDelta + (scoreUnit === 'pt' ? '' : scoreUnit) }, {
end: () => dispose(),
});
}
if (nextMono) {
const def = monoDefinitions.value.find(x => x.id === nextMono.id)!;

View file

@ -21,8 +21,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import * as os from '@/os.js';
import * as Misskey from 'misskey-js';
import * as os from '@/os.js';
import { misskeyApiGet } from '@/scripts/misskey-api.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { i18n } from '@/i18n.js';
@ -48,12 +48,12 @@ function menu(ev) {
text: i18n.ts.info,
icon: 'ti ti-info-circle',
action: async () => {
os.popup(MkCustomEmojiDetailedDialog, {
const { dispose } = os.popup(MkCustomEmojiDetailedDialog, {
emoji: await misskeyApiGet('emoji', {
name: props.emoji.name,
})
}),
}, {
anchor: ev.target,
closed: () => dispose(),
});
},
}], ev.currentTarget ?? ev.target);

View file

@ -79,6 +79,7 @@ import { defaultStore } from '@/store.js';
import { $i } from '@/account.js';
import { isSupportShare } from '@/scripts/navigator.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { pleaseLogin } from '@/scripts/please-login.js';
const props = defineProps<{
id: string;
@ -143,6 +144,7 @@ function shareWithNote() {
function like() {
if (!flash.value) return;
pleaseLogin();
os.apiWithDialog('flash/like', {
flashId: flash.value.id,
@ -154,6 +156,7 @@ function like() {
async function unlike() {
if (!flash.value) return;
pleaseLogin();
const confirm = await os.confirm({
type: 'warning',

View file

@ -1,71 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div>
</div>
</template>
<script lang="ts" setup>
import { } from 'vue';
import * as Misskey from 'misskey-js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js';
import { mainRouter } from '@/router/main.js';
async function follow(user): Promise<void> {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.tsx.followConfirm({ name: user.name || user.username }),
});
if (canceled) {
window.close();
return;
}
os.apiWithDialog('following/create', {
userId: user.id,
withReplies: defaultStore.state.defaultWithReplies,
});
user.withReplies = defaultStore.state.defaultWithReplies;
}
const acct = new URL(location.href).searchParams.get('acct');
if (acct == null) {
throw new Error('acct required');
}
let promise;
if (acct.startsWith('https://')) {
promise = misskeyApi('ap/show', {
uri: acct,
});
promise.then(res => {
if (res.type === 'User') {
follow(res.object);
} else if (res.type === 'Note') {
mainRouter.push(`/notes/${res.object.id}`);
} else {
os.alert({
type: 'error',
text: 'Not a user',
}).then(() => {
window.close();
});
}
});
} else {
promise = misskeyApi('users/show', Misskey.acct.parse(acct));
promise.then(user => {
follow(user);
});
}
os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
</script>

View file

@ -8,12 +8,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header><MkPageHeader/></template>
<MkSpacer :contentMax="800">
<div class="_gaps">
<div class="_panel">
<div class="_panel" :class="$style.link">
<MkA to="/bubble-game">
<img src="/client-assets/drop-and-fusion/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
</MkA>
</div>
<div class="_panel">
<div class="_panel" :class="$style.link">
<MkA to="/reversi">
<img src="/client-assets/reversi/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
</MkA>
@ -32,3 +32,10 @@ definePageMetadata(() => ({
icon: 'ti ti-device-gamepad',
}));
</script>
<style module>
.link:focus-within {
outline: 2px solid var(--focus);
outline-offset: -2px;
}
</style>

View file

@ -0,0 +1,97 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="800">
<div v-if="state === 'done'" class="_buttonsCenter">
<MkButton @click="close">{{ i18n.ts.close }}</MkButton>
<MkButton @click="goToMisskey">{{ i18n.ts.goToMisskey }}</MkButton>
</div>
<div v-else class="_fullInfo">
<MkLoading/>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import * as Misskey from 'misskey-js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { mainRouter } from '@/router/main.js';
import MkButton from '@/components/MkButton.vue';
const state = ref<'fetching' | 'done'>('fetching');
function fetch() {
const params = new URL(location.href).searchParams;
// acctdeprecated
let uri = params.get('uri') ?? params.get('acct');
if (uri == null) {
state.value = 'done';
return;
}
let promise: Promise<any>;
if (uri.startsWith('https://')) {
promise = misskeyApi('ap/show', {
uri,
});
promise.then(res => {
if (res.type === 'User') {
mainRouter.replace(res.object.host ? `/@${res.object.username}@${res.object.host}` : `/@${res.object.username}`);
} else if (res.type === 'Note') {
mainRouter.replace(`/notes/${res.object.id}`);
} else {
os.alert({
type: 'error',
text: 'Not a user',
});
}
});
} else {
if (uri.startsWith('acct:')) {
uri = uri.slice(5);
}
promise = misskeyApi('users/show', Misskey.acct.parse(uri));
promise.then(user => {
mainRouter.replace(user.host ? `/@${user.username}@${user.host}` : `/@${user.username}`);
});
}
os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
}
function close(): void {
window.close();
// 100ms
window.setTimeout(() => {
location.href = '/';
}, 100);
}
function goToMisskey(): void {
location.href = '/';
}
fetch();
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata({
title: i18n.ts.lookup,
icon: 'ti ti-world-search',
});
</script>

View file

@ -286,6 +286,7 @@ definePageMetadata(() => ({
background-color: var(--accentedBg);
color: var(--accent);
text-decoration: none;
outline: none;
}
}

View file

@ -44,7 +44,9 @@ async function save() {
onMounted(() => {
if (props.token == null) {
os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {}, 'closed');
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {
closed: () => dispose(),
});
mainRouter.push('/');
}
});

View file

@ -108,9 +108,11 @@ async function registerTOTP(): Promise<void> {
token: auth.result.token,
});
os.popup(defineAsyncComponent(() => import('./2fa.qrdialog.vue')), {
const { dispose } = os.popup(defineAsyncComponent(() => import('./2fa.qrdialog.vue')), {
twoFactorData,
}, {}, 'closed');
}, {
closed: () => dispose(),
});
}
async function unregisterTOTP(): Promise<void> {

View file

@ -74,22 +74,24 @@ async function removeAccount(account) {
}
function addExistingAccount() {
os.popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
done: async res => {
await addAccounts(res.id, res.i);
os.success();
init();
},
}, 'closed');
closed: () => dispose(),
});
}
function createAccount() {
os.popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
done: async res => {
await addAccounts(res.id, res.i);
switchAccountWithToken(res.i);
},
}, 'closed');
closed: () => dispose(),
});
}
async function switchAccount(account: any) {

View file

@ -23,7 +23,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
const isDesktop = ref(window.innerWidth >= 1100);
function generateToken() {
os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, {
done: async result => {
const { name, permissions } = result;
const { token } = await misskeyApi('miauth/gen-token', {
@ -38,7 +38,8 @@ function generateToken() {
text: token,
});
},
}, 'closed');
closed: () => dispose(),
});
}
const headerActions = computed(() => []);

View file

@ -138,7 +138,7 @@ const categories = computed(() => {
});
function openDecoration(avatarDecoration, index?: number) {
os.popup(defineAsyncComponent(() => import('./avatar-decoration.dialog.vue')), {
const { dispose } = os.popup(defineAsyncComponent(() => import('./avatar-decoration.dialog.vue')), {
decoration: avatarDecoration,
usingIndex: index,
}, {
@ -179,7 +179,8 @@ function openDecoration(avatarDecoration, index?: number) {
});
$i.avatarDecorations = update;
},
}, 'closed');
closed: () => dispose(),
});
}
function detachAllDecorations() {

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