Merge branch 'develop' into mahjong

This commit is contained in:
syuilo
2025-04-10 11:39:35 +09:00
1539 changed files with 96547 additions and 41240 deletions

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

@@ -0,0 +1,154 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import seedrandom from 'seedrandom';
/**
* AIで生成した無作為なファーストネーム
*/
export const firstNameDict = [
'Ethan', 'Olivia', 'Jackson', 'Emma', 'Liam', 'Ava', 'Aiden', 'Sophia', 'Mason', 'Isabella',
'Noah', 'Mia', 'Lucas', 'Harper', 'Caleb', 'Abigail', 'Samuel', 'Emily', 'Logan',
'Madison', 'Benjamin', 'Chloe', 'Elijah', 'Grace', 'Alexander', 'Scarlett', 'William', 'Zoey', 'James', 'Lily',
]
/**
* AIで生成した無作為なラストネーム
*/
export const lastNameDict = [
'Anderson', 'Johnson', 'Thompson', 'Davis', 'Rodriguez', 'Smith', 'Patel', 'Williams', 'Lee', 'Brown',
'Garcia', 'Jackson', 'Martinez', 'Taylor', 'Harris', 'Nguyen', 'Miller', 'Jones', 'Wilson',
'White', 'Thomas', 'Garcia', 'Martinez', 'Robinson', 'Turner', 'Lewis', 'Hall', 'King', 'Baker', 'Cooper',
]
/**
* AIで生成した無作為な国名
*/
export const countryDict = [
'Japan', 'Canada', 'Brazil', 'Australia', 'Italy', 'SouthAfrica', 'Mexico', 'Sweden', 'Russia', 'India',
'Germany', 'Argentina', 'South Korea', 'France', 'Nigeria', 'Turkey', 'Spain', 'Egypt', 'Thailand',
'Vietnam', 'Kenya', 'Saudi Arabia', 'Netherlands', 'Colombia', 'Poland', 'Chile', 'Malaysia', 'Ukraine', 'New Zealand', 'Peru',
]
export function text(length: number = 10, seed?: string): string {
let result = "";
// シード値を使う場合、同じ数値が羅列されるが、ランダム文字列という意味では満たせていると思うのでこのまま使っておく
const rand = seed ? seedrandom(seed)() : Math.random();
while (result.length < length) {
result += rand.toString(36).substring(2);
}
return result.substring(0, length);
}
export function integer(min: number = 0, max: number = 9999, seed?: string): number {
const rand = seed ? seedrandom(seed)() : Math.random();
return Math.floor(rand * (max - min)) + min;
}
export function date(params?: {
yearMin?: number,
yearMax?: number,
monthMin?: number,
monthMax?: number,
dayMin?: number,
dayMax?: number,
hourMin?: number,
hourMax?: number,
minuteMin?: number,
minuteMax?: number,
secondMin?: number,
secondMax?: number,
millisecondMin?: number,
millisecondMax?: number,
}, seed?: string): Date {
const year = integer(params?.yearMin ?? 1970, params?.yearMax ?? (new Date()).getFullYear(), seed);
const month = integer(params?.monthMin ?? 1, params?.monthMax ?? 12, seed);
let day = integer(params?.dayMin ?? 1, params?.dayMax ?? 31, seed);
if (month === 2) {
day = Math.min(day, 28);
} else if ([4, 6, 9, 11].includes(month)) {
day = Math.min(day, 30);
} else {
day = Math.min(day, 31);
}
const hour = integer(params?.hourMin ?? 0, params?.hourMax ?? 23, seed);
const minute = integer(params?.minuteMin ?? 0, params?.minuteMax ?? 59, seed);
const second = integer(params?.secondMin ?? 0, params?.secondMax ?? 59, seed);
const millisecond = integer(params?.millisecondMin ?? 0, params?.millisecondMax ?? 999, seed);
return new Date(year, month - 1, day, hour, minute, second, millisecond);
}
export function boolean(seed?: string): boolean {
const rand = seed ? seedrandom(seed)() : Math.random();
return rand < 0.5;
}
export function choose<T>(array: T[], seed?: string): T {
const rand = seed ? seedrandom(seed)() : Math.random();
return array[Math.floor(rand * array.length)];
}
export function firstName(seed?: string): string {
return choose(firstNameDict, seed);
}
export function lastName(seed?: string): string {
return choose(lastNameDict, seed);
}
export function country(seed?: string): string {
return choose(countryDict, seed);
}
const TIME2000 = 946684800000;
export function fakeId(seed?: string): string {
let time = new Date().getTime();
time = time - TIME2000;
if (time < 0) time = 0;
const timeStr = time.toString(36).padStart(8, '0');
const noiseStr = text(2, seed);
return timeStr + noiseStr;
}
export function imageDataUrl(options?: {
size?: {
width?: number,
height?: number,
},
color?: {
red?: number,
green?: number,
blue?: number,
alpha?: number,
}
}, seed?: string): string {
const canvas = window.document.createElement('canvas');
canvas.width = options?.size?.width ?? 100;
canvas.height = options?.size?.height ?? 100;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get 2d context');
}
ctx.beginPath()
const red = options?.color?.red ?? integer(0, 255, seed);
const green = options?.color?.green ?? integer(0, 255, seed);
const blue = options?.color?.blue ?? integer(0, 255, seed);
const alpha = options?.color?.alpha ?? 1;
ctx.arc(canvas.width / 2, canvas.height / 2, canvas.width / 2, 0, Math.PI * 2, true);
ctx.fillStyle = `rgba(${red}, ${green}, ${blue}, ${alpha})`;
ctx.fill();
return canvas.toDataURL('image/png', 1.0);
}

View File

