Merge branch Sharkey:develop into trackeropt
|
|
@ -55,7 +55,7 @@ await fs.readFile(
|
|||
'../../locales/ja-JP.yml',
|
||||
'assets/**',
|
||||
'public/**',
|
||||
'../../pnpm-lock.yaml',
|
||||
'package.json',
|
||||
]).length
|
||||
) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { DefaultBodyType, HttpResponse, HttpResponseResolver, JsonBodyType, PathParams, http } from 'msw';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import type { DefaultBodyType, HttpResponseResolver, JsonBodyType, PathParams } from 'msw';
|
||||
import seedrandom from 'seedrandom';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ export function imageDataUrl(options?: {
|
|||
alpha?: number,
|
||||
}
|
||||
}, seed?: string): string {
|
||||
const canvas = document.createElement('canvas');
|
||||
const canvas = window.document.createElement('canvas');
|
||||
canvas.width = options?.size?.width ?? 100;
|
||||
canvas.height = options?.size?.height ?? 100;
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,41 @@ export function channel(id = 'somechannelid', name = 'Some Channel', bannerUrl:
|
|||
};
|
||||
}
|
||||
|
||||
export function chatMessage(room = false, id = 'somechatmessageid', text = 'Hello!'): entities.ChatMessage {
|
||||
const fromUser = userLite();
|
||||
const toRoom = chatRoom();
|
||||
const toUser = userLite('touserid');
|
||||
return {
|
||||
id,
|
||||
createdAt: '2016-12-28T22:49:51.000Z',
|
||||
fromUserId: fromUser.id,
|
||||
fromUser,
|
||||
text,
|
||||
isRead: false,
|
||||
reactions: [],
|
||||
...room ? {
|
||||
toRoomId: toRoom.id,
|
||||
toRoom,
|
||||
} : {
|
||||
toUserId: toUser.id,
|
||||
toUser,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function chatRoom(id = 'somechatroomid', name = 'Some Chat Room'): entities.ChatRoom {
|
||||
const owner = userLite('someownerid');
|
||||
return {
|
||||
id,
|
||||
createdAt: '2016-12-28T22:49:51.000Z',
|
||||
ownerId: owner.id,
|
||||
owner,
|
||||
name,
|
||||
description: 'A chat room for testing',
|
||||
isMuted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function clip(id = 'someclipid', name = 'Some Clip'): entities.Clip {
|
||||
return {
|
||||
id,
|
||||
|
|
|
|||
|
|
@ -17,8 +17,52 @@ interface SatisfiesExpression extends estree.BaseExpression {
|
|||
reference: estree.Identifier;
|
||||
}
|
||||
|
||||
interface ImportDeclaration extends estree.ImportDeclaration {
|
||||
kind?: 'type';
|
||||
}
|
||||
|
||||
const generator = {
|
||||
...GENERATOR,
|
||||
ImportDeclaration(node: ImportDeclaration, state: State) {
|
||||
state.write('import ');
|
||||
if (node.kind === 'type') state.write('type ');
|
||||
const { specifiers } = node;
|
||||
if (specifiers.length > 0) {
|
||||
let i = 0;
|
||||
for (; i < specifiers.length; i++) {
|
||||
if (i > 0) {
|
||||
state.write(', ');
|
||||
}
|
||||
const specifier = specifiers[i]!;
|
||||
if (specifier.type === 'ImportDefaultSpecifier') {
|
||||
state.write(specifier.local.name, specifier);
|
||||
} else if (specifier.type === 'ImportNamespaceSpecifier') {
|
||||
state.write(`* as ${specifier.local.name}`, specifier);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (i < specifiers.length) {
|
||||
state.write('{');
|
||||
for (; i < specifiers.length; i++) {
|
||||
const specifier = specifiers[i]! as estree.ImportSpecifier;
|
||||
const { name } = specifier.imported as estree.Identifier;
|
||||
state.write(name, specifier);
|
||||
if (name !== specifier.local.name) {
|
||||
state.write(` as ${specifier.local.name}`);
|
||||
}
|
||||
if (i < specifiers.length - 1) {
|
||||
state.write(', ');
|
||||
}
|
||||
}
|
||||
state.write('}');
|
||||
}
|
||||
state.write(' from ');
|
||||
}
|
||||
this.Literal(node.source, state);
|
||||
|
||||
state.write(';');
|
||||
},
|
||||
SatisfiesExpression(node: SatisfiesExpression, state: State) {
|
||||
switch (node.expression.type) {
|
||||
case 'ArrowFunctionExpression': {
|
||||
|
|
@ -62,7 +106,7 @@ type ToKebab<T extends readonly string[]> = T extends readonly [
|
|||
: T extends readonly [
|
||||
infer XH extends string,
|
||||
...infer XR extends readonly string[]
|
||||
]
|
||||
]
|
||||
? `${XH}${XR extends readonly string[] ? `-${ToKebab<XR>}` : ''}`
|
||||
: '';
|
||||
|
||||
|
|
@ -132,7 +176,7 @@ function toStories(component: string): Promise<string> {
|
|||
kind={'init' as const}
|
||||
shorthand
|
||||
/> as estree.Property,
|
||||
]
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/> as estree.ObjectExpression;
|
||||
|
|
@ -155,7 +199,8 @@ function toStories(component: string): Promise<string> {
|
|||
/> as estree.ImportSpecifier,
|
||||
]),
|
||||
]}
|
||||
/> as estree.ImportDeclaration,
|
||||
kind={'type'}
|
||||
/> as ImportDeclaration,
|
||||
...(hasMsw
|
||||
? [
|
||||
<import-declaration
|
||||
|
|
@ -165,8 +210,8 @@ function toStories(component: string): Promise<string> {
|
|||
local={<identifier name='msw' /> as estree.Identifier}
|
||||
/> as estree.ImportNamespaceSpecifier,
|
||||
]}
|
||||
/> as estree.ImportDeclaration,
|
||||
]
|
||||
/> as ImportDeclaration,
|
||||
]
|
||||
: []),
|
||||
...(hasImplStories
|
||||
? []
|
||||
|
|
@ -176,8 +221,8 @@ function toStories(component: string): Promise<string> {
|
|||
specifiers={[
|
||||
<import-default-specifier local={identifier} /> as estree.ImportDefaultSpecifier,
|
||||
]}
|
||||
/> as estree.ImportDeclaration,
|
||||
]),
|
||||
/> as ImportDeclaration,
|
||||
]),
|
||||
...(hasMetaStories
|
||||
? [
|
||||
<import-declaration
|
||||
|
|
@ -187,7 +232,7 @@ function toStories(component: string): Promise<string> {
|
|||
local={<identifier name='storiesMeta' /> as estree.Identifier}
|
||||
/> as estree.ImportNamespaceSpecifier,
|
||||
]}
|
||||
/> as estree.ImportDeclaration,
|
||||
/> as ImportDeclaration,
|
||||
]
|
||||
: []),
|
||||
<variable-declaration
|
||||
|
|
@ -414,6 +459,7 @@ function toStories(component: string): Promise<string> {
|
|||
glob('src/components/MkSignupServerRules.vue'),
|
||||
glob('src/components/MkUserSetupDialog.vue'),
|
||||
glob('src/components/MkUserSetupDialog.*.vue'),
|
||||
glob('src/components/MkImgPreviewDialog.vue'),
|
||||
glob('src/components/MkInstanceCardMini.vue'),
|
||||
glob('src/components/MkInviteCode.vue'),
|
||||
glob('src/components/MkTagItem.vue'),
|
||||
|
|
|
|||
|
|
@ -39,6 +39,10 @@ const config = {
|
|||
if (~replacePluginForIsChromatic) {
|
||||
config.plugins?.splice(replacePluginForIsChromatic, 1);
|
||||
}
|
||||
|
||||
//pluginsからcreateSearchIndexを削除、複数あるかもしれないので全て削除
|
||||
config.plugins = config.plugins?.filter((plugin: Plugin) => plugin && plugin.name !== 'createSearchIndex') ?? [];
|
||||
|
||||
return mergeConfig(config, {
|
||||
plugins: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<link rel="preload" href="https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true" as="image" type="image/png" crossorigin="anonymous">
|
||||
<link rel="preload" href="https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true" as="image" type="image/jpeg" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://unpkg.com/@tabler/icons-webfont@3.3.0/dist/tabler-icons.min.css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/@tabler/icons-webfont@3.31.0/dist/tabler-icons.min.css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/@fontsource/m-plus-rounded-1c/index.css">
|
||||
<style>
|
||||
html {
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@ let moduleInitialized = false;
|
|||
let unobserve = () => {};
|
||||
let misskeyOS = null;
|
||||
|
||||
function loadTheme(applyTheme: typeof import('../src/scripts/theme')['applyTheme']) {
|
||||
function loadTheme(applyTheme: typeof import('../src/theme')['applyTheme']) {
|
||||
unobserve();
|
||||
const theme = themes[document.documentElement.dataset.misskeyTheme];
|
||||
const theme = themes[window.document.documentElement.dataset.misskeyTheme];
|
||||
if (theme) {
|
||||
applyTheme(themes[document.documentElement.dataset.misskeyTheme]);
|
||||
applyTheme(themes[window.document.documentElement.dataset.misskeyTheme]);
|
||||
} else {
|
||||
applyTheme(themes['l-light']);
|
||||
}
|
||||
|
|
@ -42,7 +42,7 @@ function loadTheme(applyTheme: typeof import('../src/scripts/theme')['applyTheme
|
|||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.documentElement, {
|
||||
observer.observe(window.document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-misskey-theme'],
|
||||
});
|
||||
|
|
@ -64,13 +64,13 @@ initialize({
|
|||
initLocalStorage();
|
||||
queueMicrotask(() => {
|
||||
Promise.all([
|
||||
import('../src/components'),
|
||||
import('../src/directives'),
|
||||
import('../src/widgets'),
|
||||
import('../src/scripts/theme'),
|
||||
import('../src/store'),
|
||||
import('../src/os'),
|
||||
]).then(([{ default: components }, { default: directives }, { default: widgets }, { applyTheme }, { defaultStore }, os]) => {
|
||||
import('../src/components/index.js'),
|
||||
import('../src/directives/index.js'),
|
||||
import('../src/widgets/index.js'),
|
||||
import('../src/theme.js'),
|
||||
import('../src/preferences.js'),
|
||||
import('../src/os.js'),
|
||||
]).then(([{ default: components }, { default: directives }, { default: widgets }, { applyTheme }, { prefer }, os]) => {
|
||||
setup((app) => {
|
||||
moduleInitialized = true;
|
||||
if (app[appInitialized]) {
|
||||
|
|
@ -83,7 +83,7 @@ queueMicrotask(() => {
|
|||
widgets(app);
|
||||
misskeyOS = os;
|
||||
if (isChromatic()) {
|
||||
defaultStore.set('animation', false);
|
||||
prefer.commit('animation', false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -104,9 +104,9 @@ const preview = {
|
|||
}
|
||||
}).catch(() => {})
|
||||
: Promise.resolve();
|
||||
const resetDefaultStorePromise = import('../src/store').then(({ defaultStore }) => {
|
||||
const resetDefaultStorePromise = import('../src/store').then(({ store }) => {
|
||||
// @ts-expect-error
|
||||
defaultStore.init();
|
||||
store.init();
|
||||
}).catch(() => {});
|
||||
Promise.all([resetIndexedDBPromise, resetDefaultStorePromise]).then(() => {
|
||||
initLocalStorage();
|
||||
|
|
|
|||
2
packages/frontend/@types/theme.d.ts
vendored
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
declare module '@@/themes/*.json5' {
|
||||
import { Theme } from '@/scripts/theme.js';
|
||||
import { Theme } from '@/theme.js';
|
||||
|
||||
const theme: Theme;
|
||||
|
||||
|
|
|
|||
BIN
packages/frontend/assets/bell_3d.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
packages/frontend/assets/cloud_3d.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
packages/frontend/assets/desktop_computer_3d.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
packages/frontend/assets/electric_plug_3d.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
packages/frontend/assets/gear_3d.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
packages/frontend/assets/link_3d.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
packages/frontend/assets/locked_with_key_3d.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
packages/frontend/assets/mens_room_3d.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
packages/frontend/assets/musical_note_3d.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
packages/frontend/assets/package_3d.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
packages/frontend/assets/prohibited_3d.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
7
packages/frontend/assets/sharkey-text-src/README.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
This logo text was made in Inkscape by @sneexy@booping.synth.download
|
||||
|
||||
If you edit it, you can use
|
||||
[svgo](https://jakearchibald.github.io/svgomg/) to generate a
|
||||
browser-compatible file, to replace `../sharkey.svg`
|
||||
|
||||
This logo is distributed under the same licence as Sharkey (AGPL ≥3).
|
||||
124
packages/frontend/assets/sharkey-text-src/sharkey_v2.svg
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
packages/frontend/assets/speaker_high_volume_3d.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
packages/frontend/assets/unlocked_3d.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
|
|
@ -54,9 +54,74 @@ export default [
|
|||
'@typescript-eslint/no-empty-interface': ['error', {
|
||||
allowSingleExtends: true,
|
||||
}],
|
||||
// window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため
|
||||
// e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため
|
||||
'id-denylist': ['error', 'window', 'e'],
|
||||
// defineExposeが誤検知されてしまう
|
||||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
'import/consistent-type-specifier-style': ['error', 'prefer-top-level'],
|
||||
// window ... グローバルスコープと衝突し、予期せぬ結果を招くため
|
||||
// e ... error や event など、複数のキーワードの頭文字であり分かりにくいため
|
||||
// close ... window.closeと衝突 or 紛らわしい
|
||||
// open ... window.openと衝突 or 紛らわしい
|
||||
// fetch ... window.fetchと衝突 or 紛らわしい
|
||||
// location ... window.locationと衝突 or 紛らわしい
|
||||
// document ... window.documentと衝突 or 紛らわしい
|
||||
// history ... window.historyと衝突 or 紛らわしい
|
||||
// scroll ... window.scrollと衝突 or 紛らわしい
|
||||
// setTimeout ... window.setTimeoutと衝突 or 紛らわしい
|
||||
// setInterval ... window.setIntervalと衝突 or 紛らわしい
|
||||
// clearTimeout ... window.clearTimeoutと衝突 or 紛らわしい
|
||||
// clearInterval ... window.clearIntervalと衝突 or 紛らわしい
|
||||
'id-denylist': ['warn', 'window', 'e', 'close', 'open', 'fetch', 'location', 'document', 'history', 'scroll', 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'],
|
||||
'no-restricted-globals': [
|
||||
'error',
|
||||
{
|
||||
'name': 'open',
|
||||
'message': 'Use `window.open`.',
|
||||
},
|
||||
{
|
||||
'name': 'close',
|
||||
'message': 'Use `window.close`.',
|
||||
},
|
||||
{
|
||||
'name': 'fetch',
|
||||
'message': 'Use `window.fetch`.',
|
||||
},
|
||||
{
|
||||
'name': 'location',
|
||||
'message': 'Use `window.location`.',
|
||||
},
|
||||
{
|
||||
'name': 'document',
|
||||
'message': 'Use `window.document`.',
|
||||
},
|
||||
{
|
||||
'name': 'history',
|
||||
'message': 'Use `window.history`.',
|
||||
},
|
||||
{
|
||||
'name': 'scroll',
|
||||
'message': 'Use `window.scroll`.',
|
||||
},
|
||||
{
|
||||
'name': 'setTimeout',
|
||||
'message': 'Use `window.setTimeout`.',
|
||||
},
|
||||
{
|
||||
'name': 'setInterval',
|
||||
'message': 'Use `window.setInterval`.',
|
||||
},
|
||||
{
|
||||
'name': 'clearTimeout',
|
||||
'message': 'Use `window.clearTimeout`.',
|
||||
},
|
||||
{
|
||||
'name': 'clearInterval',
|
||||
'message': 'Use `window.clearInterval`.',
|
||||
},
|
||||
{
|
||||
'name': 'name',
|
||||
'message': 'Use `window.name`. もしくは name という変数名を定義し忘れている',
|
||||
},
|
||||
],
|
||||
'no-shadow': ['warn'],
|
||||
'vue/attributes-order': ['error', {
|
||||
alphabetical: false,
|
||||
|
|
@ -99,6 +164,12 @@ export default [
|
|||
'vue/attribute-hyphenation': ['error', 'never'],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.stories.ts'],
|
||||
rules: {
|
||||
'no-restricted-globals': 'off',
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
"**/lib/",
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ describe(normalizeClass.name, () => {
|
|||
|
||||
it('Composition API (standard)', () => {
|
||||
const ast = parse(`
|
||||
import { c as api, d as defaultStore, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc } from './app-!~{001}~.js';
|
||||
import { c as api, d as store, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc } from './app-!~{001}~.js';
|
||||
import { M as MkContainer } from './MkContainer-!~{03M}~.js';
|
||||
import { b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode } from './vue-!~{002}~.js';
|
||||
import './photoswipe-!~{003}~.js';
|
||||
|
|
@ -74,7 +74,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|||
let fetching = ref(true);
|
||||
let images = ref([]);
|
||||
function thumbnail(image) {
|
||||
return defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(image.url) : image.thumbnailUrl;
|
||||
return store.s.disableShowingAnimatedImages ? getStaticImageUrl(image.url) : image.thumbnailUrl;
|
||||
}
|
||||
onMounted(() => {
|
||||
const image = [
|
||||
|
|
@ -173,7 +173,7 @@ export { index_photos as default };
|
|||
`.slice(1), { ecmaVersion: 'latest', sourceType: 'module' });
|
||||
unwindCssModuleClassName(ast);
|
||||
expect(generate(ast)).toBe(`
|
||||
import {c as api, d as defaultStore, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc} from './app-!~{001}~.js';
|
||||
import {c as api, d as store, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc} from './app-!~{001}~.js';
|
||||
import {M as MkContainer} from './MkContainer-!~{03M}~.js';
|
||||
import {b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode} from './vue-!~{002}~.js';
|
||||
import './photoswipe-!~{003}~.js';
|
||||
|
|
@ -190,7 +190,7 @@ const index_photos = defineComponent({
|
|||
let fetching = ref(true);
|
||||
let images = ref([]);
|
||||
function thumbnail(image) {
|
||||
return defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(image.url) : image.thumbnailUrl;
|
||||
return store.s.disableShowingAnimatedImages ? getStaticImageUrl(image.url) : image.thumbnailUrl;
|
||||
}
|
||||
onMounted(() => {
|
||||
const image = ["image/jpeg", "image/webp", "image/avif", "image/png", "image/gif", "image/apng", "image/vnd.mozilla.apng"];
|
||||
|
|
@ -268,7 +268,7 @@ export {index_photos as default};
|
|||
it('Composition API (with `useCssModule()`)', () => {
|
||||
const ast = parse(`
|
||||
import { a7 as getCurrentInstance, b as defineComponent, G as useCssModule, a1 as h, H as TransitionGroup } from './!~{002}~.js';
|
||||
import { d as defaultStore, aK as toast, b5 as MkAd, i as i18n, _ as _export_sfc } from './app-!~{001}~.js';
|
||||
import { d as store, aK as toast, b5 as MkAd, i as i18n, _ as _export_sfc } from './app-!~{001}~.js';
|
||||
|
||||
function isDebuggerEnabled(id) {
|
||||
try {
|
||||
|
|
@ -393,7 +393,7 @@ const _sfc_main = defineComponent({
|
|||
el.style.left = "";
|
||||
}
|
||||
return () => h(
|
||||
defaultStore.state.animation ? TransitionGroup : "div",
|
||||
prefer.s.animation ? TransitionGroup : "div",
|
||||
{
|
||||
class: {
|
||||
[$style["date-separated-list"]]: true,
|
||||
|
|
@ -402,7 +402,7 @@ const _sfc_main = defineComponent({
|
|||
[$style["direction-down"]]: props.direction === "down",
|
||||
[$style["direction-up"]]: props.direction === "up"
|
||||
},
|
||||
...defaultStore.state.animation ? {
|
||||
...prefer.s.animation ? {
|
||||
name: "list",
|
||||
tag: "div",
|
||||
onBeforeLeave,
|
||||
|
|
@ -441,7 +441,7 @@ export { MkDateSeparatedList as M };
|
|||
unwindCssModuleClassName(ast);
|
||||
expect(generate(ast)).toBe(`
|
||||
import {a7 as getCurrentInstance, b as defineComponent, G as useCssModule, a1 as h, H as TransitionGroup} from './!~{002}~.js';
|
||||
import {d as defaultStore, aK as toast, b5 as MkAd, i as i18n, _ as _export_sfc} from './app-!~{001}~.js';
|
||||
import {d as store, aK as toast, b5 as MkAd, i as i18n, _ as _export_sfc} from './app-!~{001}~.js';
|
||||
function isDebuggerEnabled(id) {
|
||||
try {
|
||||
return localStorage.getItem(\`DEBUG_\${id}\`) !== null;
|
||||
|
|
@ -555,7 +555,7 @@ const _sfc_main = defineComponent({
|
|||
el.style.top = "";
|
||||
el.style.left = "";
|
||||
}
|
||||
return () => h(defaultStore.state.animation ? TransitionGroup : "div", {
|
||||
return () => h(prefer.s.animation ? TransitionGroup : "div", {
|
||||
class: {
|
||||
[$style["date-separated-list"]]: true,
|
||||
[$style["date-separated-list-nogap"]]: props.noGap,
|
||||
|
|
@ -563,7 +563,7 @@ const _sfc_main = defineComponent({
|
|||
[$style["direction-down"]]: props.direction === "down",
|
||||
[$style["direction-up"]]: props.direction === "up"
|
||||
},
|
||||
...defaultStore.state.animation ? {
|
||||
...prefer.s.animation ? {
|
||||
name: "list",
|
||||
tag: "div",
|
||||
onBeforeLeave,
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
*/
|
||||
|
||||
import { generate } from 'astring';
|
||||
import * as estree from 'estree';
|
||||
import { walk } from '../node_modules/estree-walker/src/index.js';
|
||||
import type * as estree from 'estree';
|
||||
import type * as estreeWalker from 'estree-walker';
|
||||
import type { Plugin } from 'vite';
|
||||
|
||||
|
|
|
|||
756
packages/frontend/lib/vite-plugin-create-search-index.ts
Normal file
|
|
@ -0,0 +1,756 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/// <reference lib="esnext" />
|
||||
|
||||
import { parse as vueSfcParse } from 'vue/compiler-sfc';
|
||||
import {
|
||||
createLogger,
|
||||
EnvironmentModuleGraph,
|
||||
type LogErrorOptions,
|
||||
type LogOptions,
|
||||
normalizePath,
|
||||
type Plugin,
|
||||
type PluginOption
|
||||
} from 'vite';
|
||||
import fs from 'node:fs';
|
||||
import { glob } from 'glob';
|
||||
import JSON5 from 'json5';
|
||||
import MagicString, { SourceMap } from 'magic-string';
|
||||
import path from 'node:path'
|
||||
import { hash, toBase62 } from '../vite.config';
|
||||
import { minimatch } from 'minimatch';
|
||||
import {
|
||||
type AttributeNode,
|
||||
type DirectiveNode,
|
||||
type ElementNode,
|
||||
ElementTypes,
|
||||
NodeTypes,
|
||||
type RootNode,
|
||||
type SimpleExpressionNode,
|
||||
type TemplateChildNode,
|
||||
} from '@vue/compiler-core';
|
||||
|
||||
export interface SearchIndexItem {
|
||||
id: string;
|
||||
parentId?: string;
|
||||
path?: string;
|
||||
label: string;
|
||||
keywords: string[];
|
||||
icon?: string;
|
||||
inlining?: string[];
|
||||
}
|
||||
|
||||
export type Options = {
|
||||
targetFilePaths: string[],
|
||||
mainVirtualModule: string,
|
||||
modulesToHmrOnUpdate: string[],
|
||||
fileVirtualModulePrefix?: string,
|
||||
fileVirtualModuleSuffix?: string,
|
||||
verbose?: boolean,
|
||||
};
|
||||
|
||||
// マーカー関係を表す型
|
||||
interface MarkerRelation {
|
||||
parentId?: string;
|
||||
markerId: string;
|
||||
node: ElementNode;
|
||||
}
|
||||
|
||||
// ロガー
|
||||
let logger = {
|
||||
info: (msg: string, options?: LogOptions) => { },
|
||||
warn: (msg: string, options?: LogOptions) => { },
|
||||
error: (msg: string, options?: LogErrorOptions | unknown) => { },
|
||||
};
|
||||
let loggerInitialized = false;
|
||||
|
||||
function initLogger(options: Options) {
|
||||
if (loggerInitialized) return;
|
||||
loggerInitialized = true;
|
||||
const viteLogger = createLogger(options.verbose ? 'info' : 'warn');
|
||||
|
||||
logger.info = (msg, options) => {
|
||||
msg = `[create-search-index] ${msg}`;
|
||||
viteLogger.info(msg, options);
|
||||
}
|
||||
|
||||
logger.warn = (msg, options) => {
|
||||
msg = `[create-search-index] ${msg}`;
|
||||
viteLogger.warn(msg, options);
|
||||
}
|
||||
|
||||
logger.error = (msg, options) => {
|
||||
msg = `[create-search-index] ${msg}`;
|
||||
viteLogger.error(msg, options);
|
||||
}
|
||||
}
|
||||
|
||||
//region AST Utility
|
||||
|
||||
type WalkVueNode = RootNode | TemplateChildNode | SimpleExpressionNode;
|
||||
|
||||
/**
|
||||
* Walks the Vue AST.
|
||||
* @param nodes
|
||||
* @param context The context value passed to callback. you can update context for children by returning value in callback
|
||||
* @param callback Returns false if you don't want to walk inner tree
|
||||
*/
|
||||
function walkVueElements<C extends {} | null>(nodes: WalkVueNode[], context: C, callback: (node: ElementNode, context: C) => C | undefined | void | false): void {
|
||||
for (const node of nodes) {
|
||||
let currentContext = context;
|
||||
if (node.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error("Unexpected COMPOUND_EXPRESSION");
|
||||
if (node.type === NodeTypes.ELEMENT) {
|
||||
const result = callback(node, context);
|
||||
if (result === false) return;
|
||||
if (result !== undefined) currentContext = result;
|
||||
}
|
||||
if ('children' in node) {
|
||||
walkVueElements(node.children, currentContext, callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findAttribute(props: Array<AttributeNode | DirectiveNode>, name: string): AttributeNode | DirectiveNode | null {
|
||||
for (const prop of props) {
|
||||
switch (prop.type) {
|
||||
case NodeTypes.ATTRIBUTE:
|
||||
if (prop.name === name) {
|
||||
return prop;
|
||||
}
|
||||
break;
|
||||
case NodeTypes.DIRECTIVE:
|
||||
if (prop.name === 'bind' && prop.arg && 'content' in prop.arg && prop.arg.content === name) {
|
||||
return prop;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findEndOfStartTagAttributes(node: ElementNode): number {
|
||||
if (node.children.length > 0) {
|
||||
// 子要素がある場合、最初の子要素の開始位置を基準にする
|
||||
const nodeStart = node.loc.start.offset;
|
||||
const firstChildStart = node.children[0].loc.start.offset;
|
||||
const endOfStartTag = node.loc.source.lastIndexOf('>', firstChildStart - nodeStart);
|
||||
if (endOfStartTag === -1) throw new Error("Bug: Failed to find end of start tag");
|
||||
return nodeStart + endOfStartTag;
|
||||
} else {
|
||||
// 子要素がない場合、自身の終了位置から逆算
|
||||
return node.isSelfClosing ? node.loc.end.offset - 1 : node.loc.end.offset;
|
||||
}
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
/**
|
||||
* TypeScriptコード生成
|
||||
*/
|
||||
function generateJavaScriptCode(resolvedRootMarkers: SearchIndexItem[]): string {
|
||||
return `import { i18n } from '@/i18n.js';\n`
|
||||
+ `export const searchIndexes = ${customStringify(resolvedRootMarkers)};\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* オブジェクトを特殊な形式の文字列に変換する
|
||||
* i18n参照を保持しつつ適切な形式に変換
|
||||
*/
|
||||
function customStringify(obj: unknown): string {
|
||||
return JSON.stringify(obj).replaceAll(/"(.*?)"/g, (all, group) => {
|
||||
// propertyAccessProxy が i18n 参照を "${i18n.xxx}"のような形に変換してるので、これをそのまま`${i18n.xxx}`
|
||||
// のような形にすると、実行時にi18nのプロパティにアクセスするようになる。
|
||||
// objectのkeyでは``が使えないので、${ が使われている場合にのみ``に置き換えるようにする
|
||||
return group.includes('${') ? '`' + group + '`' : all;
|
||||
});
|
||||
}
|
||||
|
||||
// region extractElementText
|
||||
|
||||
/**
|
||||
* 要素のノードの中身のテキストを抽出する
|
||||
*/
|
||||
function extractElementText(node: ElementNode, id: string): string | null {
|
||||
return extractElementTextChecked(node, node.tag, id);
|
||||
}
|
||||
|
||||
function extractElementTextChecked(node: ElementNode, processingNodeName: string, id: string): string | null {
|
||||
const result: string[] = [];
|
||||
for (const child of node.children) {
|
||||
const text = extractElementText2Inner(child, processingNodeName, id);
|
||||
if (text == null) return null;
|
||||
result.push(text);
|
||||
}
|
||||
return result.join('');
|
||||
}
|
||||
|
||||
function extractElementText2Inner(node: TemplateChildNode, processingNodeName: string, id: string): string | null {
|
||||
if (node.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error("Unexpected COMPOUND_EXPRESSION");
|
||||
|
||||
switch (node.type) {
|
||||
case NodeTypes.INTERPOLATION: {
|
||||
const expr = node.content;
|
||||
if (expr.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error(`Unexpected COMPOUND_EXPRESSION`);
|
||||
const exprResult = evalExpression(expr.content);
|
||||
if (typeof exprResult !== 'string') {
|
||||
logger.error(`Result of interpolation node is not string at line ${id}:${node.loc.start.line}`);
|
||||
return null;
|
||||
}
|
||||
return exprResult;
|
||||
}
|
||||
case NodeTypes.ELEMENT:
|
||||
if (node.tagType === ElementTypes.ELEMENT) {
|
||||
return extractElementTextChecked(node, processingNodeName, id);
|
||||
} else {
|
||||
logger.error(`Unexpected ${node.tag} extracting text of ${processingNodeName} ${id}:${node.loc.start.line}`);
|
||||
return null;
|
||||
}
|
||||
case NodeTypes.TEXT:
|
||||
return node.content;
|
||||
case NodeTypes.COMMENT:
|
||||
// We skip comments
|
||||
return '';
|
||||
case NodeTypes.IF:
|
||||
case NodeTypes.IF_BRANCH:
|
||||
case NodeTypes.FOR:
|
||||
case NodeTypes.TEXT_CALL:
|
||||
logger.error(`Unexpected controlflow element extracting text of ${processingNodeName} ${id}:${node.loc.start.line}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region extractUsageInfoFromTemplateAst
|
||||
|
||||
/**
|
||||
* SearchLabel/SearchKeyword/SearchIconを探して抽出する関数
|
||||
*/
|
||||
function extractSugarTags(nodes: TemplateChildNode[], id: string): { label: string | null, keywords: string[], icon: string | null } {
|
||||
let label: string | null | undefined = undefined;
|
||||
let icon: string | null | undefined = undefined;
|
||||
const keywords: string[] = [];
|
||||
|
||||
logger.info(`Extracting labels and keywords from ${nodes.length} nodes`);
|
||||
|
||||
walkVueElements(nodes, null, (node) => {
|
||||
switch (node.tag) {
|
||||
case 'SearchMarker':
|
||||
return false; // SearchMarkerはスキップ
|
||||
case 'SearchLabel':
|
||||
if (label !== undefined) {
|
||||
logger.warn(`Duplicate SearchLabel found, ignoring the second one at ${id}:${node.loc.start.line}`);
|
||||
break; // 2つ目のSearchLabelは無視
|
||||
}
|
||||
|
||||
label = extractElementText(node, id);
|
||||
return;
|
||||
case 'SearchKeyword':
|
||||
const content = extractElementText(node, id);
|
||||
if (content) {
|
||||
keywords.push(content);
|
||||
}
|
||||
return;
|
||||
case 'SearchIcon':
|
||||
if (icon !== undefined) {
|
||||
logger.warn(`Duplicate SearchIcon found, ignoring the second one at ${id}:${node.loc.start.line}`);
|
||||
break; // 2つ目のSearchIconは無視
|
||||
}
|
||||
|
||||
if (node.children.length !== 1) {
|
||||
logger.error(`SearchIcon must have exactly one child at ${id}:${node.loc.start.line}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const iconNode = node.children[0];
|
||||
if (iconNode.type !== NodeTypes.ELEMENT) {
|
||||
logger.error(`SearchIcon must have a child element at ${id}:${node.loc.start.line}`);
|
||||
return;
|
||||
}
|
||||
icon = getStringProp(findAttribute(iconNode.props, 'class'), id);
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
|
||||
// デバッグ情報
|
||||
logger.info(`Extraction completed: label=${label}, keywords=[${keywords.join(', ')}, icon=${icon}]`);
|
||||
return { label: label ?? null, keywords, icon: icon ?? null };
|
||||
}
|
||||
|
||||
function getStringProp(attr: AttributeNode | DirectiveNode | null, id: string): string | null {
|
||||
switch (attr?.type) {
|
||||
case null:
|
||||
case undefined:
|
||||
return null;
|
||||
case NodeTypes.ATTRIBUTE:
|
||||
return attr.value?.content ?? null;
|
||||
case NodeTypes.DIRECTIVE:
|
||||
if (attr.exp == null) return null;
|
||||
if (attr.exp.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error('Unexpected COMPOUND_EXPRESSION');
|
||||
const value = evalExpression(attr.exp.content ?? '');
|
||||
if (typeof value !== 'string') {
|
||||
logger.error(`Expected string value, got ${typeof value} at ${id}:${attr.loc.start.line}`);
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function getStringArrayProp(attr: AttributeNode | DirectiveNode | null, id: string): string[] | null {
|
||||
switch (attr?.type) {
|
||||
case null:
|
||||
case undefined:
|
||||
return null;
|
||||
case NodeTypes.ATTRIBUTE:
|
||||
logger.error(`Expected directive, got attribute at ${id}:${attr.loc.start.line}`);
|
||||
return null;
|
||||
case NodeTypes.DIRECTIVE:
|
||||
if (attr.exp == null) return null;
|
||||
if (attr.exp.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error('Unexpected COMPOUND_EXPRESSION');
|
||||
const value = evalExpression(attr.exp.content ?? '');
|
||||
if (!Array.isArray(value) || !value.every(x => typeof x === 'string')) {
|
||||
logger.error(`Expected string array value, got ${typeof value} at ${id}:${attr.loc.start.line}`);
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function extractUsageInfoFromTemplateAst(
|
||||
templateAst: RootNode | undefined,
|
||||
id: string,
|
||||
): SearchIndexItem[] {
|
||||
const allMarkers: SearchIndexItem[] = [];
|
||||
const markerMap = new Map<string, SearchIndexItem>();
|
||||
|
||||
if (!templateAst) return allMarkers;
|
||||
|
||||
walkVueElements<string | null>([templateAst], null, (node, parentId) => {
|
||||
if (node.tag !== 'SearchMarker') {
|
||||
return;
|
||||
}
|
||||
|
||||
// マーカーID取得
|
||||
const markerIdProp = node.props?.find(p => p.name === 'markerId');
|
||||
const markerId = markerIdProp?.type == NodeTypes.ATTRIBUTE ? markerIdProp.value?.content : null;
|
||||
|
||||
// SearchMarkerにマーカーIDがない場合はエラー
|
||||
if (markerId == null) {
|
||||
logger.error(`Marker ID not found for node: ${JSON.stringify(node)}`);
|
||||
throw new Error(`Marker ID not found in file ${id}`);
|
||||
}
|
||||
|
||||
// マーカー基本情報
|
||||
const markerInfo: SearchIndexItem = {
|
||||
id: markerId,
|
||||
parentId: parentId ?? undefined,
|
||||
label: '', // デフォルト値
|
||||
keywords: [],
|
||||
};
|
||||
|
||||
// バインドプロパティを取得
|
||||
const path = getStringProp(findAttribute(node.props, 'path'), id)
|
||||
const icon = getStringProp(findAttribute(node.props, 'icon'), id)
|
||||
const label = getStringProp(findAttribute(node.props, 'label'), id)
|
||||
const inlining = getStringArrayProp(findAttribute(node.props, 'inlining'), id)
|
||||
const keywords = getStringArrayProp(findAttribute(node.props, 'keywords'), id)
|
||||
|
||||
if (path) markerInfo.path = path;
|
||||
if (icon) markerInfo.icon = icon;
|
||||
if (label) markerInfo.label = label;
|
||||
if (inlining) markerInfo.inlining = inlining;
|
||||
if (keywords) markerInfo.keywords = keywords;
|
||||
|
||||
//pathがない場合はファイルパスを設定
|
||||
if (markerInfo.path == null && parentId == null) {
|
||||
markerInfo.path = id.match(/.*(\/(admin|settings)\/[^\/]+)\.vue$/)?.[1];
|
||||
}
|
||||
|
||||
// SearchLabelとSearchKeywordを抽出 (AST全体を探索)
|
||||
{
|
||||
const extracted = extractSugarTags(node.children, id);
|
||||
if (extracted.label && markerInfo.label) logger.warn(`Duplicate label found for ${markerId} at ${id}:${node.loc.start.line}`);
|
||||
if (extracted.icon && markerInfo.icon) logger.warn(`Duplicate icon found for ${markerId} at ${id}:${node.loc.start.line}`);
|
||||
markerInfo.label = extracted.label ?? markerInfo.label ?? '';
|
||||
markerInfo.keywords = [...extracted.keywords, ...markerInfo.keywords];
|
||||
markerInfo.icon = extracted.icon ?? markerInfo.icon ?? undefined;
|
||||
}
|
||||
|
||||
if (!markerInfo.label) {
|
||||
logger.warn(`No label found for ${markerId} at ${id}:${node.loc.start.line}`);
|
||||
}
|
||||
|
||||
// マーカーを登録
|
||||
markerMap.set(markerId, markerInfo);
|
||||
allMarkers.push(markerInfo);
|
||||
return markerId;
|
||||
});
|
||||
|
||||
return allMarkers;
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region evalExpression
|
||||
|
||||
/**
|
||||
* expr を実行します。
|
||||
* i18n はそのアクセスを保持するために propertyAccessProxy を使用しています。
|
||||
*/
|
||||
function evalExpression(expr: string): unknown {
|
||||
const rarResult = Function('i18n', `return ${expr}`)(i18nProxy);
|
||||
// JSON.stringify を一回通すことで、 AccessProxy を文字列に変換する
|
||||
// Walk してもいいんだけど横着してJSON.stringifyしてる。ビルド時にしか通らないのであんまりパフォーマンス気にする必要ないんで
|
||||
return JSON.parse(JSON.stringify(rarResult));
|
||||
}
|
||||
|
||||
const propertyAccessProxySymbol = Symbol('propertyAccessProxySymbol');
|
||||
|
||||
type AccessProxy = {
|
||||
[propertyAccessProxySymbol]: string[],
|
||||
[k: string]: AccessProxy,
|
||||
}
|
||||
|
||||
const propertyAccessProxyHandler: ProxyHandler<AccessProxy> = {
|
||||
get(target: AccessProxy, p: string | symbol): any {
|
||||
if (p in target) {
|
||||
return (target as any)[p];
|
||||
}
|
||||
if (p == "toJSON" || p == Symbol.toPrimitive) {
|
||||
return propertyAccessProxyToJSON;
|
||||
}
|
||||
if (typeof p == 'string') {
|
||||
return target[p] = propertyAccessProxy([...target[propertyAccessProxySymbol], p]);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function propertyAccessProxyToJSON(this: AccessProxy, hint: string) {
|
||||
const expression = this[propertyAccessProxySymbol].reduce((prev, current) => {
|
||||
if (current.match(/^[a-z][0-9a-z]*$/i)) {
|
||||
return `${prev}.${current}`;
|
||||
} else {
|
||||
return `${prev}['${current}']`;
|
||||
}
|
||||
});
|
||||
return '$\{' + expression + '}';
|
||||
}
|
||||
|
||||
/**
|
||||
* プロパティのアクセスを保持するための Proxy オブジェクトを作成します。
|
||||
*
|
||||
* この関数で生成した proxy は JSON でシリアライズするか、`${}`のように string にすると、 ${property.path} のような形になる。
|
||||
* @param path
|
||||
*/
|
||||
function propertyAccessProxy(path: string[]): AccessProxy {
|
||||
const target: AccessProxy = {
|
||||
[propertyAccessProxySymbol]: path,
|
||||
};
|
||||
return new Proxy(target, propertyAccessProxyHandler);
|
||||
}
|
||||
|
||||
const i18nProxy = propertyAccessProxy(['i18n']);
|
||||
|
||||
export function collectFileMarkers(id: string, code: string): SearchIndexItem[] {
|
||||
try {
|
||||
const { descriptor, errors } = vueSfcParse(code, {
|
||||
filename: id,
|
||||
});
|
||||
|
||||
if (errors.length > 0) {
|
||||
logger.error(`Compile Error: ${id}, ${errors}`);
|
||||
return []; // エラーが発生したファイルはスキップ
|
||||
}
|
||||
|
||||
return extractUsageInfoFromTemplateAst(descriptor.template?.ast, id);
|
||||
} catch (error) {
|
||||
logger.error(`Error analyzing file ${id}:`, error);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
type TransformedCode = {
|
||||
code: string,
|
||||
map: SourceMap,
|
||||
};
|
||||
|
||||
export class MarkerIdAssigner {
|
||||
// key: file id
|
||||
private cache: Map<string, TransformedCode>;
|
||||
|
||||
constructor() {
|
||||
this.cache = new Map();
|
||||
}
|
||||
|
||||
public onInvalidate(id: string) {
|
||||
this.cache.delete(id);
|
||||
}
|
||||
|
||||
public processFile(id: string, code: string): TransformedCode {
|
||||
// try cache first
|
||||
if (this.cache.has(id)) {
|
||||
return this.cache.get(id)!;
|
||||
}
|
||||
const transformed = this.#processImpl(id, code);
|
||||
this.cache.set(id, transformed);
|
||||
return transformed;
|
||||
}
|
||||
|
||||
#processImpl(id: string, code: string): TransformedCode {
|
||||
const s = new MagicString(code); // magic-string のインスタンスを作成
|
||||
|
||||
const parsed = vueSfcParse(code, { filename: id });
|
||||
if (!parsed.descriptor.template) {
|
||||
return {
|
||||
code,
|
||||
map: s.generateMap({ source: id, includeContent: true }),
|
||||
};
|
||||
}
|
||||
const ast = parsed.descriptor.template.ast; // テンプレート AST を取得
|
||||
const markerRelations: MarkerRelation[] = []; // MarkerRelation 配列を初期化
|
||||
|
||||
if (!ast) {
|
||||
return {
|
||||
code: s.toString(), // 変更後のコードを返す
|
||||
map: s.generateMap({ source: id, includeContent: true }), // ソースマップも生成 (sourceMap: true が必要)
|
||||
};
|
||||
}
|
||||
|
||||
walkVueElements<string | null>([ast], null, (node, parentId) => {
|
||||
if (node.tag !== 'SearchMarker') return;
|
||||
|
||||
const markerIdProp = findAttribute(node.props, 'markerId');
|
||||
|
||||
let nodeMarkerId: string;
|
||||
if (markerIdProp != null) {
|
||||
if (markerIdProp.type !== NodeTypes.ATTRIBUTE) return logger.error(`markerId must be a attribute at ${id}:${markerIdProp.loc.start.line}`);
|
||||
if (markerIdProp.value == null) return logger.error(`markerId must have a value at ${id}:${markerIdProp.loc.start.line}`);
|
||||
nodeMarkerId = markerIdProp.value.content;
|
||||
} else {
|
||||
// ファイルパスと行番号からハッシュ値を生成
|
||||
// この際実行環境で差が出ないようにファイルパスを正規化
|
||||
const idKey = id.replace(/\\/g, '/').split('packages/frontend/')[1]
|
||||
const generatedMarkerId = toBase62(hash(`${idKey}:${node.loc.start.line}`));
|
||||
|
||||
// markerId attribute を追加
|
||||
const endOfStartTag = findEndOfStartTagAttributes(node);
|
||||
s.appendRight(endOfStartTag, ` markerId="${generatedMarkerId}" data-in-app-search-marker-id="${generatedMarkerId}"`);
|
||||
|
||||
nodeMarkerId = generatedMarkerId;
|
||||
}
|
||||
|
||||
markerRelations.push({
|
||||
parentId: parentId ?? undefined,
|
||||
markerId: nodeMarkerId,
|
||||
node: node,
|
||||
});
|
||||
|
||||
return nodeMarkerId;
|
||||
})
|
||||
|
||||
// 2段階目: :children 属性の追加
|
||||
// 最初に親マーカーごとに子マーカーIDを集約する処理を追加
|
||||
const parentChildrenMap = new Map<string, string[]>();
|
||||
|
||||
// 1. まず親ごとのすべての子マーカーIDを収集
|
||||
markerRelations.forEach(relation => {
|
||||
if (relation.parentId) {
|
||||
if (!parentChildrenMap.has(relation.parentId)) {
|
||||
parentChildrenMap.set(relation.parentId, []);
|
||||
}
|
||||
parentChildrenMap.get(relation.parentId)!.push(relation.markerId);
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 親ごとにまとめて :children 属性を処理
|
||||
for (const [parentId, childIds] of parentChildrenMap.entries()) {
|
||||
const parentRelation = markerRelations.find(r => r.markerId === parentId);
|
||||
if (!parentRelation) continue;
|
||||
|
||||
const parentNode = parentRelation.node;
|
||||
const childrenProp = findAttribute(parentNode.props, 'children');
|
||||
if (childrenProp != null) {
|
||||
if (childrenProp.type !== NodeTypes.DIRECTIVE) {
|
||||
console.error(`children prop should be directive (:children) at ${id}:${childrenProp.loc.start.line}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// AST で :children 属性が検出された場合、それを更新
|
||||
const childrenValue = getStringArrayProp(childrenProp, id);
|
||||
if (childrenValue == null) continue;
|
||||
|
||||
const newValue: string[] = [...childrenValue];
|
||||
for (const childId of [...childIds]) {
|
||||
if (!newValue.includes(childId)) {
|
||||
newValue.push(childId);
|
||||
}
|
||||
}
|
||||
|
||||
const expression = JSON.stringify(newValue).replaceAll(/"/g, "'");
|
||||
s.overwrite(childrenProp.exp!.loc.start.offset, childrenProp.exp!.loc.end.offset, expression);
|
||||
logger.info(`Added ${childIds.length} child markerIds to existing :children in ${id}`);
|
||||
} else {
|
||||
// :children 属性がまだない場合、新規作成
|
||||
const endOfParentStartTag = findEndOfStartTagAttributes(parentNode);
|
||||
s.appendRight(endOfParentStartTag, ` :children="${JSON5.stringify(childIds).replace(/"/g, "'")}"`);
|
||||
logger.info(`Created new :children attribute with ${childIds.length} markerIds in ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code: s.toString(), // 変更後のコードを返す
|
||||
map: s.generateMap({ source: id, includeContent: true }), // ソースマップも生成 (sourceMap: true が必要)
|
||||
};
|
||||
}
|
||||
|
||||
async getOrLoad(id: string) {
|
||||
// if there already exists a cache, return it
|
||||
// note cahce will be invalidated on file change so the cache must be up to date
|
||||
let code = this.getCached(id)?.code;
|
||||
if (code != null) {
|
||||
return code;
|
||||
}
|
||||
|
||||
// if no cache found, read and parse the file
|
||||
const originalCode = await fs.promises.readFile(id, 'utf-8');
|
||||
|
||||
// Other code may already parsed the file while we were waiting for the file to be read so re-check the cache
|
||||
code = this.getCached(id)?.code;
|
||||
if (code != null) {
|
||||
return code;
|
||||
}
|
||||
|
||||
// parse the file
|
||||
code = this.processFile(id, originalCode)?.code;
|
||||
return code;
|
||||
}
|
||||
|
||||
getCached(id: string) {
|
||||
return this.cache.get(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Rollup プラグインとして export
|
||||
export default function pluginCreateSearchIndex(options: Options): PluginOption {
|
||||
const assigner = new MarkerIdAssigner();
|
||||
return [
|
||||
createSearchIndex(options, assigner),
|
||||
pluginCreateSearchIndexVirtualModule(options, assigner),
|
||||
]
|
||||
}
|
||||
|
||||
function createSearchIndex(options: Options, assigner: MarkerIdAssigner): Plugin {
|
||||
initLogger(options); // ロガーを初期化
|
||||
const root = normalizePath(process.cwd());
|
||||
|
||||
function isTargetFile(id: string): boolean {
|
||||
const relativePath = path.posix.relative(root, id);
|
||||
return options.targetFilePaths.some(pat => minimatch(relativePath, pat))
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'autoAssignMarkerId',
|
||||
enforce: 'pre',
|
||||
|
||||
watchChange(id) {
|
||||
assigner.onInvalidate(id);
|
||||
},
|
||||
|
||||
async transform(code, id) {
|
||||
if (!id.endsWith('.vue')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTargetFile(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return assigner.processFile(id, code);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function pluginCreateSearchIndexVirtualModule(options: Options, asigner: MarkerIdAssigner): Plugin {
|
||||
const searchIndexPrefix = options.fileVirtualModulePrefix ?? 'search-index-individual:';
|
||||
const searchIndexSuffix = options.fileVirtualModuleSuffix ?? '.ts';
|
||||
const allSearchIndexFile = options.mainVirtualModule;
|
||||
const root = normalizePath(process.cwd());
|
||||
|
||||
function isTargetFile(id: string): boolean {
|
||||
const relativePath = path.posix.relative(root, id);
|
||||
return options.targetFilePaths.some(pat => minimatch(relativePath, pat))
|
||||
}
|
||||
|
||||
function parseSearchIndexFileId(id: string): string | null {
|
||||
const noQuery = id.split('?')[0];
|
||||
if (noQuery.startsWith(searchIndexPrefix) && noQuery.endsWith(searchIndexSuffix)) {
|
||||
const filePath = id.slice(searchIndexPrefix.length).slice(0, -searchIndexSuffix.length);
|
||||
if (isTargetFile(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'generateSearchIndexVirtualModule',
|
||||
// hotUpdate hook を vite:vue よりもあとに実行したいため enforce: post
|
||||
enforce: 'post',
|
||||
|
||||
async resolveId(id) {
|
||||
if (id == allSearchIndexFile) {
|
||||
return '\0' + allSearchIndexFile;
|
||||
}
|
||||
|
||||
const searchIndexFilePath = parseSearchIndexFileId(id);
|
||||
if (searchIndexFilePath != null) {
|
||||
return id;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
async load(id) {
|
||||
if (id == '\0' + allSearchIndexFile) {
|
||||
const files = await Promise.all(options.targetFilePaths.map(async (filePathPattern) => await glob(filePathPattern))).then(paths => paths.flat());
|
||||
let generatedFile = '';
|
||||
let arrayElements = '';
|
||||
for (let file of files) {
|
||||
const normalizedRelative = normalizePath(file);
|
||||
const absoluteId = normalizePath(path.join(process.cwd(), normalizedRelative)) + searchIndexSuffix;
|
||||
const variableName = normalizedRelative.replace(/[\/.-]/g, '_');
|
||||
generatedFile += `import { searchIndexes as ${variableName} } from '${searchIndexPrefix}${absoluteId}';\n`;
|
||||
arrayElements += ` ...${variableName},\n`;
|
||||
}
|
||||
generatedFile += `export let searchIndexes = [\n${arrayElements}];\n`;
|
||||
return generatedFile;
|
||||
}
|
||||
|
||||
const searchIndexFilePath = parseSearchIndexFileId(id);
|
||||
if (searchIndexFilePath != null) {
|
||||
// call load to update the index file when the file is changed
|
||||
this.addWatchFile(searchIndexFilePath);
|
||||
|
||||
const code = await asigner.getOrLoad(searchIndexFilePath);
|
||||
return generateJavaScriptCode(collectFileMarkers(searchIndexFilePath, code));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
hotUpdate(this: { environment: { moduleGraph: EnvironmentModuleGraph } }, { file, modules }) {
|
||||
if (isTargetFile(file)) {
|
||||
const updateMods = options.modulesToHmrOnUpdate.map(id => this.environment.moduleGraph.getModuleById(path.posix.join(root, id))).filter(x => x != null);
|
||||
return [...modules, ...updateMods];
|
||||
}
|
||||
return modules;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -22,28 +22,29 @@
|
|||
"@misskey-dev/browser-image-resizer": "2024.1.0",
|
||||
"@phosphor-icons/web": "^2.0.3",
|
||||
"@rollup/plugin-json": "6.1.0",
|
||||
"@rollup/plugin-replace": "5.0.7",
|
||||
"@rollup/pluginutils": "5.1.3",
|
||||
"@rollup/plugin-replace": "6.0.2",
|
||||
"@rollup/pluginutils": "5.1.4",
|
||||
"@ruffle-rs/ruffle": "0.1.0-nightly.2024.10.15",
|
||||
"@sentry/vue": "9.14.0",
|
||||
"@syuilo/aiscript": "0.19.0",
|
||||
"@transfem-org/sfm-js": "0.24.6",
|
||||
"@twemoji/parser": "15.1.1",
|
||||
"@vitejs/plugin-vue": "5.2.0",
|
||||
"@vue/compiler-sfc": "3.5.12",
|
||||
"@vitejs/plugin-vue": "5.2.3",
|
||||
"@vue/compiler-sfc": "3.5.13",
|
||||
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15",
|
||||
"astring": "1.9.0",
|
||||
"broadcast-channel": "7.0.0",
|
||||
"broadcast-channel": "7.1.0",
|
||||
"buraha": "0.0.1",
|
||||
"canvas-confetti": "1.9.3",
|
||||
"chart.js": "4.4.6",
|
||||
"chart.js": "4.4.9",
|
||||
"chartjs-adapter-date-fns": "3.0.0",
|
||||
"chartjs-chart-matrix": "2.0.1",
|
||||
"chartjs-chart-matrix": "2.1.1",
|
||||
"chartjs-plugin-gradient": "0.6.1",
|
||||
"chartjs-plugin-zoom": "2.0.1",
|
||||
"chromatic": "11.18.1",
|
||||
"chartjs-plugin-zoom": "2.2.0",
|
||||
"chromatic": "11.28.2",
|
||||
"compare-versions": "6.1.1",
|
||||
"cropperjs": "2.0.0-rc.2",
|
||||
"date-fns": "2.30.0",
|
||||
"cropperjs": "2.0.0",
|
||||
"date-fns": "4.1.0",
|
||||
"estree-walker": "3.0.3",
|
||||
"eventemitter3": "5.0.1",
|
||||
"frontend-shared": "workspace:*",
|
||||
|
|
@ -52,6 +53,7 @@
|
|||
"is-file-animated": "1.0.2",
|
||||
"json5": "2.2.3",
|
||||
"katex": "0.16.10",
|
||||
"magic-string": "0.30.17",
|
||||
"matter-js": "0.20.0",
|
||||
"misskey-bubble-game": "workspace:*",
|
||||
"misskey-js": "workspace:*",
|
||||
|
|
@ -59,88 +61,90 @@
|
|||
"moment": "^2.30.1",
|
||||
"photoswipe": "5.4.4",
|
||||
"punycode.js": "2.3.1",
|
||||
"rollup": "4.26.0",
|
||||
"sanitize-html": "2.13.1",
|
||||
"sass": "1.79.3",
|
||||
"shiki": "1.22.2",
|
||||
"rollup": "4.40.0",
|
||||
"sanitize-html": "2.16.0",
|
||||
"sass": "1.87.0",
|
||||
"shiki": "3.3.0",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"textarea-caret": "3.1.0",
|
||||
"three": "0.169.0",
|
||||
"three": "0.176.0",
|
||||
"throttle-debounce": "5.0.2",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tsc-alias": "1.8.10",
|
||||
"tsc-alias": "1.8.15",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "5.6.3",
|
||||
"uuid": "10.0.0",
|
||||
"typescript": "5.8.3",
|
||||
"uuid": "11.1.0",
|
||||
"v-code-diff": "1.13.1",
|
||||
"vite": "5.4.11",
|
||||
"vue": "3.5.12",
|
||||
"vuedraggable": "next"
|
||||
"vite": "6.3.3",
|
||||
"vue": "3.5.13",
|
||||
"vuedraggable": "next",
|
||||
"wanakana": "5.3.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"cypress": "13.15.2"
|
||||
"cypress": "14.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/summaly": "5.1.0",
|
||||
"@storybook/addon-actions": "8.4.4",
|
||||
"@storybook/addon-essentials": "8.4.4",
|
||||
"@storybook/addon-interactions": "8.4.4",
|
||||
"@storybook/addon-links": "8.4.4",
|
||||
"@storybook/addon-mdx-gfm": "8.4.4",
|
||||
"@storybook/addon-storysource": "8.4.4",
|
||||
"@storybook/blocks": "8.4.4",
|
||||
"@storybook/components": "8.4.4",
|
||||
"@storybook/core-events": "8.4.4",
|
||||
"@storybook/manager-api": "8.4.4",
|
||||
"@storybook/preview-api": "8.4.4",
|
||||
"@storybook/react": "8.4.4",
|
||||
"@storybook/react-vite": "8.4.4",
|
||||
"@storybook/test": "8.4.4",
|
||||
"@storybook/theming": "8.4.4",
|
||||
"@storybook/types": "8.4.4",
|
||||
"@storybook/vue3": "8.4.4",
|
||||
"@storybook/vue3-vite": "8.4.4",
|
||||
"@misskey-dev/summaly": "5.2.1",
|
||||
"@storybook/addon-actions": "8.6.12",
|
||||
"@storybook/addon-essentials": "8.6.12",
|
||||
"@storybook/addon-interactions": "8.6.12",
|
||||
"@storybook/addon-links": "8.6.12",
|
||||
"@storybook/addon-mdx-gfm": "8.6.12",
|
||||
"@storybook/addon-storysource": "8.6.12",
|
||||
"@storybook/blocks": "8.6.12",
|
||||
"@storybook/components": "8.6.12",
|
||||
"@storybook/core-events": "8.6.12",
|
||||
"@storybook/manager-api": "8.6.12",
|
||||
"@storybook/preview-api": "8.6.12",
|
||||
"@storybook/react": "8.6.12",
|
||||
"@storybook/react-vite": "8.6.12",
|
||||
"@storybook/test": "8.6.12",
|
||||
"@storybook/theming": "8.6.12",
|
||||
"@storybook/types": "8.6.12",
|
||||
"@storybook/vue3": "8.6.12",
|
||||
"@storybook/vue3-vite": "8.6.12",
|
||||
"@testing-library/vue": "8.1.0",
|
||||
"@types/canvas-confetti": "^1.6.4",
|
||||
"@types/estree": "1.0.6",
|
||||
"@types/canvas-confetti": "1.9.0",
|
||||
"@types/estree": "1.0.7",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/matter-js": "0.19.7",
|
||||
"@types/matter-js": "0.19.8",
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "22.9.0",
|
||||
"@types/node": "22.15.2",
|
||||
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
||||
"@types/sanitize-html": "2.13.0",
|
||||
"@types/sanitize-html": "2.15.0",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@types/throttle-debounce": "5.0.2",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@types/ws": "8.5.13",
|
||||
"@typescript-eslint/eslint-plugin": "7.17.0",
|
||||
"@typescript-eslint/parser": "7.17.0",
|
||||
"@vitest/coverage-v8": "1.6.0",
|
||||
"@vue/runtime-core": "3.5.12",
|
||||
"acorn": "8.14.0",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.31.0",
|
||||
"@typescript-eslint/parser": "8.31.0",
|
||||
"@vitest/coverage-v8": "3.1.2",
|
||||
"@vue/compiler-core": "3.5.13",
|
||||
"@vue/runtime-core": "3.5.13",
|
||||
"acorn": "8.14.1",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-vue": "9.31.0",
|
||||
"fast-glob": "3.3.2",
|
||||
"happy-dom": "10.0.3",
|
||||
"eslint-plugin-vue": "10.0.0",
|
||||
"fast-glob": "3.3.3",
|
||||
"happy-dom": "17.4.4",
|
||||
"intersection-observer": "0.12.2",
|
||||
"micromatch": "4.0.8",
|
||||
"msw": "2.6.4",
|
||||
"minimatch": "10.0.1",
|
||||
"msw": "2.7.5",
|
||||
"msw-storybook-addon": "2.0.4",
|
||||
"nodemon": "3.1.7",
|
||||
"prettier": "3.3.3",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"nodemon": "3.1.10",
|
||||
"prettier": "3.5.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"seedrandom": "3.0.5",
|
||||
"start-server-and-test": "2.0.8",
|
||||
"storybook": "8.4.4",
|
||||
"start-server-and-test": "2.0.11",
|
||||
"storybook": "8.6.12",
|
||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vitest": "1.6.0",
|
||||
"vitest-fetch-mock": "0.2.2",
|
||||
"vue-component-type-helpers": "2.1.10",
|
||||
"vue-eslint-parser": "9.4.3",
|
||||
"vue-tsc": "2.1.10"
|
||||
"vitest": "3.1.2",
|
||||
"vitest-fetch-mock": "0.4.5",
|
||||
"vue-component-type-helpers": "2.2.10",
|
||||
"vue-eslint-parser": "10.1.3",
|
||||
"vue-tsc": "2.2.10"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ import '@/style.scss';
|
|||
import { mainBoot } from '@/boot/main-boot.js';
|
||||
import { subBoot } from '@/boot/sub-boot.js';
|
||||
|
||||
const subBootPaths = ['/share', '/auth', '/miauth', '/oauth', '/signup-complete'];
|
||||
const subBootPaths = ['/share', '/auth', '/miauth', '/oauth', '/signup-complete', '/install-extensions'];
|
||||
|
||||
if (subBootPaths.some(i => location.pathname === i || location.pathname.startsWith(i + '/'))) {
|
||||
if (subBootPaths.some(i => window.location.pathname === i || window.location.pathname.startsWith(i + '/'))) {
|
||||
subBoot();
|
||||
} else {
|
||||
mainBoot();
|
||||
|
|
|
|||
|
|
@ -1,393 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { defineAsyncComponent, reactive, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { apiUrl } from '@@/js/config.js';
|
||||
import type { MenuItem, MenuButton } from '@/types/menu.js';
|
||||
import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { del, get, set } from '@/scripts/idb-proxy.js';
|
||||
import { waiting, popup, popupMenu, success, alert } from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { unisonReload, reloadChannel } from '@/scripts/unison-reload.js';
|
||||
|
||||
// TODO: 他のタブと永続化されたstateを同期
|
||||
|
||||
type Account = Misskey.entities.MeDetailed & { token: string };
|
||||
|
||||
const accountData = miLocalStorage.getItem('account');
|
||||
|
||||
// TODO: 外部からはreadonlyに
|
||||
export const $i = accountData ? reactive(JSON.parse(accountData) as Account) : null;
|
||||
|
||||
export const iAmModerator = $i != null && ($i.isAdmin === true || $i.isModerator === true);
|
||||
export const iAmAdmin = $i != null && $i.isAdmin;
|
||||
|
||||
export function signinRequired() {
|
||||
if ($i == null) throw new Error('signin required');
|
||||
return $i;
|
||||
}
|
||||
|
||||
export let notesCount = $i == null ? 0 : $i.notesCount;
|
||||
export function incNotesCount() {
|
||||
notesCount++;
|
||||
}
|
||||
|
||||
export async function signout() {
|
||||
if (!$i) return;
|
||||
|
||||
waiting();
|
||||
miLocalStorage.removeItem('account');
|
||||
await removeAccount($i.id);
|
||||
document.cookie = `token=; path=/; max-age=0${ location.protocol === 'https:' ? '; Secure' : ''}`;
|
||||
const accounts = await getAccounts();
|
||||
|
||||
//#region Remove service worker registration
|
||||
try {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const push = await registration.pushManager.getSubscription();
|
||||
if (push) {
|
||||
await window.fetch(`${apiUrl}/sw/unregister`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
i: $i.token,
|
||||
endpoint: push.endpoint,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (accounts.length === 0) {
|
||||
await navigator.serviceWorker.getRegistrations()
|
||||
.then(registrations => {
|
||||
return Promise.all(registrations.map(registration => registration.unregister()));
|
||||
});
|
||||
}
|
||||
} catch (err) {}
|
||||
//#endregion
|
||||
|
||||
if (accounts.length > 0) login(accounts[0].token);
|
||||
else unisonReload('/');
|
||||
}
|
||||
|
||||
export async function getAccounts(): Promise<{ id: Account['id'], token: Account['token'] }[]> {
|
||||
return (await get('accounts')) || [];
|
||||
}
|
||||
|
||||
export async function addAccount(id: Account['id'], token: Account['token']) {
|
||||
const accounts = await getAccounts();
|
||||
if (!accounts.some(x => x.id === id)) {
|
||||
await set('accounts', accounts.concat([{ id, token }]));
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeAccount(idOrToken: Account['id']) {
|
||||
const accounts = await getAccounts();
|
||||
const i = accounts.findIndex(x => x.id === idOrToken || x.token === idOrToken);
|
||||
if (i !== -1) accounts.splice(i, 1);
|
||||
|
||||
if (accounts.length > 0) {
|
||||
await set('accounts', accounts);
|
||||
} else {
|
||||
await del('accounts');
|
||||
}
|
||||
}
|
||||
|
||||
function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise<Account> {
|
||||
document.cookie = `token=; path=/; max-age=0${ location.protocol === 'https:' ? '; Secure' : ''}`;
|
||||
document.cookie = `token=${token}; path=/queue; max-age=86400${ location.protocol === 'https:' ? '; SameSite=Strict; Secure' : ''}`; // bull dashboardの認証とかで使う
|
||||
|
||||
return new Promise((done, fail) => {
|
||||
window.fetch(`${apiUrl}/i`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
i: token,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(res => new Promise<Account | { error: Record<string, any> }>((done2, fail2) => {
|
||||
if (res.status >= 500 && res.status < 600) {
|
||||
// サーバーエラー(5xx)の場合をrejectとする
|
||||
// (認証エラーなど4xxはresolve)
|
||||
return fail2(res);
|
||||
}
|
||||
res.json().then(done2, fail2);
|
||||
}))
|
||||
.then(async res => {
|
||||
if ('error' in res) {
|
||||
if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') {
|
||||
// SUSPENDED
|
||||
if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
|
||||
await showSuspendedDialog();
|
||||
}
|
||||
} else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') {
|
||||
// USER_IS_DELETED
|
||||
// アカウントが削除されている
|
||||
if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
|
||||
await alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.accountDeleted,
|
||||
text: i18n.ts.accountDeletedDescription,
|
||||
});
|
||||
}
|
||||
} else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') {
|
||||
// AUTHENTICATION_FAILED
|
||||
// トークンが無効化されていたりアカウントが削除されたりしている
|
||||
if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
|
||||
await alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.tokenRevoked,
|
||||
text: i18n.ts.tokenRevokedDescription,
|
||||
});
|
||||
}
|
||||
} else if (res.error.id === 'd5826d14-3982-4d2e-8011-b9e9f02499ef') {
|
||||
// rate limited
|
||||
const timeToWait = res.error.info?.resetMs ?? 1000;
|
||||
window.setTimeout(() => {
|
||||
fetchAccount(token, id, forceShowDialog).then(done, fail);
|
||||
}, timeToWait);
|
||||
return;
|
||||
} else {
|
||||
await alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.failedToFetchAccountInformation,
|
||||
text: JSON.stringify(res.error),
|
||||
});
|
||||
}
|
||||
|
||||
// rejectかつ理由がtrueの場合、削除対象であることを示す
|
||||
fail(true);
|
||||
} else {
|
||||
(res as Account).token = token;
|
||||
done(res as Account);
|
||||
}
|
||||
})
|
||||
.catch(fail);
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAccount(accountData: Account) {
|
||||
if (!$i) return;
|
||||
for (const key of Object.keys($i)) {
|
||||
delete $i[key];
|
||||
}
|
||||
for (const [key, value] of Object.entries(accountData)) {
|
||||
$i[key] = value;
|
||||
}
|
||||
miLocalStorage.setItem('account', JSON.stringify($i));
|
||||
}
|
||||
|
||||
export function updateAccountPartial(accountData: Partial<Account>) {
|
||||
if (!$i) return;
|
||||
for (const [key, value] of Object.entries(accountData)) {
|
||||
$i[key] = value;
|
||||
}
|
||||
miLocalStorage.setItem('account', JSON.stringify($i));
|
||||
}
|
||||
|
||||
export async function refreshAccount() {
|
||||
if (!$i) return;
|
||||
return fetchAccount($i.token, $i.id)
|
||||
.then(updateAccount, reason => {
|
||||
if (reason === true) return signout();
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
export async function login(token: Account['token'], redirect?: string) {
|
||||
const showing = ref(true);
|
||||
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), {
|
||||
success: false,
|
||||
showing: showing,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
if (_DEV_) console.log('logging as token ', token);
|
||||
const me = await fetchAccount(token, undefined, true)
|
||||
.catch(reason => {
|
||||
if (reason === true) {
|
||||
// 削除対象の場合
|
||||
removeAccount(token);
|
||||
}
|
||||
|
||||
showing.value = false;
|
||||
throw reason;
|
||||
});
|
||||
miLocalStorage.setItem('account', JSON.stringify(me));
|
||||
await addAccount(me.id, token);
|
||||
|
||||
if (redirect) {
|
||||
// 他のタブは再読み込みするだけ
|
||||
reloadChannel.postMessage(null);
|
||||
// このページはredirectで指定された先に移動
|
||||
location.href = redirect;
|
||||
return;
|
||||
}
|
||||
|
||||
unisonReload();
|
||||
}
|
||||
|
||||
export async function openAccountMenu(opts: {
|
||||
includeCurrentAccount?: boolean;
|
||||
withExtraOperation: boolean;
|
||||
active?: Misskey.entities.UserDetailed['id'];
|
||||
onChoose?: (account: Misskey.entities.UserDetailed) => void;
|
||||
}, ev: MouseEvent) {
|
||||
if (!$i) return;
|
||||
|
||||
async function switchAccount(account: Misskey.entities.UserDetailed) {
|
||||
const storedAccounts = await getAccounts();
|
||||
const found = storedAccounts.find(x => x.id === account.id);
|
||||
if (found == null) return;
|
||||
switchAccountWithToken(found.token);
|
||||
}
|
||||
|
||||
function switchAccountWithToken(token: string) {
|
||||
login(token);
|
||||
}
|
||||
|
||||
const storedAccounts = await getAccounts().then(accounts => accounts.filter(x => x.id !== $i.id));
|
||||
const accountsPromise = misskeyApi('users/show', { userIds: storedAccounts.map(x => x.id) });
|
||||
|
||||
function createItem(account: Misskey.entities.UserDetailed) {
|
||||
return {
|
||||
type: 'user' as const,
|
||||
user: account,
|
||||
active: opts.active != null ? opts.active === account.id : false,
|
||||
action: () => {
|
||||
if (opts.onChoose) {
|
||||
opts.onChoose(account);
|
||||
} else {
|
||||
switchAccount(account);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const accountItemPromises = storedAccounts.map(a => new Promise<ReturnType<typeof createItem> | MenuButton>(res => {
|
||||
accountsPromise.then(accounts => {
|
||||
const account = accounts.find(x => x.id === a.id);
|
||||
if (account == null) return res({
|
||||
type: 'button' as const,
|
||||
text: a.id,
|
||||
action: () => {
|
||||
switchAccountWithToken(a.token);
|
||||
},
|
||||
});
|
||||
|
||||
res(createItem(account));
|
||||
});
|
||||
}));
|
||||
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
if (opts.withExtraOperation) {
|
||||
menuItems.push({
|
||||
type: 'link',
|
||||
text: i18n.ts.profile,
|
||||
to: `/@${$i.username}`,
|
||||
avatar: $i,
|
||||
}, {
|
||||
type: 'divider',
|
||||
});
|
||||
|
||||
if (opts.includeCurrentAccount) {
|
||||
menuItems.push(createItem($i));
|
||||
}
|
||||
|
||||
menuItems.push(...accountItemPromises);
|
||||
|
||||
menuItems.push({
|
||||
type: 'parent',
|
||||
icon: 'ti ti-plus',
|
||||
text: i18n.ts.addAccount,
|
||||
children: [{
|
||||
text: i18n.ts.existingAccount,
|
||||
action: () => {
|
||||
getAccountWithSigninDialog().then(res => {
|
||||
if (res != null) {
|
||||
success();
|
||||
}
|
||||
});
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts.createAccount,
|
||||
action: () => {
|
||||
getAccountWithSignupDialog().then(res => {
|
||||
if (res != null) {
|
||||
switchAccountWithToken(res.token);
|
||||
}
|
||||
});
|
||||
},
|
||||
}],
|
||||
}, {
|
||||
type: 'link',
|
||||
icon: 'ti ti-users',
|
||||
text: i18n.ts.manageAccounts,
|
||||
to: '/settings/accounts',
|
||||
}, {
|
||||
type: 'button' as const,
|
||||
icon: 'ph-power ph-bold ph-lg',
|
||||
text: i18n.ts.logout,
|
||||
action: () => { signout(); },
|
||||
});
|
||||
} else {
|
||||
if (opts.includeCurrentAccount) {
|
||||
menuItems.push(createItem($i));
|
||||
}
|
||||
|
||||
menuItems.push(...accountItemPromises);
|
||||
}
|
||||
|
||||
popupMenu(menuItems, ev.currentTarget ?? ev.target, {
|
||||
align: 'left',
|
||||
});
|
||||
}
|
||||
|
||||
export function getAccountWithSigninDialog(): Promise<{ id: string, token: string } | null> {
|
||||
return new Promise((resolve) => {
|
||||
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
|
||||
done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => {
|
||||
await addAccount(res.id, res.i);
|
||||
resolve({ id: res.id, token: res.i });
|
||||
},
|
||||
cancelled: () => {
|
||||
resolve(null);
|
||||
},
|
||||
closed: () => {
|
||||
dispose();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function getAccountWithSignupDialog(): Promise<{ id: string, token: string } | null> {
|
||||
return new Promise((resolve) => {
|
||||
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
|
||||
done: async (res: Misskey.entities.SignupResponse) => {
|
||||
await addAccount(res.id, res.token);
|
||||
resolve({ id: res.id, token: res.token });
|
||||
},
|
||||
cancelled: () => {
|
||||
resolve(null);
|
||||
},
|
||||
closed: () => {
|
||||
dispose();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (_DEV_) {
|
||||
(window as any).$i = $i;
|
||||
}
|
||||
363
packages/frontend/src/accounts.ts
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { defineAsyncComponent, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { apiUrl, host } from '@@/js/config.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import { showSuspendedDialog } from '@/utility/show-suspended-dialog.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { waiting, popup, popupMenu, success, alert } from '@/os.js';
|
||||
import { unisonReload, reloadChannel } from '@/utility/unison-reload.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { store } from '@/store.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { signout } from '@/signout.js';
|
||||
|
||||
type AccountWithToken = Misskey.entities.MeDetailed & { token: string };
|
||||
|
||||
export async function getAccounts(): Promise<{
|
||||
host: string;
|
||||
id: Misskey.entities.User['id'];
|
||||
username: Misskey.entities.User['username'];
|
||||
user?: Misskey.entities.User | null;
|
||||
token: string | null;
|
||||
}[]> {
|
||||
const tokens = store.s.accountTokens;
|
||||
const accountInfos = store.s.accountInfos;
|
||||
const accounts = prefer.s.accounts;
|
||||
return accounts.map(([host, user]) => ({
|
||||
host,
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
user: accountInfos[host + '/' + user.id],
|
||||
token: tokens[host + '/' + user.id] ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async function addAccount(host: string, user: Misskey.entities.User, token: AccountWithToken['token']) {
|
||||
if (!prefer.s.accounts.some(x => x[0] === host && x[1].id === user.id)) {
|
||||
store.set('accountTokens', { ...store.s.accountTokens, [host + '/' + user.id]: token });
|
||||
store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + user.id]: user });
|
||||
prefer.commit('accounts', [...prefer.s.accounts, [host, { id: user.id, username: user.username }]]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeAccount(host: string, id: AccountWithToken['id']) {
|
||||
const tokens = JSON.parse(JSON.stringify(store.s.accountTokens));
|
||||
delete tokens[host + '/' + id];
|
||||
store.set('accountTokens', tokens);
|
||||
const accountInfos = JSON.parse(JSON.stringify(store.s.accountInfos));
|
||||
delete accountInfos[host + '/' + id];
|
||||
store.set('accountInfos', accountInfos);
|
||||
|
||||
prefer.commit('accounts', prefer.s.accounts.filter(x => x[0] !== host || x[1].id !== id));
|
||||
}
|
||||
|
||||
const isAccountDeleted = Symbol('isAccountDeleted');
|
||||
|
||||
function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise<Misskey.entities.MeDetailed> {
|
||||
return new Promise((done, fail) => {
|
||||
window.fetch(`${apiUrl}/i`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
i: token,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(res => new Promise<Misskey.entities.MeDetailed | { error: Record<string, any> }>((done2, fail2) => {
|
||||
if (res.status >= 500 && res.status < 600) {
|
||||
// サーバーエラー(5xx)の場合をrejectとする
|
||||
// (認証エラーなど4xxはresolve)
|
||||
return fail2(res);
|
||||
}
|
||||
res.json().then(done2, fail2);
|
||||
}))
|
||||
.then(async res => {
|
||||
if ('error' in res) {
|
||||
if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') {
|
||||
// SUSPENDED
|
||||
if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
|
||||
await showSuspendedDialog();
|
||||
}
|
||||
} else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') {
|
||||
// USER_IS_DELETED
|
||||
// アカウントが削除されている
|
||||
if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
|
||||
await alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.accountDeleted,
|
||||
text: i18n.ts.accountDeletedDescription,
|
||||
});
|
||||
}
|
||||
} else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') {
|
||||
// AUTHENTICATION_FAILED
|
||||
// トークンが無効化されていたりアカウントが削除されたりしている
|
||||
if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
|
||||
await alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.tokenRevoked,
|
||||
text: i18n.ts.tokenRevokedDescription,
|
||||
});
|
||||
}
|
||||
} else if (res.error.id === 'd5826d14-3982-4d2e-8011-b9e9f02499ef') {
|
||||
// rate limited
|
||||
const timeToWait = res.error.info?.resetMs ?? 1000;
|
||||
window.setTimeout(() => {
|
||||
fetchAccount(token, id, forceShowDialog).then(done, fail);
|
||||
}, timeToWait);
|
||||
return;
|
||||
} else {
|
||||
await alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.failedToFetchAccountInformation,
|
||||
text: JSON.stringify(res.error),
|
||||
});
|
||||
}
|
||||
|
||||
fail(isAccountDeleted);
|
||||
} else {
|
||||
done(res);
|
||||
}
|
||||
})
|
||||
.catch(fail);
|
||||
});
|
||||
}
|
||||
|
||||
export function updateCurrentAccount(accountData: Misskey.entities.MeDetailed) {
|
||||
if (!$i) return;
|
||||
const token = $i.token;
|
||||
for (const key of Object.keys($i)) {
|
||||
delete $i[key];
|
||||
}
|
||||
for (const [key, value] of Object.entries(accountData)) {
|
||||
$i[key] = value;
|
||||
}
|
||||
store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + $i.id]: $i });
|
||||
$i.token = token;
|
||||
miLocalStorage.setItem('account', JSON.stringify($i));
|
||||
}
|
||||
|
||||
export function updateCurrentAccountPartial(accountData: Partial<Misskey.entities.MeDetailed>) {
|
||||
if (!$i) return;
|
||||
for (const [key, value] of Object.entries(accountData)) {
|
||||
$i[key] = value;
|
||||
}
|
||||
|
||||
store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + $i.id]: $i });
|
||||
|
||||
miLocalStorage.setItem('account', JSON.stringify($i));
|
||||
}
|
||||
|
||||
export async function refreshCurrentAccount() {
|
||||
if (!$i) return;
|
||||
return fetchAccount($i.token, $i.id).then(updateCurrentAccount).catch(reason => {
|
||||
if (reason === isAccountDeleted) {
|
||||
removeAccount(host, $i.id);
|
||||
if (Object.keys(store.s.accountTokens).length > 0) {
|
||||
login(Object.values(store.s.accountTokens)[0]);
|
||||
} else {
|
||||
signout();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function login(token: AccountWithToken['token'], redirect?: string) {
|
||||
const showing = ref(true);
|
||||
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), {
|
||||
success: false,
|
||||
showing: showing,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
|
||||
const me = await fetchAccount(token, undefined, true).catch(reason => {
|
||||
showing.value = false;
|
||||
throw reason;
|
||||
});
|
||||
|
||||
miLocalStorage.setItem('account', JSON.stringify({
|
||||
...me,
|
||||
token,
|
||||
}));
|
||||
|
||||
await addAccount(host, me, token);
|
||||
|
||||
if (redirect) {
|
||||
// 他のタブは再読み込みするだけ
|
||||
reloadChannel.postMessage(null);
|
||||
// このページはredirectで指定された先に移動
|
||||
window.location.href = redirect;
|
||||
return;
|
||||
}
|
||||
|
||||
unisonReload();
|
||||
}
|
||||
|
||||
export async function switchAccount(host: string, id: string) {
|
||||
const token = store.s.accountTokens[host + '/' + id];
|
||||
if (token) {
|
||||
login(token);
|
||||
} else {
|
||||
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
|
||||
done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => {
|
||||
store.set('accountTokens', { ...store.s.accountTokens, [host + '/' + res.id]: res.i });
|
||||
login(res.i);
|
||||
},
|
||||
closed: () => {
|
||||
dispose();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function openAccountMenu(opts: {
|
||||
includeCurrentAccount?: boolean;
|
||||
withExtraOperation: boolean;
|
||||
active?: Misskey.entities.User['id'];
|
||||
onChoose?: (account: Misskey.entities.User) => void;
|
||||
}, ev: MouseEvent) {
|
||||
if (!$i) return;
|
||||
|
||||
function createItem(host: string, id: Misskey.entities.User['id'], username: Misskey.entities.User['username'], account: Misskey.entities.User | null | undefined, token: string): MenuItem {
|
||||
if (account) {
|
||||
return {
|
||||
type: 'user' as const,
|
||||
user: account,
|
||||
active: opts.active != null ? opts.active === id : false,
|
||||
action: async () => {
|
||||
if (opts.onChoose) {
|
||||
opts.onChoose(account);
|
||||
} else {
|
||||
switchAccount(host, id);
|
||||
}
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: 'button' as const,
|
||||
text: username,
|
||||
active: opts.active != null ? opts.active === id : false,
|
||||
action: async () => {
|
||||
if (opts.onChoose) {
|
||||
fetchAccount(token, id).then(account => {
|
||||
opts.onChoose(account);
|
||||
});
|
||||
} else {
|
||||
switchAccount(host, id);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
// TODO: $iのホストも比較したいけど通常null
|
||||
const accountItems = (await getAccounts().then(accounts => accounts.filter(x => x.id !== $i.id))).map(a => createItem(a.host, a.id, a.username, a.user, a.token));
|
||||
|
||||
if (opts.withExtraOperation) {
|
||||
menuItems.push({
|
||||
type: 'link',
|
||||
text: i18n.ts.profile,
|
||||
to: `/@${$i.username}`,
|
||||
avatar: $i,
|
||||
}, {
|
||||
type: 'divider',
|
||||
});
|
||||
|
||||
if (opts.includeCurrentAccount) {
|
||||
menuItems.push(createItem(host, $i.id, $i.username, $i, $i.token));
|
||||
}
|
||||
|
||||
menuItems.push(...accountItems);
|
||||
|
||||
menuItems.push({
|
||||
type: 'parent',
|
||||
icon: 'ti ti-plus',
|
||||
text: i18n.ts.addAccount,
|
||||
children: [{
|
||||
text: i18n.ts.existingAccount,
|
||||
action: () => {
|
||||
getAccountWithSigninDialog().then(res => {
|
||||
if (res != null) {
|
||||
success();
|
||||
}
|
||||
});
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts.createAccount,
|
||||
action: () => {
|
||||
getAccountWithSignupDialog().then(res => {
|
||||
if (res != null) {
|
||||
switchAccount(host, res.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
}],
|
||||
}, {
|
||||
type: 'link',
|
||||
icon: 'ti ti-users',
|
||||
text: i18n.ts.manageAccounts,
|
||||
to: '/settings/accounts',
|
||||
}, {
|
||||
type: 'button' as const,
|
||||
icon: 'ph-power ph-bold ph-lg',
|
||||
text: i18n.ts.logout,
|
||||
action: () => { signout(); },
|
||||
});
|
||||
} else {
|
||||
if (opts.includeCurrentAccount) {
|
||||
menuItems.push(createItem(host, $i.id, $i.username, $i, $i.token));
|
||||
}
|
||||
|
||||
menuItems.push(...accountItems);
|
||||
}
|
||||
|
||||
popupMenu(menuItems, ev.currentTarget ?? ev.target, {
|
||||
align: 'left',
|
||||
});
|
||||
}
|
||||
|
||||
export function getAccountWithSigninDialog(): Promise<{ id: string, token: string } | null> {
|
||||
return new Promise((resolve) => {
|
||||
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
|
||||
done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => {
|
||||
const user = await fetchAccount(res.i, res.id, true);
|
||||
await addAccount(host, user, res.i);
|
||||
resolve({ id: res.id, token: res.i });
|
||||
},
|
||||
cancelled: () => {
|
||||
resolve(null);
|
||||
},
|
||||
closed: () => {
|
||||
dispose();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function getAccountWithSignupDialog(): Promise<{ id: string, token: string } | null> {
|
||||
return new Promise((resolve) => {
|
||||
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
|
||||
done: async (res: Misskey.entities.SignupResponse) => {
|
||||
const user = JSON.parse(JSON.stringify(res));
|
||||
delete user.token;
|
||||
await addAccount(host, user, res.token);
|
||||
resolve({ id: res.id, token: res.token });
|
||||
},
|
||||
cancelled: () => {
|
||||
resolve(null);
|
||||
},
|
||||
closed: () => {
|
||||
dispose();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -8,8 +8,8 @@ import * as Misskey from 'misskey-js';
|
|||
import { url, lang } from '@@/js/config.js';
|
||||
import { assertStringAndIsIn } from './common.js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { $i } from '@/account.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { customEmojis } from '@/custom-emojis.js';
|
||||
|
||||
|
|
@ -68,7 +68,7 @@ export function createAiScriptEnv(opts: { storageKey: string, token?: string })
|
|||
}),
|
||||
'Mk:api': values.FN_NATIVE(async ([ep, param, token]) => {
|
||||
utils.assertString(ep);
|
||||
if (ep.value.includes('://')) {
|
||||
if (ep.value.includes('://') || ep.value.includes('..')) {
|
||||
throw new errors.AiScriptRuntimeError('invalid endpoint');
|
||||
}
|
||||
if (token) {
|
||||
|
|
@ -76,7 +76,7 @@ export function createAiScriptEnv(opts: { storageKey: string, token?: string })
|
|||
// バグがあればundefinedもあり得るため念のため
|
||||
if (typeof token.value !== 'string') throw new Error('invalid token');
|
||||
}
|
||||
const actualToken: string|null = token?.value ?? opts.token ?? null;
|
||||
const actualToken: string | null = token?.value ?? opts.token ?? null;
|
||||
if (param == null) {
|
||||
throw new errors.AiScriptRuntimeError('expected param');
|
||||
}
|
||||
|
|
@ -3,7 +3,8 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { errors, utils, type values } from '@syuilo/aiscript';
|
||||
import { errors, utils } from '@syuilo/aiscript';
|
||||
import type { values } from '@syuilo/aiscript';
|
||||
|
||||
export function assertStringAndIsIn<A extends readonly string[]>(value: values.Value | undefined, expects: A): asserts value is values.VStr & { value: A[number] } {
|
||||
utils.assertString(value);
|
||||
|
|
@ -5,7 +5,8 @@
|
|||
|
||||
import { utils, values } from '@syuilo/aiscript';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { ref, Ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { assertStringAndIsIn } from './common.js';
|
||||
|
||||
|
|
@ -3,29 +3,32 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { computed, watch, version as vueVersion, App } from 'vue';
|
||||
import { watch, version as vueVersion } from 'vue';
|
||||
import { compareVersions } from 'compare-versions';
|
||||
import { version, lang, langsVersion, updateLocale, locale } from '@@/js/config.js';
|
||||
import { version, lang, langsVersion, updateLocale, locale, apiUrl } from '@@/js/config.js';
|
||||
import defaultLightTheme from '@@/themes/l-light.json5';
|
||||
import defaultDarkTheme from '@@/themes/d-green-lime.json5';
|
||||
import type { App } from 'vue';
|
||||
import widgets from '@/widgets/index.js';
|
||||
import directives from '@/directives/index.js';
|
||||
import components from '@/components/index.js';
|
||||
import { applyTheme } from '@/scripts/theme.js';
|
||||
import { isDeviceDarkmode } from '@/scripts/is-device-darkmode.js';
|
||||
import { applyTheme } from '@/theme.js';
|
||||
import { isDeviceDarkmode } from '@/utility/is-device-darkmode.js';
|
||||
import { updateI18n, i18n } from '@/i18n.js';
|
||||
import { $i, refreshAccount, login } from '@/account.js';
|
||||
import { defaultStore, ColdDeviceStorage } from '@/store.js';
|
||||
import { refreshCurrentAccount, login } from '@/accounts.js';
|
||||
import { store } from '@/store.js';
|
||||
import { fetchInstance, instance } from '@/instance.js';
|
||||
import { deviceKind, updateDeviceKind } from '@/scripts/device-kind.js';
|
||||
import { reloadChannel } from '@/scripts/unison-reload.js';
|
||||
import { getUrlWithoutLoginId } from '@/scripts/login-id.js';
|
||||
import { getAccountFromId } from '@/scripts/get-account-from-id.js';
|
||||
import { deviceKind, updateDeviceKind } from '@/utility/device-kind.js';
|
||||
import { reloadChannel } from '@/utility/unison-reload.js';
|
||||
import { getUrlWithoutLoginId } from '@/utility/login-id.js';
|
||||
import { getAccountFromId } from '@/utility/get-account-from-id.js';
|
||||
import { deckStore } from '@/ui/deck/deck-store.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { fetchCustomEmojis } from '@/custom-emojis.js';
|
||||
import { setupRouter } from '@/router/main.js';
|
||||
import { createMainRouter } from '@/router/definition.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { $i } from '@/i.js';
|
||||
|
||||
export async function common(createVue: () => App<Element>) {
|
||||
export async function common(createVue: () => Promise<App<Element>>) {
|
||||
console.info(`Sharkey v${version}`);
|
||||
|
||||
if (_DEV_) {
|
||||
|
|
@ -33,11 +36,6 @@ export async function common(createVue: () => App<Element>) {
|
|||
|
||||
console.info(`vue ${vueVersion}`);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).$i = $i;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).$store = defaultStore;
|
||||
|
||||
window.addEventListener('error', event => {
|
||||
console.error(event);
|
||||
/*
|
||||
|
|
@ -97,32 +95,32 @@ export async function common(createVue: () => App<Element>) {
|
|||
//#endregion
|
||||
|
||||
// タッチデバイスでCSSの:hoverを機能させる
|
||||
document.addEventListener('touchend', () => {}, { passive: true });
|
||||
window.document.addEventListener('touchend', () => {}, { passive: true });
|
||||
|
||||
// URLに#pswpを含む場合は取り除く
|
||||
if (location.hash === '#pswp') {
|
||||
history.replaceState(null, '', location.href.replace('#pswp', ''));
|
||||
if (window.location.hash === '#pswp') {
|
||||
window.history.replaceState(null, '', window.location.href.replace('#pswp', ''));
|
||||
}
|
||||
|
||||
// 一斉リロード
|
||||
reloadChannel.addEventListener('message', path => {
|
||||
if (path !== null) location.href = path;
|
||||
else location.reload();
|
||||
if (path !== null) window.location.href = path;
|
||||
else window.location.reload();
|
||||
});
|
||||
|
||||
// If mobile, insert the viewport meta tag
|
||||
if (['smartphone', 'tablet'].includes(deviceKind)) {
|
||||
const viewport = document.getElementsByName('viewport').item(0);
|
||||
const viewport = window.document.getElementsByName('viewport').item(0);
|
||||
viewport.setAttribute('content',
|
||||
`${viewport.getAttribute('content')}, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover`);
|
||||
}
|
||||
|
||||
//#region Set lang attr
|
||||
const html = document.documentElement;
|
||||
const html = window.document.documentElement;
|
||||
html.setAttribute('lang', lang);
|
||||
//#endregion
|
||||
|
||||
await defaultStore.ready;
|
||||
await store.ready;
|
||||
await deckStore.ready;
|
||||
|
||||
const fetchInstanceMetaPromise = fetchInstance();
|
||||
|
|
@ -132,11 +130,11 @@ export async function common(createVue: () => App<Element>) {
|
|||
});
|
||||
|
||||
//#region loginId
|
||||
const params = new URLSearchParams(location.search);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const loginId = params.get('loginId');
|
||||
|
||||
if (loginId) {
|
||||
const target = getUrlWithoutLoginId(location.href);
|
||||
const target = getUrlWithoutLoginId(window.location.href);
|
||||
|
||||
if (!$i || $i.id !== loginId) {
|
||||
const account = await getAccountFromId(loginId);
|
||||
|
|
@ -145,71 +143,78 @@ export async function common(createVue: () => App<Element>) {
|
|||
}
|
||||
}
|
||||
|
||||
history.replaceState({ misskey: 'loginId' }, '', target);
|
||||
window.history.replaceState({ misskey: 'loginId' }, '', target);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
// NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため)
|
||||
watch(defaultStore.reactiveState.darkMode, (darkMode) => {
|
||||
applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme'));
|
||||
watch(store.r.darkMode, (darkMode) => {
|
||||
applyTheme(darkMode
|
||||
? (prefer.s.darkTheme ?? defaultDarkTheme)
|
||||
: (prefer.s.lightTheme ?? defaultLightTheme),
|
||||
);
|
||||
}, { immediate: miLocalStorage.getItem('theme') == null });
|
||||
|
||||
document.documentElement.dataset.colorScheme = defaultStore.state.darkMode ? 'dark' : 'light';
|
||||
window.document.documentElement.dataset.colorScheme = store.s.darkMode ? 'dark' : 'light';
|
||||
|
||||
const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme'));
|
||||
const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme'));
|
||||
const darkTheme = prefer.model('darkTheme');
|
||||
const lightTheme = prefer.model('lightTheme');
|
||||
|
||||
watch(darkTheme, (theme) => {
|
||||
if (defaultStore.state.darkMode) {
|
||||
applyTheme(theme);
|
||||
if (store.s.darkMode) {
|
||||
applyTheme(theme ?? defaultDarkTheme);
|
||||
}
|
||||
});
|
||||
|
||||
watch(lightTheme, (theme) => {
|
||||
if (!defaultStore.state.darkMode) {
|
||||
applyTheme(theme);
|
||||
if (!store.s.darkMode) {
|
||||
applyTheme(theme ?? defaultLightTheme);
|
||||
}
|
||||
});
|
||||
|
||||
//#region Sync dark mode
|
||||
if (ColdDeviceStorage.get('syncDeviceDarkMode')) {
|
||||
defaultStore.set('darkMode', isDeviceDarkmode());
|
||||
if (prefer.s.syncDeviceDarkMode) {
|
||||
store.set('darkMode', isDeviceDarkmode());
|
||||
}
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (mql) => {
|
||||
if (ColdDeviceStorage.get('syncDeviceDarkMode')) {
|
||||
defaultStore.set('darkMode', mql.matches);
|
||||
if (prefer.s.syncDeviceDarkMode) {
|
||||
store.set('darkMode', mql.matches);
|
||||
}
|
||||
});
|
||||
//#endregion
|
||||
|
||||
if (prefer.s.darkTheme && store.s.darkMode) {
|
||||
if (miLocalStorage.getItem('themeId') !== prefer.s.darkTheme.id) applyTheme(prefer.s.darkTheme);
|
||||
} else if (prefer.s.lightTheme && !store.s.darkMode) {
|
||||
if (miLocalStorage.getItem('themeId') !== prefer.s.lightTheme.id) applyTheme(prefer.s.lightTheme);
|
||||
}
|
||||
|
||||
fetchInstanceMetaPromise.then(() => {
|
||||
if (defaultStore.state.themeInitial) {
|
||||
if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON.parse(instance.defaultLightTheme));
|
||||
if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON.parse(instance.defaultDarkTheme));
|
||||
defaultStore.set('themeInitial', false);
|
||||
}
|
||||
// TODO: instance.defaultLightTheme/instance.defaultDarkThemeが不正な形式だった場合のケア
|
||||
if (prefer.s.lightTheme == null && instance.defaultLightTheme != null) prefer.commit('lightTheme', JSON.parse(instance.defaultLightTheme));
|
||||
if (prefer.s.darkTheme == null && instance.defaultDarkTheme != null) prefer.commit('darkTheme', JSON.parse(instance.defaultDarkTheme));
|
||||
});
|
||||
|
||||
watch(defaultStore.reactiveState.overridedDeviceKind, (kind) => {
|
||||
watch(prefer.r.overridedDeviceKind, (kind) => {
|
||||
updateDeviceKind(kind);
|
||||
}, { immediate: true });
|
||||
|
||||
watch(defaultStore.reactiveState.useBlurEffectForModal, v => {
|
||||
document.documentElement.style.setProperty('--MI-modalBgFilter', v ? 'blur(4px)' : 'none');
|
||||
watch(prefer.r.useBlurEffectForModal, v => {
|
||||
window.document.documentElement.style.setProperty('--MI-modalBgFilter', v ? 'blur(4px)' : 'none');
|
||||
}, { immediate: true });
|
||||
|
||||
watch(defaultStore.reactiveState.useBlurEffect, v => {
|
||||
watch(prefer.r.useBlurEffect, v => {
|
||||
if (v) {
|
||||
document.documentElement.style.removeProperty('--MI-blur');
|
||||
window.document.documentElement.style.removeProperty('--MI-blur');
|
||||
} else {
|
||||
document.documentElement.style.setProperty('--MI-blur', 'none');
|
||||
window.document.documentElement.style.setProperty('--MI-blur', 'none');
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// Keep screen on
|
||||
const onVisibilityChange = () => document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
const onVisibilityChange = () => window.document.addEventListener('visibilitychange', () => {
|
||||
if (window.document.visibilityState === 'visible') {
|
||||
try {
|
||||
navigator.wakeLock.request('screen');
|
||||
} catch (err) {
|
||||
|
|
@ -217,13 +222,13 @@ export async function common(createVue: () => App<Element>) {
|
|||
}
|
||||
}
|
||||
});
|
||||
if (defaultStore.state.keepScreenOn && 'wakeLock' in navigator) {
|
||||
if (prefer.s.keepScreenOn && 'wakeLock' in navigator) {
|
||||
navigator.wakeLock.request('screen')
|
||||
.then(onVisibilityChange)
|
||||
.catch(() => {
|
||||
// On WebKit-based browsers, user activation is required to send wake lock request
|
||||
// https://webkit.org/blog/13862/the-user-activation-api/
|
||||
document.addEventListener(
|
||||
window.document.addEventListener(
|
||||
'click',
|
||||
() => navigator.wakeLock.request('screen').then(onVisibilityChange),
|
||||
{ once: true },
|
||||
|
|
@ -231,13 +236,17 @@ export async function common(createVue: () => App<Element>) {
|
|||
});
|
||||
}
|
||||
|
||||
if (prefer.s.makeEveryTextElementsSelectable) {
|
||||
window.document.documentElement.classList.add('forceSelectableAll');
|
||||
}
|
||||
|
||||
//#region Fetch user
|
||||
if ($i && $i.token) {
|
||||
if (_DEV_) {
|
||||
console.log('account cache found. refreshing...');
|
||||
}
|
||||
|
||||
refreshAccount();
|
||||
refreshCurrentAccount();
|
||||
}
|
||||
//#endregion
|
||||
|
||||
|
|
@ -245,9 +254,7 @@ export async function common(createVue: () => App<Element>) {
|
|||
await fetchCustomEmojis();
|
||||
} catch (err) { /* empty */ }
|
||||
|
||||
const app = createVue();
|
||||
|
||||
setupRouter(app, createMainRouter);
|
||||
const app = await createVue();
|
||||
|
||||
if (_DEV_) {
|
||||
app.config.performance = true;
|
||||
|
|
@ -262,19 +269,54 @@ export async function common(createVue: () => App<Element>) {
|
|||
const rootEl = ((): HTMLElement => {
|
||||
const MISSKEY_MOUNT_DIV_ID = 'sharkey_app';
|
||||
|
||||
const currentRoot = document.getElementById(MISSKEY_MOUNT_DIV_ID);
|
||||
const currentRoot = window.document.getElementById(MISSKEY_MOUNT_DIV_ID);
|
||||
|
||||
if (currentRoot) {
|
||||
console.warn('multiple import detected');
|
||||
return currentRoot;
|
||||
}
|
||||
|
||||
const root = document.createElement('div');
|
||||
const root = window.document.createElement('div');
|
||||
root.id = MISSKEY_MOUNT_DIV_ID;
|
||||
document.body.appendChild(root);
|
||||
window.document.body.appendChild(root);
|
||||
return root;
|
||||
})();
|
||||
|
||||
if (instance.sentryForFrontend) {
|
||||
const Sentry = await import('@sentry/vue');
|
||||
Sentry.init({
|
||||
app,
|
||||
integrations: [
|
||||
...(instance.sentryForFrontend.vueIntegration !== undefined ? [
|
||||
Sentry.vueIntegration(instance.sentryForFrontend.vueIntegration ?? undefined),
|
||||
] : []),
|
||||
...(instance.sentryForFrontend.browserTracingIntegration !== undefined ? [
|
||||
Sentry.browserTracingIntegration(instance.sentryForFrontend.browserTracingIntegration ?? undefined),
|
||||
] : []),
|
||||
...(instance.sentryForFrontend.replayIntegration !== undefined ? [
|
||||
Sentry.replayIntegration(instance.sentryForFrontend.replayIntegration ?? undefined),
|
||||
] : []),
|
||||
],
|
||||
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
tracesSampleRate: 1.0,
|
||||
|
||||
// Set `tracePropagationTargets` to control for which URLs distributed tracing should be enabled
|
||||
...(instance.sentryForFrontend.browserTracingIntegration !== undefined ? {
|
||||
tracePropagationTargets: [apiUrl],
|
||||
} : {}),
|
||||
|
||||
// Capture Replay for 10% of all sessions,
|
||||
// plus for 100% of sessions with an error
|
||||
...(instance.sentryForFrontend.replayIntegration !== undefined ? {
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
} : {}),
|
||||
|
||||
...instance.sentryForFrontend.options,
|
||||
});
|
||||
}
|
||||
|
||||
app.mount(rootEl);
|
||||
|
||||
// boot.jsのやつを解除
|
||||
|
|
@ -284,34 +326,37 @@ export async function common(createVue: () => App<Element>) {
|
|||
removeSplash();
|
||||
|
||||
//#region Self-XSS 対策メッセージ
|
||||
console.log(
|
||||
`%c${i18n.ts._selfXssPrevention.warning}`,
|
||||
'color: #f00; background-color: #ff0; font-size: 36px; padding: 4px;',
|
||||
);
|
||||
console.log(
|
||||
`%c${i18n.ts._selfXssPrevention.title}`,
|
||||
'color: #f00; font-weight: 900; font-family: "Hiragino Sans W9", "Hiragino Kaku Gothic ProN", sans-serif; font-size: 24px;',
|
||||
);
|
||||
console.log(
|
||||
`%c${i18n.ts._selfXssPrevention.description1}`,
|
||||
'font-size: 16px; font-weight: 700;',
|
||||
);
|
||||
console.log(
|
||||
`%c${i18n.ts._selfXssPrevention.description2}`,
|
||||
'font-size: 16px;',
|
||||
'font-size: 20px; font-weight: 700; color: #f00;',
|
||||
);
|
||||
console.log(i18n.tsx._selfXssPrevention.description3({ link: 'https://misskey-hub.net/docs/for-users/resources/self-xss/' }));
|
||||
if (!_DEV_) {
|
||||
console.log(
|
||||
`%c${i18n.ts._selfXssPrevention.warning}`,
|
||||
'color: #f00; background-color: #ff0; font-size: 36px; padding: 4px;',
|
||||
);
|
||||
console.log(
|
||||
`%c${i18n.ts._selfXssPrevention.title}`,
|
||||
'color: #f00; font-weight: 900; font-family: "Hiragino Sans W9", "Hiragino Kaku Gothic ProN", sans-serif; font-size: 24px;',
|
||||
);
|
||||
console.log(
|
||||
`%c${i18n.ts._selfXssPrevention.description1}`,
|
||||
'font-size: 16px; font-weight: 700;',
|
||||
);
|
||||
console.log(
|
||||
`%c${i18n.ts._selfXssPrevention.description2}`,
|
||||
'font-size: 16px;',
|
||||
'font-size: 20px; font-weight: 700; color: #f00;',
|
||||
);
|
||||
console.log(i18n.tsx._selfXssPrevention.description3({ link: 'https://misskey-hub.net/docs/for-users/resources/self-xss/' }));
|
||||
}
|
||||
//#endregion
|
||||
|
||||
return {
|
||||
isClientUpdated,
|
||||
lastVersion,
|
||||
app,
|
||||
};
|
||||
}
|
||||
|
||||
function removeSplash() {
|
||||
const splash = document.getElementById('splash');
|
||||
const splash = window.document.getElementById('splash');
|
||||
if (splash) {
|
||||
splash.style.opacity = '0';
|
||||
splash.style.pointerEvents = 'none';
|
||||
|
|
|
|||
|
|
@ -5,55 +5,59 @@
|
|||
|
||||
import { createApp, defineAsyncComponent, markRaw } from 'vue';
|
||||
import { ui } from '@@/js/config.js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { compareVersions } from 'compare-versions';
|
||||
import { common } from './common.js';
|
||||
import type * as Misskey from 'misskey-js';
|
||||
import type { Component } from 'vue';
|
||||
import type { Keymap } from '@/utility/hotkey.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { alert, confirm, popup, post, toast } from '@/os.js';
|
||||
import { alert, confirm, popup, post } from '@/os.js';
|
||||
import { useStream } from '@/stream.js';
|
||||
import * as sound from '@/scripts/sound.js';
|
||||
import { $i, signout, updateAccountPartial } from '@/account.js';
|
||||
import * as sound from '@/utility/sound.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import { ColdDeviceStorage, defaultStore } from '@/store.js';
|
||||
import { reactionPicker } from '@/scripts/reaction-picker.js';
|
||||
import { store } from '@/store.js';
|
||||
import { reactionPicker } from '@/utility/reaction-picker.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js';
|
||||
import { initializeSw } from '@/scripts/initialize-sw.js';
|
||||
import { claimAchievement, claimedAchievements } from '@/utility/achievements.js';
|
||||
import { initializeSw } from '@/utility/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 { setFavIconDot } from '@/scripts/favicon-dot.js';
|
||||
import { type Keymap, makeHotkey } from '@/scripts/hotkey.js';
|
||||
import { emojiPicker } from '@/utility/emoji-picker.js';
|
||||
import { mainRouter } from '@/router.js';
|
||||
import { setFavIconDot } from '@/utility/favicon-dot.js';
|
||||
import { makeHotkey } from '@/utility/hotkey.js';
|
||||
import { addCustomEmoji, removeCustomEmojis, updateCustomEmojis } from '@/custom-emojis.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { launchPlugins } from '@/plugin.js';
|
||||
import { updateCurrentAccountPartial } from '@/accounts.js';
|
||||
import { signout } from '@/signout.js';
|
||||
import { migrateOldSettings } from '@/pref-migrate.js';
|
||||
|
||||
export async function mainBoot() {
|
||||
const { isClientUpdated } = await common(() => {
|
||||
const { isClientUpdated, lastVersion } = await common(async () => {
|
||||
let uiStyle = ui;
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
if (!$i) uiStyle = 'visitor';
|
||||
|
||||
if (searchParams.has('zen')) uiStyle = 'zen';
|
||||
if (uiStyle === 'deck' && deckStore.state.useSimpleUiForNonRootPages && location.pathname !== '/') uiStyle = 'zen';
|
||||
if (uiStyle === 'deck' && prefer.s['deck.useSimpleUiForNonRootPages'] && window.location.pathname !== '/') uiStyle = 'zen';
|
||||
|
||||
if (searchParams.has('ui')) uiStyle = searchParams.get('ui');
|
||||
|
||||
let rootComponent: Component;
|
||||
switch (uiStyle) {
|
||||
case 'zen':
|
||||
rootComponent = defineAsyncComponent(() => import('@/ui/zen.vue'));
|
||||
rootComponent = await import('@/ui/zen.vue').then(x => x.default);
|
||||
break;
|
||||
case 'deck':
|
||||
rootComponent = defineAsyncComponent(() => import('@/ui/deck.vue'));
|
||||
rootComponent = await import('@/ui/deck.vue').then(x => x.default);
|
||||
break;
|
||||
case 'visitor':
|
||||
rootComponent = defineAsyncComponent(() => import('@/ui/visitor.vue'));
|
||||
break;
|
||||
case 'classic':
|
||||
rootComponent = defineAsyncComponent(() => import('@/ui/classic.vue'));
|
||||
rootComponent = await import('@/ui/visitor.vue').then(x => x.default);
|
||||
break;
|
||||
default:
|
||||
rootComponent = defineAsyncComponent(() => import('@/ui/universal.vue'));
|
||||
rootComponent = await import('@/ui/universal.vue').then(x => x.default);
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -67,13 +71,21 @@ export async function mainBoot() {
|
|||
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
|
||||
// prefereces migration
|
||||
// TODO: そのうち消す
|
||||
if (lastVersion && (compareVersions('2025.3.2-alpha.0', lastVersion) === 1)) {
|
||||
console.log('Preferences migration');
|
||||
|
||||
migrateOldSettings();
|
||||
}
|
||||
}
|
||||
|
||||
const stream = useStream();
|
||||
|
||||
let reloadDialogShowing = false;
|
||||
stream.on('_disconnected_', async () => {
|
||||
if (defaultStore.state.serverDisconnectedBehavior === 'dialog') {
|
||||
if (prefer.s.serverDisconnectedBehavior === 'dialog') {
|
||||
if (reloadDialogShowing) return;
|
||||
reloadDialogShowing = true;
|
||||
const { canceled } = await confirm({
|
||||
|
|
@ -83,7 +95,7 @@ export async function mainBoot() {
|
|||
});
|
||||
reloadDialogShowing = false;
|
||||
if (!canceled) {
|
||||
location.reload();
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -100,30 +112,24 @@ export async function mainBoot() {
|
|||
removeCustomEmojis(emojiData.emojis);
|
||||
});
|
||||
|
||||
for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) {
|
||||
import('@/plugin.js').then(async ({ install }) => {
|
||||
// Workaround for https://bugs.webkit.org/show_bug.cgi?id=242740
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
install(plugin);
|
||||
});
|
||||
}
|
||||
launchPlugins();
|
||||
|
||||
try {
|
||||
if (defaultStore.state.enableSeasonalScreenEffect) {
|
||||
if (prefer.s.enableSeasonalScreenEffect) {
|
||||
const month = new Date().getMonth() + 1;
|
||||
if (defaultStore.state.hemisphere === 'S') {
|
||||
if (prefer.s.hemisphere === 'S') {
|
||||
// ▼南半球
|
||||
if (month === 7 || month === 8) {
|
||||
const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect;
|
||||
const SnowfallEffect = (await import('@/utility/snowfall-effect.js')).SnowfallEffect;
|
||||
new SnowfallEffect({}).render();
|
||||
}
|
||||
} else {
|
||||
// ▼北半球
|
||||
if (month === 12 || month === 1) {
|
||||
const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect;
|
||||
const SnowfallEffect = (await import('@/utility/snowfall-effect.js')).SnowfallEffect;
|
||||
new SnowfallEffect({}).render();
|
||||
} else if (month === 3 || month === 4) {
|
||||
const SakuraEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect;
|
||||
const SakuraEffect = (await import('@/utility/snowfall-effect.js')).SnowfallEffect;
|
||||
new SakuraEffect({
|
||||
sakura: true,
|
||||
}).render();
|
||||
|
|
@ -136,8 +142,8 @@ export async function mainBoot() {
|
|||
}
|
||||
|
||||
if ($i) {
|
||||
defaultStore.loaded.then(() => {
|
||||
if (defaultStore.state.accountSetupWizard !== -1) {
|
||||
store.loaded.then(async () => {
|
||||
if (store.s.accountSetupWizard !== -1) {
|
||||
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
|
|
@ -152,7 +158,7 @@ export async function mainBoot() {
|
|||
});
|
||||
}
|
||||
|
||||
function onAnnouncementCreated (ev: { announcement: Misskey.entities.Announcement }) {
|
||||
function onAnnouncementCreated(ev: { announcement: Misskey.entities.Announcement }) {
|
||||
const announcement = ev.announcement;
|
||||
if (announcement.display === 'dialog') {
|
||||
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), {
|
||||
|
|
@ -260,7 +266,7 @@ export async function mainBoot() {
|
|||
let lastVisibilityChangedAt = Date.now();
|
||||
|
||||
function claimPlainLucky() {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
if (window.document.visibilityState !== 'visible') {
|
||||
if (justPlainLuckyTimer != null) window.clearTimeout(justPlainLuckyTimer);
|
||||
return;
|
||||
}
|
||||
|
|
@ -275,7 +281,7 @@ export async function mainBoot() {
|
|||
window.addEventListener('visibilitychange', () => {
|
||||
const now = Date.now();
|
||||
|
||||
if (document.visibilityState === 'visible') {
|
||||
if (window.document.visibilityState === 'visible') {
|
||||
// タブを高速で切り替えたら取得処理が何度も走るのを防ぐ
|
||||
if ((now - lastVisibilityChangedAt) < 1000 * 10) {
|
||||
justPlainLuckyTimer = window.setTimeout(claimPlainLucky, 1000 * 10);
|
||||
|
|
@ -320,7 +326,7 @@ export async function mainBoot() {
|
|||
|
||||
const latestDonationInfoShownAt = miLocalStorage.getItem('latestDonationInfoShownAt');
|
||||
const neverShowDonationInfo = miLocalStorage.getItem('neverShowDonationInfo');
|
||||
if (neverShowDonationInfo !== 'true' && (createdAt.getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3))) && !location.pathname.startsWith('/miauth')) {
|
||||
if (neverShowDonationInfo !== 'true' && (createdAt.getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3))) && !window.location.pathname.startsWith('/miauth')) {
|
||||
if (latestDonationInfoShownAt == null || (new Date(latestDonationInfoShownAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 30)))) {
|
||||
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkDonation.vue')), {}, {
|
||||
closed: () => dispose(),
|
||||
|
|
@ -343,7 +349,7 @@ export async function mainBoot() {
|
|||
}
|
||||
|
||||
function attemptShowNotificationDot() {
|
||||
if (defaultStore.state.enableFaviconNotificationDot) {
|
||||
if (prefer.s.enableFaviconNotificationDot) {
|
||||
setFavIconDot(true);
|
||||
}
|
||||
}
|
||||
|
|
@ -354,13 +360,13 @@ export async function mainBoot() {
|
|||
|
||||
// 自分の情報が更新されたとき
|
||||
main.on('meUpdated', i => {
|
||||
updateAccountPartial(i);
|
||||
updateCurrentAccountPartial(i);
|
||||
});
|
||||
|
||||
main.on('readAllNotifications', () => {
|
||||
setFavIconDot(false);
|
||||
|
||||
updateAccountPartial({
|
||||
updateCurrentAccountPartial({
|
||||
hasUnreadNotification: false,
|
||||
unreadNotificationsCount: 0,
|
||||
});
|
||||
|
|
@ -370,39 +376,24 @@ export async function mainBoot() {
|
|||
attemptShowNotificationDot();
|
||||
|
||||
const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1;
|
||||
updateAccountPartial({
|
||||
updateCurrentAccountPartial({
|
||||
hasUnreadNotification: true,
|
||||
unreadNotificationsCount,
|
||||
});
|
||||
});
|
||||
|
||||
main.on('unreadMention', () => {
|
||||
updateAccountPartial({ hasUnreadMentions: true });
|
||||
});
|
||||
|
||||
main.on('readAllUnreadMentions', () => {
|
||||
updateAccountPartial({ hasUnreadMentions: false });
|
||||
});
|
||||
|
||||
main.on('unreadSpecifiedNote', () => {
|
||||
updateAccountPartial({ hasUnreadSpecifiedNotes: true });
|
||||
});
|
||||
|
||||
main.on('readAllUnreadSpecifiedNotes', () => {
|
||||
updateAccountPartial({ hasUnreadSpecifiedNotes: false });
|
||||
});
|
||||
|
||||
main.on('readAllAntennas', () => {
|
||||
updateAccountPartial({ hasUnreadAntenna: false });
|
||||
});
|
||||
|
||||
main.on('unreadAntenna', () => {
|
||||
updateAccountPartial({ hasUnreadAntenna: true });
|
||||
updateCurrentAccountPartial({ hasUnreadAntenna: true });
|
||||
sound.playMisskeySfx('antenna');
|
||||
});
|
||||
|
||||
main.on('newChatMessage', () => {
|
||||
updateCurrentAccountPartial({ hasUnreadChatMessages: true });
|
||||
sound.playMisskeySfx('chatMessage');
|
||||
});
|
||||
|
||||
main.on('readAllAnnouncements', () => {
|
||||
updateAccountPartial({ hasUnreadAnnouncement: false });
|
||||
updateCurrentAccountPartial({ hasUnreadAnnouncement: false });
|
||||
});
|
||||
|
||||
// 個人宛てお知らせが発行されたとき
|
||||
|
|
@ -422,13 +413,13 @@ export async function mainBoot() {
|
|||
post();
|
||||
},
|
||||
'd': () => {
|
||||
defaultStore.set('darkMode', !defaultStore.state.darkMode);
|
||||
store.set('darkMode', !store.s.darkMode);
|
||||
},
|
||||
's': () => {
|
||||
mainRouter.push('/search');
|
||||
},
|
||||
} as const satisfies Keymap;
|
||||
document.addEventListener('keydown', makeHotkey(keymap), { passive: false });
|
||||
window.document.addEventListener('keydown', makeHotkey(keymap), { passive: false });
|
||||
|
||||
initializeSw();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,11 @@
|
|||
|
||||
import { createApp, defineAsyncComponent } from 'vue';
|
||||
import { common } from './common.js';
|
||||
import { emojiPicker } from '@/scripts/emoji-picker.js';
|
||||
import { emojiPicker } from '@/utility/emoji-picker.js';
|
||||
import UiMinimum from '@/ui/minimum.vue';
|
||||
|
||||
export async function subBoot() {
|
||||
const { isClientUpdated } = await common(() => createApp(
|
||||
defineAsyncComponent(() => import('@/ui/minimum.vue')),
|
||||
));
|
||||
const { isClientUpdated } = await common(async () => createApp(UiMinimum));
|
||||
|
||||
emojiPicker.init();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
*/
|
||||
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { Cache } from '@/scripts/cache.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { Cache } from '@/utility/cache.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
|
||||
export const clipsCache = new Cache<Misskey.entities.Clip[]>(1000 * 60 * 30, () => misskeyApi('clips/list'));
|
||||
export const rolesCache = new Cache(1000 * 60 * 30, () => misskeyApi('admin/roles/list'));
|
||||
|
|
|
|||
|
|
@ -17,21 +17,19 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script setup lang="ts">
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { computed, defineAsyncComponent, shallowRef } from 'vue';
|
||||
import { computed, defineAsyncComponent, useTemplateRef } from 'vue';
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers';
|
||||
import type MkNote from '@/components/MkNote.vue';
|
||||
import type SkNote from '@/components/SkNote.vue';
|
||||
import { defaultStore } from '@/store';
|
||||
import { prefer } from '@/preferences';
|
||||
|
||||
const XNote = computed(() =>
|
||||
defineAsyncComponent(() =>
|
||||
defaultStore.reactiveState.noteDesign.value === 'misskey'
|
||||
? import('@/components/MkNote.vue')
|
||||
: import('@/components/SkNote.vue'),
|
||||
),
|
||||
prefer.r.noteDesign.value === 'misskey'
|
||||
? defineAsyncComponent(() => import('@/components/MkNote.vue'))
|
||||
: defineAsyncComponent(() => import('@/components/SkNote.vue')),
|
||||
);
|
||||
|
||||
const rootEl = shallowRef<ComponentExposed<typeof MkNote | typeof SkNote>>();
|
||||
const rootEl = useTemplateRef<ComponentExposed<typeof MkNote | typeof SkNote>>('rootEl');
|
||||
|
||||
defineExpose({ rootEl });
|
||||
|
||||
|
|
|
|||
38
packages/frontend/src/components/DynamicNoteDetailed.vue
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<XNoteDetailed
|
||||
ref="rootEl"
|
||||
:note="note"
|
||||
:initialTab="initialTab"
|
||||
:expandAllCws="expandAllCws"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { computed, defineAsyncComponent, useTemplateRef } from 'vue';
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers';
|
||||
import type MkNoteDetailed from '@/components/MkNoteDetailed.vue';
|
||||
import type SkNoteDetailed from '@/components/SkNoteDetailed.vue';
|
||||
import { prefer } from '@/preferences';
|
||||
|
||||
const XNoteDetailed = computed(() =>
|
||||
prefer.r.noteDesign.value === 'misskey'
|
||||
? defineAsyncComponent(() => import('@/components/MkNoteDetailed.vue'))
|
||||
: defineAsyncComponent(() => import('@/components/SkNoteDetailed.vue')),
|
||||
);
|
||||
|
||||
const rootEl = useTemplateRef<ComponentExposed<typeof MkNoteDetailed | typeof SkNoteDetailed>>('rootEl');
|
||||
|
||||
defineExpose({ rootEl });
|
||||
|
||||
defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
initialTab?: string;
|
||||
expandAllCws?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
46
packages/frontend/src/components/DynamicNoteSimple.vue
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<XNoteSimple
|
||||
ref="rootEl"
|
||||
:note="note"
|
||||
:expandAllCws="expandAllCws"
|
||||
:hideFiles="hideFiles"
|
||||
@editScheduledNote="() => emit('editScheduleNote')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { computed, defineAsyncComponent, useTemplateRef } from 'vue';
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers';
|
||||
import type MkNoteSimple from '@/components/MkNoteSimple.vue';
|
||||
import type SkNoteSimple from '@/components/SkNoteSimple.vue';
|
||||
import { prefer } from '@/preferences';
|
||||
|
||||
const XNoteSimple = computed(() =>
|
||||
prefer.r.noteDesign.value === 'misskey'
|
||||
? defineAsyncComponent(() => import('@/components/MkNoteSimple.vue'))
|
||||
: defineAsyncComponent(() => import('@/components/SkNoteSimple.vue')),
|
||||
);
|
||||
|
||||
const rootEl = useTemplateRef<ComponentExposed<typeof MkNoteSimple | typeof SkNoteSimple>>('rootEl');
|
||||
|
||||
defineExpose({ rootEl });
|
||||
|
||||
defineProps<{
|
||||
note: Misskey.entities.Note & {
|
||||
isSchedule?: boolean,
|
||||
scheduledNoteId?: string
|
||||
};
|
||||
expandAllCws?: boolean;
|
||||
hideFiles?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'editScheduleNote'): void;
|
||||
}>();
|
||||
</script>
|
||||
|
|
@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #label>{{ i18n.ts.target }}: <MkAcct :user="report.targetUser"/></template>
|
||||
<template #suffix>#{{ report.targetUserId.toUpperCase() }}</template>
|
||||
|
||||
<div style="container-type: inline-size;">
|
||||
<div style="height: 300px; --MI-stickyTop: 0; --MI-stickyBottom: 0;">
|
||||
<RouterView :router="targetRouter"/>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
|
@ -53,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #label>{{ i18n.ts.reporter }}: <MkAcct :user="report.reporter"/></template>
|
||||
<template #suffix>#{{ report.reporterId.toUpperCase() }}</template>
|
||||
|
||||
<div style="container-type: inline-size;">
|
||||
<div style="height: 300px; --MI-stickyTop: 0; --MI-stickyBottom: 0;">
|
||||
<RouterView :router="reporterRouter"/>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
|
@ -88,9 +88,9 @@ import { i18n } from '@/i18n.js';
|
|||
import { dateString } from '@/filters/date.js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import RouterView from '@/components/global/RouterView.vue';
|
||||
import { useRouterFactory } from '@/router/supplier';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
|
||||
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
|
||||
import { createRouter } from '@/router.js';
|
||||
|
||||
const props = defineProps<{
|
||||
report: Misskey.entities.AdminAbuseUserReportsResponse[number];
|
||||
|
|
@ -100,10 +100,9 @@ const emit = defineEmits<{
|
|||
(ev: 'resolved', reportId: string): void;
|
||||
}>();
|
||||
|
||||
const routerFactory = useRouterFactory();
|
||||
const targetRouter = routerFactory(`/admin/user/${props.report.targetUserId}`);
|
||||
const targetRouter = createRouter(`/admin/user/${props.report.targetUserId}`);
|
||||
targetRouter.init();
|
||||
const reporterRouter = routerFactory(`/admin/user/${props.report.reporterId}`);
|
||||
const reporterRouter = createRouter(`/admin/user/${props.report.reporterId}`);
|
||||
reporterRouter.init();
|
||||
|
||||
const moderationNote = ref(props.report.moderationNote ?? '');
|
||||
|
|
@ -135,7 +134,7 @@ function forward() {
|
|||
|
||||
function showMenu(ev: MouseEvent) {
|
||||
os.popupMenu([{
|
||||
icon: 'ti ti-id',
|
||||
icon: 'ti ti-hash',
|
||||
text: 'Copy ID',
|
||||
action: () => {
|
||||
copyToClipboard(props.report.id);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { userDetailed } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
</I18n>
|
||||
</template>
|
||||
<MkSpacer :marginMin="20" :marginMax="28">
|
||||
<div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;">
|
||||
<div class="_gaps_m" :class="$style.root">
|
||||
<div class="">
|
||||
<MkTextarea v-model="comment">
|
||||
|
|
@ -25,12 +25,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkButton primary full :disabled="comment.length === 0" @click="send">{{ i18n.ts.send }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
</MkWindow>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, shallowRef } from 'vue';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkWindow from '@/components/MkWindow.vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
|
|
@ -47,7 +47,7 @@ const emit = defineEmits<{
|
|||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const uiWindow = shallowRef<InstanceType<typeof MkWindow>>();
|
||||
const uiWindow = useTemplateRef('uiWindow');
|
||||
const comment = ref(props.initialComment ?? '');
|
||||
|
||||
function send() {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import { userDetailed } from '../../.storybook/fakes.js';
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import * as Misskey from 'misskey-js';
|
|||
import MkMention from './MkMention.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { host as localHost } from '@@/js/config.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
|
||||
const user = ref<Misskey.entities.UserLite>();
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@
|
|||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { userDetailed } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkAchievements from './MkAchievements.vue';
|
||||
import { ACHIEVEMENT_TYPES } from '@/scripts/achievements.js';
|
||||
import { ACHIEVEMENT_TYPES } from '@/utility/achievements.js';
|
||||
export const Empty = {
|
||||
render(args) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -55,9 +55,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
import * as Misskey from 'misskey-js';
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/scripts/achievements.js';
|
||||
import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/utility/achievements.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
user: Misskey.entities.User;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import isChromatic from 'chromatic/isChromatic';
|
||||
import MkAnalogClock from './MkAnalogClock.vue';
|
||||
export const Default = {
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { globalEvents } from '@/events.js';
|
||||
import { defaultIdlingRenderScheduler } from '@/scripts/idle-render.js';
|
||||
import { defaultIdlingRenderScheduler } from '@/utility/idle-render.js';
|
||||
|
||||
// https://stackoverflow.com/questions/1878907/how-can-i-find-the-difference-between-two-angles
|
||||
const angleDiff = (a: number, b: number) => {
|
||||
|
|
@ -192,7 +192,7 @@ function tick() {
|
|||
tick();
|
||||
|
||||
function calcColors() {
|
||||
const computedStyle = getComputedStyle(document.documentElement);
|
||||
const computedStyle = getComputedStyle(window.document.documentElement);
|
||||
const dark = tinycolor(computedStyle.getPropertyValue('--MI_THEME-bg')).isDark();
|
||||
const accent = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString();
|
||||
majorGraduationColor.value = dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)';
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<canvas ref="canvasEl" style="width: 100%; height: 100%; pointer-events: none;"></canvas>
|
||||
<canvas ref="canvasEl" style="display: block; width: 100%; height: 100%; pointer-events: none;"></canvas>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, shallowRef } from 'vue';
|
||||
import { onMounted, onUnmounted, useTemplateRef } from 'vue';
|
||||
import isChromatic from 'chromatic/isChromatic';
|
||||
|
||||
const canvasEl = shallowRef<HTMLCanvasElement>();
|
||||
const canvasEl = useTemplateRef('canvasEl');
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
scale?: number;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkAnnouncementDialog from './MkAnnouncementDialog.vue';
|
||||
|
|
|
|||
|
|
@ -22,22 +22,23 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, shallowRef } from 'vue';
|
||||
import { onMounted, useTemplateRef } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import MkModal from '@/components/MkModal.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { $i, updateAccountPartial } from '@/account.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { updateCurrentAccountPartial } from '@/accounts.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
announcement: Misskey.entities.Announcement;
|
||||
}>(), {
|
||||
});
|
||||
|
||||
const rootEl = shallowRef<HTMLDivElement>();
|
||||
const modal = shallowRef<InstanceType<typeof MkModal>>();
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
const modal = useTemplateRef('modal');
|
||||
|
||||
async function ok() {
|
||||
if (props.announcement.needConfirmationToRead) {
|
||||
|
|
@ -51,7 +52,7 @@ async function ok() {
|
|||
|
||||
modal.value?.close();
|
||||
misskeyApi('i/read-announcement', { announcementId: props.announcement.id });
|
||||
updateAccountPartial({
|
||||
updateCurrentAccountPartial({
|
||||
unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== props.announcement.id),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkAntennaEditor from './MkAntennaEditor.vue';
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkSpacer :contentMax="700">
|
||||
<div class="_spacer" style="--MI_SPACER-w: 700px;">
|
||||
<div>
|
||||
<div class="_gaps_m">
|
||||
<MkInput v-model="name">
|
||||
|
|
@ -39,6 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch>
|
||||
<MkSwitch v-model="caseSensitive">{{ i18n.ts.caseSensitive }}</MkSwitch>
|
||||
<MkSwitch v-model="withFile">{{ i18n.ts.withFileAntenna }}</MkSwitch>
|
||||
<MkSwitch v-model="excludeNotesInSensitiveChannel">{{ i18n.ts.excludeNotesInSensitiveChannel }}</MkSwitch>
|
||||
</div>
|
||||
<div :class="$style.actions">
|
||||
<div class="_buttons">
|
||||
|
|
@ -47,22 +48,22 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watch, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import type { DeepPartial } from '@/utility/merge.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { deepMerge } from '@/scripts/merge.js';
|
||||
import type { DeepPartial } from '@/scripts/merge.js';
|
||||
import { deepMerge } from '@/utility/merge.js';
|
||||
|
||||
type PartialAllowedAntenna = Omit<Misskey.entities.Antenna, 'id' | 'createdAt' | 'updatedAt'> & {
|
||||
id?: string;
|
||||
|
|
@ -86,6 +87,7 @@ const initialAntenna = deepMerge<PartialAllowedAntenna>(props.antenna ?? {}, {
|
|||
caseSensitive: false,
|
||||
localOnly: false,
|
||||
withFile: false,
|
||||
excludeNotesInSensitiveChannel: false,
|
||||
isActive: true,
|
||||
hasUnreadNote: false,
|
||||
notify: false,
|
||||
|
|
@ -108,6 +110,7 @@ const localOnly = ref<boolean>(initialAntenna.localOnly);
|
|||
const excludeBots = ref<boolean>(initialAntenna.excludeBots);
|
||||
const withReplies = ref<boolean>(initialAntenna.withReplies);
|
||||
const withFile = ref<boolean>(initialAntenna.withFile);
|
||||
const excludeNotesInSensitiveChannel = ref<boolean>(initialAntenna.excludeNotesInSensitiveChannel);
|
||||
const userLists = ref<Misskey.entities.UserList[] | null>(null);
|
||||
|
||||
watch(() => src.value, async () => {
|
||||
|
|
@ -124,6 +127,7 @@ async function saveAntenna() {
|
|||
excludeBots: excludeBots.value,
|
||||
withReplies: withReplies.value,
|
||||
withFile: withFile.value,
|
||||
excludeNotesInSensitiveChannel: excludeNotesInSensitiveChannel.value,
|
||||
caseSensitive: caseSensitive.value,
|
||||
localOnly: localOnly.value,
|
||||
users: users.value.trim().split('\n').map(x => x.trim()),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkAntennaEditorDialog from './MkAntennaEditorDialog.vue';
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { shallowRef } from 'vue';
|
||||
import { useTemplateRef } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import XAntennaEditor from '@/components/MkAntennaEditor.vue';
|
||||
|
|
@ -40,7 +40,7 @@ const emit = defineEmits<{
|
|||
(ev: 'closed'): void,
|
||||
}>();
|
||||
|
||||
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
|
||||
const dialog = useTemplateRef('dialog');
|
||||
|
||||
function onAntennaCreated(newAntenna: Misskey.entities.Antenna) {
|
||||
emit('created', newAntenna);
|
||||
|
|
|
|||
|
|
@ -63,14 +63,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Ref, ref, computed } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import * as os from '@/os.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/scripts/aiscript/ui.js';
|
||||
import type { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/aiscript/ui.js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkPostForm from '@/components/MkPostForm.vue';
|
||||
|
||||
|
|
|
|||
|
|
@ -117,14 +117,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
|
||||
import { $i, getAccounts, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/account.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { getAccounts, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/accounts.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
import { getProxiedImageUrl } from '@/scripts/media-proxy.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { getProxiedImageUrl } from '@/utility/media-proxy.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
|
||||
const props = defineProps<{
|
||||
name?: string;
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { expect, userEvent, waitFor, within } from '@storybook/test';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { userDetailed } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkAutocomplete from './MkAutocomplete.vue';
|
||||
import MkInput from './MkInput.vue';
|
||||
import { tick } from '@/scripts/test-utils.js';
|
||||
import { tick } from '@/utility/test-utils.js';
|
||||
const common = {
|
||||
render(args) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -15,12 +15,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</li>
|
||||
<li tabindex="-1" :class="$style.item" @click="chooseUser()" @keydown="onKeydown">{{ i18n.ts.selectUser }}</li>
|
||||
</ol>
|
||||
<ol v-else-if="hashtags.length > 0" ref="suggests" :class="$style.list">
|
||||
<ol v-else-if="type === 'hashtag' && hashtags.length > 0" ref="suggests" :class="$style.list">
|
||||
<li v-for="hashtag in hashtags" tabindex="-1" :class="$style.item" @click="complete(type, hashtag)" @keydown="onKeydown">
|
||||
<span class="name">{{ hashtag }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
<ol v-else-if="emojis.length > 0" ref="suggests" :class="$style.list">
|
||||
<ol v-else-if="type === 'emoji' || type === 'emojiComplete' && emojis.length > 0" ref="suggests" :class="$style.list">
|
||||
<li v-for="emoji in emojis" :key="emoji.emoji" :class="$style.item" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown">
|
||||
<MkCustomEmoji v-if="'isCustomEmoji' in emoji && emoji.isCustomEmoji" :name="emoji.emoji" :class="$style.emoji" :fallbackToImage="true"/>
|
||||
<MkEmoji v-else :emoji="emoji.emoji" :class="$style.emoji"/>
|
||||
|
|
@ -30,12 +30,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<span v-if="emoji.aliasOf" :class="$style.emojiAlias">({{ emoji.aliasOf }})</span>
|
||||
</li>
|
||||
</ol>
|
||||
<ol v-else-if="mfmTags.length > 0" ref="suggests" :class="$style.list">
|
||||
<ol v-else-if="type === 'mfmTag' && mfmTags.length > 0" ref="suggests" :class="$style.list">
|
||||
<li v-for="tag in mfmTags" tabindex="-1" :class="$style.item" @click="complete(type, tag)" @keydown="onKeydown">
|
||||
<span>{{ tag }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
<ol v-else-if="mfmParams.length > 0" ref="suggests" :class="$style.list">
|
||||
<ol v-else-if="type === 'mfmParam' && mfmParams.length > 0" ref="suggests" :class="$style.list">
|
||||
<li v-for="param in mfmParams" tabindex="-1" :class="$style.item" @click="complete(type, q.params.toSpliced(-1, 1, param).join(','))" @keydown="onKeydown">
|
||||
<span>{{ param }}</span>
|
||||
</li>
|
||||
|
|
@ -44,26 +44,60 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { markRaw, ref, shallowRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
|
||||
import { markRaw, ref, useTemplateRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import { emojilist, getEmojiName } from '@@/js/emojilist.js';
|
||||
import contains from '@/scripts/contains.js';
|
||||
import { char2twemojiFilePath, char2fluentEmojiFilePath, char2tossfaceFilePath } from '@@/js/emoji-base.js';
|
||||
import { MFM_TAGS, MFM_PARAMS } from '@@/js/const.js';
|
||||
import type { EmojiDef } from '@/utility/search-emoji.js';
|
||||
import contains from '@/utility/contains.js';
|
||||
import { acct } from '@/filters/user.js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import { store } from '@/store.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { customEmojis } from '@/custom-emojis.js';
|
||||
import { MFM_TAGS, MFM_PARAMS } from '@@/js/const.js';
|
||||
import { searchEmoji, EmojiDef } from '@/scripts/search-emoji.js';
|
||||
import { searchEmoji, searchEmojiExact } from '@/utility/search-emoji.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
export type CompleteInfo = {
|
||||
user: {
|
||||
payload: any;
|
||||
query: string | null;
|
||||
},
|
||||
hashtag: {
|
||||
payload: string;
|
||||
query: string;
|
||||
},
|
||||
// `:emo` -> `:emoji:` or some unicode emoji
|
||||
emoji: {
|
||||
payload: string;
|
||||
query: string;
|
||||
},
|
||||
// like emoji but for `:emoji:` -> unicode emoji
|
||||
emojiComplete: {
|
||||
payload: string;
|
||||
query: string;
|
||||
},
|
||||
mfmTag: {
|
||||
payload: string;
|
||||
query: string;
|
||||
},
|
||||
mfmParam: {
|
||||
payload: string;
|
||||
query: {
|
||||
tag: string;
|
||||
params: string[];
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const lib = emojilist.filter(x => x.category !== 'flags');
|
||||
|
||||
const emojiDb = computed(() => {
|
||||
const unicodeEmojiDB = computed(() => {
|
||||
//#region Unicode Emoji
|
||||
const char2path = defaultStore.reactiveState.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : defaultStore.reactiveState.emojiStyle.value === 'tossface' ? char2tossfaceFilePath : char2fluentEmojiFilePath;
|
||||
const char2path = prefer.r.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : prefer.r.emojiStyle.value === 'tossface' ? char2tossfaceFilePath : char2fluentEmojiFilePath;
|
||||
|
||||
const unicodeEmojiDB: EmojiDef[] = lib.map(x => ({
|
||||
emoji: x.char,
|
||||
|
|
@ -71,7 +105,7 @@ const emojiDb = computed(() => {
|
|||
url: char2path(x.char),
|
||||
}));
|
||||
|
||||
for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) {
|
||||
for (const index of Object.values(store.s.additionalUnicodeEmojiIndexes)) {
|
||||
for (const [emoji, keywords] of Object.entries(index)) {
|
||||
for (const k of keywords) {
|
||||
unicodeEmojiDB.push({
|
||||
|
|
@ -85,6 +119,12 @@ const emojiDb = computed(() => {
|
|||
}
|
||||
|
||||
unicodeEmojiDB.sort((a, b) => a.name.length - b.name.length);
|
||||
|
||||
return unicodeEmojiDB;
|
||||
});
|
||||
|
||||
const emojiDb = computed(() => {
|
||||
//#region Unicode Emoji
|
||||
//#endregion
|
||||
|
||||
//#region Custom Emoji
|
||||
|
|
@ -112,7 +152,7 @@ const emojiDb = computed(() => {
|
|||
customEmojiDB.sort((a, b) => a.name.length - b.name.length);
|
||||
//#endregion
|
||||
|
||||
return markRaw([...customEmojiDB, ...unicodeEmojiDB]);
|
||||
return markRaw([...customEmojiDB, ...unicodeEmojiDB.value]);
|
||||
});
|
||||
|
||||
export default {
|
||||
|
|
@ -121,23 +161,28 @@ export default {
|
|||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const props = defineProps<{
|
||||
type: string;
|
||||
q: any;
|
||||
textarea: HTMLTextAreaElement;
|
||||
<script lang="ts" setup generic="T extends keyof CompleteInfo">
|
||||
type PropsType<T extends keyof CompleteInfo> = {
|
||||
type: T;
|
||||
q: CompleteInfo[T]['query'];
|
||||
// なぜかわからないけど HTMLTextAreaElement | HTMLInputElement だと addEventListener/removeEventListenerがエラー
|
||||
textarea: (HTMLTextAreaElement | HTMLInputElement) & HTMLElement;
|
||||
close: () => void;
|
||||
x: number;
|
||||
y: number;
|
||||
}>();
|
||||
};
|
||||
//const props = defineProps<PropsType<keyof CompleteInfo>>();
|
||||
// ↑と同じだけど↓にしないとdiscriminated unionにならない。
|
||||
// https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes-func.html#discriminated-unions
|
||||
const props = defineProps<PropsType<'user'> | PropsType<'hashtag'> | PropsType<'emoji'> | PropsType<'emojiComplete'> | PropsType<'mfmTag'> | PropsType<'mfmParam'>>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'done', value: { type: string; value: any }): void;
|
||||
<T extends keyof CompleteInfo>(event: 'done', value: { type: T; value: CompleteInfo[T]['payload'] }): void;
|
||||
(event: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const suggests = ref<Element>();
|
||||
const rootEl = shallowRef<HTMLDivElement>();
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
|
||||
const fetching = ref(true);
|
||||
const users = ref<any[]>([]);
|
||||
|
|
@ -149,14 +194,14 @@ const mfmParams = ref<string[]>([]);
|
|||
const select = ref(-1);
|
||||
const zIndex = os.claimZIndex('high');
|
||||
|
||||
function complete(type: string, value: any) {
|
||||
function complete<T extends keyof CompleteInfo>(type: T, value: CompleteInfo[T]['payload']) {
|
||||
emit('done', { type, value });
|
||||
emit('closed');
|
||||
if (type === 'emoji') {
|
||||
let recents = defaultStore.state.recentlyUsedEmojis;
|
||||
if (type === 'emoji' || type === 'emojiComplete') {
|
||||
let recents = store.s.recentlyUsedEmojis;
|
||||
recents = recents.filter((emoji: any) => emoji !== value);
|
||||
recents.unshift(value);
|
||||
defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32));
|
||||
store.set('recentlyUsedEmojis', recents.splice(0, 32));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -197,8 +242,10 @@ function exec() {
|
|||
users.value = JSON.parse(cache);
|
||||
fetching.value = false;
|
||||
} else {
|
||||
const [username, host] = props.q.toString().split('@');
|
||||
misskeyApi('users/search-by-username-and-host', {
|
||||
username: props.q,
|
||||
username: username,
|
||||
host: host,
|
||||
limit: 10,
|
||||
detail: false,
|
||||
}).then(searchedUsers => {
|
||||
|
|
@ -234,11 +281,13 @@ function exec() {
|
|||
} else if (props.type === 'emoji') {
|
||||
if (!props.q || props.q === '') {
|
||||
// 最近使った絵文字をサジェスト
|
||||
emojis.value = defaultStore.state.recentlyUsedEmojis.map(emoji => emojiDb.value.find(dbEmoji => dbEmoji.emoji === emoji)).filter(x => x) as EmojiDef[];
|
||||
emojis.value = store.s.recentlyUsedEmojis.map(emoji => emojiDb.value.find(dbEmoji => dbEmoji.emoji === emoji)).filter(x => x) as EmojiDef[];
|
||||
return;
|
||||
}
|
||||
|
||||
emojis.value = searchEmoji(props.q.normalize('NFC').toLowerCase(), emojiDb.value);
|
||||
} else if (props.type === 'emojiComplete') {
|
||||
emojis.value = searchEmojiExact(props.q.normalize('NFC').toLowerCase(), unicodeEmojiDB.value);
|
||||
} else if (props.type === 'mfmTag') {
|
||||
if (!props.q || props.q === '') {
|
||||
mfmTags.value = MFM_TAGS;
|
||||
|
|
@ -355,7 +404,7 @@ onMounted(() => {
|
|||
|
||||
props.textarea.addEventListener('keydown', onKeydown);
|
||||
|
||||
document.body.addEventListener('mousedown', onMousedown);
|
||||
window.document.body.addEventListener('mousedown', onMousedown);
|
||||
|
||||
nextTick(() => {
|
||||
exec();
|
||||
|
|
@ -371,7 +420,7 @@ onMounted(() => {
|
|||
onBeforeUnmount(() => {
|
||||
props.textarea.removeEventListener('keydown', onKeydown);
|
||||
|
||||
document.body.removeEventListener('mousedown', onMousedown);
|
||||
window.document.body.removeEventListener('mousedown', onMousedown);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -407,7 +456,7 @@ onBeforeUnmount(() => {
|
|||
text-overflow: ellipsis;
|
||||
|
||||
&:hover {
|
||||
background: var(--MI_THEME-X3);
|
||||
background: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
|
||||
&[data-selected='true'] {
|
||||
|
|
@ -416,7 +465,7 @@ onBeforeUnmount(() => {
|
|||
}
|
||||
|
||||
&:active {
|
||||
background: var(--MI_THEME-accentDarken);
|
||||
background: hsl(from var(--MI_THEME-accent) h s calc(l - 10));
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { userDetailed } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
userIds: string[];
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import MkButton from './MkButton.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<button
|
||||
v-if="!link"
|
||||
ref="el" class="_button"
|
||||
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]"
|
||||
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]"
|
||||
:type="type"
|
||||
:name="name"
|
||||
:value="value"
|
||||
:disabled="disabled"
|
||||
:disabled="disabled || wait"
|
||||
@click="emit('click', $event)"
|
||||
@mousedown="onMousedown"
|
||||
>
|
||||
|
|
@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</button>
|
||||
<MkA
|
||||
v-else class="_button"
|
||||
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]"
|
||||
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]"
|
||||
:to="to ?? '#'"
|
||||
:behavior="linkBehavior"
|
||||
@mousedown="onMousedown"
|
||||
|
|
@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onMounted, shallowRef } from 'vue';
|
||||
import { nextTick, onMounted, useTemplateRef } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
|
|
@ -57,14 +57,15 @@ const props = defineProps<{
|
|||
name?: string;
|
||||
value?: string;
|
||||
disabled?: boolean;
|
||||
iconOnly?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'click', payload: MouseEvent): void;
|
||||
}>();
|
||||
|
||||
const el = shallowRef<HTMLElement | null>(null);
|
||||
const ripples = shallowRef<HTMLElement | null>(null);
|
||||
const el = useTemplateRef('el');
|
||||
const ripples = useTemplateRef('ripples');
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autofocus) {
|
||||
|
|
@ -91,7 +92,7 @@ function onMousedown(evt: MouseEvent): void {
|
|||
const target = evt.target! as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
|
||||
const ripple = document.createElement('div');
|
||||
const ripple = window.document.createElement('div');
|
||||
ripple.classList.add(ripples.value!.dataset.childrenClass!);
|
||||
ripple.style.top = (evt.clientY - rect.top - 1).toString() + 'px';
|
||||
ripple.style.left = (evt.clientX - rect.left - 1).toString() + 'px';
|
||||
|
|
@ -147,6 +148,11 @@ function onMousedown(evt: MouseEvent): void {
|
|||
background: var(--MI_THEME-buttonHoverBg);
|
||||
}
|
||||
|
||||
&.iconOnly {
|
||||
padding: 7px;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
&.small {
|
||||
font-size: 90%;
|
||||
padding: 6px 12px;
|
||||
|
|
@ -220,28 +226,28 @@ function onMousedown(evt: MouseEvent): void {
|
|||
background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB));
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5)));
|
||||
background: linear-gradient(90deg, hsl(from var(--MI_THEME-buttonGradateA) h s calc(l + 5)), hsl(from var(--MI_THEME-buttonGradateB) h s calc(l + 5)));
|
||||
}
|
||||
|
||||
&:not(:disabled):active {
|
||||
background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5)));
|
||||
background: linear-gradient(90deg, hsl(from var(--MI_THEME-buttonGradateA) h s calc(l + 5)), hsl(from var(--MI_THEME-buttonGradateB) h s calc(l + 5)));
|
||||
}
|
||||
}
|
||||
|
||||
&.danger {
|
||||
font-weight: bold;
|
||||
color: #ff2a2a;
|
||||
color: var(--MI_THEME-error);
|
||||
|
||||
&.primary {
|
||||
color: #fff;
|
||||
background: #ff2a2a;
|
||||
background: var(--MI_THEME-error);
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: #ff4242;
|
||||
background: hsl(from var(--MI_THEME-error) h s calc(l + 10));
|
||||
}
|
||||
|
||||
&:not(:disabled):active {
|
||||
background: #d42e2e;
|
||||
background: hsl(from var(--MI_THEME-error) h s calc(l - 10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -250,6 +256,10 @@ function onMousedown(evt: MouseEvent): void {
|
|||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.wait {
|
||||
cursor: wait !important;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, shallowRef, computed, onMounted, onBeforeUnmount, watch, onUnmounted } from 'vue';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { ref, useTemplateRef, computed, onMounted, onBeforeUnmount, watch, onUnmounted } from 'vue';
|
||||
import { store } from '@/store.js';
|
||||
|
||||
// APIs provided by Captcha services
|
||||
// see: https://docs.hcaptcha.com/configuration/#javascript-api
|
||||
|
|
@ -53,6 +53,8 @@ type CaptchaContainer = {
|
|||
};
|
||||
|
||||
declare global {
|
||||
// Window を拡張してるため、空ではない
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface Window extends CaptchaContainer { }
|
||||
}
|
||||
|
||||
|
|
@ -70,7 +72,7 @@ const emit = defineEmits<{
|
|||
|
||||
const available = ref(false);
|
||||
|
||||
const captchaEl = shallowRef<HTMLDivElement | undefined>();
|
||||
const captchaEl = useTemplateRef('captchaEl');
|
||||
const captchaWidgetId = ref<string | undefined>(undefined);
|
||||
const testcaptchaInput = ref('');
|
||||
const testcaptchaPassed = ref(false);
|
||||
|
|
@ -96,6 +98,7 @@ const src = computed(() => {
|
|||
case 'fc': return 'https://cdn.jsdelivr.net/npm/friendly-challenge@0.9.18/widget.min.js';
|
||||
case 'mcaptcha': return null;
|
||||
case 'testcaptcha': return null;
|
||||
default: return null;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -115,7 +118,7 @@ watch(() => [props.instanceUrl, props.sitekey, props.secretKey], async () => {
|
|||
if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha') {
|
||||
available.value = true;
|
||||
} else if (src.value !== null) {
|
||||
(document.getElementById(scriptId.value) ?? document.head.appendChild(Object.assign(document.createElement('script'), {
|
||||
(window.document.getElementById(scriptId.value) ?? window.document.head.appendChild(Object.assign(window.document.createElement('script'), {
|
||||
async: true,
|
||||
id: scriptId.value,
|
||||
src: src.value,
|
||||
|
|
@ -152,12 +155,12 @@ async function requestRender() {
|
|||
if (captcha.value.render && captchaEl.value instanceof Element && props.sitekey) {
|
||||
// reCAPTCHAのレンダリング重複判定を回避するため、captchaEl配下に仮のdivを用意する.
|
||||
// (同じdivに対して複数回renderを呼び出すとreCAPTCHAはエラーを返すので)
|
||||
const elem = document.createElement('div');
|
||||
const elem = window.document.createElement('div');
|
||||
captchaEl.value.appendChild(elem);
|
||||
|
||||
captchaWidgetId.value = captcha.value.render(elem, {
|
||||
sitekey: props.sitekey,
|
||||
theme: defaultStore.state.darkMode ? 'dark' : 'light',
|
||||
theme: store.s.darkMode ? 'dark' : 'light',
|
||||
callback: callback,
|
||||
'expired-callback': () => callback(undefined),
|
||||
'error-callback': () => callback(undefined),
|
||||
|
|
@ -185,7 +188,7 @@ async function requestRender() {
|
|||
|
||||
function clearWidget() {
|
||||
if (props.provider === 'mcaptcha') {
|
||||
const container = document.getElementById('mcaptcha__widget-container');
|
||||
const container = window.document.getElementById('mcaptcha__widget-container');
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,20 +2,18 @@
|
|||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
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 type { StoryObj } from '@storybook/vue3';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
return new Promise(resolve => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export const Default = {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
|
|
@ -103,13 +103,13 @@ async function onClick() {
|
|||
background: var(--MI_THEME-accent);
|
||||
|
||||
&:hover {
|
||||
background: var(--MI_THEME-accentLighten);
|
||||
border-color: var(--MI_THEME-accentLighten);
|
||||
background: hsl(from var(--MI_THEME-accent) h s calc(l + 10));
|
||||
border-color: hsl(from var(--MI_THEME-accent) h s calc(l + 10));
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--MI_THEME-accentDarken);
|
||||
border-color: var(--MI_THEME-accentDarken);
|
||||
background: hsl(from var(--MI_THEME-accent) h s calc(l - 10));
|
||||
border-color: hsl(from var(--MI_THEME-accent) h s calc(l - 10));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { channel } from '../../.storybook/fakes.js';
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkPagination :pagination="pagination">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img :src="infoImageUrl" class="_ghost"/>
|
||||
<img :src="infoImageUrl" draggable="false"/>
|
||||
<div>{{ i18n.ts.notFound }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -19,8 +19,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Paging } from '@/components/MkPagination.vue';
|
||||
import MkChannelPreview from '@/components/MkChannelPreview.vue';
|
||||
import MkPagination, { Paging } from '@/components/MkPagination.vue';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { channel } from '../../.storybook/fakes.js';
|
||||
import MkChannelPreview from './MkChannelPreview.vue';
|
||||
export const Default = {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { http } from 'msw';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import { getChartResolver } from '../../.storybook/charts.js';
|
||||
|
|
|
|||
|
|
@ -45,23 +45,19 @@ export type ChartSrc =
|
|||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
/* eslint-disable id-denylist --
|
||||
Chart.js has a `data` attribute in most chart definitions, which triggers the
|
||||
id-denylist violation when setting it. This is causing about 60+ lint issues.
|
||||
As this is part of Chart.js's API it makes sense to disable the check here.
|
||||
*/
|
||||
import { onMounted, ref, shallowRef, watch } from 'vue';
|
||||
|
||||
import { onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import { Chart } from 'chart.js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { misskeyApiGet } from '@/scripts/misskey-api.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
|
||||
import { chartVLine } from '@/scripts/chart-vline.js';
|
||||
import { alpha } from '@/scripts/color.js';
|
||||
import { misskeyApiGet } from '@/utility/misskey-api.js';
|
||||
import { store } from '@/store.js';
|
||||
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
|
||||
import { chartVLine } from '@/utility/chart-vline.js';
|
||||
import { alpha } from '@/utility/color.js';
|
||||
import date from '@/filters/date.js';
|
||||
import bytes from '@/filters/bytes.js';
|
||||
import { initChart } from '@/scripts/init-chart.js';
|
||||
import { chartLegend } from '@/scripts/chart-legend.js';
|
||||
import { initChart } from '@/utility/init-chart.js';
|
||||
import { chartLegend } from '@/utility/chart-legend.js';
|
||||
import MkChartLegend from '@/components/MkChartLegend.vue';
|
||||
|
||||
initChart();
|
||||
|
|
@ -96,7 +92,7 @@ const props = withDefaults(defineProps<{
|
|||
nowForChromatic: undefined,
|
||||
});
|
||||
|
||||
const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>();
|
||||
const legendEl = useTemplateRef('legendEl');
|
||||
|
||||
const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
|
||||
const negate = arr => arr.map(x => -x);
|
||||
|
|
@ -134,7 +130,7 @@ let chartData: {
|
|||
bytes?: boolean;
|
||||
} | null = null;
|
||||
|
||||
const chartEl = shallowRef<HTMLCanvasElement | null>(null);
|
||||
const chartEl = useTemplateRef('chartEl');
|
||||
const fetching = ref(true);
|
||||
|
||||
const getDate = (ago: number) => {
|
||||
|
|
@ -161,7 +157,7 @@ const render = () => {
|
|||
chartInstance.destroy();
|
||||
}
|
||||
|
||||
const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
|
||||
const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
|
||||
|
||||
const maxes = chartData.series.map((x, i) => Math.max(...x.data.map(d => d.y)));
|
||||
|
||||
|
|
@ -849,7 +845,7 @@ watch(() => [props.src, props.span], fetchAndRender);
|
|||
onMounted(() => {
|
||||
fetchAndRender();
|
||||
});
|
||||
/* eslint-enable id-denylist */
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { shallowRef } from 'vue';
|
||||
import { Chart, LegendItem } from 'chart.js';
|
||||
import { Chart } from 'chart.js';
|
||||
import type { LegendItem } from 'chart.js';
|
||||
|
||||
const chart = shallowRef<Chart>();
|
||||
const type = shallowRef<string>();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { chatMessage } from '../../.storybook/fakes';
|
||||
import MkChatHistories from './MkChatHistories.vue';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import type * as Misskey from 'misskey-js';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkChatHistories,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkChatHistories v-bind="props" />',
|
||||
};
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
msw: {
|
||||
handlers: [
|
||||
http.post('/api/chat/history', async ({ request }) => {
|
||||
const body = await request.json() as Misskey.entities.ChatHistoryRequest;
|
||||
action('POST /api/chat/history')(body);
|
||||
return HttpResponse.json([chatMessage(body.room)]);
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkChatHistories>;
|
||||
208
packages/frontend/src/components/MkChatHistories.vue
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div v-if="history.length > 0" class="_gaps_s">
|
||||
<MkA
|
||||
v-for="item in history"
|
||||
:key="item.id"
|
||||
:class="[$style.message, { [$style.isMe]: item.isMe, [$style.isRead]: item.message.isRead }]"
|
||||
class="_panel"
|
||||
:to="item.message.toRoomId ? `/chat/room/${item.message.toRoomId}` : `/chat/user/${item.other!.id}`"
|
||||
>
|
||||
<MkAvatar v-if="item.message.toRoomId" :class="$style.messageAvatar" :user="item.message.fromUser" indicator :preview="false"/>
|
||||
<MkAvatar v-else-if="item.other" :class="$style.messageAvatar" :user="item.other" indicator :preview="false"/>
|
||||
<div :class="$style.messageBody">
|
||||
<header v-if="item.message.toRoom" :class="$style.messageHeader">
|
||||
<span :class="$style.messageHeaderName"><i class="ti ti-users"></i> {{ item.message.toRoom.name }}</span>
|
||||
<MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/>
|
||||
</header>
|
||||
<header v-else :class="$style.messageHeader">
|
||||
<MkUserName :class="$style.messageHeaderName" :user="item.other!"/>
|
||||
<MkAcct :class="$style.messageHeaderUsername" :user="item.other!"/>
|
||||
<MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/>
|
||||
</header>
|
||||
<div :class="$style.messageBodyText"><span v-if="item.isMe" :class="$style.youSaid">{{ i18n.ts.you }}:</span>{{ item.message.text }}</div>
|
||||
</div>
|
||||
</MkA>
|
||||
</div>
|
||||
<div v-if="!initializing && history.length == 0" class="_fullinfo">
|
||||
<div>{{ i18n.ts._chat.noHistory }}</div>
|
||||
</div>
|
||||
<MkLoading v-if="initializing"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onActivated, onDeactivated, onMounted, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { useInterval } from '@@/js/use-interval.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
||||
const history = ref<{
|
||||
id: string;
|
||||
message: Misskey.entities.ChatMessage;
|
||||
other: Misskey.entities.ChatMessage['fromUser'] | Misskey.entities.ChatMessage['toUser'] | null;
|
||||
isMe: boolean;
|
||||
}[]>([]);
|
||||
|
||||
const initializing = ref(true);
|
||||
const fetching = ref(false);
|
||||
|
||||
async function fetchHistory() {
|
||||
if (fetching.value) return;
|
||||
|
||||
fetching.value = true;
|
||||
|
||||
const [userMessages, roomMessages] = await Promise.all([
|
||||
misskeyApi('chat/history', { room: false }),
|
||||
misskeyApi('chat/history', { room: true }),
|
||||
]);
|
||||
|
||||
history.value = [...userMessages, ...roomMessages]
|
||||
.toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.map(m => ({
|
||||
id: m.id,
|
||||
message: m,
|
||||
other: (!('room' in m) || m.room == null) ? (m.fromUserId === $i.id ? m.toUser : m.fromUser) : null,
|
||||
isMe: m.fromUserId === $i.id,
|
||||
}));
|
||||
|
||||
fetching.value = false;
|
||||
initializing.value = false;
|
||||
}
|
||||
|
||||
let isActivated = true;
|
||||
|
||||
onActivated(() => {
|
||||
isActivated = true;
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
isActivated = false;
|
||||
});
|
||||
|
||||
useInterval(() => {
|
||||
// TODO: DOM的にバックグラウンドになっていないかどうかも考慮する
|
||||
if (!window.document.hidden && isActivated) {
|
||||
fetchHistory();
|
||||
}
|
||||
}, 1000 * 10, {
|
||||
immediate: false,
|
||||
afterMounted: true,
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
fetchHistory();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchHistory();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.message {
|
||||
position: relative;
|
||||
display: flex;
|
||||
padding: 16px 24px;
|
||||
|
||||
&.isRead,
|
||||
&.isMe {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:not(.isMe):not(.isRead) {
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 100%;
|
||||
background-color: var(--MI_THEME-accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 500px) {
|
||||
.message {
|
||||
font-size: 90%;
|
||||
padding: 14px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 450px) {
|
||||
.message {
|
||||
font-size: 80%;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.messageAvatar {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin: 0 16px 0 0;
|
||||
}
|
||||
|
||||
@container (max-width: 500px) {
|
||||
.messageAvatar {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 450px) {
|
||||
.messageAvatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.messageBody {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.messageHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.messageHeaderName {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.messageHeaderUsername {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.messageHeaderTime {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.messageBodyText {
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.youSaid {
|
||||
font-weight: bold;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -2,18 +2,16 @@
|
|||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { expect, userEvent, within } from '@storybook/test';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkClickerGame from './MkClickerGame.vue';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
return new Promise(resolve => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export const Default = {
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
|
|||
import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { useInterval } from '@@/js/use-interval.js';
|
||||
import * as game from '@/scripts/clicker-game.js';
|
||||
import * as game from '@/utility/clicker-game.js';
|
||||
import number from '@/filters/number.js';
|
||||
import { claimAchievement } from '@/scripts/achievements.js';
|
||||
import { claimAchievement } from '@/utility/achievements.js';
|
||||
|
||||
const saveData = game.saveData;
|
||||
const cookies = computed(() => saveData.value?.cookies);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { clip } from '../../.storybook/fakes.js';
|
||||
import MkClipPreview from './MkClipPreview.vue';
|
||||
export const Default = {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
import * as Misskey from 'misskey-js';
|
||||
import { computed } from 'vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { $i } from '@/account.js';
|
||||
import { $i } from '@/i.js';
|
||||
import number from '@/filters/number.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
import { computed, ref, watch } from 'vue';
|
||||
import { bundledLanguagesInfo } from 'shiki/langs';
|
||||
import type { BundledLanguage } from 'shiki/langs';
|
||||
import { getHighlighter, getTheme } from '@/scripts/code-highlighter.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { getHighlighter, getTheme } from '@/utility/code-highlighter.js';
|
||||
import { store } from '@/store.js';
|
||||
|
||||
const props = defineProps<{
|
||||
code: string;
|
||||
|
|
@ -22,7 +22,7 @@ const props = defineProps<{
|
|||
}>();
|
||||
|
||||
const highlighter = await getHighlighter();
|
||||
const darkMode = defaultStore.reactiveState.darkMode;
|
||||
const darkMode = store.r.darkMode;
|
||||
const codeLang = ref<BundledLanguage | 'aiscript'>('js');
|
||||
|
||||
const [lightThemeName, darkThemeName] = await Promise.all([
|
||||
|
|
@ -93,7 +93,7 @@ watch(() => props.lang, (to) => {
|
|||
|
||||
.codeBlockRoot :global(.shiki) {
|
||||
padding: 1em;
|
||||
margin: .5em 0;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
border-radius: var(--MI-radius-sm);
|
||||
border: 1px solid var(--MI_THEME-divider);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import MkCode from './MkCode.vue';
|
||||
const code = `for (let i, 100) {
|
||||
<: if (i % 15 == 0) "FizzBuzz"
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</button>
|
||||
<Suspense>
|
||||
<template #fallback>
|
||||
<MkLoading />
|
||||
<MkLoading/>
|
||||
</template>
|
||||
<XCode v-if="show && lang" :code="code" :lang="lang"/>
|
||||
<pre v-else-if="show" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre>
|
||||
<XCode v-if="show && lang" class="_selectable" :code="code" :lang="lang"/>
|
||||
<pre v-else-if="show" class="_selectable" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre>
|
||||
<button v-else :class="$style.codePlaceholderRoot" @click="show = true">
|
||||
<div :class="$style.codePlaceholderContainer">
|
||||
<div><i class="ti ti-code"></i> {{ i18n.ts.code }}</div>
|
||||
|
|
@ -28,9 +28,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
import { defineAsyncComponent, ref } from 'vue';
|
||||
import * as os from '@/os.js';
|
||||
import MkLoading from '@/components/global/MkLoading.vue';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
|
||||
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
code: string;
|
||||
|
|
@ -42,13 +42,12 @@ const props = withDefaults(defineProps<{
|
|||
forceShow: false,
|
||||
});
|
||||
|
||||
const show = ref(props.forceShow === true ? true : !defaultStore.state.dataSaver.code);
|
||||
const show = ref(props.forceShow === true ? true : !prefer.s.dataSaver.code);
|
||||
|
||||
const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue'));
|
||||
|
||||
function copy() {
|
||||
copyToClipboard(props.code);
|
||||
os.success();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -71,11 +70,9 @@ function copy() {
|
|||
.codeBlockFallbackRoot {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
background: var(--MI_THEME-bg);
|
||||
padding: 1em;
|
||||
margin: .5em 0;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
border-radius: var(--MI-radius-sm);
|
||||
}
|
||||
|
||||
.codeBlockFallbackCode {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import MkCodeEditor from './MkCodeEditor.vue';
|
||||
const code = `for (let i, 100) {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, toRefs, shallowRef, nextTick } from 'vue';
|
||||
import { ref, watch, toRefs, useTemplateRef, nextTick } from 'vue';
|
||||
import { debounce } from 'throttle-debounce';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
|
@ -61,7 +61,7 @@ const { modelValue } = toRefs(props);
|
|||
const v = ref<string>(modelValue.value ?? '');
|
||||
const focused = ref(false);
|
||||
const changed = ref(false);
|
||||
const inputEl = shallowRef<HTMLTextAreaElement>();
|
||||
const inputEl = useTemplateRef('inputEl');
|
||||
|
||||
const focus = () => inputEl.value?.focus();
|
||||
|
||||
|
|
@ -140,7 +140,7 @@ watch(v, newValue => {
|
|||
.caption {
|
||||
font-size: 0.85em;
|
||||
padding: 8px 0 0 0;
|
||||
color: var(--MI_THEME-fgTransparentWeak);
|
||||
color: color(from var(--MI_THEME-fg) srgb r g b / 0.75);
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import MkCodeInline from './MkCodeInline.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import MkColorInput from './MkColorInput.vue';
|
||||
export const Default = {
|
||||
|
|
|
|||
|
|
@ -19,12 +19,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
@input="onInput"
|
||||
>
|
||||
</div>
|
||||
<MkButton @click="removeColor">{{ i18n.ts.reset }}</MkButton>
|
||||
<div :class="$style.caption"><slot name="caption"></slot></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, shallowRef, toRefs } from 'vue';
|
||||
import { ref, useTemplateRef, toRefs } from 'vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | null;
|
||||
|
|
@ -39,11 +42,15 @@ const emit = defineEmits<{
|
|||
|
||||
const { modelValue } = toRefs(props);
|
||||
const v = ref(modelValue.value);
|
||||
const inputEl = shallowRef<HTMLElement>();
|
||||
const inputEl = useTemplateRef('inputEl');
|
||||
|
||||
const onInput = () => {
|
||||
emit('update:modelValue', v.value ?? '');
|
||||
};
|
||||
const removeColor = () => {
|
||||
v.value = null;
|
||||
onInput();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
|
@ -60,7 +67,7 @@ const onInput = () => {
|
|||
.caption {
|
||||
font-size: 0.85em;
|
||||
padding: 8px 0 0 0;
|
||||
color: var(--MI_THEME-fgTransparentWeak);
|
||||
color: color(from var(--MI_THEME-fg) srgb r g b / 0.75);
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
|
|
|
|||
|
|
@ -19,10 +19,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</header>
|
||||
<Transition
|
||||
:enterActiveClass="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''"
|
||||
:leaveActiveClass="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''"
|
||||
:enterFromClass="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''"
|
||||
:leaveToClass="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''"
|
||||
:enterActiveClass="prefer.s.animation ? $style.transition_toggle_enterActive : ''"
|
||||
:leaveActiveClass="prefer.s.animation ? $style.transition_toggle_leaveActive : ''"
|
||||
:enterFromClass="prefer.s.animation ? $style.transition_toggle_enterFrom : ''"
|
||||
:leaveToClass="prefer.s.animation ? $style.transition_toggle_leaveTo : ''"
|
||||
@enter="enter"
|
||||
@afterEnter="afterEnter"
|
||||
@leave="leave"
|
||||
|
|
@ -39,8 +39,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
|
|
@ -58,9 +58,9 @@ const props = withDefaults(defineProps<{
|
|||
maxHeight: null,
|
||||
});
|
||||
|
||||
const rootEl = shallowRef<HTMLElement>();
|
||||
const contentEl = shallowRef<HTMLElement>();
|
||||
const headerEl = shallowRef<HTMLElement>();
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
const contentEl = useTemplateRef('contentEl');
|
||||
const headerEl = useTemplateRef('headerEl');
|
||||
const showBody = ref(props.expanded);
|
||||
const ignoreOmit = ref(false);
|
||||
const omitted = ref(false);
|
||||
|
|
@ -180,11 +180,15 @@ onUnmounted(() => {
|
|||
top: var(--MI-stickyTop, 0px);
|
||||
left: 0;
|
||||
color: var(--MI_THEME-panelHeaderFg);
|
||||
background: var(--MI_THEME-panelHeaderBg);
|
||||
border-bottom: solid 0.5px var(--MI_THEME-panelHeaderDivider);
|
||||
background: color-mix(in srgb, var(--MI_THEME-panelHeaderBg) 35%, transparent);
|
||||
z-index: 2;
|
||||
line-height: 1.4em;
|
||||
background: color-mix(in srgb, var(--MI_THEME-panelHeaderBg) 35%, transparent);
|
||||
}
|
||||
|
||||
@container style(--MI_THEME-panelHeaderBg: var(--MI_THEME-panel)) {
|
||||
.header {
|
||||
box-shadow: 0 0.5px 0 0 light-dark(#0002, #fff2);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
|
|
@ -216,6 +220,14 @@ onUnmounted(() => {
|
|||
.content {
|
||||
--MI-stickyTop: 0px;
|
||||
|
||||
/*
|
||||
理屈は知らないけど、ここでbackgroundを設定しておかないと
|
||||
スクロールコンテナーが少なくともChromeにおいて
|
||||
main thread scrolling になってしまい、パフォーマンスが(多分)落ちる。
|
||||
backgroundが透明だと裏側を描画しないといけなくなるとかそういう理由かもしれない
|
||||
*/
|
||||
background: var(--MI_THEME-panel);
|
||||
|
||||
&.omitted {
|
||||
position: relative;
|
||||
max-height: var(--maxHeight);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { userEvent, within } from '@storybook/test';
|
||||
import MkContextMenu from './MkContextMenu.vue';
|
||||
import * as os from '@/os.js';
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<Transition
|
||||
appear
|
||||
:enterActiveClass="defaultStore.state.animation ? $style.transition_fade_enterActive : ''"
|
||||
:leaveActiveClass="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''"
|
||||
:enterFromClass="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''"
|
||||
:leaveToClass="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''"
|
||||
:enterActiveClass="prefer.s.animation ? $style.transition_fade_enterActive : ''"
|
||||
:leaveActiveClass="prefer.s.animation ? $style.transition_fade_leaveActive : ''"
|
||||
:enterFromClass="prefer.s.animation ? $style.transition_fade_enterFrom : ''"
|
||||
:leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''"
|
||||
>
|
||||
<div ref="rootEl" :class="$style.root" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}">
|
||||
<MkMenu :items="items" :align="'left'" @close="emit('closed')"/>
|
||||
|
|
@ -18,11 +18,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onBeforeUnmount, shallowRef, ref } from 'vue';
|
||||
import { onMounted, onBeforeUnmount, useTemplateRef, ref } from 'vue';
|
||||
import MkMenu from './MkMenu.vue';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import contains from '@/scripts/contains.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import contains from '@/utility/contains.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import * as os from '@/os.js';
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -34,7 +34,7 @@ const emit = defineEmits<{
|
|||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const rootEl = shallowRef<HTMLDivElement>();
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
|
||||
const zIndex = ref<number>(os.claimZIndex('high'));
|
||||
|
||||
|
|
@ -68,11 +68,11 @@ onMounted(() => {
|
|||
rootEl.value.style.left = `${left}px`;
|
||||
}
|
||||
|
||||
document.body.addEventListener('mousedown', onMousedown);
|
||||
window.document.body.addEventListener('mousedown', onMousedown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.body.removeEventListener('mousedown', onMousedown);
|
||||
window.document.body.removeEventListener('mousedown', onMousedown);
|
||||
});
|
||||
|
||||
function onMousedown(evt: Event) {
|
||||
|
|
|
|||
|
|
@ -3,14 +3,12 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { file } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkCropperDialog from './MkCropperDialog.vue';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
|
|
@ -55,7 +53,7 @@ export const Default = {
|
|||
http.get('/proxy/image.webp', async ({ request }) => {
|
||||
const url = new URL(request.url).searchParams.get('url');
|
||||
if (url === 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true') {
|
||||
const image = await (await fetch('client-assets/fedi.jpg')).blob();
|
||||
const image = await (await window.fetch('client-assets/fedi.jpg')).blob();
|
||||
return new HttpResponse(image, {
|
||||
headers: {
|
||||
'Content-Type': 'image/jpeg',
|
||||
|
|
|
|||
|
|
@ -31,17 +31,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, shallowRef, ref } from 'vue';
|
||||
import { onMounted, useTemplateRef, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import Cropper from 'cropperjs';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { apiUrl } from '@@/js/config.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { $i } from '@/account.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { apiUrl } from '@@/js/config.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { getProxiedImageUrl } from '@/scripts/media-proxy.js';
|
||||
import { getProxiedImageUrl } from '@/utility/media-proxy.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'ok', cropped: Misskey.entities.DriveFile): void;
|
||||
|
|
@ -56,8 +56,8 @@ const props = defineProps<{
|
|||
}>();
|
||||
|
||||
const imgUrl = getProxiedImageUrl(props.file.url, undefined, true);
|
||||
const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
|
||||
const imgEl = shallowRef<HTMLImageElement>();
|
||||
const dialogEl = useTemplateRef('dialogEl');
|
||||
const imgEl = useTemplateRef('imgEl');
|
||||
let cropper: Cropper | null = null;
|
||||
const loading = ref(true);
|
||||
|
||||
|
|
@ -81,8 +81,8 @@ const ok = async () => {
|
|||
formData.append('i', $i!.token);
|
||||
if (props.uploadFolder) {
|
||||
formData.append('folderId', props.uploadFolder);
|
||||
} else if (props.uploadFolder !== null && defaultStore.state.uploadFolder) {
|
||||
formData.append('folderId', defaultStore.state.uploadFolder);
|
||||
} else if (props.uploadFolder !== null && prefer.s.uploadFolder) {
|
||||
formData.append('folderId', prefer.s.uploadFolder);
|
||||
}
|
||||
|
||||
window.fetch(apiUrl + '/drive/files/create', {
|
||||
|
|
@ -122,7 +122,7 @@ onMounted(() => {
|
|||
cropper = new Cropper(imgEl.value!, {
|
||||
});
|
||||
|
||||
const computedStyle = getComputedStyle(document.documentElement);
|
||||
const computedStyle = getComputedStyle(window.document.documentElement);
|
||||
|
||||
const selection = cropper.getCropperSelection()!;
|
||||
selection.themeColor = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString();
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { emojiDetailed } from '../../.storybook/fakes.js';
|
||||
import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue';
|
||||
export const Default = {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkModalWindow ref="dialogEl" @close="cancel()" @closed="emit('closed')">
|
||||
<template #header>:{{ emoji.name }}:</template>
|
||||
<template #default>
|
||||
<MkSpacer>
|
||||
<div class="_spacer">
|
||||
<div style="display: flex; flex-direction: column; gap: 1em;">
|
||||
<div :class="$style.emojiImgWrapper">
|
||||
<MkCustomEmoji :name="emoji.name" :normal="true" :useOriginalSize="true" style="height: 100%;"></MkCustomEmoji>
|
||||
|
|
@ -50,21 +50,21 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
</MkKeyValue>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
</template>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { shallowRef } from 'vue';
|
||||
import { useTemplateRef } from 'vue';
|
||||
import MkLink from '@/components/MkLink.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import MkKeyValue from '@/components/MkKeyValue.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
emoji: Misskey.entities.EmojiDetailed,
|
||||
emoji: Misskey.entities.EmojiDetailed,
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -73,7 +73,7 @@ const emit = defineEmits<{
|
|||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
|
||||
const dialogEl = useTemplateRef('dialogEl');
|
||||
|
||||
function cancel() {
|
||||
emit('cancel');
|
||||
|
|
@ -85,7 +85,7 @@ function cancel() {
|
|||
.emojiImgWrapper {
|
||||
max-width: 100%;
|
||||
height: 40cqh;
|
||||
background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-X5) 8px, var(--MI_THEME-X5) 14px);
|
||||
background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)) 8px, light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)) 14px);
|
||||
border-radius: var(--MI-radius);
|
||||
margin: auto;
|
||||
overflow-y: hidden;
|
||||
|
|
@ -101,7 +101,7 @@ function cancel() {
|
|||
display: inline-block;
|
||||
word-break: break-all;
|
||||
padding: 3px 10px;
|
||||
background-color: var(--MI_THEME-X5);
|
||||
background-color: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05));
|
||||
border: solid 1px var(--MI_THEME-divider);
|
||||
border-radius: var(--MI-radius);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { expect, userEvent, within } from '@storybook/test';
|
||||
import { file } from '../../.storybook/fakes.js';
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
import { computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import type { PollEditorModelValue } from '@/components/MkPollEditor.vue';
|
||||
import { concat } from '@/scripts/array.js';
|
||||
import { concat } from '@/utility/array.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
|
||||
|
|
|
|||
|
|
@ -3,16 +3,19 @@ SPDX-FileCopyrightText: syuilo and misskey-project
|
|||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<!-- TODO: 親からスタイルを当てにくいことや実装がトリッキーなことを鑑み廃止または使用の縮小(timeline-date-separate.tsを使う) -->
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, h, PropType, TransitionGroup, useCssModule } from 'vue';
|
||||
import { defineComponent, h, TransitionGroup, useCssModule } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import type { MisskeyEntity } from '@/types/date-separated-list.js';
|
||||
import MkAd from '@/components/global/MkAd.vue';
|
||||
import { isDebuggerEnabled, stackTraceInstances } from '@/debug.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { MisskeyEntity } from '@/types/date-separated-list.js';
|
||||
import { $i } from '@/account.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { getDateText } from '@/utility/timeline-date-separate.js';
|
||||
import { $i } from '@/i.js';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
|
|
@ -45,15 +48,6 @@ export default defineComponent({
|
|||
setup(props, { slots, expose }) {
|
||||
const $style = useCssModule(); // カスタムレンダラなので使っても大丈夫
|
||||
|
||||
function getDateText(dateInstance: Date) {
|
||||
const date = dateInstance.getDate();
|
||||
const month = dateInstance.getMonth() + 1;
|
||||
return i18n.tsx.monthAndDay({
|
||||
month: month.toString(),
|
||||
day: date.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (props.items.length === 0) return;
|
||||
|
||||
const renderChildrenImpl = (shouldHideAds: boolean) => props.items.map((item, i) => {
|
||||
|
|
@ -115,7 +109,7 @@ export default defineComponent({
|
|||
});
|
||||
|
||||
const renderChildren = () => {
|
||||
const shouldHideAds = !defaultStore.state.forceShowAds && $i && $i.policies.canHideAds;
|
||||
const shouldHideAds = (!prefer.s.forceShowAds && $i && $i.policies.canHideAds) ?? false;
|
||||
|
||||
const children = renderChildrenImpl(shouldHideAds);
|
||||
if (isDebuggerEnabled(6864)) {
|
||||
|
|
@ -152,7 +146,7 @@ export default defineComponent({
|
|||
[$style['direction-up']]: props.direction === 'up',
|
||||
};
|
||||
|
||||
return () => defaultStore.state.animation ? h(TransitionGroup, {
|
||||
return () => prefer.s.animation ? h(TransitionGroup, {
|
||||
class: classes,
|
||||
name: 'list',
|
||||
tag: 'div',
|
||||
|
|
@ -170,21 +164,17 @@ export default defineComponent({
|
|||
container-type: inline-size;
|
||||
|
||||
&:global {
|
||||
> .list-move {
|
||||
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
> .list-move {
|
||||
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
&.deny-move-transition > .list-move {
|
||||
transition: none !important;
|
||||
}
|
||||
> .list-enter-active {
|
||||
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
> .list-enter-active {
|
||||
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
> *:empty {
|
||||
display: none;
|
||||
}
|
||||
> *:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.date-separated-list-nogap) > *:not(:last-child) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { expect, userEvent, waitFor, within } from '@storybook/test';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkDialog from './MkDialog.vue';
|
||||
const Base = {
|
||||
|
|
|
|||