Merge branch Sharkey:develop into trackeropt

This commit is contained in:
Vavency 2025-05-08 11:38:00 +00:00
commit 7a66f3718e
1335 changed files with 46115 additions and 25421 deletions

View file

@ -55,7 +55,7 @@ await fs.readFile(
'../../locales/ja-JP.yml',
'assets/**',
'public/**',
'../../pnpm-lock.yaml',
'package.json',
]).length
) {
return;

View file

@ -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';

View file

@ -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;

View file

@ -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,

View file

@ -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'),

View file

@ -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: [
{

View file

@ -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 {

View file

@ -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();

View file

@ -4,7 +4,7 @@
*/
declare module '@@/themes/*.json5' {
import { Theme } from '@/scripts/theme.js';
import { Theme } from '@/theme.js';
const theme: Theme;

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View 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).

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View file

@ -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/",

View file

@ -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,

View file

@ -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';

View 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;
}
};
}

View file

@ -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"
}
}

View file

@ -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();

View file

@ -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;
}

View 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();
},
});
});
}

View file

@ -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');
}

View file

@ -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);

View file

@ -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';

View file

@ -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';

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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'));

View file

@ -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 });

View 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>

View 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>

View file

@ -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);

View file

@ -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';

View file

@ -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() {

View file

@ -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';

View file

@ -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>();

View file

@ -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 {

View file

@ -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;

View file

@ -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 = {

View file

@ -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)';

View file

@ -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;

View file

@ -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';

View file

@ -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),
});
}

View file

@ -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';

View file

@ -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()),

View file

@ -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';

View file

@ -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);

View file

@ -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';

View file

@ -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;

View file

@ -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 {

View file

@ -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;
}
}

View file

@ -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';

View file

@ -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[];

View file

@ -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) {

View file

@ -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;
}

View file

@ -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) {
// reCAPTCHAcaptchaEldiv.
// divrenderreCAPTCHA
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 = '';
}

View file

@ -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 = {

View file

@ -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));
}
}

View file

@ -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';

View file

@ -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';

View file

@ -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 = {

View file

@ -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';

View file

@ -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>

View file

@ -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>();

View file

@ -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>;

View 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>

View file

@ -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 = {

View file

@ -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);

View file

@ -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 = {

View file

@ -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<{

View file

@ -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);

View file

@ -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"

View file

@ -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 {

View file

@ -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) {

View file

@ -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;

View file

@ -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) {

View file

@ -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 = {

View file

@ -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;

View file

@ -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);

View file

@ -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';

View file

@ -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) {

View file

@ -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',

View file

@ -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();

View file

@ -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 = {

View file

@ -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);
}

View file

@ -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';

View file

@ -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';

View file

@ -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) {

View file

@ -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 = {

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