@@ -3,7 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { AISCRIPT_VERSION } from '@syuilo/aiscript';
import type { entities } from 'misskey-js'
import { date, imageDataUrl, text } from "./fake-utils.js";
export function abuseUserReport() {
return {
@@ -47,18 +49,7 @@ export function clip(id = 'someclipid', name = 'Some Clip'): entities.Clip {
createdAt: '2016-12-28T22:49:51.000Z',
lastClippedAt: null,
userId: 'someuserid',
user: {
id: 'someuserid',
name: 'Misskey User',
username: 'miskist',
host: 'misskey-hub.net',
avatarUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true',
avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay',
avatarDecorations: [],
emojis: {},
badgeRoles: [],
onlineStatus: 'unknown',
},
user: userLite(),
notesCount: undefined,
name,
description: 'Some clip description',
@@ -125,6 +116,49 @@ export function file(isSensitive = false) {
};
}
const script = `/// @ ${AISCRIPT_VERSION}
var name = ""
Ui:render([
Ui:C:textInput({
label: "Your name"
onInput: @(v) { name = v }
})
Ui:C:button({
text: "Hello"
onClick: @() {
Mk:dialog(null, \`Hello, {name}!\`)
}
})
])
`;
export function flash(): entities.Flash {
return {
id: 'someflashid',
createdAt: '2016-12-28T22:49:51.000Z',
updatedAt: '2016-12-28T22:49:51.000Z',
userId: 'someuserid',
user: userLite(),
title: 'Some Play title',
summary: 'Some Play summary',
script,
visibility: 'public',
likedCount: 0,
isLiked: false,
};
}
export function folder(id = 'somefolderid', name = 'Some Folder', parentId: string | null = null): entities.DriveFolder {
return {
id,
createdAt: '2016-12-28T22:49:51.000Z',
name,
parentId,
};
}
export function federationInstance(): entities.FederationInstance {
return {
id: 'someinstanceid',
@@ -154,7 +188,27 @@ export function federationInstance(): entities.FederationInstance {
};
}
export function userDetailed(id = 'someuserid', username = 'miskist', host = 'misskey-hub.net', name = 'Misskey User'): entities.UserDetailed {
export function note(id = 'somenoteid'): entities.Note {
return {
id,
createdAt: '2016-12-28T22:49:51.000Z',
deletedAt: null,
text: 'some note',
cw: null,
userId: 'someuserid',
user: userLite(),
visibility: 'public',
reactionAcceptance: 'nonSensitiveOnly',
reactionEmojis: {},
reactions: {},
myReaction: null,
reactionCount: 0,
renoteCount: 0,
repliesCount: 0,
};
}
export function userLite(id = 'someuserid', username = 'miskist', host: entities.UserDetailed['host'] = 'misskey-hub.net', name: entities.UserDetailed['name'] = 'Misskey User'): entities.UserLite {
return {
id,
username,
@@ -165,6 +219,12 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi
avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay',
avatarDecorations: [],
emojis: {},
};
}
export function userDetailed(id = 'someuserid', username = 'miskist', host: entities.UserDetailed['host'] = 'misskey-hub.net', name: entities.UserDetailed['name'] = 'Misskey User'): entities.UserDetailed {
return {
...userLite(id, username, host, name),
bannerBlurhash: 'eQA^IW^-MH8w9tE8I=S^o{$*R4RikXtSxutRozjEnNR.RQadoyozog',
bannerUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true',
birthday: '2014-06-20',
@@ -215,7 +275,7 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi
movedTo: null,
alsoKnownAs: null,
notify: 'none',
memo: null
memo: null,
};
}
@@ -242,3 +302,93 @@ export function inviteCode(isUsed = false, hasExpiration = false, isExpired = fa
used: isUsed,
}
}
export function role(params: {
id?: string,
name?: string,
color?: string | null,
iconUrl?: string | null,
description?: string,
isModerator?: boolean,
isAdministrator?: boolean,
displayOrder?: number,
createdAt?: string,
updatedAt?: string,
target?: 'manual' | 'conditional',
isPublic?: boolean,
isExplorable?: boolean,
asBadge?: boolean,
canEditMembersByModerator?: boolean,
usersCount?: number,
}, seed?: string): entities.Role {
const prefix = params.displayOrder ? params.displayOrder.toString().padStart(3, '0') + '-' : '';
const genId = text(36, seed);
const createdAt = params.createdAt ?? date({}, seed).toISOString();
const updatedAt = params.updatedAt ?? date({}, seed).toISOString();
return {
id: params.id ?? genId,
name: params.name ?? `${prefix}TestRole-${genId}`,
color: params.color ?? '#445566',
iconUrl: params.iconUrl ?? null,
description: params.description ?? '',
isModerator: params.isModerator ?? false,
isAdministrator: params.isAdministrator ?? false,
displayOrder: params.displayOrder ?? 0,
createdAt: createdAt,
updatedAt: updatedAt,
target: params.target ?? 'manual',
isPublic: params.isPublic ?? true,
isExplorable: params.isExplorable ?? true,
asBadge: params.asBadge ?? true,
canEditMembersByModerator: params.canEditMembersByModerator ?? false,
usersCount: params.usersCount ?? 10,
condFormula: {
id: '',
type: 'or',
values: []
},
policies: {},
}
}
export function emoji(params?: {
id?: string,
name?: string,
host?: string,
uri?: string,
publicUrl?: string,
originalUrl?: string,
type?: string,
aliases?: string[],
category?: string,
license?: string,
isSensitive?: boolean,
localOnly?: boolean,
roleIdsThatCanBeUsedThisEmojiAsReaction?: {id:string, name:string}[],
updatedAt?: string,
}, seed?: string): entities.EmojiDetailedAdmin {
const _seed = seed ?? (params?.id ?? "DEFAULT_SEED");
const id = params?.id ?? text(32, _seed);
const name = params?.name ?? text(8, _seed);
const updatedAt = params?.updatedAt ?? date({}, _seed).toISOString();
const image = imageDataUrl({}, _seed)
return {
id: id,
name: name,
host: params?.host ?? null,
uri: params?.uri ?? null,
publicUrl: params?.publicUrl ?? image,
originalUrl: params?.originalUrl ?? image,
type: params?.type ?? 'image/png',
aliases: params?.aliases ?? [`alias1-${name}`, `alias2-${name}`],
category: params?.category ?? null,
license: params?.license ?? null,
isSensitive: params?.isSensitive ?? false,
localOnly: params?.localOnly ?? false,
roleIdsThatCanBeUsedThisEmojiAsReaction: params?.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [],
updatedAt: updatedAt,
}
}

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
@@ -397,15 +442,33 @@ function toStories(component: string): Promise<string> {
const globs = await Promise.all([
glob('src/components/global/Mk*.vue'),
glob('src/components/global/RouterView.vue'),
glob('src/components/Mk[A-C]*.vue'),
glob('src/components/MkDigitalClock.vue'),
glob('src/components/MkAbuseReportWindow.vue'),
glob('src/components/MkAccountMoved.vue'),
glob('src/components/MkAchievements.vue'),
glob('src/components/MkAnalogClock.vue'),
glob('src/components/MkAnimBg.vue'),
glob('src/components/MkAnnouncementDialog.vue'),
glob('src/components/MkAntennaEditor.vue'),
glob('src/components/MkAntennaEditorDialog.vue'),
glob('src/components/MkAsUi.vue'),
glob('src/components/MkAutocomplete.vue'),
glob('src/components/MkAvatars.vue'),
glob('src/components/Mk[B-E]*.vue'),
glob('src/components/MkFlashPreview.vue'),
glob('src/components/MkGalleryPostPreview.vue'),
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'),
glob('src/components/MkRoleSelectDialog.vue'),
glob('src/components/grid/MkGrid.vue'),
glob('src/pages/admin/custom-emojis-manager2.vue'),
glob('src/pages/admin/overview.ap-requests.vue'),
glob('src/pages/user/home.vue'),
glob('src/pages/search.vue'),
]);
const components = globs.flat();
await Promise.all(components.map(async (component) => {

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

@@ -30,7 +30,7 @@ const keys = [
'd-u0',
]
await Promise.all(keys.map((key) => readFile(new URL(`../src/themes/${key}.json5`, import.meta.url), 'utf8'))).then((sources) => {
await Promise.all(keys.map((key) => readFile(new URL(`../../frontend-shared/themes/${key}.json5`, import.meta.url), 'utf8'))).then((sources) => {
writeFile(
new URL('./themes.ts', import.meta.url),
`export default ${JSON.stringify(

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

@@ -3,8 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
declare module '@/themes/*.json5' {
import { Theme } from '@/scripts/theme.js';
declare module '@@/themes/*.json5' {
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -47,9 +47,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,

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

@@ -4,7 +4,6 @@
"type": "module",
"scripts": {
"watch": "vite",
"dev": "vite --config vite.config.local-dev.ts --debug hmr",
"build": "vite build",
"storybook-dev": "nodemon --verbose --watch src --ext \"mdx,ts,vue\" --ignore \"*.stories.ts\" --exec \"pnpm build-storybook-pre && pnpm exec storybook dev -p 6006 --ci\"",
"build-storybook-pre": "(tsc -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js",
@@ -17,126 +16,133 @@
"lint": "pnpm typecheck && pnpm eslint"
},
"dependencies": {
"@discordapp/twemoji": "15.0.3",
"@analytics/google-analytics": "1.1.0",
"@discordapp/twemoji": "15.1.0",
"@github/webauthn-json": "2.1.1",
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
"@misskey-dev/browser-image-resizer": "2024.1.0",
"@rollup/plugin-json": "6.1.0",
"@rollup/plugin-replace": "5.0.7",
"@rollup/pluginutils": "5.1.0",
"@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.1.4",
"@sentry/vue": "9.8.0",
"@syuilo/aiscript": "0.19.0",
"@tabler/icons-webfont": "3.3.0",
"@tabler/icons-webfont": "3.31.0",
"@twemoji/parser": "15.1.1",
"@vitejs/plugin-vue": "5.1.0",
"@vue/compiler-sfc": "3.4.34",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.11",
"astring": "1.8.6",
"@vitejs/plugin-vue": "5.2.3",
"@vue/compiler-sfc": "3.5.13",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15",
"analytics": "0.8.16",
"astring": "1.9.0",
"broadcast-channel": "7.0.0",
"buraha": "0.0.1",
"canvas-confetti": "1.9.3",
"chart.js": "4.4.3",
"chart.js": "4.4.8",
"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.5.6",
"chartjs-plugin-zoom": "2.2.0",
"chromatic": "11.27.0",
"compare-versions": "6.1.1",
"cropperjs": "2.0.0-rc.1",
"date-fns": "2.30.0",
"escape-regexp": "0.0.1",
"cropperjs": "2.0.0",
"date-fns": "4.1.0",
"estree-walker": "3.0.3",
"eventemitter3": "5.0.1",
"frontend-shared": "workspace:*",
"idb-keyval": "6.2.1",
"insert-text-at-cursor": "0.3.0",
"is-file-animated": "1.0.2",
"json5": "2.2.3",
"matter-js": "0.19.0",
"magic-string": "0.30.17",
"matter-js": "0.20.0",
"mfm-js": "0.24.0",
"misskey-bubble-game": "workspace:*",
"misskey-mahjong": "workspace:*",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
"photoswipe": "5.4.4",
"punycode": "2.3.1",
"rollup": "4.19.1",
"sanitize-html": "2.13.0",
"sass": "1.77.8",
"shiki": "1.12.0",
"punycode.js": "2.3.1",
"rollup": "4.36.0",
"sanitize-html": "2.15.0",
"sass": "1.86.0",
"shiki": "3.2.1",
"strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0",
"three": "0.167.0",
"three": "0.174.0",
"throttle-debounce": "5.0.2",
"tinycolor2": "1.6.0",
"tsc-alias": "1.8.10",
"tsc-alias": "1.8.11",
"tsconfig-paths": "4.2.0",
"typescript": "5.5.4",
"uuid": "10.0.0",
"v-code-diff": "1.12.0",
"vite": "5.3.5",
"vue": "3.4.34",
"vuedraggable": "next"
"typescript": "5.8.2",
"uuid": "11.1.0",
"v-code-diff": "1.13.1",
"vite": "6.2.4",
"vue": "3.5.13",
"vuedraggable": "next",
"wanakana": "5.3.1"
},
"devDependencies": {
"@misskey-dev/summaly": "5.1.0",
"@storybook/addon-actions": "8.2.6",
"@storybook/addon-essentials": "8.2.6",
"@storybook/addon-interactions": "8.2.6",
"@storybook/addon-links": "8.2.6",
"@storybook/addon-mdx-gfm": "8.2.6",
"@storybook/addon-storysource": "8.2.6",
"@storybook/blocks": "8.2.6",
"@storybook/components": "8.2.6",
"@storybook/core-events": "8.2.6",
"@storybook/manager-api": "8.2.6",
"@storybook/preview-api": "8.2.6",
"@storybook/react": "8.2.6",
"@storybook/react-vite": "8.2.6",
"@storybook/test": "8.2.6",
"@storybook/theming": "8.2.6",
"@storybook/types": "8.2.6",
"@storybook/vue3": "8.2.6",
"@storybook/vue3-vite": "8.1.11",
"@misskey-dev/summaly": "5.2.0",
"@storybook/addon-actions": "8.6.7",
"@storybook/addon-essentials": "8.6.7",
"@storybook/addon-interactions": "8.6.7",
"@storybook/addon-links": "8.6.7",
"@storybook/addon-mdx-gfm": "8.6.7",
"@storybook/addon-storysource": "8.6.7",
"@storybook/blocks": "8.6.7",
"@storybook/components": "8.6.7",
"@storybook/core-events": "8.6.7",
"@storybook/manager-api": "8.6.7",
"@storybook/preview-api": "8.6.7",
"@storybook/react": "8.6.7",
"@storybook/react-vite": "8.6.7",
"@storybook/test": "8.6.7",
"@storybook/theming": "8.6.7",
"@storybook/types": "8.6.7",
"@storybook/vue3": "8.6.7",
"@storybook/vue3-vite": "8.6.7",
"@testing-library/vue": "8.1.0",
"@types/escape-regexp": "0.0.3",
"@types/estree": "1.0.5",
"@types/matter-js": "0.19.7",
"@types/canvas-confetti": "1.9.0",
"@types/estree": "1.0.6",
"@types/matter-js": "0.19.8",
"@types/micromatch": "4.0.9",
"@types/node": "20.14.12",
"@types/punycode": "2.1.4",
"@types/sanitize-html": "2.11.0",
"@types/node": "22.13.11",
"@types/punycode.js": "npm:@types/punycode@2.1.4",
"@types/sanitize-html": "2.13.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.11",
"@typescript-eslint/eslint-plugin": "7.17.0",
"@typescript-eslint/parser": "7.17.0",
"@vitest/coverage-v8": "1.6.0",
"@vue/runtime-core": "3.4.34",
"acorn": "8.12.1",
"@types/ws": "8.18.0",
"@typescript-eslint/eslint-plugin": "8.27.0",
"@typescript-eslint/parser": "8.27.0",
"@vitest/coverage-v8": "3.0.9",
"@vue/compiler-core": "3.5.13",
"@vue/runtime-core": "3.5.13",
"acorn": "8.14.1",
"cross-env": "7.0.3",
"cypress": "13.13.1",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-vue": "9.27.0",
"fast-glob": "3.3.2",
"happy-dom": "10.0.3",
"cypress": "14.2.0",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-vue": "10.0.0",
"fast-glob": "3.3.3",
"happy-dom": "17.4.4",
"intersection-observer": "0.12.2",
"micromatch": "4.0.7",
"msw": "2.3.4",
"msw-storybook-addon": "2.0.3",
"nodemon": "3.1.4",
"prettier": "3.3.3",
"react": "18.3.1",
"react-dom": "18.3.1",
"micromatch": "4.0.8",
"minimatch": "10.0.1",
"msw": "2.7.3",
"msw-storybook-addon": "2.0.4",
"nodemon": "3.1.9",
"prettier": "3.5.3",
"react": "19.0.0",
"react-dom": "19.0.0",
"seedrandom": "3.0.5",
"start-server-and-test": "2.0.4",
"storybook": "8.2.6",
"start-server-and-test": "2.0.11",
"storybook": "8.6.7",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"vite-node": "3.0.9",
"vite-plugin-turbosnap": "1.0.3",
"vitest": "1.6.0",
"vitest-fetch-mock": "0.2.2",
"vue-component-type-helpers": "2.0.29",
"vue-eslint-parser": "9.4.3",
"vue-tsc": "2.0.29"
"vitest": "3.0.9",
"vitest-fetch-mock": "0.4.5",
"vue-component-type-helpers": "2.2.8",
"vue-eslint-parser": "10.1.1",
"vue-tsc": "2.2.8"
}
}

View File

@@ -6,13 +6,15 @@
// https://vitejs.dev/config/build-options.html#build-modulepreload
import 'vite/modulepreload-polyfill';
import '@tabler/icons-webfont/dist/tabler-icons.scss';
import '@/style.scss';
import { mainBoot } from '@/boot/main-boot.js';
import { subBoot } from '@/boot/sub-boot.js';
const subBootPaths = ['/share', '/auth', '/miauth', '/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,95 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
// devモードで起動される際index.htmlを使うときはrouterが暴発してしまってうまく読み込めない。
// よって、devモードとして起動されるときはビルド時に組み込む形としておく。
// (pnpm start時はpugファイルの中で静的リソースとして読み込むようになっており、この問題は起こっていない)
import '@tabler/icons-webfont/dist/tabler-icons.scss';
await main();
import('@/_boot_.js');
/**
* backend/src/server/web/boot.jsで差し込まれている起動処理のうち、最低限必要なものを模倣するための処理
*/
async function main() {
const forceError = localStorage.getItem('forceError');
if (forceError != null) {
renderError('FORCED_ERROR', 'This error is forced by having forceError in local storage.');
}
//#region Detect language & fetch translations
// dev-modeの場合は常に取り直す
const supportedLangs = _LANGS_.map(it => it[0]);
let lang: string | null | undefined = localStorage.getItem('lang');
if (lang == null || !supportedLangs.includes(lang)) {
if (supportedLangs.includes(navigator.language)) {
lang = navigator.language;
} else {
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
// Fallback
if (lang == null) lang = 'en-US';
}
}
// TODO:今のままだと言語ファイル変更後はpnpm devをリスタートする必要があるので、chokidarを使ったり等で対応できるようにする
const locale = _LANGS_FULL_.find(it => it[0] === lang);
localStorage.setItem('lang', lang);
localStorage.setItem('locale', JSON.stringify(locale[1]));
localStorage.setItem('localeVersion', _VERSION_);
//#endregion
//#region Theme
const theme = localStorage.getItem('theme');
if (theme) {
for (const [k, v] of Object.entries(JSON.parse(theme))) {
document.documentElement.style.setProperty(`--${k}`, v.toString());
// HTMLの theme-color 適用
if (k === 'htmlThemeColor') {
for (const tag of document.head.children) {
if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
tag.setAttribute('content', v);
break;
}
}
}
}
}
const colorScheme = localStorage.getItem('colorScheme');
if (colorScheme) {
document.documentElement.style.setProperty('color-scheme', colorScheme);
}
//#endregion
const fontSize = localStorage.getItem('fontSize');
if (fontSize) {
document.documentElement.classList.add('f-' + fontSize);
}
const useSystemFont = localStorage.getItem('useSystemFont');
if (useSystemFont) {
document.documentElement.classList.add('useSystemFont');
}
const wallpaper = localStorage.getItem('wallpaper');
if (wallpaper) {
document.documentElement.style.backgroundImage = `url(${wallpaper})`;
}
const customCss = localStorage.getItem('customCss');
if (customCss && customCss.length > 0) {
const style = document.createElement('style');
style.innerHTML = customCss;
document.head.appendChild(style);
}
}
function renderError(code: string, details?: string) {
console.log(code, details);
}

View File

@@ -1,325 +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 { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js';
import { MenuButton } from '@/types/menu.js';
import { del, get, set } from '@/scripts/idb-proxy.js';
import { apiUrl } from '@/config.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);
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> {
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 {
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: 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));
document.cookie = `token=${token}; path=/; max-age=31536000`; // bull dashboardの認証とかで使う
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;
function showSigninDialog() {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
done: res => {
addAccount(res.id, res.i);
success();
},
closed: () => dispose(),
});
}
function createAccount() {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
done: res => {
addAccount(res.id, res.i);
switchAccountWithToken(res.i);
},
closed: () => dispose(),
});
}
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));
});
}));
if (opts.withExtraOperation) {
popupMenu([...[{
type: 'link' as const,
text: i18n.ts.profile,
to: `/@${ $i.username }`,
avatar: $i,
}, { type: 'divider' as const }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
type: 'parent' as const,
icon: 'ti ti-plus',
text: i18n.ts.addAccount,
children: [{
text: i18n.ts.existingAccount,
action: () => { showSigninDialog(); },
}, {
text: i18n.ts.createAccount,
action: () => { createAccount(); },
}],
}, {
type: 'link' as const,
icon: 'ti ti-users',
text: i18n.ts.manageAccounts,
to: '/settings/accounts',
}]], ev.currentTarget ?? ev.target, {
align: 'left',
});
} else {
popupMenu([...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises], ev.currentTarget ?? ev.target, {
align: 'left',
});
}
}
if (_DEV_) {
(window as any).$i = $i;
}

View File

@@ -0,0 +1,339 @@
/*
* 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;
user: Misskey.entities.User;
token: string | null;
}[]> {
const tokens = store.s.accountTokens;
const accounts = prefer.s.accounts;
return accounts.map(([host, user]) => ({
host,
user,
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 });
prefer.commit('accounts', [...prefer.s.accounts, [host, user]]);
}
}
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);
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 {
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;
}
prefer.commit('accounts', prefer.s.accounts.map(([host, user]) => {
// TODO: $iのホストも比較したいけど通常null
if (user.id === $i.id) {
return [host, $i];
} else {
return [host, user];
}
}));
$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;
}
prefer.commit('accounts', prefer.s.accounts.map(([host, user]) => {
// TODO: $iのホストも比較したいけど通常null
if (user.id === $i.id) {
const newUser = JSON.parse(JSON.stringify($i));
for (const [key, value] of Object.entries(accountData)) {
newUser[key] = value;
}
return [host, newUser];
}
return [host, user];
}));
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, account: Misskey.entities.User): MenuItem {
return {
type: 'user' as const,
user: account,
active: opts.active != null ? opts.active === account.id : false,
action: async () => {
if (opts.onChoose) {
opts.onChoose(account);
} else {
switchAccount(host, account.id);
}
},
};
}
const menuItems: MenuItem[] = [];
// TODO: $iのホストも比較したいけど通常null
const accountItems = (await getAccounts().then(accounts => accounts.filter(x => x.user.id !== $i.id))).map(a => createItem(a.host, a.user));
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));
}
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',
});
} else {
if (opts.includeCurrentAccount) {
menuItems.push(createItem(host, $i));
}
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

@@ -3,14 +3,24 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { utils, values } from '@syuilo/aiscript';
import { errors, utils, values } from '@syuilo/aiscript';
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';
import { url, lang } from '@/config.js';
import { nyaize } from '@/scripts/nyaize.js';
const DIALOG_TYPES = [
'error',
'info',
'success',
'warning',
'waiting',
'question',
] as const;
export function aiScriptReadline(q: string): Promise<string> {
return new Promise(ok => {
@@ -22,15 +32,20 @@ export function aiScriptReadline(q: string): Promise<string> {
});
}
export function createAiScriptEnv(opts) {
export function createAiScriptEnv(opts: { storageKey: string, token?: string }) {
return {
USER_ID: $i ? values.STR($i.id) : values.NULL,
USER_NAME: $i ? values.STR($i.name) : values.NULL,
USER_NAME: $i?.name ? values.STR($i.name) : values.NULL,
USER_USERNAME: $i ? values.STR($i.username) : values.NULL,
CUSTOM_EMOJIS: utils.jsToVal(customEmojis.value),
LOCALE: values.STR(lang),
SERVER_URL: values.STR(url),
'Mk:dialog': values.FN_NATIVE(async ([title, text, type]) => {
utils.assertString(title);
utils.assertString(text);
if (type != null) {
assertStringAndIsIn(type, DIALOG_TYPES);
}
await os.alert({
type: type ? type.value : 'info',
title: title.value,
@@ -39,6 +54,11 @@ export function createAiScriptEnv(opts) {
return values.NULL;
}),
'Mk:confirm': values.FN_NATIVE(async ([title, text, type]) => {
utils.assertString(title);
utils.assertString(text);
if (type != null) {
assertStringAndIsIn(type, DIALOG_TYPES);
}
const confirm = await os.confirm({
type: type ? type.value : 'question',
title: title.value,
@@ -48,14 +68,20 @@ export function createAiScriptEnv(opts) {
}),
'Mk:api': values.FN_NATIVE(async ([ep, param, token]) => {
utils.assertString(ep);
if (ep.value.includes('://')) throw new Error('invalid endpoint');
if (ep.value.includes('://')) {
throw new errors.AiScriptRuntimeError('invalid endpoint');
}
if (token) {
utils.assertString(token);
// バグがあればundefinedもあり得るため念のため
if (typeof token.value !== 'string') throw new Error('invalid token');
}
const actualToken: string|null = token?.value ?? opts.token ?? null;
return misskeyApi(ep.value, utils.valToJs(param), actualToken).then(res => {
const actualToken: string | null = token?.value ?? opts.token ?? null;
if (param == null) {
throw new errors.AiScriptRuntimeError('expected param');
}
utils.assertObject(param);
return misskeyApi(ep.value, utils.valToJs(param) as object, actualToken).then(res => {
return utils.jsToVal(res);
}, err => {
return values.ERROR('request_failed', utils.jsToVal(err));
@@ -75,19 +101,25 @@ export function createAiScriptEnv(opts) {
*/
'Mk:save': values.FN_NATIVE(([key, value]) => {
utils.assertString(key);
utils.expectAny(value);
miLocalStorage.setItem(`aiscript:${opts.storageKey}:${key.value}`, JSON.stringify(utils.valToJs(value)));
return values.NULL;
}),
'Mk:load': values.FN_NATIVE(([key]) => {
utils.assertString(key);
return utils.jsToVal(JSON.parse(miLocalStorage.getItem(`aiscript:${opts.storageKey}:${key.value}`)));
return utils.jsToVal(miLocalStorage.getItemAsJson(`aiscript:${opts.storageKey}:${key.value}`) ?? null);
}),
'Mk:remove': values.FN_NATIVE(([key]) => {
utils.assertString(key);
miLocalStorage.removeItem(`aiscript:${opts.storageKey}:${key.value}`);
return values.NULL;
}),
'Mk:url': values.FN_NATIVE(() => {
return values.STR(window.location.href);
}),
'Mk:nyaize': values.FN_NATIVE(([text]) => {
utils.assertString(text);
return values.STR(nyaize(text.value));
return values.STR(Misskey.nyaize(text.value));
}),
};
}

View File

@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
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);
const str = value.value;
if (!expects.includes(str)) {
const expected = expects.map((expect) => `"${expect}"`).join(', ');
throw new errors.AiScriptRuntimeError(`"${value.value}" is not in ${expected}`);
}
}

View File

@@ -5,8 +5,18 @@
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';
const ALIGNS = ['left', 'center', 'right'] as const;
const FONTS = ['serif', 'sans-serif', 'monospace'] as const;
const BORDER_STYLES = ['hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset'] as const;
type Align = (typeof ALIGNS)[number];
type Font = (typeof FONTS)[number];
type BorderStyle = (typeof BORDER_STYLES)[number];
export type AsUiComponentBase = {
id: string;
@@ -21,12 +31,14 @@ export type AsUiRoot = AsUiComponentBase & {
export type AsUiContainer = AsUiComponentBase & {
type: 'container';
children?: AsUiComponent['id'][];
align?: 'left' | 'center' | 'right';
align?: Align;
bgColor?: string;
fgColor?: string;
font?: 'serif' | 'sans-serif' | 'monospace';
font?: Font;
borderWidth?: number;
borderColor?: string;
borderStyle?: BorderStyle;
borderRadius?: number;
padding?: number;
rounded?: boolean;
hidden?: boolean;
@@ -38,7 +50,7 @@ export type AsUiText = AsUiComponentBase & {
size?: number;
bold?: boolean;
color?: string;
font?: 'serif' | 'sans-serif' | 'monospace';
font?: Font;
};
export type AsUiMfm = AsUiComponentBase & {
@@ -47,14 +59,14 @@ export type AsUiMfm = AsUiComponentBase & {
size?: number;
bold?: boolean;
color?: string;
font?: 'serif' | 'sans-serif' | 'monospace';
onClickEv?: (evId: string) => void
font?: Font;
onClickEv?: (evId: string) => Promise<void>;
};
export type AsUiButton = AsUiComponentBase & {
type: 'button';
text?: string;
onClick?: () => void;
onClick?: () => Promise<void>;
primary?: boolean;
rounded?: boolean;
disabled?: boolean;
@@ -67,7 +79,7 @@ export type AsUiButtons = AsUiComponentBase & {
export type AsUiSwitch = AsUiComponentBase & {
type: 'switch';
onChange?: (v: boolean) => void;
onChange?: (v: boolean) => Promise<void>;
default?: boolean;
label?: string;
caption?: string;
@@ -75,7 +87,7 @@ export type AsUiSwitch = AsUiComponentBase & {
export type AsUiTextarea = AsUiComponentBase & {
type: 'textarea';
onInput?: (v: string) => void;
onInput?: (v: string) => Promise<void>;
default?: string;
label?: string;
caption?: string;
@@ -83,7 +95,7 @@ export type AsUiTextarea = AsUiComponentBase & {
export type AsUiTextInput = AsUiComponentBase & {
type: 'textInput';
onInput?: (v: string) => void;
onInput?: (v: string) => Promise<void>;
default?: string;
label?: string;
caption?: string;
@@ -91,7 +103,7 @@ export type AsUiTextInput = AsUiComponentBase & {
export type AsUiNumberInput = AsUiComponentBase & {
type: 'numberInput';
onInput?: (v: number) => void;
onInput?: (v: number) => Promise<void>;
default?: number;
label?: string;
caption?: string;
@@ -103,7 +115,7 @@ export type AsUiSelect = AsUiComponentBase & {
text: string;
value: string;
}[];
onChange?: (v: string) => void;
onChange?: (v: string) => Promise<void>;
default?: string;
label?: string;
caption?: string;
@@ -138,11 +150,15 @@ export type AsUiPostForm = AsUiComponentBase & {
export type AsUiComponent = AsUiRoot | AsUiContainer | AsUiText | AsUiMfm | AsUiButton | AsUiButtons | AsUiSwitch | AsUiTextarea | AsUiTextInput | AsUiNumberInput | AsUiSelect | AsUiFolder | AsUiPostFormButton | AsUiPostForm;
type Options<T extends AsUiComponent> = T extends AsUiButtons
? Omit<T, 'id' | 'type' | 'buttons'> & { 'buttons'?: Options<AsUiButton>[] }
: Omit<T, 'id' | 'type'>;
export function patch(id: string, def: values.Value, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>) {
// TODO
}
function getRootOptions(def: values.Value | undefined): Omit<AsUiRoot, 'id' | 'type'> {
function getRootOptions(def: values.Value | undefined): Options<AsUiRoot> {
utils.assertObject(def);
const children = def.value.get('children');
@@ -151,28 +167,34 @@ function getRootOptions(def: values.Value | undefined): Omit<AsUiRoot, 'id' | 't
return {
children: children.value.map(v => {
utils.assertObject(v);
return v.value.get('id').value;
const id = v.value.get('id');
utils.assertString(id);
return id.value;
}),
};
}
function getContainerOptions(def: values.Value | undefined): Omit<AsUiContainer, 'id' | 'type'> {
function getContainerOptions(def: values.Value | undefined): Options<AsUiContainer> {
utils.assertObject(def);
const children = def.value.get('children');
if (children) utils.assertArray(children);
const align = def.value.get('align');
if (align) utils.assertString(align);
if (align) assertStringAndIsIn(align, ALIGNS);
const bgColor = def.value.get('bgColor');
if (bgColor) utils.assertString(bgColor);
const fgColor = def.value.get('fgColor');
if (fgColor) utils.assertString(fgColor);
const font = def.value.get('font');
if (font) utils.assertString(font);
if (font) assertStringAndIsIn(font, FONTS);
const borderWidth = def.value.get('borderWidth');
if (borderWidth) utils.assertNumber(borderWidth);
const borderColor = def.value.get('borderColor');
if (borderColor) utils.assertString(borderColor);
const borderStyle = def.value.get('borderStyle');
if (borderStyle) assertStringAndIsIn(borderStyle, BORDER_STYLES);
const borderRadius = def.value.get('borderRadius');
if (borderRadius) utils.assertNumber(borderRadius);
const padding = def.value.get('padding');
if (padding) utils.assertNumber(padding);
const rounded = def.value.get('rounded');
@@ -183,7 +205,9 @@ function getContainerOptions(def: values.Value | undefined): Omit<AsUiContainer,
return {
children: children ? children.value.map(v => {
utils.assertObject(v);
return v.value.get('id').value;
const id = v.value.get('id');
utils.assertString(id);
return id.value;
}) : [],
align: align?.value,
fgColor: fgColor?.value,
@@ -191,13 +215,15 @@ function getContainerOptions(def: values.Value | undefined): Omit<AsUiContainer,
font: font?.value,
borderWidth: borderWidth?.value,
borderColor: borderColor?.value,
borderStyle: borderStyle?.value,
borderRadius: borderRadius?.value,
padding: padding?.value,
rounded: rounded?.value,
hidden: hidden?.value,
};
}
function getTextOptions(def: values.Value | undefined): Omit<AsUiText, 'id' | 'type'> {
function getTextOptions(def: values.Value | undefined): Options<AsUiText> {
utils.assertObject(def);
const text = def.value.get('text');
@@ -209,7 +235,7 @@ function getTextOptions(def: values.Value | undefined): Omit<AsUiText, 'id' | 't
const color = def.value.get('color');
if (color) utils.assertString(color);
const font = def.value.get('font');
if (font) utils.assertString(font);
if (font) assertStringAndIsIn(font, FONTS);
return {
text: text?.value,
@@ -220,7 +246,7 @@ function getTextOptions(def: values.Value | undefined): Omit<AsUiText, 'id' | 't
};
}
function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiMfm, 'id' | 'type'> {
function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiMfm> {
utils.assertObject(def);
const text = def.value.get('text');
@@ -232,7 +258,7 @@ function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, arg
const color = def.value.get('color');
if (color) utils.assertString(color);
const font = def.value.get('font');
if (font) utils.assertString(font);
if (font) assertStringAndIsIn(font, FONTS);
const onClickEv = def.value.get('onClickEv');
if (onClickEv) utils.assertFunction(onClickEv);
@@ -242,13 +268,13 @@ function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, arg
bold: bold?.value,
color: color?.value,
font: font?.value,
onClickEv: (evId: string) => {
if (onClickEv) call(onClickEv, [values.STR(evId)]);
onClickEv: async (evId: string) => {
if (onClickEv) await call(onClickEv, [values.STR(evId)]);
},
};
}
function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiTextInput, 'id' | 'type'> {
function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiTextInput> {
utils.assertObject(def);
const onInput = def.value.get('onInput');
@@ -261,8 +287,8 @@ function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VF
if (caption) utils.assertString(caption);
return {
onInput: (v) => {
if (onInput) call(onInput, [utils.jsToVal(v)]);
onInput: async (v) => {
if (onInput) await call(onInput, [utils.jsToVal(v)]);
},
default: defaultValue?.value,
label: label?.value,
@@ -270,7 +296,7 @@ function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VF
};
}
function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiTextarea, 'id' | 'type'> {
function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiTextarea> {
utils.assertObject(def);
const onInput = def.value.get('onInput');
@@ -283,8 +309,8 @@ function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn
if (caption) utils.assertString(caption);
return {
onInput: (v) => {
if (onInput) call(onInput, [utils.jsToVal(v)]);
onInput: async (v) => {
if (onInput) await call(onInput, [utils.jsToVal(v)]);
},
default: defaultValue?.value,
label: label?.value,
@@ -292,7 +318,7 @@ function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn
};
}
function getNumberInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiNumberInput, 'id' | 'type'> {
function getNumberInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiNumberInput> {
utils.assertObject(def);
const onInput = def.value.get('onInput');
@@ -305,8 +331,8 @@ function getNumberInputOptions(def: values.Value | undefined, call: (fn: values.
if (caption) utils.assertString(caption);
return {
onInput: (v) => {
if (onInput) call(onInput, [utils.jsToVal(v)]);
onInput: async (v) => {
if (onInput) await call(onInput, [utils.jsToVal(v)]);
},
default: defaultValue?.value,
label: label?.value,
@@ -314,7 +340,7 @@ function getNumberInputOptions(def: values.Value | undefined, call: (fn: values.
};
}
function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiButton, 'id' | 'type'> {
function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiButton> {
utils.assertObject(def);
const text = def.value.get('text');
@@ -330,8 +356,8 @@ function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn,
return {
text: text?.value,
onClick: () => {
if (onClick) call(onClick, []);
onClick: async () => {
if (onClick) await call(onClick, []);
},
primary: primary?.value,
rounded: rounded?.value,
@@ -339,7 +365,7 @@ function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn,
};
}
function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiButtons, 'id' | 'type'> {
function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiButtons> {
utils.assertObject(def);
const buttons = def.value.get('buttons');
@@ -361,8 +387,8 @@ function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn,
return {
text: text.value,
onClick: () => {
call(onClick, []);
onClick: async () => {
await call(onClick, []);
},
primary: primary?.value,
rounded: rounded?.value,
@@ -372,7 +398,7 @@ function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn,
};
}
function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiSwitch, 'id' | 'type'> {
function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiSwitch> {
utils.assertObject(def);
const onChange = def.value.get('onChange');
@@ -385,8 +411,8 @@ function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn,
if (caption) utils.assertString(caption);
return {
onChange: (v) => {
if (onChange) call(onChange, [utils.jsToVal(v)]);
onChange: async (v) => {
if (onChange) await call(onChange, [utils.jsToVal(v)]);
},
default: defaultValue?.value,
label: label?.value,
@@ -394,7 +420,7 @@ function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn,
};
}
function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiSelect, 'id' | 'type'> {
function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiSelect> {
utils.assertObject(def);
const items = def.value.get('items');
@@ -420,8 +446,8 @@ function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn,
value: value ? value.value : text.value,
};
}) : [],
onChange: (v) => {
if (onChange) call(onChange, [utils.jsToVal(v)]);
onChange: async (v) => {
if (onChange) await call(onChange, [utils.jsToVal(v)]);
},
default: defaultValue?.value,
label: label?.value,
@@ -429,7 +455,7 @@ function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn,
};
}
function getFolderOptions(def: values.Value | undefined): Omit<AsUiFolder, 'id' | 'type'> {
function getFolderOptions(def: values.Value | undefined): Options<AsUiFolder> {
utils.assertObject(def);
const children = def.value.get('children');
@@ -442,7 +468,9 @@ function getFolderOptions(def: values.Value | undefined): Omit<AsUiFolder, 'id'
return {
children: children ? children.value.map(v => {
utils.assertObject(v);
return v.value.get('id').value;
const id = v.value.get('id');
utils.assertString(id);
return id.value;
}) : [],
title: title?.value ?? '',
opened: opened?.value ?? true,
@@ -467,7 +495,7 @@ function getPostFormProps(form: values.VObj): PostFormPropsForAsUi {
};
}
function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiPostFormButton, 'id' | 'type'> {
function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiPostFormButton> {
utils.assertObject(def);
const text = def.value.get('text');
@@ -489,7 +517,7 @@ function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: valu
};
}
function getPostFormOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiPostForm, 'id' | 'type'> {
function getPostFormOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiPostForm> {
utils.assertObject(def);
const form = def.value.get('form');
@@ -503,18 +531,26 @@ function getPostFormOptions(def: values.Value | undefined, call: (fn: values.VFn
}
export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: Ref<AsUiRoot>) => void) {
type OptionsConverter<T extends AsUiComponent, C> = (def: values.Value | undefined, call: C) => Options<T>;
const instances = {};
function createComponentInstance(type: AsUiComponent['type'], def: values.Value | undefined, id: values.Value | undefined, getOptions: (def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>) => any, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>) {
function createComponentInstance<T extends AsUiComponent, C>(
type: T['type'],
def: values.Value | undefined,
id: values.Value | undefined,
getOptions: OptionsConverter<T, C>,
call: C,
) {
if (id) utils.assertString(id);
const _id = id?.value ?? uuid();
const component = ref({
...getOptions(def, call),
type,
id: _id,
});
} as T);
components.push(component);
const instance = values.OBJ(new Map([
const instance = values.OBJ(new Map<string, values.Value>([
['id', values.STR(_id)],
['update', values.FN_NATIVE(([def], opts) => {
utils.assertObject(def);
@@ -539,7 +575,7 @@ export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: R
'Ui:patch': values.FN_NATIVE(([id, val], opts) => {
utils.assertString(id);
utils.assertArray(val);
patch(id.value, val.value, opts.call);
// patch(id.value, val.value, opts.call); // TODO
}),
'Ui:get': values.FN_NATIVE(([id], opts) => {
@@ -558,7 +594,9 @@ export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: R
rootComponent.value.children = children.value.map(v => {
utils.assertObject(v);
return v.value.get('id').value;
const id = v.value.get('id');
utils.assertString(id);
return id.value;
});
}),

View File

@@ -0,0 +1,107 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as Misskey from 'misskey-js';
import type { AnalyticsInstance, AnalyticsPlugin } from 'analytics';
/**
* analytics moduleを読み込まなくても動作するようにするためのラッパー
*/
class AnalyticsProxy implements AnalyticsInstance {
private analytics?: AnalyticsInstance;
constructor(analytics?: AnalyticsInstance) {
if (analytics) {
this.analytics = analytics;
}
}
public setAnalytics(analytics: AnalyticsInstance) {
if (this.analytics) {
throw new Error('Analytics instance already exists.');
}
this.analytics = analytics;
}
public identify(...args: Parameters<AnalyticsInstance['identify']>) {
return this.analytics?.identify(...args) ?? Promise.resolve();
}
public track(...args: Parameters<AnalyticsInstance['track']>) {
return this.analytics?.track(...args) ?? Promise.resolve();
}
public page(...args: Parameters<AnalyticsInstance['page']>) {
return this.analytics?.page(...args) ?? Promise.resolve();
}
public user(...args: Parameters<AnalyticsInstance['user']>) {
return this.analytics?.user(...args) ?? Promise.resolve();
}
public reset(...args: Parameters<AnalyticsInstance['reset']>) {
return this.analytics?.reset(...args) ?? Promise.resolve();
}
public ready(...args: Parameters<AnalyticsInstance['ready']>) {
return this.analytics?.ready(...args) ?? function () { void 0; };
}
public on(...args: Parameters<AnalyticsInstance['on']>) {
return this.analytics?.on(...args) ?? function () { void 0; };
}
public once(...args: Parameters<AnalyticsInstance['once']>) {
return this.analytics?.once(...args) ?? function () { void 0; };
}
public getState(...args: Parameters<AnalyticsInstance['getState']>) {
return this.analytics?.getState(...args) ?? Promise.resolve();
}
public get storage() {
return this.analytics?.storage ?? {
getItem: () => null,
setItem: () => void 0,
removeItem: () => void 0,
};
}
public get plugins() {
return this.analytics?.plugins ?? {
enable: (p, c) => Promise.resolve(c ? c() : void 0),
disable: (p, c) => Promise.resolve(c ? c() : void 0),
};
}
}
export const analytics = new AnalyticsProxy();
export async function initAnalytics(instance: Misskey.entities.MetaDetailed) {
// アナリティクスプロバイダに関する設定がひとつもない場合は、アナリティクスモジュールを読み込まない
if (!instance.googleAnalyticsMeasurementId) {
return;
}
const { default: Analytics } = await import('analytics');
const plugins: AnalyticsPlugin[] = [];
// Google Analytics
if (instance.googleAnalyticsMeasurementId) {
const { default: googleAnalytics } = await import('@analytics/google-analytics');
plugins.push(googleAnalytics({
measurementIds: [instance.googleAnalyticsMeasurementId],
debug: _DEV_,
}));
}
analytics.setAnalytics(Analytics({
app: 'misskey',
version: _VERSION_,
debug: _DEV_,
plugins,
}));
}

View File

@@ -3,28 +3,33 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { computed, watch, version as vueVersion, App } from 'vue';
import { computed, watch, version as vueVersion } from 'vue';
import { compareVersions } from 'compare-versions';
import { version, lang, 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 { version, lang, updateLocale, locale } from '@/config.js';
import { applyTheme } from '@/scripts/theme.js';
import { isDeviceDarkmode } from '@/scripts/is-device-darkmode.js';
import { updateI18n } from '@/i18n.js';
import { $i, refreshAccount, login } from '@/account.js';
import { defaultStore, ColdDeviceStorage } from '@/store.js';
import { applyTheme } from '@/theme.js';
import { isDeviceDarkmode } from '@/utility/is-device-darkmode.js';
import { updateI18n, i18n } from '@/i18n.js';
import { refreshCurrentAccount, login } from '@/accounts.js';
import { store } from '@/store.js';
import { fetchInstance, instance } from '@/instance.js';
import { deviceKind } 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 { analytics, initAnalytics } from '@/analytics.js';
import { miLocalStorage } from '@/local-storage.js';
import { fetchCustomEmojis } from '@/custom-emojis.js';
import { setupRouter } 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(`Misskey v${version}`);
if (_DEV_) {
@@ -32,11 +37,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);
/*
@@ -95,27 +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 (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();
@@ -125,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);
@@ -138,84 +143,88 @@ 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'));
document.documentElement.dataset.colorMode = darkMode ? 'dark' : 'light';
watch(store.r.darkMode, (darkMode) => {
applyTheme(darkMode
? (prefer.s.darkTheme ?? defaultDarkTheme)
: (prefer.s.lightTheme ?? defaultLightTheme),
);
}, { immediate: miLocalStorage.getItem('theme') == null });
document.documentElement.dataset.colorMode = 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);
} else {
if (defaultStore.state.darkMode) {
applyTheme(darkTheme.value);
} else {
applyTheme(lightTheme.value);
}
}
// 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.useBlurEffectForModal, v => {
document.documentElement.style.setProperty('--modalBgFilter', v ? 'blur(4px)' : 'none');
watch(prefer.r.overridedDeviceKind, (kind) => {
updateDeviceKind(kind);
}, { immediate: true });
watch(defaultStore.reactiveState.useBlurEffect, v => {
watch(prefer.r.useBlurEffectForModal, v => {
window.document.documentElement.style.setProperty('--MI-modalBgFilter', v ? 'blur(4px)' : 'none');
}, { immediate: true });
watch(prefer.r.useBlurEffect, v => {
if (v) {
document.documentElement.style.removeProperty('--blur');
window.document.documentElement.style.removeProperty('--MI-blur');
} else {
document.documentElement.style.setProperty('--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') {
navigator.wakeLock.request('screen');
}
});
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 },
@@ -223,13 +232,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
@@ -237,9 +250,20 @@ export async function common(createVue: () => App<Element>) {
await fetchCustomEmojis();
} catch (err) { /* empty */ }
const app = createVue();
// analytics
fetchInstanceMetaPromise.then(async () => {
await initAnalytics(instance);
setupRouter(app);
if ($i) {
analytics.identify($i.id);
}
analytics.page({
path: window.location.pathname,
});
});
const app = await createVue();
if (_DEV_) {
app.config.performance = true;
@@ -254,19 +278,54 @@ export async function common(createVue: () => App<Element>) {
const rootEl = ((): HTMLElement => {
const MISSKEY_MOUNT_DIV_ID = 'misskey_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のやつを解除
@@ -275,14 +334,38 @@ export async function common(createVue: () => App<Element>) {
removeSplash();
//#region 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

@@ -4,33 +4,63 @@
*/
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 { ui } from '@/config.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, updateAccount } 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 { deckStore } from '@/ui/deck/deck-store.js';
import { emojiPicker } from '@/scripts/emoji-picker.js';
import { mainRouter } from '@/router/main.js';
import { type Keymap, makeHotkey } from '@/scripts/hotkey.js';
import { claimAchievement, claimedAchievements } from '@/utility/achievements.js';
import { initializeSw } from '@/utility/initialize-sw.js';
import { emojiPicker } from '@/utility/emoji-picker.js';
import { mainRouter } from '@/router.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(() => createApp(
new URLSearchParams(window.location.search).has('zen') || (ui === 'deck' && deckStore.state.useSimpleUiForNonRootPages && location.pathname !== '/') ? defineAsyncComponent(() => import('@/ui/zen.vue')) :
!$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) :
ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) :
ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) :
defineAsyncComponent(() => import('@/ui/universal.vue')),
));
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' && 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 = await import('@/ui/zen.vue').then(x => x.default);
break;
case 'deck':
rootComponent = await import('@/ui/deck.vue').then(x => x.default);
break;
case 'visitor':
rootComponent = await import('@/ui/visitor.vue').then(x => x.default);
break;
default:
rootComponent = await import('@/ui/universal.vue').then(x => x.default);
break;
}
return createApp(rootComponent);
});
reactionPicker.init();
emojiPicker.init();
@@ -39,15 +69,23 @@ 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 === 'reload') {
location.reload();
} else if (defaultStore.state.serverDisconnectedBehavior === 'dialog') {
if (prefer.s.serverDisconnectedBehavior === 'reload') {
window.location.reload();
} else if (prefer.s.serverDisconnectedBehavior === 'dialog') {
if (reloadDialogShowing) return;
reloadDialogShowing = true;
const { canceled } = await confirm({
@@ -57,35 +95,41 @@ export async function mainBoot() {
});
reloadDialogShowing = false;
if (!canceled) {
location.reload();
window.location.reload();
}
}
});
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);
});
}
stream.on('emojiAdded', emojiData => {
addCustomEmoji(emojiData.emoji);
});
stream.on('emojiUpdated', emojiData => {
updateCustomEmojis(emojiData.emojis);
});
stream.on('emojiDeleted', emojiData => {
removeCustomEmojis(emojiData.emojis);
});
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();
@@ -98,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(),
});
@@ -114,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')), {
@@ -217,35 +261,72 @@ export async function mainBoot() {
claimAchievement('collectAchievements30');
}
window.setInterval(() => {
if (Math.floor(Math.random() * 20000) === 0) {
claimAchievement('justPlainLucky');
if (!claimedAchievements.includes('justPlainLucky')) {
let justPlainLuckyTimer: number | null = null;
let lastVisibilityChangedAt = Date.now();
function claimPlainLucky() {
if (window.document.visibilityState !== 'visible') {
if (justPlainLuckyTimer != null) window.clearTimeout(justPlainLuckyTimer);
return;
}
if (Math.floor(Math.random() * 20000) === 0) {
claimAchievement('justPlainLucky');
} else {
justPlainLuckyTimer = window.setTimeout(claimPlainLucky, 1000 * 10);
}
}
}, 1000 * 10);
window.setTimeout(() => {
claimAchievement('client30min');
}, 1000 * 60 * 30);
window.addEventListener('visibilitychange', () => {
const now = Date.now();
window.setTimeout(() => {
claimAchievement('client60min');
}, 1000 * 60 * 60);
if (window.document.visibilityState === 'visible') {
// タブを高速で切り替えたら取得処理が何度も走るのを防ぐ
if ((now - lastVisibilityChangedAt) < 1000 * 10) {
justPlainLuckyTimer = window.setTimeout(claimPlainLucky, 1000 * 10);
} else {
claimPlainLucky();
}
} else if (justPlainLuckyTimer != null) {
window.clearTimeout(justPlainLuckyTimer);
justPlainLuckyTimer = null;
}
const lastUsed = miLocalStorage.getItem('lastUsed');
if (lastUsed) {
const lastUsedDate = parseInt(lastUsed, 10);
// 二時間以上前なら
if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) {
toast(i18n.tsx.welcomeBackWithName({
name: $i.name || $i.username,
}));
}
lastVisibilityChangedAt = now;
}, { passive: true });
claimPlainLucky();
}
miLocalStorage.setItem('lastUsed', Date.now().toString());
if (!claimedAchievements.includes('client30min')) {
window.setTimeout(() => {
claimAchievement('client30min');
}, 1000 * 60 * 30);
}
if (!claimedAchievements.includes('client60min')) {
window.setTimeout(() => {
claimAchievement('client60min');
}, 1000 * 60 * 60);
}
// 邪魔
//const lastUsed = miLocalStorage.getItem('lastUsed');
//if (lastUsed) {
// const lastUsedDate = parseInt(lastUsed, 10);
// // 二時間以上前なら
// if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) {
// toast(i18n.tsx.welcomeBackWithName({
// name: $i.name || $i.username,
// }));
// }
//}
//miLocalStorage.setItem('lastUsed', Date.now().toString());
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(),
@@ -271,11 +352,11 @@ export async function mainBoot() {
// 自分の情報が更新されたとき
main.on('meUpdated', i => {
updateAccount(i);
updateCurrentAccountPartial(i);
});
main.on('readAllNotifications', () => {
updateAccount({
updateCurrentAccountPartial({
hasUnreadNotification: false,
unreadNotificationsCount: 0,
});
@@ -283,39 +364,24 @@ export async function mainBoot() {
main.on('unreadNotification', () => {
const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1;
updateAccount({
updateCurrentAccountPartial({
hasUnreadNotification: true,
unreadNotificationsCount,
});
});
main.on('unreadMention', () => {
updateAccount({ hasUnreadMentions: true });
});
main.on('readAllUnreadMentions', () => {
updateAccount({ hasUnreadMentions: false });
});
main.on('unreadSpecifiedNote', () => {
updateAccount({ hasUnreadSpecifiedNotes: true });
});
main.on('readAllUnreadSpecifiedNotes', () => {
updateAccount({ hasUnreadSpecifiedNotes: false });
});
main.on('readAllAntennas', () => {
updateAccount({ hasUnreadAntenna: false });
});
main.on('unreadAntenna', () => {
updateAccount({ hasUnreadAntenna: true });
updateCurrentAccountPartial({ hasUnreadAntenna: true });
sound.playMisskeySfx('antenna');
});
main.on('newChatMessage', () => {
updateCurrentAccountPartial({ hasUnreadChatMessages: true });
sound.playMisskeySfx('chatMessage');
});
main.on('readAllAnnouncements', () => {
updateAccount({ hasUnreadAnnouncement: false });
updateCurrentAccountPartial({ hasUnreadAnnouncement: false });
});
// 個人宛てお知らせが発行されたとき
@@ -335,13 +401,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

@@ -1,54 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { action } from '@storybook/addon-actions';
import { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { abuseUserReport } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkAbuseReport from './MkAbuseReport.vue';
export const Default = {
render(args) {
return {
components: {
MkAbuseReport,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
events() {
return {
resolved: action('resolved'),
};
},
},
template: '<MkAbuseReport v-bind="props" v-on="events" />',
};
},
args: {
report: abuseUserReport(),
},
parameters: {
layout: 'fullscreen',
msw: {
handlers: [
...commonHandlers,
http.post('/api/admin/resolve-abuse-user-report', async ({ request }) => {
action('POST /api/admin/resolve-abuse-user-report')(await request.json());
return HttpResponse.json({});
}),
],
},
},
} satisfies StoryObj<typeof MkAbuseReport>;

View File

@@ -4,112 +4,150 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="bcekxzvu _margin _panel">
<div class="target">
<MkA v-user-preview="report.targetUserId" class="info" :to="`/admin/user/${report.targetUserId}`" :behavior="'window'">
<MkAvatar class="avatar" :user="report.targetUser" indicator/>
<div class="names">
<MkUserName class="name" :user="report.targetUser"/>
<MkAcct class="acct" :user="report.targetUser" style="display: block;"/>
</div>
</MkA>
<MkKeyValue>
<template #key>{{ i18n.ts.registeredDate }}</template>
<template #value>{{ dateString(report.targetUser.createdAt) }} (<MkTime :time="report.targetUser.createdAt"/>)</template>
</MkKeyValue>
</div>
<div class="detail">
<div>
<Mfm :text="report.comment" :linkNavigationBehavior="'window'"/>
<MkFolder>
<template #icon>
<i v-if="report.resolved && report.resolvedAs === 'accept'" class="ti ti-check" style="color: var(--MI_THEME-success)"></i>
<i v-else-if="report.resolved && report.resolvedAs === 'reject'" class="ti ti-x" style="color: var(--MI_THEME-error)"></i>
<i v-else-if="report.resolved" class="ti ti-slash"></i>
<i v-else class="ti ti-exclamation-circle" style="color: var(--MI_THEME-warn)"></i>
</template>
<template #label><MkAcct :user="report.targetUser"/> (by <MkAcct :user="report.reporter"/>)</template>
<template #caption>{{ report.comment }}</template>
<template #suffix><MkTime :time="report.createdAt"/></template>
<template #footer>
<div class="_buttons">
<template v-if="!report.resolved">
<MkButton @click="resolve('accept')"><i class="ti ti-check" style="color: var(--MI_THEME-success)"></i> {{ i18n.ts._abuseUserReport.resolve }} ({{ i18n.ts._abuseUserReport.accept }})</MkButton>
<MkButton @click="resolve('reject')"><i class="ti ti-x" style="color: var(--MI_THEME-error)"></i> {{ i18n.ts._abuseUserReport.resolve }} ({{ i18n.ts._abuseUserReport.reject }})</MkButton>
<MkButton @click="resolve(null)"><i class="ti ti-slash"></i> {{ i18n.ts._abuseUserReport.resolve }} ({{ i18n.ts.other }})</MkButton>
</template>
<template v-if="report.targetUser.host != null">
<MkButton :disabled="report.forwarded" primary @click="forward"><i class="ti ti-corner-up-right"></i> {{ i18n.ts._abuseUserReport.forward }}</MkButton>
<div v-tooltip:dialog="i18n.ts._abuseUserReport.forwardDescription" class="_button _help"><i class="ti ti-help-circle"></i></div>
</template>
<button class="_button" style="margin-left: auto; width: 34px;" @click="showMenu"><i class="ti ti-dots"></i></button>
</div>
<hr/>
<div>{{ i18n.ts.reporter }}: <MkA :to="`/admin/user/${report.reporter.id}`" class="_link" :behavior="'window'">@{{ report.reporter.username }}</MkA></div>
</template>
<div class="_gaps_s">
<MkFolder :withSpacer="false">
<template #icon><MkAvatar :user="report.targetUser" style="width: 18px; height: 18px;"/></template>
<template #label>{{ i18n.ts.target }}: <MkAcct :user="report.targetUser"/></template>
<template #suffix>#{{ report.targetUserId.toUpperCase() }}</template>
<div style="height: 300px; --MI-stickyTop: 0; --MI-stickyBottom: 0;">
<RouterView :router="targetRouter"/>
</div>
</MkFolder>
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-message-2"></i></template>
<template #label>{{ i18n.ts.details }}</template>
<div class="_gaps_s">
<Mfm :text="report.comment" :linkNavigationBehavior="'window'"/>
</div>
</MkFolder>
<MkFolder :withSpacer="false">
<template #icon><MkAvatar :user="report.reporter" style="width: 18px; height: 18px;"/></template>
<template #label>{{ i18n.ts.reporter }}: <MkAcct :user="report.reporter"/></template>
<template #suffix>#{{ report.reporterId.toUpperCase() }}</template>
<div style="height: 300px; --MI-stickyTop: 0; --MI-stickyBottom: 0;">
<RouterView :router="reporterRouter"/>
</div>
</MkFolder>
<MkFolder :defaultOpen="false">
<template #icon><i class="ti ti-message-2"></i></template>
<template #label>{{ i18n.ts.moderationNote }}</template>
<template #suffix>{{ moderationNote.length > 0 ? '...' : i18n.ts.none }}</template>
<div class="_gaps_s">
<MkTextarea v-model="moderationNote" manualSave>
<template #caption>{{ i18n.ts.moderationNoteDescription }}</template>
</MkTextarea>
</div>
</MkFolder>
<div v-if="report.assignee">
{{ i18n.ts.moderator }}:
<MkAcct :user="report.assignee"/>
</div>
<div><MkTime :time="report.createdAt"/></div>
<div class="action">
<MkSwitch v-model="forward" :disabled="report.targetUser.host == null || report.resolved">
{{ i18n.ts.forwardReport }}
<template #caption>{{ i18n.ts.forwardReportIsAnonymous }}</template>
</MkSwitch>
<MkButton v-if="!report.resolved" primary @click="resolve">{{ i18n.ts.abuseMarkAsResolved }}</MkButton>
</div>
</div>
</div>
</MkFolder>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { provide, ref, watch } from 'vue';
import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import * as os from '@/os.js';
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 MkTextarea from '@/components/MkTextarea.vue';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { createRouter } from '@/router.js';
const props = defineProps<{
report: any;
report: Misskey.entities.AdminAbuseUserReportsResponse[number];
}>();
const emit = defineEmits<{
(ev: 'resolved', reportId: string): void;
}>();
const forward = ref(props.report.forwarded);
const targetRouter = createRouter(`/admin/user/${props.report.targetUserId}`);
targetRouter.init();
const reporterRouter = createRouter(`/admin/user/${props.report.reporterId}`);
reporterRouter.init();
function resolve() {
os.apiWithDialog('admin/resolve-abuse-user-report', {
forward: forward.value,
const moderationNote = ref(props.report.moderationNote ?? '');
watch(moderationNote, async () => {
os.apiWithDialog('admin/update-abuse-user-report', {
reportId: props.report.id,
moderationNote: moderationNote.value,
}).then(() => {
});
});
function resolve(resolvedAs) {
os.apiWithDialog('admin/resolve-abuse-user-report', {
reportId: props.report.id,
resolvedAs,
}).then(() => {
emit('resolved', props.report.id);
});
}
function forward() {
os.apiWithDialog('admin/forward-abuse-user-report', {
reportId: props.report.id,
}).then(() => {
});
}
function showMenu(ev: MouseEvent) {
os.popupMenu([{
icon: 'ti ti-hash',
text: 'Copy ID',
action: () => {
copyToClipboard(props.report.id);
},
}, {
icon: 'ti ti-json',
text: 'Copy JSON',
action: () => {
copyToClipboard(JSON.stringify(props.report, null, '\t'));
},
}], ev.currentTarget ?? ev.target);
}
</script>
<style lang="scss" scoped>
.bcekxzvu {
display: flex;
> .target {
width: 35%;
box-sizing: border-box;
text-align: left;
padding: 24px;
border-right: solid 1px var(--divider);
> .info {
display: flex;
box-sizing: border-box;
align-items: center;
padding: 14px;
border-radius: 8px;
--c: rgb(255 196 0 / 15%);
background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%);
background-size: 16px 16px;
> .avatar {
width: 42px;
height: 42px;
}
> .names {
margin-left: 0.3em;
padding: 0 8px;
flex: 1;
> .name {
font-weight: bold;
}
}
}
}
> .detail {
flex: 1;
padding: 24px;
}
}
<style lang="scss" module>
</style>

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

@@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</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';
@@ -39,7 +39,7 @@ import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
const props = defineProps<{
user: Misskey.entities.UserDetailed;
user: Misskey.entities.UserLite;
initialComment?: string;
}>();
@@ -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

@@ -16,8 +16,8 @@ import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import MkMention from './MkMention.vue';
import { i18n } from '@/i18n.js';
import { host as localHost } from '@/config.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { host as localHost } from '@@/js/config.js';
import { misskeyApi } from '@/utility/misskey-api.js';
const user = ref<Misskey.entities.UserLite>();
@@ -32,9 +32,9 @@ misskeyApi('users/show', { userId: props.movedTo }).then(u => user.value = u);
.root {
padding: 16px;
font-size: 90%;
background: var(--infoWarnBg);
color: var(--error);
border-radius: var(--radius);
background: var(--MI_THEME-infoWarnBg);
color: var(--MI_THEME-error);
border-radius: var(--MI-radius);
}
.link {

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,13 +192,13 @@ function tick() {
tick();
function calcColors() {
const computedStyle = getComputedStyle(document.documentElement);
const dark = tinycolor(computedStyle.getPropertyValue('--bg')).isDark();
const accent = tinycolor(computedStyle.getPropertyValue('--accent')).toHexString();
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)';
//minorGraduationColor = dark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
sHandColor.value = dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)';
mHandColor.value = tinycolor(computedStyle.getPropertyValue('--fg')).toHexString();
mHandColor.value = tinycolor(computedStyle.getPropertyValue('--MI_THEME-fg')).toHexString();
hHandColor.value = accent;
nowColor.value = accent;
}

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

@@ -9,9 +9,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.header">
<span :class="$style.icon">
<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i>
<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i>
<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i>
<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i>
<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i>
<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--MI_THEME-error);"></i>
<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--MI_THEME-success);"></i>
</span>
<span :class="$style.title">{{ announcement.title }}</span>
</div>
@@ -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, updateAccount } 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 });
updateAccount({
updateCurrentAccountPartial({
unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== props.announcement.id),
});
}
@@ -83,8 +84,8 @@ onMounted(() => {
min-width: 320px;
max-width: 480px;
box-sizing: border-box;
background: var(--panel);
border-radius: var(--radius);
background: var(--MI_THEME-panel);
border-radius: var(--MI-radius);
}
.header {

View File

@@ -0,0 +1,62 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { action } from '@storybook/addon-actions';
import type { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkAntennaEditor from './MkAntennaEditor.vue';
export const Default = {
render(args) {
return {
components: {
MkAntennaEditor,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
events() {
return {
created: action('created'),
updated: action('updated'),
deleted: action('deleted'),
};
},
},
template: '<MkAntennaEditor v-bind="props" v-on="events" />',
};
},
args: {
},
parameters: {
layout: 'fullscreen',
msw: {
handlers: [
...commonHandlers,
http.post('/api/antennas/create', async ({ request }) => {
action('POST /api/antennas/create')(await request.json());
return HttpResponse.json({});
}),
http.post('/api/antennas/update', async ({ request }) => {
action('POST /api/antennas/update')(await request.json());
return HttpResponse.json({});
}),
http.post('/api/antennas/delete', async ({ request }) => {
action('POST /api/antennas/delete')(await request.json());
return HttpResponse.json();
}),
],
},
},
} satisfies StoryObj<typeof MkAntennaEditor>;

View File

@@ -39,11 +39,12 @@ 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">
<MkButton inline primary @click="saveAntenna()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
<MkButton v-if="antenna.id != null" inline danger @click="deleteAntenna()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
<MkButton v-if="initialAntenna.id != null" inline danger @click="deleteAntenna()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div>
</div>
</div>
@@ -53,36 +54,63 @@ SPDX-License-Identifier: AGPL-3.0-only
<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 '@/utility/merge.js';
type PartialAllowedAntenna = Omit<Misskey.entities.Antenna, 'id' | 'createdAt' | 'updatedAt'> & {
id?: string;
createdAt?: string;
updatedAt?: string;
};
const props = defineProps<{
antenna: Misskey.entities.Antenna
antenna?: DeepPartial<PartialAllowedAntenna>;
}>();
const initialAntenna = deepMerge<PartialAllowedAntenna>(props.antenna ?? {}, {
name: '',
src: 'all',
userListId: null,
users: [],
keywords: [],
excludeKeywords: [],
excludeBots: false,
withReplies: false,
caseSensitive: false,
localOnly: false,
withFile: false,
excludeNotesInSensitiveChannel: false,
isActive: true,
hasUnreadNote: false,
notify: false,
});
const emit = defineEmits<{
(ev: 'created'): void,
(ev: 'updated'): void,
(ev: 'created', newAntenna: Misskey.entities.Antenna): void,
(ev: 'updated', editedAntenna: Misskey.entities.Antenna): void,
(ev: 'deleted'): void,
}>();
const name = ref<string>(props.antenna.name);
const src = ref<Misskey.entities.AntennasCreateRequest['src']>(props.antenna.src);
const userListId = ref<string | null>(props.antenna.userListId);
const users = ref<string>(props.antenna.users.join('\n'));
const keywords = ref<string>(props.antenna.keywords.map(x => x.join(' ')).join('\n'));
const excludeKeywords = ref<string>(props.antenna.excludeKeywords.map(x => x.join(' ')).join('\n'));
const caseSensitive = ref<boolean>(props.antenna.caseSensitive);
const localOnly = ref<boolean>(props.antenna.localOnly);
const excludeBots = ref<boolean>(props.antenna.excludeBots);
const withReplies = ref<boolean>(props.antenna.withReplies);
const withFile = ref<boolean>(props.antenna.withFile);
const name = ref<string>(initialAntenna.name);
const src = ref<Misskey.entities.AntennasCreateRequest['src']>(initialAntenna.src);
const userListId = ref<string | null>(initialAntenna.userListId);
const users = ref<string>(initialAntenna.users.join('\n'));
const keywords = ref<string>(initialAntenna.keywords.map(x => x.join(' ')).join('\n'));
const excludeKeywords = ref<string>(initialAntenna.excludeKeywords.map(x => x.join(' ')).join('\n'));
const caseSensitive = ref<boolean>(initialAntenna.caseSensitive);
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 () => {
@@ -99,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()),
@@ -106,24 +135,26 @@ async function saveAntenna() {
excludeKeywords: excludeKeywords.value.trim().split('\n').map(x => x.trim().split(' ')),
};
if (props.antenna.id == null) {
await os.apiWithDialog('antennas/create', antennaData);
emit('created');
if (initialAntenna.id == null) {
const res = await os.apiWithDialog('antennas/create', antennaData);
emit('created', res);
} else {
await os.apiWithDialog('antennas/update', { ...antennaData, antennaId: props.antenna.id });
emit('updated');
const res = await os.apiWithDialog('antennas/update', { ...antennaData, antennaId: initialAntenna.id });
emit('updated', res);
}
}
async function deleteAntenna() {
if (initialAntenna.id == null) return;
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.tsx.removeAreYouSure({ x: props.antenna.name }),
text: i18n.tsx.removeAreYouSure({ x: initialAntenna.name }),
});
if (canceled) return;
await misskeyApi('antennas/delete', {
antennaId: props.antenna.id,
antennaId: initialAntenna.id,
});
os.success();
@@ -133,7 +164,7 @@ async function deleteAntenna() {
function addUser() {
os.selectUser({ includeSelf: true }).then(user => {
users.value = users.value.trim();
users.value += '\n@' + Misskey.acct.toString(user as any);
users.value += '\n@' + Misskey.acct.toString(user);
users.value = users.value.trim();
});
}
@@ -143,6 +174,6 @@ function addUser() {
.actions {
margin-top: 16px;
padding: 24px 0;
border-top: solid 0.5px var(--divider);
border-top: solid 0.5px var(--MI_THEME-divider);
}
</style>

View File

@@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { action } from '@storybook/addon-actions';
import type { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkAntennaEditorDialog from './MkAntennaEditorDialog.vue';
export const Default = {
render(args) {
return {
components: {
MkAntennaEditorDialog,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
events() {
return {
created: action('created'),
updated: action('updated'),
deleted: action('deleted'),
closed: action('closed'),
};
},
},
template: '<MkAntennaEditorDialog v-bind="props" v-on="events" />',
};
},
args: {
},
parameters: {
layout: 'centered',
msw: {
handlers: [
...commonHandlers,
http.post('/api/antennas/create', async ({ request }) => {
action('POST /api/antennas/create')(await request.json());
return HttpResponse.json({});
}),
http.post('/api/antennas/update', async ({ request }) => {
action('POST /api/antennas/update')(await request.json());
return HttpResponse.json({});
}),
http.post('/api/antennas/delete', async ({ request }) => {
action('POST /api/antennas/delete')(await request.json());
return HttpResponse.json();
}),
],
},
},
} satisfies StoryObj<typeof MkAntennaEditorDialog>;

View File

@@ -0,0 +1,63 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialog"
:withOkButton="false"
:width="500"
:height="550"
@close="close()"
@closed="emit('closed')"
>
<template #header>{{ antenna == null ? i18n.ts.createAntenna : i18n.ts.editAntenna }}</template>
<XAntennaEditor
:antenna="antenna"
@created="onAntennaCreated"
@updated="onAntennaUpdated"
@deleted="onAntennaDeleted"
/>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import XAntennaEditor from '@/components/MkAntennaEditor.vue';
import { i18n } from '@/i18n.js';
defineProps<{
antenna?: Misskey.entities.Antenna;
}>();
const emit = defineEmits<{
(ev: 'created', newAntenna: Misskey.entities.Antenna): void,
(ev: 'updated', editedAntenna: Misskey.entities.Antenna): void,
(ev: 'deleted'): void,
(ev: 'closed'): void,
}>();
const dialog = useTemplateRef('dialog');
function onAntennaCreated(newAntenna: Misskey.entities.Antenna) {
emit('created', newAntenna);
dialog.value?.close();
}
function onAntennaUpdated(editedAntenna: Misskey.entities.Antenna) {
emit('updated', editedAntenna);
dialog.value?.close();
}
function onAntennaDeleted() {
emit('deleted');
dialog.value?.close();
}
function close() {
dialog.value?.close();
}
</script>

View File

@@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template>
</MkInput>
<MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="c.default ?? null" @update:modelValue="c.onChange">
<MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="valueForSelect" @update:modelValue="onSelectUpdate">
<template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template>
<option v-for="item in c.items" :key="item.value" :value="item.value">{{ item.text }}</option>
@@ -54,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/>
</template>
</MkFolder>
<div v-else-if="c.type === 'container'" :class="[$style.container, { [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }]" :style="{ textAlign: c.align, backgroundColor: c.bgColor, color: c.fgColor, borderWidth: c.borderWidth ? `${c.borderWidth}px` : 0, borderColor: c.borderColor ?? 'var(--divider)', padding: c.padding ? `${c.padding}px` : 0, borderRadius: c.rounded ? '8px' : 0 }">
<div v-else-if="c.type === 'container'" :class="[$style.container, { [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }]" :style="containerStyle">
<template v-for="child in c.children" :key="child">
<MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size" :align="c.align"/>
</template>
@@ -63,22 +63,23 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { Ref, ref } 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';
const props = withDefaults(defineProps<{
component: AsUiComponent;
components: Ref<AsUiComponent>[];
size: 'small' | 'medium' | 'large';
align: 'left' | 'center' | 'right';
size?: 'small' | 'medium' | 'large';
align?: 'left' | 'center' | 'right';
}>(), {
size: 'medium',
align: 'left',
@@ -86,7 +87,7 @@ const props = withDefaults(defineProps<{
const c = props.component;
function g(id) {
function g(id: string) {
const v = props.components.find(x => x.value.id === id)?.value;
if (v) return v;
@@ -97,15 +98,47 @@ function g(id) {
} as AsUiRoot;
}
const containerStyle = computed(() => {
if (c.type !== 'container') return undefined;
// width, color, styleのうち一つでも指定があれば、枠線がちゃんと表示されるようにwidthとstyleのデフォルト値を設定
// radiusは単に角を丸める用途もあるため除外
const isBordered = c.borderWidth ?? c.borderColor ?? c.borderStyle;
const border = isBordered ? {
borderWidth: c.borderWidth ?? '1px',
borderColor: c.borderColor ?? 'var(--MI_THEME-divider)',
borderStyle: c.borderStyle ?? 'solid',
} : undefined;
return {
textAlign: c.align,
backgroundColor: c.bgColor,
color: c.fgColor,
padding: c.padding ? `${c.padding}px` : 0,
borderRadius: (c.borderRadius ?? (c.rounded ? 8 : 0)) + 'px',
...border,
};
});
const valueForSwitch = ref('default' in c && typeof c.default === 'boolean' ? c.default : false);
function onSwitchUpdate(v) {
function onSwitchUpdate(v: boolean) {
valueForSwitch.value = v;
if ('onChange' in c && c.onChange) {
c.onChange(v as never);
}
}
const valueForSelect = ref('default' in c && typeof c.default !== 'boolean' ? c.default ?? null : null);
function onSelectUpdate(v) {
valueForSelect.value = v;
if ('onChange' in c && c.onChange) {
c.onChange(v as never);
}
}
function openPostForm() {
const form = (c as AsUiPostFormButton).form;
if (!form) return;
@@ -142,7 +175,7 @@ function openPostForm() {
}
.postForm {
background: var(--bg);
background: var(--MI_THEME-bg);
border-radius: 8px;
}
</style>

View File

@@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MkAuthConfirm from './MkAuthConfirm.vue';
void MkAuthConfirm;

View File

@@ -0,0 +1,449 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.wrapper">
<Transition
mode="out-in"
:enterActiveClass="$style.transition_enterActive"
:leaveActiveClass="$style.transition_leaveActive"
:enterFromClass="$style.transition_enterFrom"
:leaveToClass="$style.transition_leaveTo"
:inert="_waiting"
>
<div v-if="phase === 'accountSelect'" key="accountSelect" :class="$style.root" class="_gaps">
<div :class="$style.header" class="_gaps_s">
<div :class="$style.iconFallback">
<i class="ti ti-user"></i>
</div>
<div :class="$style.headerText">{{ i18n.ts.pleaseSelectAccount }}</div>
</div>
<div>
<div :class="$style.accountSelectorLabel">{{ i18n.ts.selectAccount }}</div>
<div :class="$style.accountSelectorList">
<template v-for="[id, user] in users">
<input :id="'account-' + id" v-model="selectedUser" type="radio" name="accountSelector" :value="id" :class="$style.accountSelectorRadio"/>
<label :for="'account-' + id" :class="$style.accountSelectorItem">
<MkAvatar :user="user" :class="$style.accountSelectorAvatar"/>
<div :class="$style.accountSelectorBody">
<MkUserName :user="user" :class="$style.accountSelectorName"/>
<MkAcct :user="user" :class="$style.accountSelectorAcct"/>
</div>
</label>
</template>
<button class="_button" :class="[$style.accountSelectorItem, $style.accountSelectorAddAccountRoot]" @click="clickAddAccount">
<div :class="[$style.accountSelectorAvatar, $style.accountSelectorAddAccountAvatar]">
<i class="ti ti-user-plus"></i>
</div>
<div :class="[$style.accountSelectorBody, $style.accountSelectorName]">{{ i18n.ts.addAccount }}</div>
</button>
</div>
</div>
<div class="_buttonsCenter">
<MkButton rounded gradate :disabled="selectedUser === null" @click="clickChooseAccount">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</div>
<div v-else-if="phase === 'consent'" key="consent" :class="$style.root" class="_gaps">
<div :class="$style.header" class="_gaps_s">
<img v-if="icon" :class="$style.icon" :src="getProxiedImageUrl(icon, 'preview')"/>
<div v-else :class="$style.iconFallback">
<i class="ti ti-apps"></i>
</div>
<div :class="$style.headerText">{{ name ? i18n.tsx._auth.shareAccess({ name }) : i18n.ts._auth.shareAccessAsk }}</div>
</div>
<div v-if="permissions && permissions.length > 0" class="_gaps_s" :class="$style.permissionRoot">
<div>{{ name ? i18n.tsx._auth.permission({ name }) : i18n.ts._auth.permissionAsk }}</div>
<div :class="$style.permissionListWrapper">
<ul :class="$style.permissionList">
<li v-for="p in permissions" :key="p">{{ i18n.ts._permissions[p] }}</li>
</ul>
</div>
</div>
<slot name="consentAdditionalInfo"></slot>
<div>
<div :class="$style.accountSelectorLabel">
{{ i18n.ts._auth.scopeUser }} <button class="_textButton" @click="clickBackToAccountSelect">{{ i18n.ts.switchAccount }}</button>
</div>
<div :class="$style.accountSelectorList">
<div :class="[$style.accountSelectorItem, $style.static]">
<MkAvatar :user="users.get(selectedUser!)!" :class="$style.accountSelectorAvatar"/>
<div :class="$style.accountSelectorBody">
<MkUserName :user="users.get(selectedUser!)!" :class="$style.accountSelectorName"/>
<MkAcct :user="users.get(selectedUser!)!" :class="$style.accountSelectorAcct"/>
</div>
</div>
</div>
</div>
<div class="_buttonsCenter">
<MkButton rounded @click="clickCancel">{{ i18n.ts.reject }}</MkButton>
<MkButton rounded gradate @click="clickAccept">{{ i18n.ts.accept }}</MkButton>
</div>
</div>
<div v-else-if="phase === 'success'" key="success" :class="$style.root" class="_gaps_s">
<div :class="$style.header" class="_gaps_s">
<div :class="$style.iconFallback">
<i class="ti ti-check"></i>
</div>
<div :class="$style.headerText">{{ i18n.ts._auth.accepted }}</div>
<div :class="$style.headerTextSub">{{ i18n.ts._auth.pleaseGoBack }}</div>
</div>
</div>
<div v-else-if="phase === 'denied'" key="denied" :class="$style.root" class="_gaps_s">
<div :class="$style.header" class="_gaps_s">
<div :class="$style.iconFallback">
<i class="ti ti-x"></i>
</div>
<div :class="$style.headerText">{{ i18n.ts._auth.denied }}</div>
</div>
</div>
<div v-else-if="phase === 'failed'" key="failed" :class="$style.root" class="_gaps_s">
<div :class="$style.header" class="_gaps_s">
<div :class="$style.iconFallback">
<i class="ti ti-x"></i>
</div>
<div :class="$style.headerText">{{ i18n.ts.somethingHappened }}</div>
</div>
</div>
</Transition>
<div v-if="_waiting" :class="$style.waitingRoot">
<MkLoading/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue';
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 '@/utility/media-proxy.js';
import { misskeyApi } from '@/utility/misskey-api.js';
const props = defineProps<{
name?: string;
icon?: string;
permissions?: (typeof Misskey.permissions[number])[];
manualWaiting?: boolean;
waitOnDeny?: boolean;
}>();
const emit = defineEmits<{
(ev: 'accept', token: string): void;
(ev: 'deny', token: string): void;
}>();
const waiting = ref(true);
const _waiting = computed(() => waiting.value || props.manualWaiting);
const phase = ref<'accountSelect' | 'consent' | 'success' | 'denied' | 'failed'>('accountSelect');
const selectedUser = ref<string | null>(null);
const users = ref(new Map<string, Misskey.entities.UserDetailed & { token: string; }>());
async function init() {
waiting.value = true;
users.value.clear();
if ($i) {
users.value.set($i.id, $i);
}
const accounts = await getAccounts();
const accountIdsToFetch = accounts.map(a => a.user.id).filter(id => !users.value.has(id));
if (accountIdsToFetch.length > 0) {
const usersRes = await misskeyApi('users/show', {
userIds: accountIdsToFetch,
});
for (const user of usersRes) {
if (users.value.has(user.id)) continue;
users.value.set(user.id, {
...user,
token: accounts.find(a => a.user.id === user.id)!.token,
});
}
}
waiting.value = false;
}
init();
function clickAddAccount(ev: MouseEvent) {
selectedUser.value = null;
os.popupMenu([{
text: i18n.ts.existingAccount,
action: () => {
getAccountWithSigninDialog().then(async (res) => {
if (res != null) {
os.success();
await init();
if (users.value.has(res.id)) {
selectedUser.value = res.id;
}
}
});
},
}, {
text: i18n.ts.createAccount,
action: () => {
getAccountWithSignupDialog().then(async (res) => {
if (res != null) {
os.success();
await init();
if (users.value.has(res.id)) {
selectedUser.value = res.id;
}
}
});
},
}], ev.currentTarget ?? ev.target);
}
function clickChooseAccount() {
if (selectedUser.value === null) return;
phase.value = 'consent';
}
function clickBackToAccountSelect() {
selectedUser.value = null;
phase.value = 'accountSelect';
}
function clickCancel() {
if (selectedUser.value === null) return;
const user = users.value.get(selectedUser.value)!;
const token = user.token;
if (props.waitOnDeny) {
waiting.value = true;
}
emit('deny', token);
}
async function clickAccept() {
if (selectedUser.value === null) return;
const user = users.value.get(selectedUser.value)!;
const token = user.token;
waiting.value = true;
emit('accept', token);
}
function showUI(state: 'success' | 'denied' | 'failed') {
phase.value = state;
waiting.value = false;
}
defineExpose({
showUI,
});
</script>
<style lang="scss" module>
.transition_enterActive,
.transition_leaveActive {
transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
}
.transition_enterFrom {
opacity: 0;
transform: translateX(50px);
}
.transition_leaveTo {
opacity: 0;
transform: translateX(-50px);
}
.wrapper {
overflow-x: hidden;
overflow-x: clip;
position: relative;
width: 100%;
height: 100%;
}
.waitingRoot {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: color-mix(in srgb, var(--MI_THEME-panel), transparent 50%);
display: flex;
justify-content: center;
align-items: center;
z-index: 1;
cursor: wait;
}
.root {
position: relative;
box-sizing: border-box;
width: 100%;
padding: 48px 24px;
}
.header {
margin: 0 auto;
max-width: 320px;
}
.icon,
.iconFallback {
display: block;
margin: 0 auto;
width: 54px;
height: 54px;
}
.icon {
border-radius: 50%;
border: 1px solid var(--MI_THEME-divider);
background-color: #fff;
object-fit: contain;
}
.iconFallback {
border-radius: 50%;
background-color: var(--MI_THEME-accentedBg);
color: var(--MI_THEME-accent);
text-align: center;
line-height: 54px;
font-size: 18px;
}
.headerText,
.headerTextSub {
text-align: center;
word-break: normal;
word-break: auto-phrase;
}
.headerText {
font-size: 16px;
font-weight: 700;
}
.permissionRoot {
padding: 16px;
border-radius: var(--MI-radius);
background-color: var(--MI_THEME-bg);
}
.permissionListWrapper {
max-height: 350px;
overflow-y: auto;
padding: 12px;
border-radius: var(--MI-radius);
background-color: var(--MI_THEME-panel);
}
.permissionList {
margin: 0 0 0 1.5em;
padding: 0;
font-size: 90%;
}
.accountSelectorLabel {
font-size: 0.85em;
opacity: 0.7;
margin-bottom: 8px;
}
.accountSelectorList {
border-radius: var(--MI-radius);
border: 1px solid var(--MI_THEME-divider);
overflow: hidden;
overflow: clip;
}
.accountSelectorRadio {
position: absolute;
clip: rect(0, 0, 0, 0);
pointer-events: none;
&:focus-visible + .accountSelectorItem {
outline: 2px solid var(--MI_THEME-accent);
outline-offset: -4px;
}
&:checked:focus-visible + .accountSelectorItem {
outline-color: #fff;
}
&:checked + .accountSelectorItem {
background: var(--MI_THEME-accent);
color: #fff;
}
}
.accountSelectorItem {
display: flex;
align-items: center;
padding: 8px;
font-size: 14px;
-webkit-tap-highlight-color: transparent;
cursor: pointer;
&:hover {
background: var(--MI_THEME-buttonHoverBg);
}
&.static {
cursor: unset;
&:hover {
background: none;
}
}
}
.accountSelectorAddAccountRoot {
width: 100%;
}
.accountSelectorBody {
padding: 0 8px;
min-width: 0;
}
.accountSelectorAvatar {
width: 45px;
height: 45px;
}
.accountSelectorAddAccountAvatar {
background-color: var(--MI_THEME-accentedBg);
color: var(--MI_THEME-accent);
font-size: 16px;
line-height: 45px;
text-align: center;
border-radius: 50%;
}
.accountSelectorName {
display: block;
font-weight: bold;
}
.accountSelectorAcct {
opacity: 0.5;
}
</style>

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

@@ -44,26 +44,28 @@ 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 contains from '@/scripts/contains.js';
import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base.js';
import { emojilist, getEmojiName } from '@@/js/emojilist.js';
import { char2twemojiFilePath, char2fluentEmojiFilePath } 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 { emojilist, getEmojiName } from '@/scripts/emojilist.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 '@/const.js';
import { searchEmoji, EmojiDef } from '@/scripts/search-emoji.js';
import { searchEmoji } from '@/utility/search-emoji.js';
import { prefer } from '@/preferences.js';
const lib = emojilist.filter(x => x.category !== 'flags');
const emojiDb = computed(() => {
//#region Unicode Emoji
const char2path = defaultStore.reactiveState.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath;
const char2path = prefer.r.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath;
const unicodeEmojiDB: EmojiDef[] = lib.map(x => ({
emoji: x.char,
@@ -71,7 +73,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({
@@ -137,7 +139,7 @@ const emit = defineEmits<{
}>();
const suggests = ref<Element>();
const rootEl = shallowRef<HTMLDivElement>();
const rootEl = useTemplateRef('rootEl');
const fetching = ref(true);
const users = ref<any[]>([]);
@@ -153,10 +155,10 @@ function complete(type: string, value: any) {
emit('done', { type, value });
emit('closed');
if (type === 'emoji') {
let recents = defaultStore.state.recentlyUsedEmojis;
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 +199,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,7 +238,7 @@ 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;
}
@@ -355,7 +359,7 @@ onMounted(() => {
props.textarea.addEventListener('keydown', onKeydown);
document.body.addEventListener('mousedown', onMousedown);
window.document.body.addEventListener('mousedown', onMousedown);
nextTick(() => {
exec();
@@ -371,7 +375,7 @@ onMounted(() => {
onBeforeUnmount(() => {
props.textarea.removeEventListener('keydown', onKeydown);
document.body.removeEventListener('mousedown', onMousedown);
window.document.body.removeEventListener('mousedown', onMousedown);
});
</script>
@@ -407,16 +411,16 @@ onBeforeUnmount(() => {
text-overflow: ellipsis;
&:hover {
background: var(--X3);
background: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05));
}
&[data-selected='true'] {
background: var(--accent);
background: var(--MI_THEME-accent);
color: #fff !important;
}
&:active {
background: var(--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';
@@ -129,7 +130,7 @@ function onMousedown(evt: MouseEvent): void {
font-size: 95%;
box-shadow: none;
text-decoration: none;
background: var(--buttonBg);
background: var(--MI_THEME-buttonBg);
border-radius: 5px;
overflow: clip;
box-sizing: border-box;
@@ -140,11 +141,16 @@ function onMousedown(evt: MouseEvent): void {
}
&:not(:disabled):hover {
background: var(--buttonHoverBg);
background: var(--MI_THEME-buttonHoverBg);
}
&:not(:disabled):active {
background: var(--buttonHoverBg);
background: var(--MI_THEME-buttonHoverBg);
}
&.iconOnly {
padding: 7px;
min-width: auto;
}
&.small {
@@ -167,15 +173,15 @@ function onMousedown(evt: MouseEvent): void {
&.primary {
font-weight: bold;
color: var(--fgOnAccent) !important;
background: var(--accent);
color: var(--MI_THEME-fgOnAccent) !important;
background: var(--MI_THEME-accent);
&:not(:disabled):hover {
background: var(--X8);
background: hsl(from var(--MI_THEME-accent) h s calc(l + 5));
}
&:not(:disabled):active {
background: var(--X8);
background: hsl(from var(--MI_THEME-accent) h s calc(l + 5));
}
}
@@ -216,37 +222,42 @@ function onMousedown(evt: MouseEvent): void {
&.gradate {
font-weight: bold;
color: var(--fgOnAccent) !important;
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
color: var(--MI_THEME-fgOnAccent) !important;
background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB));
&:not(:disabled):hover {
background: linear-gradient(90deg, var(--X8), var(--X8));
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, var(--X8), var(--X8));
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 {
color: #ff2a2a;
font-weight: bold;
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));
}
}
}
&:disabled {
opacity: 0.7;
opacity: 0.5;
}
&.wait {
cursor: wait !important;
}
&:focus-visible {

View File

@@ -10,15 +10,29 @@ SPDX-License-Identifier: AGPL-3.0-only
<div id="mcaptcha__widget-container" class="m-captcha-style"></div>
<div ref="captchaEl"></div>
</div>
<div v-if="props.provider == 'testcaptcha'" style="background: #eee; border: solid 1px #888; padding: 8px; color: #000; max-width: 320px; display: flex; gap: 10px; align-items: center; box-shadow: 2px 2px 6px #0004; border-radius: 4px;">
<img src="/client-assets/testcaptcha.png" style="width: 60px; height: 60px; "/>
<div v-if="testcaptchaPassed">
<div style="color: green;">Test captcha passed!</div>
</div>
<div v-else>
<div style="font-size: 13px; margin-bottom: 4px;">Type "ai-chan-kawaii" to pass captcha</div>
<input v-model="testcaptchaInput" data-cy-testcaptcha-input/>
<button type="button" data-cy-testcaptcha-submit @click="testcaptchaSubmit">Submit</button>
</div>
</div>
<div v-else ref="captchaEl"></div>
</div>
</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
// see: https://developers.google.com/recaptcha/docs/display?hl=ja
// see: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#explicitly-render-the-turnstile-widget
export type Captcha = {
render(container: string | Node, options: {
readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown;
@@ -29,19 +43,22 @@ export type Captcha = {
getResponse(id: string): string;
};
export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'mcaptcha';
export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'mcaptcha' | 'testcaptcha';
type CaptchaContainer = {
readonly [_ in CaptchaProvider]?: Captcha;
};
declare global {
// Window を拡張してるため、空ではない
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface Window extends CaptchaContainer { }
}
const props = defineProps<{
provider: CaptchaProvider;
sitekey: string | null; // null will show error on request
secretKey?: string | null;
instanceUrl?: string | null;
modelValue?: string | null;
}>();
@@ -52,7 +69,10 @@ 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);
const variable = computed(() => {
switch (props.provider) {
@@ -60,6 +80,7 @@ const variable = computed(() => {
case 'recaptcha': return 'grecaptcha';
case 'turnstile': return 'turnstile';
case 'mcaptcha': return 'mcaptcha';
case 'testcaptcha': return 'testcaptcha';
}
});
@@ -71,6 +92,7 @@ const src = computed(() => {
case 'recaptcha': return 'https://www.recaptcha.net/recaptcha/api.js?render=explicit';
case 'turnstile': return 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
case 'mcaptcha': return null;
case 'testcaptcha': return null;
}
});
@@ -78,10 +100,19 @@ const scriptId = computed(() => `script-${props.provider}`);
const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha);
if (loaded || props.provider === 'mcaptcha') {
watch(() => [props.instanceUrl, props.sitekey, props.secretKey], async () => {
// 変更があったときはリフレッシュと再レンダリングをしておかないと、変更後の値で再検証が出来ない
if (available.value) {
callback(undefined);
clearWidget();
await requestRender();
}
});
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,
@@ -90,17 +121,43 @@ if (loaded || props.provider === 'mcaptcha') {
}
function reset() {
if (captcha.value.reset) captcha.value.reset();
if (captcha.value.reset && captchaWidgetId.value !== undefined) {
try {
captcha.value.reset(captchaWidgetId.value);
} catch (error: unknown) {
// ignore
if (_DEV_) console.warn(error);
}
}
testcaptchaPassed.value = false;
testcaptchaInput.value = '';
}
function remove() {
if (captcha.value.remove && captchaWidgetId.value) {
try {
if (_DEV_) console.log('remove', props.provider, captchaWidgetId.value);
captcha.value.remove(captchaWidgetId.value);
} catch (error: unknown) {
// ignore
if (_DEV_) console.warn(error);
}
}
}
async function requestRender() {
if (captcha.value.render && captchaEl.value instanceof Element) {
captcha.value.render(captchaEl.value, {
if (captcha.value.render && captchaEl.value instanceof Element && props.sitekey) {
// reCAPTCHAのレンダリング重複判定を回避するため、captchaEl配下に仮のdivを用意する.
// 同じdivに対して複数回renderを呼び出すとreCAPTCHAはエラーを返すので
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,
'error-callback': callback,
'expired-callback': () => callback(undefined),
'error-callback': () => callback(undefined),
});
} else if (props.provider === 'mcaptcha' && props.instanceUrl && props.sitekey) {
const { default: Widget } = await import('@mcaptcha/vanilla-glue');
@@ -115,6 +172,23 @@ async function requestRender() {
}
}
function clearWidget() {
if (props.provider === 'mcaptcha') {
const container = window.document.getElementById('mcaptcha__widget-container');
if (container) {
container.innerHTML = '';
}
} else {
reset();
remove();
if (captchaEl.value) {
// レンダリング先のコンテナの中身を掃除し、フォームが増殖するのを抑止
captchaEl.value.innerHTML = '';
}
}
}
function callback(response?: string) {
emit('update:modelValue', typeof response === 'string' ? response : null);
}
@@ -127,6 +201,12 @@ function onReceivedMessage(message: MessageEvent) {
}
}
function testcaptchaSubmit() {
testcaptchaPassed.value = testcaptchaInput.value === 'ai-chan-kawaii';
callback(testcaptchaPassed.value ? 'testcaptcha-passed' : undefined);
if (!testcaptchaPassed.value) testcaptchaInput.value = '';
}
onMounted(() => {
if (available.value) {
window.addEventListener('message', onReceivedMessage);
@@ -141,7 +221,7 @@ onUnmounted(() => {
});
onBeforeUnmount(() => {
reset();
clearWidget();
});
defineExpose({

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<{
@@ -68,9 +68,9 @@ async function onClick() {
position: relative;
display: inline-block;
font-weight: bold;
color: var(--accent);
color: var(--MI_THEME-accent);
background: transparent;
border: solid 1px var(--accent);
border: solid 1px var(--MI_THEME-accent);
padding: 0;
height: 31px;
font-size: 16px;
@@ -99,17 +99,17 @@ async function onClick() {
}
&.active {
color: var(--fgOnAccent);
background: var(--accent);
color: var(--MI_THEME-fgOnAccent);
background: var(--MI_THEME-accent);
&:hover {
background: var(--accentLighten);
border-color: var(--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(--accentDarken);
border-color: var(--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

@@ -47,11 +47,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js';
const props = defineProps<{
channel: Record<string, any>;
channel: Misskey.entities.Channel;
}>();
const getLastReadedAt = (): number | null => {
@@ -100,7 +101,7 @@ const bannerStyle = computed(() => {
height: 100%;
border-radius: inherit;
pointer-events: none;
box-shadow: inset 0 0 0 2px var(--focus);
box-shadow: inset 0 0 0 2px var(--MI_THEME-focus);
}
}
@@ -117,14 +118,16 @@ const bannerStyle = computed(() => {
left: 0;
width: 100%;
height: 64px;
background: linear-gradient(0deg, var(--panel), var(--X15));
background: linear-gradient(0deg, var(--MI_THEME-panel), color(from var(--MI_THEME-panel) srgb r g b / 0));
}
> .name {
position: absolute;
top: 16px;
left: 16px;
max-width: calc(100% - 32px);
padding: 12px 16px;
box-sizing: border-box;
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 1.2em;
@@ -148,7 +151,7 @@ const bannerStyle = computed(() => {
bottom: 16px;
left: 16px;
background: rgba(0, 0, 0, 0.7);
color: var(--warn);
color: var(--MI_THEME-warn);
border-radius: 6px;
font-weight: bold;
font-size: 1em;
@@ -167,7 +170,7 @@ const bannerStyle = computed(() => {
> footer {
padding: 12px 16px;
border-top: solid 0.5px var(--divider);
border-top: solid 0.5px var(--MI_THEME-divider);
> span {
opacity: 0.7;
@@ -213,8 +216,8 @@ const bannerStyle = computed(() => {
top: 0;
right: 0;
transform: translate(25%, -25%);
background-color: var(--accent);
border: solid var(--bg) 4px;
background-color: var(--MI_THEME-accent);
border: solid var(--MI_THEME-bg) 4px;
border-radius: 100%;
width: 1.5rem;
height: 1.5rem;

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

@@ -13,29 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</template>
<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 { 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 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 MkChartLegend from '@/components/MkChartLegend.vue';
initChart();
type ChartSrc =
<script lang="ts">
export type ChartSrc =
| 'federation'
| 'ap-request'
| 'users'
@@ -62,7 +41,26 @@ type ChartSrc =
| 'per-user-pv'
| 'per-user-following'
| 'per-user-followers'
| 'per-user-drive'
| 'per-user-drive';
</script>
<script lang="ts" setup>
import { onMounted, ref, useTemplateRef, watch } from 'vue';
import { Chart } from 'chart.js';
import * as Misskey from 'misskey-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 '@/utility/init-chart.js';
import { chartLegend } from '@/utility/chart-legend.js';
import MkChartLegend from '@/components/MkChartLegend.vue';
initChart();
const props = withDefaults(defineProps<{
src: ChartSrc;
@@ -94,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);
@@ -132,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) => {
@@ -159,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)));
@@ -847,7 +845,7 @@ watch(() => [props.src, props.span], fetchAndRender);
onMounted(() => {
fetchAndRender();
});
/* eslint-enable id-denylist */
</script>
<style lang="scss" module>
@@ -861,8 +859,8 @@ onMounted(() => {
left: 0;
width: 100%;
height: 100%;
-webkit-backdrop-filter: var(--blur, blur(12px));
backdrop-filter: var(--blur, blur(12px));
-webkit-backdrop-filter: var(--MI-blur, blur(12px));
backdrop-filter: var(--MI-blur, blur(12px));
display: flex;
justify-content: center;
align-items: center;

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>();
@@ -53,11 +54,11 @@ defineExpose({
> .item {
font-size: 85%;
padding: 4px 12px 4px 8px;
border: solid 1px var(--divider);
border: solid 1px var(--MI_THEME-divider);
border-radius: 999px;
&:hover {
border-color: var(--inputBorderHover);
border-color: var(--MI_THEME-inputBorderHover);
}
&.disabled {

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

@@ -22,10 +22,10 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, onMounted, onUnmounted, ref } from 'vue';
import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue';
import * as os from '@/os.js';
import { useInterval } from '@/scripts/use-interval.js';
import * as game from '@/scripts/clicker-game.js';
import { useInterval } from '@@/js/use-interval.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 = {
@@ -31,6 +31,7 @@ export const Default = {
},
args: {
clip: clip(),
noUserInfo: false,
},
parameters: {
layout: 'fullscreen',

View File

@@ -12,10 +12,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="clip.lastClippedAt">{{ i18n.ts.updatedAt }}: <MkTime :time="clip.lastClippedAt" mode="detail"/></div>
<div v-if="clip.notesCount != null">{{ i18n.ts.notesCount }}: {{ number(clip.notesCount) }} / {{ $i?.policies.noteEachClipsLimit }} ({{ i18n.tsx.remainingN({ n: remaining }) }})</div>
</div>
<div :class="$style.divider"></div>
<div>
<MkAvatar :user="clip.user" :class="$style.userAvatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/>
</div>
<template v-if="!props.noUserInfo">
<div :class="$style.divider"></div>
<div>
<MkAvatar :user="clip.user" :class="$style.userAvatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/>
</div>
</template>
</div>
</MkA>
</template>
@@ -24,12 +26,15 @@ 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 = defineProps<{
const props = withDefaults(defineProps<{
clip: Misskey.entities.Clip;
}>();
noUserInfo?: boolean;
}>(), {
noUserInfo: false,
});
const remaining = computed(() => {
return ($i?.policies && props.clip.notesCount != null) ? ($i.policies.noteEachClipsLimit - props.clip.notesCount) : i18n.ts.unknown;
@@ -44,13 +49,13 @@ const remaining = computed(() => {
outline: none;
.root {
box-shadow: inset 0 0 0 2px var(--focus);
box-shadow: inset 0 0 0 2px var(--MI_THEME-focus);
}
}
&:hover {
text-decoration: none;
color: var(--accent);
color: var(--MI_THEME-accent);
}
}
@@ -60,7 +65,7 @@ const remaining = computed(() => {
.divider {
height: 1px;
background: var(--divider);
background: var(--MI_THEME-divider);
}
.description {

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([
@@ -74,10 +74,8 @@ watch(() => props.lang, (to) => {
<style module lang="scss">
.codeBlockRoot :global(.shiki) {
padding: 1em;
margin: .5em 0;
margin: 0;
overflow: auto;
border-radius: 8px;
border: 1px solid var(--divider);
font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
color: var(--shiki-fallback);

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

@@ -5,12 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="$style.codeBlockRoot">
<button :class="$style.codeBlockCopyButton" class="_button" @click="copy">
<button v-if="copyButton" :class="$style.codeBlockCopyButton" class="_button" @click="copy">
<i class="ti ti-copy"></i>
</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>
@@ -28,22 +28,26 @@ 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 = defineProps<{
const props = withDefaults(defineProps<{
code: string;
forceShow?: boolean;
copyButton?: boolean;
lang?: string;
}>();
}>(), {
copyButton: true,
forceShow: false,
});
const show = ref(!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>
@@ -66,7 +70,7 @@ function copy() {
.codeBlockFallbackRoot {
display: block;
overflow-wrap: anywhere;
background: var(--bg);
background: var(--MI_THEME-bg);
padding: 1em;
margin: .5em 0;
overflow: auto;
@@ -89,8 +93,8 @@ function copy() {
border-radius: 8px;
padding: 24px;
margin-top: 4px;
color: var(--fg);
background: var(--bg);
color: var(--MI_THEME-fg);
background: var(--MI_THEME-bg);
}
.codePlaceholderContainer {

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(--fgTransparentWeak);
color: color(from var(--MI_THEME-fg) srgb r g b / 0.75);
&:empty {
display: none;
@@ -160,17 +160,17 @@ watch(v, newValue => {
margin: 0;
border-radius: 6px;
padding: 0;
color: var(--fg);
border: solid 1px var(--panel);
color: var(--MI_THEME-fg);
border: solid 1px var(--MI_THEME-panel);
transition: border-color 0.1s ease-out;
font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
&:hover {
border-color: var(--inputBorderHover) !important;
border-color: var(--MI_THEME-inputBorderHover) !important;
}
}
.focused.codeEditorRoot {
border-color: var(--accent) !important;
border-color: var(--MI_THEME-accent) !important;
border-radius: 6px;
}
@@ -196,7 +196,7 @@ watch(v, newValue => {
resize: none;
text-align: left;
color: transparent;
caret-color: var(--fg);
caret-color: var(--MI_THEME-fg);
background-color: transparent;
border: 0;
border-radius: 6px;
@@ -211,6 +211,6 @@ watch(v, newValue => {
}
.textarea::selection {
color: var(--bg);
color: var(--MI_THEME-bg);
}
</style>

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

@@ -18,7 +18,7 @@ const props = defineProps<{
display: inline-block;
font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
overflow-wrap: anywhere;
background: var(--bg);
background: var(--MI_THEME-bg);
padding: .1em;
border-radius: .3em;
}

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

@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { ref, shallowRef, toRefs } from 'vue';
import { ref, useTemplateRef, toRefs } from 'vue';
const props = defineProps<{
modelValue: string | null;
@@ -39,7 +39,7 @@ 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 ?? '');
@@ -60,7 +60,7 @@ const onInput = () => {
.caption {
font-size: 0.85em;
padding: 8px 0 0 0;
color: var(--fgTransparentWeak);
color: color(from var(--MI_THEME-fg) srgb r g b / 0.75);
&:empty {
display: none;
@@ -72,8 +72,8 @@ const onInput = () => {
&.focused {
> .inputCore {
border-color: var(--accent) !important;
//box-shadow: 0 0 0 4px var(--focus);
border-color: var(--MI_THEME-accent) !important;
//box-shadow: 0 0 0 4px var(--MI_THEME-focus);
}
}
@@ -98,9 +98,9 @@ const onInput = () => {
font: inherit;
font-weight: normal;
font-size: 1em;
color: var(--fg);
background: var(--panel);
border: solid 1px var(--panel);
color: var(--MI_THEME-fg);
background: var(--MI_THEME-panel);
border: solid 1px var(--MI_THEME-panel);
border-radius: 6px;
outline: none;
box-shadow: none;
@@ -108,7 +108,7 @@ const onInput = () => {
transition: border-color 0.1s ease-out;
&:hover {
border-color: var(--inputBorderHover) !important;
border-color: var(--MI_THEME-inputBorderHover) !important;
}
}
</style>

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"
@@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<div v-show="showBody" ref="contentEl" :class="[$style.content, { [$style.omitted]: omitted }]">
<slot></slot>
<button v-if="omitted" :class="$style.fade" class="_button" @click="() => { ignoreOmit = true; omitted = false; }">
<button v-if="omitted" :class="$style.fade" class="_button" @click="showMore">
<span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span>
</button>
</div>
@@ -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<{
@@ -48,6 +48,7 @@ const props = withDefaults(defineProps<{
thin?: boolean;
naked?: boolean;
foldable?: boolean;
onUnfold?: () => boolean; // return false to prevent unfolding
scrollable?: boolean;
expanded?: boolean;
maxHeight?: number | null;
@@ -57,33 +58,37 @@ 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);
function enter(el) {
function enter(el: Element) {
if (!(el instanceof HTMLElement)) return;
const elementHeight = el.getBoundingClientRect().height;
el.style.height = 0;
el.style.height = '0';
el.offsetHeight; // reflow
el.style.height = Math.min(elementHeight, props.maxHeight ?? Infinity) + 'px';
el.style.height = `${Math.min(elementHeight, props.maxHeight ?? Infinity)}px`;
}
function afterEnter(el) {
el.style.height = null;
function afterEnter(el: Element) {
if (!(el instanceof HTMLElement)) return;
el.style.height = '';
}
function leave(el) {
function leave(el: Element) {
if (!(el instanceof HTMLElement)) return;
const elementHeight = el.getBoundingClientRect().height;
el.style.height = elementHeight + 'px';
el.style.height = `${elementHeight}px`;
el.offsetHeight; // reflow
el.style.height = 0;
el.style.height = '0';
}
function afterLeave(el) {
el.style.height = null;
function afterLeave(el: Element) {
if (!(el instanceof HTMLElement)) return;
el.style.height = '';
}
const calcOmit = () => {
@@ -97,6 +102,13 @@ const omitObserver = new ResizeObserver((entries, observer) => {
calcOmit();
});
function showMore() {
if (props.onUnfold && !props.onUnfold()) return;
ignoreOmit.value = true;
omitted.value = false;
}
onMounted(() => {
watch(showBody, v => {
if (!rootEl.value) return;
@@ -165,15 +177,20 @@ onUnmounted(() => {
.header {
position: sticky;
top: var(--stickyTop, 0px);
top: var(--MI-stickyTop, 0px);
left: 0;
color: var(--panelHeaderFg);
background: var(--panelHeaderBg);
border-bottom: solid 0.5px var(--panelHeaderDivider);
color: var(--MI_THEME-panelHeaderFg);
background: var(--MI_THEME-panelHeaderBg);
z-index: 2;
line-height: 1.4em;
}
@container style(--MI_THEME-panelHeaderBg: var(--MI_THEME-panel)) {
.header {
box-shadow: 0 0.5px 0 0 light-dark(#0002, #fff2);
}
}
.title {
margin: 0;
padding: 12px 16px;
@@ -201,7 +218,15 @@ onUnmounted(() => {
}
.content {
--stickyTop: 0px;
--MI-stickyTop: 0px;
/*
理屈は知らないけど、ここでbackgroundを設定しておかないと
スクロールコンテナーが少なくともChromeにおいて
main thread scrolling になってしまい、パフォーマンスが(多分)落ちる。
backgroundが透明だと裏側を描画しないといけなくなるとかそういう理由かもしれない
*/
background: var(--MI_THEME-panel);
&.omitted {
position: relative;
@@ -216,11 +241,11 @@ onUnmounted(() => {
left: 0;
width: 100%;
height: 64px;
background: linear-gradient(0deg, var(--panel), var(--X15));
background: linear-gradient(0deg, var(--MI_THEME-panel), color(from var(--MI_THEME-panel) srgb r g b / 0));
> .fadeLabel {
display: inline-block;
background: var(--panel);
background: var(--MI_THEME-panel);
padding: 6px 10px;
font-size: 0.8em;
border-radius: 999px;
@@ -229,7 +254,7 @@ onUnmounted(() => {
&:hover {
> .fadeLabel {
background: var(--panelHighlight);
background: var(--MI_THEME-panelHighlight);
}
}
}

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 { MenuItem } from '@/types/menu.js';
import contains from '@/scripts/contains.js';
import { defaultStore } from '@/store.js';
import type { MenuItem } from '@/types/menu.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

@@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:withOkButton="true"
@close="cancel()"
@ok="ok()"
@closed="$emit('closed')"
@closed="emit('closed')"
>
<template #header>{{ i18n.ts.cropImage }}</template>
<template #default="{ width, height }">
@@ -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 '@/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,10 +122,10 @@ 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('--accent')).toHexString();
selection.themeColor = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString();
selection.aspectRatio = props.aspectRatio;
selection.initialAspectRatio = props.aspectRatio;
selection.outlined = true;
@@ -170,8 +170,8 @@ onMounted(() => {
display: flex;
align-items: center;
justify-content: center;
-webkit-backdrop-filter: var(--blur, blur(10px));
backdrop-filter: var(--blur, blur(10px));
-webkit-backdrop-filter: var(--MI-blur, blur(10px));
backdrop-filter: var(--MI-blur, blur(10px));
background: rgba(0, 0, 0, 0.5);
}

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

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow ref="dialogEl" @close="cancel()" @closed="$emit('closed')">
<MkModalWindow ref="dialogEl" @close="cancel()" @closed="emit('closed')">
<template #header>:{{ emoji.name }}:</template>
<template #default>
<MkSpacer>
@@ -57,14 +57,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import * as Misskey from 'misskey-js';
import { defineProps, 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,8 +85,8 @@ function cancel() {
.emojiImgWrapper {
max-width: 100%;
height: 40cqh;
background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--X5) 8px, var(--X5) 14px);
border-radius: var(--radius);
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,8 +101,8 @@ function cancel() {
display: inline-block;
word-break: break-all;
padding: 3px 10px;
background-color: var(--X5);
border: solid 1px var(--divider);
border-radius: var(--radius);
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);
}
</style>

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

@@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MkDateSeparatedList from './MkDateSeparatedList.vue';
void MkDateSeparatedList;

View File

@@ -3,14 +3,18 @@ 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 { defaultStore } from '@/store.js';
import { MisskeyEntity } from '@/types/date-separated-list.js';
import { instance } from '@/instance.js';
import { prefer } from '@/preferences.js';
import { getDateText } from '@/utility/timeline-date-separate.js';
export default defineComponent({
props: {
@@ -43,15 +47,6 @@ export default defineComponent({
setup(props, { slots, expose }) {
const $style = useCssModule(); // カスタムレンダラなので使っても大丈夫
function getDateText(time: string) {
const date = new Date(time).getDate();
const month = new Date(time).getMonth() + 1;
return i18n.tsx.monthAndDay({
month: month.toString(),
day: date.toString(),
});
}
if (props.items.length === 0) return;
const renderChildrenImpl = () => props.items.map((item, i) => {
@@ -62,9 +57,16 @@ export default defineComponent({
})[0];
if (el.key == null && item.id) el.key = item.id;
const date = new Date(item.createdAt);
const nextDate = props.items[i + 1] ? new Date(props.items[i + 1].createdAt) : null;
if (
i !== props.items.length - 1 &&
new Date(item.createdAt).getDate() !== new Date(props.items[i + 1].createdAt).getDate()
nextDate != null && (
date.getFullYear() !== nextDate.getFullYear() ||
date.getMonth() !== nextDate.getMonth() ||
date.getDate() !== nextDate.getDate()
)
) {
const separator = h('div', {
class: $style['separator'],
@@ -78,12 +80,12 @@ export default defineComponent({
h('i', {
class: `ti ti-chevron-up ${$style['date-1-icon']}`,
}),
getDateText(item.createdAt),
getDateText(date),
]),
h('span', {
class: $style['date-2'],
}, [
getDateText(props.items[i + 1].createdAt),
getDateText(nextDate),
h('i', {
class: `ti ti-chevron-down ${$style['date-2-icon']}`,
}),
@@ -92,11 +94,13 @@ export default defineComponent({
return [el, separator];
} else {
if (props.ad && item._shouldInsertAd_) {
return [h(MkAd, {
if (props.ad && instance.ads.length > 0 && item._shouldInsertAd_) {
return [h('div', {
key: item.id + ':ad',
class: $style['ad-wrapper'],
}, [h(MkAd, {
prefer: ['horizontal', 'horizontal-big'],
}), el];
})]), el];
} else {
return el;
}
@@ -118,14 +122,14 @@ export default defineComponent({
return children;
};
function onBeforeLeave(element: Element) {
const el = element as HTMLElement;
function onBeforeLeave(el: Element) {
if (!(el instanceof HTMLElement)) return;
el.style.top = `${el.offsetTop}px`;
el.style.left = `${el.offsetLeft}px`;
}
function onLeaveCancelled(element: Element) {
const el = element as HTMLElement;
function onLeaveCancelled(el: Element) {
if (!(el instanceof HTMLElement)) return;
el.style.top = '';
el.style.left = '';
}
@@ -139,7 +143,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',
@@ -157,25 +161,21 @@ 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) {
margin-bottom: var(--margin);
margin-bottom: var(--MI-margin);
}
}
@@ -187,7 +187,7 @@ export default defineComponent({
box-shadow: none;
&:not(:last-child) {
border-bottom: solid 0.5px var(--divider);
border-bottom: solid 0.5px var(--MI_THEME-divider);
}
}
}
@@ -228,7 +228,7 @@ export default defineComponent({
line-height: 32px;
text-align: center;
font-size: 12px;
color: var(--dateLabelFg);
color: var(--MI_THEME-dateLabelFg);
}
.date-1 {
@@ -246,5 +246,11 @@ export default defineComponent({
.date-2-icon {
margin-left: 8px;
}
.ad-wrapper {
padding: 8px;
background-size: auto auto;
background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-bg) 8px, var(--MI_THEME-bg) 14px);
}
</style>

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