Merge tag '2023.12.0-beta.5' into merge-upstream

This commit is contained in:
riku6460
2023-12-21 07:03:04 +09:00
150 changed files with 4706 additions and 2076 deletions

View File

@@ -1,6 +1,6 @@
<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@2.37.0/tabler-icons.min.css">
<link rel="stylesheet" href="https://unpkg.com/@tabler/icons-webfont@2.44.0/tabler-icons.min.css">
<link rel="stylesheet" href="https://unpkg.com/@fontsource/m-plus-rounded-1c/index.css">
<style>
html {

View File

@@ -13,3 +13,6 @@ declare const _PERF_PREFIX_: string;
declare const _DATA_TRANSFER_DRIVE_FILE_: string;
declare const _DATA_TRANSFER_DRIVE_FOLDER_: string;
declare const _DATA_TRANSFER_DECK_COLUMN_: string;
// for dev-mode
declare const _LANGS_FULL_: string[][];

View File

@@ -180,7 +180,7 @@ import './photoswipe-!~{003}~.js';
const _hoisted_1 = createBaseVNode("i", {
class: "ti ti-photo"
}, null, -1);
const _sfc_main = defineComponent({
const index_photos = defineComponent({
__name: "index.photos",
props: {
user: {}
@@ -261,7 +261,6 @@ const style0 = {
const cssModules = {
"$style": style0
};
const index_photos = _sfc_main;
export {index_photos as default};
`.slice(1));
});

View File

@@ -13,13 +13,13 @@ function isFalsyIdentifier(identifier: estree.Identifier): boolean {
return identifier.name === 'undefined' || identifier.name === 'NaN';
}
function normalizeClassWalker(tree: estree.Node): string | null {
function normalizeClassWalker(tree: estree.Node, stack: string | undefined): string | null {
if (tree.type === 'Identifier') return isFalsyIdentifier(tree) ? '' : null;
if (tree.type === 'Literal') return typeof tree.value === 'string' ? tree.value : '';
if (tree.type === 'BinaryExpression') {
if (tree.operator !== '+') return null;
const left = normalizeClassWalker(tree.left);
const right = normalizeClassWalker(tree.right);
const left = normalizeClassWalker(tree.left, stack);
const right = normalizeClassWalker(tree.right, stack);
if (left === null || right === null) return null;
return `${left}${right}`;
}
@@ -33,15 +33,15 @@ function normalizeClassWalker(tree: estree.Node): string | null {
if (tree.type === 'ArrayExpression') {
const values = tree.elements.map((treeNode) => {
if (treeNode === null) return '';
if (treeNode.type === 'SpreadElement') return normalizeClassWalker(treeNode.argument);
return normalizeClassWalker(treeNode);
if (treeNode.type === 'SpreadElement') return normalizeClassWalker(treeNode.argument, stack);
return normalizeClassWalker(treeNode, stack);
});
if (values.some((x) => x === null)) return null;
return values.join(' ');
}
if (tree.type === 'ObjectExpression') {
const values = tree.properties.map((treeNode) => {
if (treeNode.type === 'SpreadElement') return normalizeClassWalker(treeNode.argument);
if (treeNode.type === 'SpreadElement') return normalizeClassWalker(treeNode.argument, stack);
let x = treeNode.value;
let inveted = false;
while (x.type === 'UnaryExpression' && x.operator === '!') {
@@ -67,18 +67,26 @@ function normalizeClassWalker(tree: estree.Node): string | null {
if (values.some((x) => x === null)) return null;
return values.join(' ');
}
console.error(`Unexpected node type: ${tree.type}`);
if (
tree.type !== 'CallExpression' &&
tree.type !== 'ChainExpression' &&
tree.type !== 'ConditionalExpression' &&
tree.type !== 'LogicalExpression' &&
tree.type !== 'MemberExpression') {
console.error(stack ? `Unexpected node type: ${tree.type} (in ${stack})` : `Unexpected node type: ${tree.type}`);
}
return null;
}
export function normalizeClass(tree: estree.Node): string | null {
const walked = normalizeClassWalker(tree);
export function normalizeClass(tree: estree.Node, stack?: string): string | null {
const walked = normalizeClassWalker(tree, stack);
return walked && walked.replace(/^\s+|\s+(?=\s)|\s+$/g, '');
}
export function unwindCssModuleClassName(ast: estree.Node): void {
(walk as typeof estreeWalker.walk)(ast, {
enter(node, parent): void {
//#region
if (parent?.type !== 'Program') return;
if (node.type !== 'VariableDeclaration') return;
if (node.declarations.length !== 1) return;
@@ -102,6 +110,14 @@ export function unwindCssModuleClassName(ast: estree.Node): void {
return true;
});
if (!~__cssModulesIndex) return;
/* This region assumeed that the entered node looks like the following code.
*
* ```ts
* const SomeComponent = _export_sfc(_sfc_main, [["foo", bar], ["__cssModules", cssModules]]);
* ```
*/
//#endregion
//#region
const cssModuleForestName = ((node.declarations[0].init.arguments[1].elements[__cssModulesIndex] as estree.ArrayExpression).elements[1] as estree.Identifier).name;
const cssModuleForestNode = parent.body.find((x) => {
if (x.type !== 'VariableDeclaration') return false;
@@ -117,6 +133,16 @@ export function unwindCssModuleClassName(ast: estree.Node): void {
if (property.value.type !== 'Identifier') return [];
return [[property.key.value as string, property.value.name as string]];
}));
/* This region collected a VariableDeclaration node in the module that looks like the following code.
*
* ```ts
* const cssModules = {
* "$style": style0,
* };
* ```
*/
//#endregion
//#region
const sfcMain = parent.body.find((x) => {
if (x.type !== 'VariableDeclaration') return false;
if (x.declarations.length !== 1) return false;
@@ -146,7 +172,22 @@ export function unwindCssModuleClassName(ast: estree.Node): void {
if (ctx.type !== 'Identifier') return;
if (ctx.name !== '_ctx') return;
if (render.argument.body.type !== 'BlockStatement') return;
/* This region assumed that `sfcMain` looks like the following code.
*
* ```ts
* const _sfc_main = defineComponent({
* setup(_props) {
* ...
* return (_ctx, _cache) => {
* ...
* };
* },
* });
* ```
*/
//#endregion
for (const [key, value] of moduleForest) {
//#region
const cssModuleTreeNode = parent.body.find((x) => {
if (x.type !== 'VariableDeclaration') return false;
if (x.declarations.length !== 1) return false;
@@ -172,6 +213,19 @@ export function unwindCssModuleClassName(ast: estree.Node): void {
if (actualValue.declarations[0].init?.type !== 'Literal') return [];
return [[actualKey, actualValue.declarations[0].init.value as string]];
}));
/* This region collected VariableDeclaration nodes in the module that looks like the following code.
*
* ```ts
* const foo = "bar";
* const baz = "qux";
* const style0 = {
* foo: foo,
* baz: baz,
* };
* ```
*/
//#endregion
//#region
(walk as typeof estreeWalker.walk)(render.argument.body, {
enter(childNode) {
if (childNode.type !== 'MemberExpression') return;
@@ -189,6 +243,39 @@ export function unwindCssModuleClassName(ast: estree.Node): void {
});
},
});
/* This region inlined the reference identifier of the class name in the render function into the actual literal, as in the following code.
*
* ```ts
* const _sfc_main = defineComponent({
* setup(_props) {
* ...
* return (_ctx, _cache) => {
* ...
* return openBlock(), createElementBlock("div", {
* class: normalizeClass(_ctx.$style.foo),
* }, null);
* };
* },
* });
* ```
*
* ↓
*
* ```ts
* const _sfc_main = defineComponent({
* setup(_props) {
* ...
* return (_ctx, _cache) => {
* ...
* return openBlock(), createElementBlock("div", {
* class: normalizeClass("bar"),
* }, null);
* };
* },
* });
*/
//#endregion
//#region
(walk as typeof estreeWalker.walk)(render.argument.body, {
enter(childNode) {
if (childNode.type !== 'MemberExpression') return;
@@ -205,13 +292,47 @@ export function unwindCssModuleClassName(ast: estree.Node): void {
});
},
});
/* This region replaced the reference identifier of missing class names in the render function with `undefined`, as in the following code.
*
* ```ts
* const _sfc_main = defineComponent({
* setup(_props) {
* ...
* return (_ctx, _cache) => {
* ...
* return openBlock(), createElementBlock("div", {
* class: normalizeClass(_ctx.$style.hoge),
* }, null);
* };
* },
* });
* ```
*
* ↓
*
* ```ts
* const _sfc_main = defineComponent({
* setup(_props) {
* ...
* return (_ctx, _cache) => {
* ...
* return openBlock(), createElementBlock("div", {
* class: normalizeClass(undefined),
* }, null);
* };
* },
* });
* ```
*/
//#endregion
//#region
(walk as typeof estreeWalker.walk)(render.argument.body, {
enter(childNode) {
if (childNode.type !== 'CallExpression') return;
if (childNode.callee.type !== 'Identifier') return;
if (childNode.callee.name !== 'normalizeClass') return;
if (childNode.arguments.length !== 1) return;
const normalized = normalizeClass(childNode.arguments[0]);
const normalized = normalizeClass(childNode.arguments[0], name);
if (normalized === null) return;
this.replace({
type: 'Literal',
@@ -219,8 +340,60 @@ export function unwindCssModuleClassName(ast: estree.Node): void {
});
},
});
/* This region compiled the `normalizeClass` call into a pseudo-AOT compilation, as in the following code.
*
* ```ts
* const _sfc_main = defineComponent({
* setup(_props) {
* ...
* return (_ctx, _cache) => {
* ...
* return openBlock(), createElementBlock("div", {
* class: normalizeClass("bar"),
* }, null);
* };
* },
* });
* ```
*
* ↓
*
* ```ts
* const _sfc_main = defineComponent({
* setup(_props) {
* ...
* return (_ctx, _cache) => {
* ...
* return openBlock(), createElementBlock("div", {
* class: "bar",
* }, null);
* };
* },
* });
* ```
*/
//#endregion
}
//#region
if (node.declarations[0].init.arguments[1].elements.length === 1) {
(walk as typeof estreeWalker.walk)(ast, {
enter(childNode) {
if (childNode.type !== 'Identifier') return;
if (childNode.name !== ident) return;
this.replace({
type: 'Identifier',
name: node.declarations[0].id.name,
});
},
});
this.remove();
/* NOTE: The above logic is valid as long as the following two conditions are met.
*
* - the uniqueness of `ident` is kept throughout the module
* - `_export_sfc` is noop when the second argument is an empty array
*
* Otherwise, the below logic should be used instead.
this.replace({
type: 'VariableDeclaration',
declarations: [{
@@ -236,6 +409,7 @@ export function unwindCssModuleClassName(ast: estree.Node): void {
}],
kind: 'const',
});
*/
} else {
this.replace({
type: 'VariableDeclaration',
@@ -263,6 +437,35 @@ export function unwindCssModuleClassName(ast: estree.Node): void {
kind: 'const',
});
}
/* This region removed the `__cssModules` reference from the second argument of `_export_sfc`, as in the following code.
*
* ```ts
* const SomeComponent = _export_sfc(_sfc_main, [["foo", bar], ["__cssModules", cssModules]]);
* ```
*
* ↓
*
* ```ts
* const SomeComponent = _export_sfc(_sfc_main, [["foo", bar]]);
* ```
*
* When the declaration becomes noop, it is removed as follows.
*
* ```ts
* const _sfc_main = defineComponent({
* ...
* });
* const SomeComponent = _export_sfc(_sfc_main, []);
* ```
*
* ↓
*
* ```ts
* const SomeComponent = defineComponent({
* ...
* });
*/
//#endregion
},
});
}

View File

@@ -19,18 +19,16 @@
"dependencies": {
"@discordapp/twemoji": "14.1.2",
"@github/webauthn-json": "2.1.1",
"@rollup/plugin-alias": "5.1.0",
"@rollup/plugin-json": "6.0.1",
"@rollup/plugin-json": "6.1.0",
"@rollup/plugin-replace": "5.0.5",
"@rollup/plugin-typescript": "11.1.5",
"@rollup/pluginutils": "5.1.0",
"@syuilo/aiscript": "0.16.0",
"@tabler/icons-webfont": "2.37.0",
"@vitejs/plugin-vue": "4.5.1",
"@vue/compiler-sfc": "3.3.9",
"astring": "1.8.6",
"autosize": "6.0.1",
"@tabler/icons-webfont": "2.44.0",
"@vitejs/plugin-vue": "4.5.2",
"@vue/compiler-sfc": "3.3.11",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.0.6",
"astring": "1.8.6",
"broadcast-channel": "6.0.0",
"browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3",
"buraha": "0.0.1",
@@ -47,7 +45,7 @@
"escape-regexp": "0.0.1",
"estree-walker": "3.0.3",
"eventemitter3": "5.0.1",
"gsap": "3.12.3",
"gsap": "3.12.4",
"idb-keyval": "6.2.1",
"insert-text-at-cursor": "0.3.0",
"is-file-animated": "1.0.2",
@@ -57,11 +55,10 @@
"misskey-js": "workspace:*",
"photoswipe": "5.4.3",
"punycode": "2.3.1",
"querystring": "0.2.1",
"rollup": "4.7.0",
"rollup": "4.9.0",
"sanitize-html": "2.11.0",
"shiki": "0.14.6",
"sass": "1.69.5",
"shiki": "0.14.7",
"strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0",
"three": "0.159.0",
@@ -73,30 +70,29 @@
"typescript": "5.3.3",
"uuid": "9.0.1",
"v-code-diff": "1.7.2",
"vanilla-tilt": "1.8.1",
"vite": "5.0.7",
"vite": "5.0.10",
"vue": "3.3.11",
"vuedraggable": "next"
},
"devDependencies": {
"@storybook/addon-actions": "7.6.4",
"@storybook/addon-essentials": "7.6.4",
"@storybook/addon-interactions": "7.6.4",
"@storybook/addon-links": "7.6.4",
"@storybook/addon-storysource": "7.6.4",
"@storybook/addons": "7.6.4",
"@storybook/blocks": "7.6.4",
"@storybook/core-events": "7.6.4",
"@storybook/addon-actions": "7.6.5",
"@storybook/addon-essentials": "7.6.5",
"@storybook/addon-interactions": "7.6.5",
"@storybook/addon-links": "7.6.5",
"@storybook/addon-storysource": "7.6.5",
"@storybook/addons": "7.6.5",
"@storybook/blocks": "7.6.5",
"@storybook/core-events": "7.6.5",
"@storybook/jest": "0.2.3",
"@storybook/manager-api": "7.6.4",
"@storybook/preview-api": "7.6.4",
"@storybook/react": "7.6.4",
"@storybook/react-vite": "7.6.4",
"@storybook/manager-api": "7.6.5",
"@storybook/preview-api": "7.6.5",
"@storybook/react": "7.6.5",
"@storybook/react-vite": "7.6.5",
"@storybook/testing-library": "0.2.2",
"@storybook/theming": "7.6.4",
"@storybook/types": "7.6.4",
"@storybook/vue3": "7.6.4",
"@storybook/vue3-vite": "7.6.4",
"@storybook/theming": "7.6.5",
"@storybook/types": "7.6.5",
"@storybook/vue3": "7.6.5",
"@storybook/vue3-vite": "7.6.5",
"@testing-library/vue": "8.0.1",
"@types/escape-regexp": "0.0.3",
"@types/estree": "1.0.5",
@@ -108,29 +104,29 @@
"@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6",
"@types/uuid": "9.0.7",
"@types/websocket": "1.0.10",
"@types/ws": "8.5.10",
"@typescript-eslint/eslint-plugin": "6.13.2",
"@typescript-eslint/parser": "6.13.2",
"@typescript-eslint/eslint-plugin": "6.14.0",
"@typescript-eslint/parser": "6.14.0",
"@vitest/coverage-v8": "0.34.6",
"@vue/runtime-core": "3.3.11",
"acorn": "8.11.2",
"cross-env": "7.0.3",
"cypress": "13.6.1",
"eslint": "8.55.0",
"eslint-plugin-import": "2.29.0",
"eslint": "8.56.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-vue": "9.19.2",
"fast-glob": "3.3.2",
"happy-dom": "10.0.3",
"intersection-observer": "0.12.2",
"micromatch": "4.0.5",
"msw": "1.3.2",
"msw-storybook-addon": "1.10.0",
"nodemon": "3.0.2",
"prettier": "3.1.0",
"prettier": "3.1.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"start-server-and-test": "2.0.3",
"storybook": "7.6.4",
"storybook": "7.6.5",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"summaly": "github:misskey-dev/summaly",
"vite-plugin-turbosnap": "1.0.3",

View File

@@ -8,4 +8,88 @@
// (pnpm start時はpugファイルの中で静的リソースとして読み込むようになっており、この問題は起こっていない)
import '@tabler/icons-webfont/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

@@ -16,7 +16,7 @@ import { unisonReload, reloadChannel } from '@/scripts/unison-reload.js';
// TODO: 他のタブと永続化されたstateを同期
type Account = Misskey.entities.MeDetailed;
type Account = Misskey.entities.MeDetailed & { token: string };
const accountData = miLocalStorage.getItem('account');
@@ -284,7 +284,7 @@ export async function openAccountMenu(opts: {
text: i18n.ts.profile,
to: `/@${ $i.username }`,
avatar: $i,
}, null, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
}, { type: 'divider' }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
type: 'parent' as const,
icon: 'ti ti-plus',
text: i18n.ts.addAccount,

View File

@@ -8,23 +8,20 @@ import { compareVersions } from 'compare-versions';
import widgets from '@/widgets/index.js';
import directives from '@/directives/index.js';
import components from '@/components/index.js';
import { version, ui, lang, updateLocale } from '@/config.js';
import { version, ui, lang, updateLocale, locale } from '@/config.js';
import { applyTheme } from '@/scripts/theme.js';
import { isDeviceDarkmode } from '@/scripts/is-device-darkmode.js';
import { i18n, updateI18n } from '@/i18n.js';
import { confirm, alert, post, popup, toast } from '@/os.js';
import { $i, refreshAccount, login, updateAccount, signout } from '@/account.js';
import { defaultStore, ColdDeviceStorage } from '@/store.js';
import { fetchInstance, instance } from '@/instance.js';
import { deviceKind } from '@/scripts/device-kind.js';
import { reloadChannel } from '@/scripts/unison-reload.js';
import { reactionPicker } from '@/scripts/reaction-picker.js';
import { getUrlWithoutLoginId } from '@/scripts/login-id.js';
import { getAccountFromId } from '@/scripts/get-account-from-id.js';
import { deckStore } from '@/ui/deck/deck-store.js';
import { miLocalStorage } from '@/local-storage.js';
import { fetchCustomEmojis } from '@/custom-emojis.js';
import { mainRouter } from '@/router.js';
export async function common(createVue: () => App<Element>) {
console.info(`Misskey v${version}`);
@@ -88,7 +85,7 @@ export async function common(createVue: () => App<Element>) {
//#region Detect language & fetch translations
const localeVersion = miLocalStorage.getItem('localeVersion');
const localeOutdated = (localeVersion == null || localeVersion !== version);
const localeOutdated = (localeVersion == null || localeVersion !== version || locale == null);
if (localeOutdated) {
const res = await window.fetch(`/assets/locales/${lang}.${version}.json`);
if (res.status === 200) {

View File

@@ -20,6 +20,7 @@ import { mainRouter } from '@/router.js';
import { initializeSw } from '@/scripts/initialize-sw.js';
import { deckStore } from '@/ui/deck/deck-store.js';
import { emojiPicker } from '@/scripts/emoji-picker.js';
import { SnowfallEffect } from '@/scripts/snowfall-effect.js';
export async function mainBoot() {
const { isClientUpdated } = await common(() => createApp(
@@ -75,6 +76,13 @@ export async function mainBoot() {
},
};
if (defaultStore.state.enableSeasonalScreenEffect) {
const month = new Date().getMonth() + 1;
if (month === 12 || month === 1) {
new SnowfallEffect().render();
}
}
if ($i) {
// only add post shortcuts if logged in
hotkeys['p|n'] = post;

View File

@@ -21,8 +21,9 @@ const props = withDefaults(defineProps<{
focus: 1.0,
});
function loadShader(gl, type, source) {
function loadShader(gl: WebGLRenderingContext, type: number, source: string) {
const shader = gl.createShader(type);
if (shader == null) return null;
try {
gl.shaderSource(shader, source);
@@ -46,11 +47,13 @@ function loadShader(gl, type, source) {
return shader;
}
function initShaderProgram(gl, vsSource, fsSource) {
function initShaderProgram(gl: WebGLRenderingContext, vsSource: string, fsSource: string) {
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource);
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource);
const shaderProgram = gl.createProgram();
if (shaderProgram == null || vertexShader == null || fragmentShader == null) return null;
gl.attachShader(shaderProgram, vertexShader);
gl.attachShader(shaderProgram, fragmentShader);
gl.linkProgram(shaderProgram);
@@ -71,8 +74,10 @@ let handle: ReturnType<typeof window['requestAnimationFrame']> | null = null;
onMounted(() => {
const canvas = canvasEl.value!;
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
let width = canvas.offsetWidth;
let height = canvas.offsetHeight;
canvas.width = width;
canvas.height = height;
const gl = canvas.getContext('webgl', { premultipliedAlpha: true });
if (gl == null) return;
@@ -205,6 +210,7 @@ onMounted(() => {
gl_FragColor = vec4( color, max(max(color.x, color.y), color.z) );
}
`);
if (shaderProgram == null) return;
gl.useProgram(shaderProgram);
const u_resolution = gl.getUniformLocation(shaderProgram, 'u_resolution');
@@ -234,7 +240,23 @@ onMounted(() => {
gl!.uniform1f(u_time, 0);
gl!.drawArrays(gl!.TRIANGLE_STRIP, 0, 4);
} else {
function render(timeStamp) {
function render(timeStamp: number) {
let sizeChanged = false;
if (Math.abs(height - canvas.offsetHeight) > 2) {
height = canvas.offsetHeight;
canvas.height = height;
sizeChanged = true;
}
if (Math.abs(width - canvas.offsetWidth) > 2) {
width = canvas.offsetWidth;
canvas.width = width;
sizeChanged = true;
}
if (sizeChanged && gl) {
gl.uniform2fv(u_resolution, [width, height]);
gl.viewport(0, 0, width, height);
}
gl!.uniform1f(u_time, timeStamp);
gl!.drawArrays(gl!.TRIANGLE_STRIP, 0, 4);

View File

@@ -54,7 +54,7 @@ watch(() => props.lang, (to) => {
return new Promise((resolve) => {
fetchLanguage(to).then(() => resolve);
});
}, { immediate: true, });
}, { immediate: true });
</script>
<style scoped lang="scss">

View File

@@ -9,7 +9,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkLoading v-if="!inline ?? true"/>
</template>
<code v-if="inline" :class="$style.codeInlineRoot">{{ code }}</code>
<XCode v-else-if="show" :code="code" :lang="lang"/>
<XCode v-else-if="show && lang" :code="code" :lang="lang"/>
<pre v-else-if="show" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre>
<button v-else :class="$style.codePlaceholderRoot" @click="show = true">
<div :class="$style.codePlaceholderContainer">
<div><i class="ti ti-code"></i> {{ i18n.ts.code }}</div>
@@ -47,6 +48,21 @@ const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue'))
border-radius: .3em;
}
.codeBlockFallbackRoot {
display: block;
overflow-wrap: anywhere;
color: #D4D4D4;
background: #1E1E1E;
padding: 1em;
margin: .5em 0;
overflow: auto;
border-radius: 8px;
}
.codeBlockFallbackCode {
font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
}
.codePlaceholderRoot {
display: block;
width: 100%;

View File

@@ -4,31 +4,39 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="[$style.codeEditorRoot, { [$style.disabled]: disabled, [$style.focused]: focused }]">
<div :class="$style.codeEditorScroller">
<textarea
ref="inputEl"
v-model="vModel"
:class="[$style.textarea]"
:placeholder="placeholder"
:disabled="disabled"
:required="required"
:readonly="readonly"
autocomplete="off"
wrap="off"
spellcheck="false"
@focus="focused = true"
@blur="focused = false"
@keydown="onKeydown($event)"
@input="onInput"
></textarea>
<XCode :class="$style.codeEditorHighlighter" :codeEditor="true" :code="v" :lang="lang"/>
<div>
<div :class="$style.label" @click="focus"><slot name="label"></slot></div>
<div :class="[$style.codeEditorRoot, { [$style.focused]: focused }]">
<div :class="$style.codeEditorScroller">
<textarea
ref="inputEl"
v-model="vModel"
:class="[$style.textarea]"
:placeholder="placeholder"
:disabled="disabled"
:required="required"
:readonly="readonly"
autocomplete="off"
wrap="off"
spellcheck="false"
@focus="focused = true"
@blur="focused = false"
@keydown="onKeydown($event)"
@input="onInput"
></textarea>
<XCode :class="$style.codeEditorHighlighter" :codeEditor="true" :code="v" :lang="lang"/>
</div>
</div>
<div :class="$style.caption"><slot name="caption"></slot></div>
<MkButton v-if="manualSave && changed" primary :class="$style.save" @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</div>
</template>
<script lang="ts" setup>
import { ref, watch, toRefs, shallowRef, nextTick } from 'vue';
import { debounce } from 'throttle-debounce';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
import XCode from '@/components/MkCode.core.vue';
const props = withDefaults(defineProps<{
@@ -38,6 +46,8 @@ const props = withDefaults(defineProps<{
required?: boolean;
readonly?: boolean;
disabled?: boolean;
debounce?: boolean;
manualSave?: boolean;
}>(), {
lang: 'js',
});
@@ -56,6 +66,8 @@ const focused = ref(false);
const changed = ref(false);
const inputEl = shallowRef<HTMLTextAreaElement>();
const focus = () => inputEl.value?.focus();
const onInput = (ev) => {
v.value = ev.target?.value ?? v.value;
changed.value = true;
@@ -102,16 +114,48 @@ const updated = () => {
emit('update:modelValue', v.value);
};
const debouncedUpdated = debounce(1000, updated);
watch(modelValue, newValue => {
v.value = newValue ?? '';
});
watch(v, () => {
updated();
watch(v, newValue => {
if (!props.manualSave) {
if (props.debounce) {
debouncedUpdated();
} else {
updated();
}
}
});
</script>
<style lang="scss" module>
.label {
font-size: 0.85em;
padding: 0 0 8px 0;
user-select: none;
&:empty {
display: none;
}
}
.caption {
font-size: 0.85em;
padding: 8px 0 0 0;
color: var(--fgTransparentWeak);
&:empty {
display: none;
}
}
.save {
margin: 8px 0 0 0;
}
.codeEditorRoot {
min-width: 100%;
max-width: 100%;
@@ -119,6 +163,7 @@ watch(v, () => {
overflow-y: hidden;
box-sizing: border-box;
margin: 0;
border-radius: 6px;
padding: 0;
color: var(--fg);
border: solid 1px var(--panel);
@@ -159,9 +204,10 @@ watch(v, () => {
caret-color: rgb(225, 228, 232);
background-color: transparent;
border: 0;
border-radius: 6px;
outline: 0;
min-width: calc(100% - 24px);
height: calc(100% - 24px);
height: 100%;
padding: 12px;
line-height: 1.5em;
font-size: 1em;

View File

@@ -39,6 +39,7 @@ import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js';
import { claimAchievement } from '@/scripts/achievements.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { MenuItem } from '@/types/menu.js';
const props = withDefaults(defineProps<{
folder: Misskey.entities.DriveFolder;
@@ -250,7 +251,7 @@ function setAsUploadFolder() {
}
function onContextmenu(ev: MouseEvent) {
let menu;
let menu: MenuItem[];
menu = [{
text: i18n.ts.openInWindow,
icon: 'ti ti-app-window',
@@ -260,18 +261,18 @@ function onContextmenu(ev: MouseEvent) {
}, {
}, 'closed');
},
}, null, {
}, { type: 'divider' }, {
text: i18n.ts.rename,
icon: 'ti ti-forms',
action: rename,
}, null, {
}, { type: 'divider' }, {
text: i18n.ts.delete,
icon: 'ti ti-trash',
danger: true,
action: deleteFolder,
}];
if (defaultStore.state.devMode) {
menu = menu.concat([null, {
menu = menu.concat([{ type: 'divider' }, {
icon: 'ti ti-id',
text: i18n.ts.copyFolderId,
action: () => {

View File

@@ -616,7 +616,7 @@ function getMenu() {
type: 'switch',
text: i18n.ts.keepOriginalUploading,
ref: keepOriginal,
}, null, {
}, { type: 'divider' }, {
text: i18n.ts.addFile,
type: 'label',
}, {
@@ -627,7 +627,7 @@ function getMenu() {
text: i18n.ts.fromUrl,
icon: 'ti ti-link',
action: () => { urlUpload(); },
}, null, {
}, { type: 'divider' }, {
text: folder.value ? folder.value.name : i18n.ts.drive,
type: 'label',
}, folder.value ? {

View File

@@ -26,35 +26,35 @@ SPDX-License-Identifier: AGPL-3.0-only
</section>
<!-- フォルダの中にはカスタム絵文字やフォルダがある -->
<section v-else v-panel style="border-radius: 6px; border-bottom: 0.5px solid var(--divider);">
<header class="_acrylic" @click="shown = !shown">
<header class="_acrylic" @click="shown = !shown">
<i class="toggle ti-fw" :class="shown ? 'ti ti-chevron-down' : 'ti ti-chevron-up'"></i> <slot></slot> (<i class="ti ti-folder ti-fw"></i>:{{ customEmojiTree.length }} <i class="ti ti-icons ti-fw"></i>:{{ emojis.length }})
</header>
<div v-if="shown" style="padding-left: 9px;">
<MkEmojiPickerSection
v-for="child in customEmojiTree"
:key="`custom:${child.category}`"
:initialShown="initialShown"
:emojis="computed(() => customEmojis.filter(e => e.category === child.category).map(e => `:${e.name}:`))"
:hasChildSection="child.children.length !== 0"
:customEmojiTree="child.children"
@chosen="nestedChosen"
>
{{ child.category || i18n.ts.other }}
</MkEmojiPickerSection>
</div>
<div v-if="shown" class="body">
<button
v-for="emoji in emojis"
:key="emoji"
:data-emoji="emoji"
class="_button item"
@pointerenter="computeButtonTitle"
@click="emit('chosen', emoji, $event)"
>
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/>
<MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/>
</button>
</div>
</header>
<div v-if="shown" style="padding-left: 9px;">
<MkEmojiPickerSection
v-for="child in customEmojiTree"
:key="`custom:${child.category}`"
:initialShown="initialShown"
:emojis="computed(() => customEmojis.filter(e => e.category === child.category).map(e => `:${e.name}:`))"
:hasChildSection="child.children.length !== 0"
:customEmojiTree="child.children"
@chosen="nestedChosen"
>
{{ child.category || i18n.ts.other }}
</MkEmojiPickerSection>
</div>
<div v-if="shown" class="body">
<button
v-for="emoji in emojis"
:key="emoji"
:data-emoji="emoji"
class="_button item"
@pointerenter="computeButtonTitle"
@click="emit('chosen', emoji, $event)"
>
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/>
<MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/>
</button>
</div>
</section>
</template>

View File

@@ -77,8 +77,8 @@ SPDX-License-Identifier: AGPL-3.0-only
:key="`custom:${child.category}`"
:initialShown="false"
:emojis="computed(() => customEmojis.filter(e => child.category === '' ? (e.category === 'null' || !e.category) : e.category === child.category).filter(filterAvailable).map(e => `:${e.name}:`))"
:hasChildSection="child.children.length !== 0"
:customEmojiTree="child.children"
:hasChildSection="child.children.length !== 0"
:customEmojiTree="child.children"
@chosen="chosen"
>
{{ child.category || i18n.ts.other }}
@@ -103,12 +103,12 @@ import { ref, shallowRef, computed, watch, onMounted } from 'vue';
import * as Misskey from 'misskey-js';
import XSection from '@/components/MkEmojiPicker.section.vue';
import {
emojilist,
emojiCharByCategory,
UnicodeEmojiDef,
unicodeEmojiCategories as categories,
getEmojiName,
CustomEmojiFolderTree
emojilist,
emojiCharByCategory,
UnicodeEmojiDef,
unicodeEmojiCategories as categories,
getEmojiName,
CustomEmojiFolderTree,
} from '@/scripts/emojilist.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import * as os from '@/os.js';
@@ -121,10 +121,11 @@ import { $i } from '@/account.js';
const props = withDefaults(defineProps<{
showPinned?: boolean;
asReactionPicker?: boolean;
pinnedEmojis?: string[];
maxHeight?: number;
asDrawer?: boolean;
asWindow?: boolean;
asReactionPicker?: boolean; // 今は使われてないが将来的に使いそう
}>(), {
showPinned: true,
});
@@ -137,24 +138,22 @@ const searchEl = shallowRef<HTMLInputElement>();
const emojisEl = shallowRef<HTMLDivElement>();
const {
reactions: pinnedReactions,
reactionPickerSize,
reactionPickerWidth,
reactionPickerHeight,
disableShowingAnimatedImages,
emojiPickerScale,
emojiPickerWidth,
emojiPickerHeight,
recentlyUsedEmojis,
} = defaultStore.reactiveState;
const pinned = computed(() => props.asReactionPicker ? pinnedReactions.value : []); // TODO: 非リアクションの絵文字ピッカー用のpinned絵文字を設定可能にする
const size = computed(() => props.asReactionPicker ? reactionPickerSize.value : 1);
const width = computed(() => props.asReactionPicker ? reactionPickerWidth.value : 3);
const height = computed(() => props.asReactionPicker ? reactionPickerHeight.value : 2);
const pinned = computed(() => props.pinnedEmojis);
const size = computed(() => emojiPickerScale.value);
const width = computed(() => emojiPickerWidth.value);
const height = computed(() => emojiPickerHeight.value);
const q = ref<string>('');
const searchResultCustom = ref<Misskey.entities.EmojiSimple[]>([]);
const searchResultUnicode = ref<UnicodeEmojiDef[]>([]);
const tab = ref<'index' | 'custom' | 'unicode' | 'tags'>('index');
const customEmojiFolderRoot: CustomEmojiFolderTree = { category: "", children: [] };
const customEmojiFolderRoot: CustomEmojiFolderTree = { category: '', children: [] };
function parseAndMergeCategories(input: string, root: CustomEmojiFolderTree): CustomEmojiFolderTree {
const parts = (input && input !== 'null' ? input : '').split(' / ');
@@ -180,9 +179,9 @@ function parseAndMergeCategories(input: string, root: CustomEmojiFolderTree): Cu
}
customEmojiCategories.value.forEach(ec => {
if (ec !== null) {
parseAndMergeCategories(ec, customEmojiFolderRoot);
}
if (ec !== null) {
parseAndMergeCategories(ec, customEmojiFolderRoot);
}
});
parseAndMergeCategories('', customEmojiFolderRoot);
@@ -332,8 +331,8 @@ watch(q, () => {
});
function filterAvailable(emoji: Misskey.entities.EmojiSimple): boolean {
return ((emoji.roleIdsThatCanBeUsedThisEmojiAsReaction === undefined || emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0) || ($i && $i.roles.some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.includes(r.id)))) &&
((emoji.roleIdsThatCanNotBeUsedThisEmojiAsReaction === undefined || emoji.roleIdsThatCanNotBeUsedThisEmojiAsReaction.length === 0) || ($i && !$i.roles.some(r => emoji.roleIdsThatCanNotBeUsedThisEmojiAsReaction.includes(r.id))));
return ((emoji.roleIdsThatCanBeUsedThisEmojiAsReaction === undefined || emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0) || (!!$i && $i.roles.some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction?.includes(r.id)))) &&
((emoji.roleIdsThatCanNotBeUsedThisEmojiAsReaction === undefined || emoji.roleIdsThatCanNotBeUsedThisEmojiAsReaction.length === 0) || (!!$i && !$i.roles.some(r => emoji.roleIdsThatCanNotBeUsedThisEmojiAsReaction?.includes(r.id))));
}
function focus() {
@@ -373,7 +372,7 @@ function chosen(emoji: any, ev?: MouseEvent) {
emit('chosen', key);
// 最近使った絵文字更新
if (!pinned.value.includes(key)) {
if (!pinned.value?.includes(key)) {
let recents = defaultStore.state.recentlyUsedEmojis;
recents = recents.filter((emoji: any) => emoji !== key);
recents.unshift(key);

View File

@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="modal"
v-slot="{ type, maxHeight }"
:zPriority="'middle'"
:preferType="asReactionPicker && defaultStore.state.reactionPickerUseDrawerForMobile === false ? 'popup' : 'auto'"
:preferType="defaultStore.state.emojiPickerUseDrawerForMobile === false ? 'popup' : 'auto'"
:transparentBg="true"
:manualShowing="manualShowing"
:src="src"
@@ -22,6 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
class="_popup _shadow"
:class="{ [$style.drawer]: type === 'drawer' }"
:showPinned="showPinned"
:pinnedEmojis="pinnedEmojis"
:asReactionPicker="asReactionPicker"
:asDrawer="type === 'drawer'"
:max-height="maxHeight"
@@ -40,11 +41,13 @@ const props = withDefaults(defineProps<{
manualShowing?: boolean | null;
src?: HTMLElement;
showPinned?: boolean;
pinnedEmojis?: string[],
asReactionPicker?: boolean;
choseAndClose?: boolean;
}>(), {
manualShowing: null,
showPinned: true,
pinnedEmojis: undefined,
asReactionPicker: false,
choseAndClose: true,
});

View File

@@ -104,7 +104,7 @@ async function onClick() {
});
emit('update:user', {
...props.user,
withReplies: defaultStore.state.defaultWithReplies
withReplies: defaultStore.state.defaultWithReplies,
});
hasPendingFollowRequestFromYou.value = true;

View File

@@ -26,11 +26,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
</MkInput>
<MkInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model="values[item]" type="text">
<MkInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model="values[item]" type="text" :mfmAutocomplete="form[item].treatAsMfm">
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
</MkInput>
<MkTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]">
<MkTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]" :mfmAutocomplete="form[item].treatAsMfm" :mfmPreview="form[item].treatAsMfm">
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
</MkTextarea>

View File

@@ -43,11 +43,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { onMounted, nextTick, ref, shallowRef, watch, computed, toRefs } from 'vue';
import { onMounted, onUnmounted, nextTick, ref, shallowRef, watch, computed, toRefs } from 'vue';
import { debounce } from 'throttle-debounce';
import MkButton from '@/components/MkButton.vue';
import { useInterval } from '@/scripts/use-interval.js';
import { i18n } from '@/i18n.js';
import { Autocomplete, SuggestionType } from '@/scripts/autocomplete.js';
const props = defineProps<{
modelValue: string | number | null;
@@ -59,6 +60,7 @@ const props = defineProps<{
placeholder?: string;
autofocus?: boolean;
autocomplete?: string;
mfmAutocomplete?: boolean | SuggestionType[],
autocapitalize?: string;
spellcheck?: boolean;
step?: any;
@@ -94,6 +96,7 @@ const height =
props.small ? 33 :
props.large ? 39 :
36;
let autocomplete: Autocomplete;
const focus = () => inputEl.value.focus();
const onInput = (ev: KeyboardEvent) => {
@@ -167,6 +170,16 @@ onMounted(() => {
focus();
}
});
if (props.mfmAutocomplete) {
autocomplete = new Autocomplete(inputEl.value, v, props.mfmAutocomplete === true ? null : props.mfmAutocomplete);
}
});
onUnmounted(() => {
if (autocomplete) {
autocomplete.detach();
}
});
defineExpose({

View File

@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@contextmenu.self="e => e.preventDefault()"
>
<template v-for="(item, i) in items2">
<div v-if="item === null" role="separator" :class="$style.divider"></div>
<div v-if="item.type === 'divider'" role="separator" :class="$style.divider"></div>
<span v-else-if="item.type === 'label'" role="menuitem" :class="[$style.label, $style.item]">
<span>{{ item.text }}</span>
</span>

View File

@@ -463,7 +463,7 @@ function showRenoteMenu(viaKeyboard = false): void {
pleaseLogin();
os.popupMenu([
getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
null,
{ type: 'divider' },
getUnrenote(),
], renoteTime.value, {
viaKeyboard: viaKeyboard,
@@ -471,7 +471,7 @@ function showRenoteMenu(viaKeyboard = false): void {
} else {
os.popupMenu([
getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
null,
{ type: 'divider' },
getAbuseNoteMenu(note.value, i18n.ts.reportAbuseRenote),
$i.isModerator || $i.isAdmin ? getUnrenote() : undefined,
], renoteTime.value, {

View File

@@ -145,7 +145,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'reactions' }]" @click="tab = 'reactions'"><i class="ti ti-icons"></i> {{ i18n.ts.reactions }}</button>
</div>
<div>
<div v-if="tab === 'replies'" :class="$style.tab_replies">
<div v-if="tab === 'replies'">
<div v-if="!repliesLoaded" style="padding: 16px">
<MkButton style="margin: 0 auto;" primary rounded @click="loadReplies">{{ i18n.ts.loadReplies }}</MkButton>
</div>

View File

@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ref, shallowRef } from 'vue';
import MkModal from './MkModal.vue';
import MkMenu from './MkMenu.vue';
import { MenuItem } from '@/types/menu';
import { MenuItem } from '@/types/menu.js';
defineProps<{
items: MenuItem[];

View File

@@ -860,7 +860,7 @@ async function insertEmoji(ev: MouseEvent) {
},
() => {
textAreaReadOnly.value = false;
focus();
nextTick(() => focus());
},
);
}

View File

@@ -139,12 +139,14 @@ if (!mock) {
<style lang="scss" module>
.root {
display: inline-block;
display: inline-flex;
height: 42px;
margin: 2px;
padding: 0 6px;
font-size: 1.5em;
border-radius: 6px;
align-items: center;
justify-content: center;
&.canToggle {
background: var(--buttonBg);
@@ -183,7 +185,7 @@ if (!mock) {
&.reacted, &.reacted:hover {
background: var(--accentedBg);
color: var(--accent);
box-shadow: 0 0 0px 1px var(--accent) inset;
box-shadow: 0 0 0 1px var(--accent) inset;
> .count {
color: var(--accent);

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="[$style.root, { [$style.disabled]: disabled, [$style.checked]: checked }]">
<div :class="[$style.root, { [$style.disabled]: disabled }]">
<input
ref="input"
type="checkbox"
@@ -64,9 +64,6 @@ const toggle = () => {
opacity: 0.6;
cursor: not-allowed;
}
//&.checked {
//}
}
.input {

View File

@@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:readonly="readonly"
:placeholder="placeholder"
:pattern="pattern"
:autocomplete="autocomplete"
:autocomplete="props.autocomplete"
:spellcheck="spellcheck"
@focus="focused = true"
@blur="focused = false"
@@ -26,16 +26,21 @@ SPDX-License-Identifier: AGPL-3.0-only
></textarea>
</div>
<div :class="$style.caption"><slot name="caption"></slot></div>
<button v-if="mfmPreview" style="font-size: 0.85em;" class="_textButton" type="button" @click="preview = !preview">{{ i18n.ts.preview }}</button>
<div v-if="mfmPreview" v-show="preview" v-panel :class="$style.mfmPreview">
<Mfm :text="v"/>
</div>
<MkButton v-if="manualSave && changed" primary :class="$style.save" @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</div>
</template>
<script lang="ts" setup>
import { onMounted, nextTick, ref, watch, computed, toRefs, shallowRef } from 'vue';
import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, shallowRef } from 'vue';
import { debounce } from 'throttle-debounce';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
import { Autocomplete, SuggestionType } from '@/scripts/autocomplete.js';
const props = defineProps<{
modelValue: string | null;
@@ -46,6 +51,8 @@ const props = defineProps<{
placeholder?: string;
autofocus?: boolean;
autocomplete?: string;
mfmAutocomplete?: boolean | SuggestionType[],
mfmPreview?: boolean;
spellcheck?: boolean;
debounce?: boolean;
manualSave?: boolean;
@@ -68,6 +75,8 @@ const changed = ref(false);
const invalid = ref(false);
const filled = computed(() => v.value !== '' && v.value != null);
const inputEl = shallowRef<HTMLTextAreaElement>();
const preview = ref(false);
let autocomplete: Autocomplete;
const focus = () => inputEl.value.focus();
const onInput = (ev) => {
@@ -82,6 +91,16 @@ const onKeydown = (ev: KeyboardEvent) => {
if (ev.code === 'Enter') {
emit('enter');
}
if (props.code && ev.key === 'Tab') {
const pos = inputEl.value?.selectionStart ?? 0;
const posEnd = inputEl.value?.selectionEnd ?? v.value.length;
v.value = v.value.slice(0, pos) + '\t' + v.value.slice(posEnd);
nextTick(() => {
inputEl.value?.setSelectionRange(pos + 1, pos + 1);
});
ev.preventDefault();
}
};
const updated = () => {
@@ -113,6 +132,16 @@ onMounted(() => {
focus();
}
});
if (props.mfmAutocomplete) {
autocomplete = new Autocomplete(inputEl.value, v, props.mfmAutocomplete === true ? null : props.mfmAutocomplete);
}
});
onUnmounted(() => {
if (autocomplete) {
autocomplete.detach();
}
});
</script>
@@ -194,4 +223,12 @@ onMounted(() => {
.save {
margin: 8px 0 0 0;
}
.mfmPreview {
padding: 12px;
border-radius: var(--radius);
box-sizing: border-box;
min-height: 130px;
pointer-events: none;
}
</style>

View File

@@ -103,7 +103,7 @@ function showMenu(ev) {
action: () => {
os.pageWindow('/about-misskey');
},
}, null, (instance.impressumUrl) ? {
}, { type: 'divider' }, (instance.impressumUrl) ? {
text: i18n.ts.impressum,
icon: 'ti ti-file-invoice',
action: () => {
@@ -121,7 +121,7 @@ function showMenu(ev) {
action: () => {
window.open(instance.privacyPolicyUrl, '_blank', 'noopener');
},
} : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl) ? undefined : null, {
} : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl) ? undefined : { type: 'divider' }, {
text: i18n.ts.help,
icon: 'ti ti-help-circle',
action: () => {

View File

@@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { onBeforeUnmount, onMounted, provide, shallowRef, ref } from 'vue';
import contains from '@/scripts/contains.js';
import * as os from '@/os.js';
import { MenuItem } from '@/types/menu';
import { MenuItem } from '@/types/menu.js';
import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js';

View File

@@ -6,6 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="[$style.root, { [$style.rootFirst]: first }]">
<div :class="[$style.label, { [$style.labelFirst]: first }]"><slot name="label"></slot></div>
<div :class="[$style.description]"><slot name="description"></slot></div>
<div :class="$style.main">
<slot></slot>
</div>
@@ -31,7 +32,7 @@ defineProps<{
.label {
font-weight: bold;
padding: 1.5em 0 0 0;
margin: 0 0 16px 0;
margin: 0 0 8px 0;
&:empty {
display: none;
@@ -45,4 +46,10 @@ defineProps<{
.main {
margin: 1.5em 0 0 0;
}
.description {
font-size: 0.85em;
color: var(--fgTransparentWeak);
margin: 0 0 8px 0;
}
</style>

View File

@@ -57,7 +57,7 @@ function onContextmenu(ev) {
action: () => {
router.push(props.to, 'forcePage');
},
}, null, {
}, { type: 'divider' }, {
icon: 'ti ti-external-link',
text: i18n.ts.openInNewTab,
action: () => {

View File

@@ -23,16 +23,19 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</div>
<img
v-if="showDecoration && (decoration || user.avatarDecorations.length > 0)"
:class="[$style.decoration]"
:src="decoration?.url ?? user.avatarDecorations[0].url"
:style="{
rotate: getDecorationAngle(),
scale: getDecorationScale(),
}"
alt=""
>
<template v-if="showDecoration">
<img
v-for="decoration in decorations ?? user.avatarDecorations"
:class="[$style.decoration]"
:src="decoration.url"
:style="{
rotate: getDecorationAngle(decoration),
scale: getDecorationScale(decoration),
translate: getDecorationOffset(decoration),
}"
alt=""
>
</template>
</component>
</template>
@@ -57,19 +60,14 @@ const props = withDefaults(defineProps<{
link?: boolean;
preview?: boolean;
indicator?: boolean;
decoration?: {
url: string;
angle?: number;
flipH?: boolean;
flipV?: boolean;
};
decorations?: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>[];
forceShowDecoration?: boolean;
}>(), {
target: null,
link: false,
preview: false,
indicator: false,
decoration: undefined,
decorations: undefined,
forceShowDecoration: false,
});
@@ -92,30 +90,22 @@ function onClick(ev: MouseEvent): void {
emit('click', ev);
}
function getDecorationAngle() {
let angle;
if (props.decoration) {
angle = props.decoration.angle ?? 0;
} else if (props.user.avatarDecorations.length > 0) {
angle = props.user.avatarDecorations[0].angle ?? 0;
} else {
angle = 0;
}
function getDecorationAngle(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) {
const angle = decoration.angle ?? 0;
return angle === 0 ? undefined : `${angle * 360}deg`;
}
function getDecorationScale() {
let scaleX;
if (props.decoration) {
scaleX = props.decoration.flipH ? -1 : 1;
} else if (props.user.avatarDecorations.length > 0) {
scaleX = props.user.avatarDecorations[0].flipH ? -1 : 1;
} else {
scaleX = 1;
}
function getDecorationScale(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) {
const scaleX = decoration.flipH ? -1 : 1;
return scaleX === 1 ? undefined : `${scaleX} 1`;
}
function getDecorationOffset(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) {
const offsetX = decoration.offsetX ?? 0;
const offsetY = decoration.offsetY ?? 0;
return offsetX === 0 && offsetY === 0 ? undefined : `${offsetX * 100}% ${offsetY * 100}%`;
}
const color = ref<string | undefined>();
watch(() => props.user.avatarBlurhash, () => {

View File

@@ -25,6 +25,7 @@ import { defaultStore } from '@/store.js';
import { customEmojisMap } from '@/custom-emojis.js';
import * as os from '@/os.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import * as sound from '@/scripts/sound.js';
import { i18n } from '@/i18n.js';
const props = defineProps<{
@@ -90,6 +91,7 @@ function onClick(ev: MouseEvent) {
icon: 'ti ti-plus',
action: () => {
react(`:${props.name}:`);
sound.play('reaction');
},
}] : [])], ev.currentTarget ?? ev.target);
}

View File

@@ -16,6 +16,7 @@ import { defaultStore } from '@/store.js';
import { getEmojiName } from '@/scripts/emojilist.js';
import * as os from '@/os.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import * as sound from '@/scripts/sound.js';
import { i18n } from '@/i18n.js';
const props = defineProps<{
@@ -56,6 +57,7 @@ function onClick(ev: MouseEvent) {
icon: 'ti ti-plus',
action: () => {
react(props.emoji);
sound.play('reaction');
},
}] : [])], ev.currentTarget ?? ev.target);
}

View File

@@ -0,0 +1,53 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div ref="rootEl" :class="$style.root">
<div v-if="!showing" :class="$style.placeholder"></div>
<slot v-else></slot>
</div>
</template>
<script lang="ts" setup>
import { nextTick, onMounted, onActivated, onBeforeUnmount, ref, shallowRef } from 'vue';
const rootEl = shallowRef<HTMLDivElement>();
const showing = ref(false);
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
showing.value = true;
}
},
);
onMounted(() => {
nextTick(() => {
observer.observe(rootEl.value!);
});
});
onActivated(() => {
nextTick(() => {
observer.observe(rootEl.value!);
});
});
onBeforeUnmount(() => {
observer.disconnect();
});
</script>
<style lang="scss" module>
.root {
display: block;
}
.placeholder {
display: block;
min-height: 150px;
}
</style>

View File

@@ -37,7 +37,7 @@ type MfmProps = {
isNote?: boolean;
emojiUrls?: string[];
rootScale?: number;
nyaize: boolean | 'respect';
nyaize?: boolean | 'respect';
parsedNodes?: mfm.MfmNode[] | null;
enableEmojiMenu?: boolean;
enableEmojiMenuReaction?: boolean;
@@ -107,22 +107,26 @@ export default function(props: MfmProps) {
switch (token.props.name) {
case 'tada': {
const speed = validTime(token.props.args.speed) ?? '1s';
style = 'font-size: 150%;' + (useAnim ? `animation: tada ${speed} linear infinite both;` : '');
const delay = validTime(token.props.args.delay) ?? '0s';
style = 'font-size: 150%;' + (useAnim ? `animation: tada ${speed} linear infinite both; animation-delay: ${delay};` : '');
break;
}
case 'jelly': {
const speed = validTime(token.props.args.speed) ?? '1s';
style = (useAnim ? `animation: mfm-rubberBand ${speed} linear infinite both;` : '');
const delay = validTime(token.props.args.delay) ?? '0s';
style = (useAnim ? `animation: mfm-rubberBand ${speed} linear infinite both; animation-delay: ${delay};` : '');
break;
}
case 'twitch': {
const speed = validTime(token.props.args.speed) ?? '0.5s';
style = useAnim ? `animation: mfm-twitch ${speed} ease infinite;` : '';
const delay = validTime(token.props.args.delay) ?? '0s';
style = useAnim ? `animation: mfm-twitch ${speed} ease infinite; animation-delay: ${delay};` : '';
break;
}
case 'shake': {
const speed = validTime(token.props.args.speed) ?? '0.5s';
style = useAnim ? `animation: mfm-shake ${speed} ease infinite;` : '';
const delay = validTime(token.props.args.delay) ?? '0s';
style = useAnim ? `animation: mfm-shake ${speed} ease infinite; animation-delay: ${delay};` : '';
break;
}
case 'spin': {
@@ -135,17 +139,20 @@ export default function(props: MfmProps) {
token.props.args.y ? 'mfm-spinY' :
'mfm-spin';
const speed = validTime(token.props.args.speed) ?? '1.5s';
style = useAnim ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : '';
const delay = validTime(token.props.args.delay) ?? '0s';
style = useAnim ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction}; animation-delay: ${delay};` : '';
break;
}
case 'jump': {
const speed = validTime(token.props.args.speed) ?? '0.75s';
style = useAnim ? `animation: mfm-jump ${speed} linear infinite;` : '';
const delay = validTime(token.props.args.delay) ?? '0s';
style = useAnim ? `animation: mfm-jump ${speed} linear infinite; animation-delay: ${delay};` : '';
break;
}
case 'bounce': {
const speed = validTime(token.props.args.speed) ?? '0.75s';
style = useAnim ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom;` : '';
const delay = validTime(token.props.args.delay) ?? '0s';
style = useAnim ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom; animation-delay: ${delay};` : '';
break;
}
case 'flip': {
@@ -195,7 +202,8 @@ export default function(props: MfmProps) {
}, genEl(token.children, scale));
}
const speed = validTime(token.props.args.speed) ?? '1s';
style = `animation: mfm-rainbow ${speed} linear infinite;`;
const delay = validTime(token.props.args.delay) ?? '0s';
style = `animation: mfm-rainbow ${speed} linear infinite; animation-delay: ${delay};`;
break;
}
case 'sparkle': {

View File

@@ -25,6 +25,7 @@ import MkPageHeader from './global/MkPageHeader.vue';
import MkSpacer from './global/MkSpacer.vue';
import MkFooterSpacer from './global/MkFooterSpacer.vue';
import MkStickyContainer from './global/MkStickyContainer.vue';
import MkLazy from './global/MkLazy.vue';
export default function(app: App) {
for (const [key, value] of Object.entries(components)) {
@@ -53,6 +54,7 @@ export const components = {
MkSpacer: MkSpacer,
MkFooterSpacer: MkFooterSpacer,
MkStickyContainer: MkStickyContainer,
MkLazy: MkLazy,
};
declare module '@vue/runtime-core' {
@@ -77,5 +79,6 @@ declare module '@vue/runtime-core' {
MkSpacer: typeof MkSpacer;
MkFooterSpacer: typeof MkFooterSpacer;
MkStickyContainer: typeof MkStickyContainer;
MkLazy: typeof MkLazy;
}
}

View File

@@ -84,6 +84,7 @@ export const ROLE_POLICIES = [
'userListLimit',
'userEachUserListsLimit',
'rateLimitFactor',
'avatarDecorationLimit',
] as const;
// なんか動かない

View File

@@ -12,17 +12,20 @@
<html>
<head>
<meta charset="UTF-8" />
<title>misskey</title>
<title>[DEV] Loading...</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;"
worker-src 'self';
script-src 'self' 'unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' data: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;"
/>
<meta property="og:site_name" content="[DEV BUILD] Misskey" />
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>

View File

@@ -546,7 +546,7 @@ export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea:
});
}
export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement, options?: {
export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement | EventTarget | null, options?: {
align?: string;
width?: number;
viaKeyboard?: boolean;

View File

@@ -135,10 +135,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-else-if="tab === 'roles'" class="_gaps">
<MkButton primary rounded @click="assignRole"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton>
<div v-for="role in info.roles" :key="role.id" :class="$style.roleItem">
<div v-for="role in info.roles" :key="role.id">
<div :class="$style.roleItemMain">
<MkRolePreview :class="$style.role" :role="role" :forModeration="true"/>
<button class="_button" :class="$style.roleToggle" @click="toggleRoleItem(role)"><i class="ti ti-chevron-down"></i></button>
<button class="_button" @click="toggleRoleItem(role)"><i class="ti ti-chevron-down"></i></button>
<button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button>
<button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button>
</div>
@@ -330,41 +330,41 @@ async function toggleSuspend(v) {
}
async function unsetUserAvatar() {
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.unsetUserAvatarConfirm,
});
if (confirm.canceled) return;
const process = async () => {
await os.api('admin/unset-user-avatar', { userId: user.value.id });
os.success();
};
await process().catch(err => {
os.alert({
type: 'error',
text: err.toString(),
});
});
refreshUser();
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.unsetUserAvatarConfirm,
});
if (confirm.canceled) return;
const process = async () => {
await os.api('admin/unset-user-avatar', { userId: user.value.id });
os.success();
};
await process().catch(err => {
os.alert({
type: 'error',
text: err.toString(),
});
});
refreshUser();
}
async function unsetUserBanner() {
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.unsetUserBannerConfirm,
});
if (confirm.canceled) return;
const process = async () => {
await os.api('admin/unset-user-banner', { userId: user.value.id });
os.success();
};
await process().catch(err => {
os.alert({
type: 'error',
text: err.toString(),
});
});
refreshUser();
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.unsetUserBannerConfirm,
});
if (confirm.canceled) return;
const process = async () => {
await os.api('admin/unset-user-banner', { userId: user.value.id });
os.success();
};
await process().catch(err => {
os.alert({
type: 'error',
text: err.toString(),
});
});
refreshUser();
}
async function deleteAllFiles() {
@@ -639,9 +639,6 @@ definePageMetadata(computed(() => ({
}
}
.roleItem {
}
.roleItemMain {
display: flex;
}

View File

@@ -40,7 +40,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInput ref="announceTitleEl" v-model="announcement.title" :large="false">
<template #label>{{ i18n.ts.title }}&nbsp;<button v-tooltip="i18n.ts.emoji" :class="['_button']" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button></template>
</MkInput>
<MkTextarea v-model="announcement.text">
<MkTextarea v-model="announcement.text" mfmAutocomplete :mfmPreview="true">
<template #label>{{ i18n.ts.text }}</template>
</MkTextarea>
<MkInput v-model="announcement.imageUrl" type="url">
@@ -100,7 +100,6 @@ import * as misskey from 'misskey-js';
import XHeader from './_header_.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkRadios from '@/components/MkRadios.vue';
import MkInfo from '@/components/MkInfo.vue';
@@ -114,6 +113,7 @@ const announceTitleEl = shallowRef<HTMLInputElement | null>(null);
const user = ref<misskey.entities.UserLite | null>(null);
const offset = ref(0);
const hasMore = ref(false);
import MkTextarea from '@/components/MkTextarea.vue';
const announcements = ref<any[]>([]);

View File

@@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkTime :time="log.createdAt"/>
</template>
<div :class="$style.root">
<div>
<div style="display: flex; gap: var(--margin); flex-wrap: wrap;">
<div style="flex: 1;">{{ i18n.ts.moderator }}: <MkA :to="`/admin/user/${log.userId}`" class="_link">@{{ log.user?.username }}</MkA></div>
<div style="flex: 1;">{{ i18n.ts.dateAndTime }}: <MkTime :time="log.createdAt" mode="detail"/></div>
@@ -134,9 +134,6 @@ const props = defineProps<{
</script>
<style lang="scss" module>
.root {
}
.avatar {
width: 18px;
height: 18px;

View File

@@ -591,6 +591,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.avatarDecorationLimit, 'avatarDecorationLimit'])">
<template #label>{{ i18n.ts._role._options.avatarDecorationLimit }}</template>
<template #suffix>
<span v-if="role.policies.avatarDecorationLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.avatarDecorationLimit.value }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.avatarDecorationLimit)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.avatarDecorationLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkInput v-model="role.policies.avatarDecorationLimit.value" type="number" :min="0">
<template #label>{{ i18n.ts._role._options.avatarDecorationLimit }}</template>
</MkInput>
<MkRange v-model="role.policies.avatarDecorationLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
</div>
</FormSlot>
</div>
@@ -609,7 +629,7 @@ import MkSwitch from '@/components/MkSwitch.vue';
import MkRange from '@/components/MkRange.vue';
import FormSlot from '@/components/form/slot.vue';
import { i18n } from '@/i18n.js';
import { ROLE_POLICIES } from '@/const';
import { ROLE_POLICIES } from '@/const.js';
import { instance } from '@/instance.js';
import { deepClone } from '@/scripts/clone.js';

View File

@@ -216,6 +216,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.avatarDecorationLimit, 'avatarDecorationLimit'])">
<template #label>{{ i18n.ts._role._options.avatarDecorationLimit }}</template>
<template #suffix>{{ policies.avatarDecorationLimit }}</template>
<MkInput v-model="policies.avatarDecorationLimit" type="number" :min="0">
</MkInput>
</MkFolder>
<MkButton primary rounded @click="updateBaseRole">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>

View File

@@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.name }}</template>
</MkInput>
<MkTextarea v-model="description">
<MkTextarea v-model="description" mfmAutocomplete :mfmPreview="true">
<template #label>{{ i18n.ts.description }}</template>
</MkTextarea>
@@ -70,7 +70,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, ref, watch, defineAsyncComponent } from 'vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkColorInput from '@/components/MkColorInput.vue';
@@ -81,6 +80,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import { i18n } from '@/i18n.js';
import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkTextarea from '@/components/MkTextarea.vue';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));

View File

@@ -101,6 +101,7 @@ const headerActions = computed(() => clip.value && isOwned.value ? [{
type: 'string',
required: false,
multiline: true,
treatAsMfm: true,
label: i18n.ts.description,
default: clip.value.description,
},

View File

@@ -15,9 +15,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts._play.summary }}</template>
</MkTextarea>
<MkButton primary @click="selectPreset">{{ i18n.ts.selectFromPresets }}<i class="ti ti-chevron-down"></i></MkButton>
<MkTextarea v-model="script" class="_monospace" tall spellcheck="false">
<MkCodeEditor v-model="script" lang="is">
<template #label>{{ i18n.ts._play.script }}</template>
</MkTextarea>
</MkCodeEditor>
<div class="_buttons">
<MkButton primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
<MkButton @click="show"><i class="ti ti-eye"></i> {{ i18n.ts.show }}</MkButton>
@@ -40,6 +40,7 @@ import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkTextarea from '@/components/MkTextarea.vue';
import MkCodeEditor from '@/components/MkCodeEditor.vue';
import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import { useRouter } from '@/router.js';

View File

@@ -165,12 +165,8 @@ async function run() {
return new Promise(ok => {
os.inputText({
title: q,
}).then(({ canceled, result: a }) => {
if (canceled) {
ok('');
} else {
ok(a);
}
}).then(({ result: a }) => {
ok(a ?? '');
});
});
},

View File

@@ -60,6 +60,7 @@ async function create() {
type: 'string',
required: false,
multiline: true,
treatAsMfm: true,
label: i18n.ts.description,
},
isPublic: {

View File

@@ -60,7 +60,7 @@ function setFilter(ev) {
action: () => {
includeTypes.value = null;
},
}, null, ...typeItems] : typeItems;
}, { type: 'divider' }, ...typeItems] : typeItems;
os.popupMenu(items, ev.currentTarget ?? ev.target);
}

View File

@@ -9,16 +9,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header><i class="ti ti-align-left"></i> {{ i18n.ts._pages.blocks.text }}</template>
<section>
<textarea v-model="text" :class="$style.textarea"></textarea>
<textarea ref="inputEl" v-model="text" :class="$style.textarea"></textarea>
</section>
</XContainer>
</template>
<script lang="ts" setup>
/* eslint-disable vue/no-mutating-props */
import { watch, ref } from 'vue';
import { watch, ref, shallowRef, onMounted, onUnmounted } from 'vue';
import XContainer from '../page-editor.container.vue';
import { i18n } from '@/i18n.js';
import { Autocomplete } from '@/scripts/autocomplete.js';
const props = defineProps<{
modelValue: any
@@ -28,7 +29,10 @@ const emit = defineEmits<{
(ev: 'update:modelValue', value: any): void;
}>();
let autocomplete: Autocomplete;
const text = ref(props.modelValue.text ?? '');
const inputEl = shallowRef<HTMLTextAreaElement | null>(null);
watch(text, () => {
emit('update:modelValue', {
@@ -36,6 +40,14 @@ watch(text, () => {
text: text.value,
});
});
onMounted(() => {
autocomplete = new Autocomplete(inputEl.value, text);
});
onUnmounted(() => {
autocomplete.detach();
});
</script>
<style lang="scss" module>

View File

@@ -26,9 +26,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkKeyValue>
</FormSplit>
<MkTextarea v-model="valueForEditor" tall class="_monospace">
<MkCodeEditor v-model="valueForEditor" lang="json5">
<template #label>{{ i18n.ts.value }} (JSON)</template>
</MkTextarea>
</MkCodeEditor>
<MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
@@ -52,7 +52,7 @@ import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkButton from '@/components/MkButton.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkCodeEditor from '@/components/MkCodeEditor.vue';
import FormSplit from '@/components/form/split.vue';
import FormInfo from '@/components/MkInfo.vue';

View File

@@ -7,15 +7,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_m">
<FormInfo warn>{{ i18n.ts.customCssWarn }}</FormInfo>
<MkTextarea v-model="localCustomCss" manualSave tall class="_monospace" style="tab-size: 2;">
<MkCodeEditor v-model="localCustomCss" manualSave lang="css">
<template #label>CSS</template>
</MkTextarea>
</MkCodeEditor>
</div>
</template>
<script lang="ts" setup>
import { ref, watch, computed } from 'vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkCodeEditor from '@/components/MkCodeEditor.vue';
import FormInfo from '@/components/MkInfo.vue';
import * as os from '@/os.js';
import { unisonReload } from '@/scripts/unison-reload.js';

View File

@@ -0,0 +1,274 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps_m">
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-pin"></i></template>
<template #label>{{ i18n.ts.pinned }} ({{ i18n.ts.reaction }})</template>
<template #caption>{{ i18n.ts.pinnedEmojisForReactionSettingDescription }}</template>
<div class="_gaps">
<div>
<div v-panel style="border-radius: 6px;">
<Sortable
v-model="pinnedEmojisForReaction"
:class="$style.emojis"
:itemKey="item => item"
:animation="150"
:delay="100"
:delayOnTouchOnly="true"
>
<template #item="{element}">
<button class="_button" :class="$style.emojisItem" @click="removeReaction(element, $event)">
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true"/>
<MkEmoji v-else :emoji="element" :normal="true"/>
</button>
</template>
<template #footer>
<button class="_button" :class="$style.emojisAdd" @click="chooseReaction">
<i class="ti ti-plus"></i>
</button>
</template>
</Sortable>
</div>
<div :class="$style.editorCaption">{{ i18n.ts.reactionSettingDescription2 }}</div>
</div>
<div class="_buttons">
<MkButton inline @click="previewReaction"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton>
<MkButton inline danger @click="setDefaultReaction"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton>
<MkButton inline danger @click="overwriteFromPinnedEmojis"><i class="ti ti-copy"></i> {{ i18n.ts.overwriteFromPinnedEmojis }}</MkButton>
</div>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-pin"></i></template>
<template #label>{{ i18n.ts.pinned }} ({{ i18n.ts.general }})</template>
<template #caption>{{ i18n.ts.pinnedEmojisSettingDescription }}</template>
<div class="_gaps">
<div>
<div v-panel style="border-radius: 6px;">
<Sortable
v-model="pinnedEmojis"
:class="$style.emojis"
:itemKey="item => item"
:animation="150"
:delay="100"
:delayOnTouchOnly="true"
>
<template #item="{element}">
<button class="_button" :class="$style.emojisItem" @click="removeEmoji(element, $event)">
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true"/>
<MkEmoji v-else :emoji="element" :normal="true"/>
</button>
</template>
<template #footer>
<button class="_button" :class="$style.emojisAdd" @click="chooseEmoji">
<i class="ti ti-plus"></i>
</button>
</template>
</Sortable>
</div>
<div :class="$style.editorCaption">{{ i18n.ts.reactionSettingDescription2 }}</div>
</div>
<div class="_buttons">
<MkButton inline @click="previewEmoji"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton>
<MkButton inline danger @click="setDefaultEmoji"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton>
<MkButton inline danger @click="overwriteFromPinnedEmojisForReaction"><i class="ti ti-copy"></i> {{ i18n.ts.overwriteFromPinnedEmojisForReaction }}</MkButton>
</div>
</div>
</MkFolder>
<FormSection>
<template #label>{{ i18n.ts.emojiPickerDisplay }}</template>
<div class="_gaps_m">
<MkRadios v-model="emojiPickerScale">
<template #label>{{ i18n.ts.size }}</template>
<option :value="1">{{ i18n.ts.small }}</option>
<option :value="2">{{ i18n.ts.medium }}</option>
<option :value="3">{{ i18n.ts.large }}</option>
</MkRadios>
<MkRadios v-model="emojiPickerWidth">
<template #label>{{ i18n.ts.numberOfColumn }}</template>
<option :value="1">5</option>
<option :value="2">6</option>
<option :value="3">7</option>
<option :value="4">8</option>
<option :value="5">9</option>
</MkRadios>
<MkRadios v-model="emojiPickerHeight">
<template #label>{{ i18n.ts.height }}</template>
<option :value="1">{{ i18n.ts.small }}</option>
<option :value="2">{{ i18n.ts.medium }}</option>
<option :value="3">{{ i18n.ts.large }}</option>
<option :value="4">{{ i18n.ts.large }}+</option>
</MkRadios>
<MkSwitch v-model="emojiPickerUseDrawerForMobile">
{{ i18n.ts.useDrawerReactionPickerForMobile }}
<template #caption>{{ i18n.ts.needReloadToApply }}</template>
</MkSwitch>
</div>
</FormSection>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, Ref, watch } from 'vue';
import Sortable from 'vuedraggable';
import MkRadios from '@/components/MkRadios.vue';
import MkButton from '@/components/MkButton.vue';
import FormSection from '@/components/form/section.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import * as os from '@/os.js';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { deepClone } from '@/scripts/clone.js';
import { reactionPicker } from '@/scripts/reaction-picker.js';
import { emojiPicker } from '@/scripts/emoji-picker.js';
import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue';
import MkEmoji from '@/components/global/MkEmoji.vue';
import MkFolder from '@/components/MkFolder.vue';
const pinnedEmojisForReaction: Ref<string[]> = ref(deepClone(defaultStore.state.reactions));
const pinnedEmojis: Ref<string[]> = ref(deepClone(defaultStore.state.pinnedEmojis));
const emojiPickerScale = computed(defaultStore.makeGetterSetter('emojiPickerScale'));
const emojiPickerWidth = computed(defaultStore.makeGetterSetter('emojiPickerWidth'));
const emojiPickerHeight = computed(defaultStore.makeGetterSetter('emojiPickerHeight'));
const emojiPickerUseDrawerForMobile = computed(defaultStore.makeGetterSetter('emojiPickerUseDrawerForMobile'));
const removeReaction = (reaction: string, ev: MouseEvent) => remove(pinnedEmojisForReaction, reaction, ev);
const chooseReaction = (ev: MouseEvent) => pickEmoji(pinnedEmojisForReaction, ev);
const setDefaultReaction = () => setDefault(pinnedEmojisForReaction);
const removeEmoji = (reaction: string, ev: MouseEvent) => remove(pinnedEmojis, reaction, ev);
const chooseEmoji = (ev: MouseEvent) => pickEmoji(pinnedEmojis, ev);
const setDefaultEmoji = () => setDefault(pinnedEmojis);
function previewReaction(ev: MouseEvent) {
reactionPicker.show(getHTMLElement(ev));
}
function previewEmoji(ev: MouseEvent) {
emojiPicker.show(getHTMLElement(ev));
}
async function overwriteFromPinnedEmojis() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.overwriteContentConfirm,
});
if (canceled) {
return;
}
pinnedEmojisForReaction.value = [...pinnedEmojis.value];
}
async function overwriteFromPinnedEmojisForReaction() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.overwriteContentConfirm,
});
if (canceled) {
return;
}
pinnedEmojis.value = [...pinnedEmojisForReaction.value];
}
function remove(itemsRef: Ref<string[]>, reaction: string, ev: MouseEvent) {
os.popupMenu([{
text: i18n.ts.remove,
action: () => {
itemsRef.value = itemsRef.value.filter(x => x !== reaction);
},
}], getHTMLElement(ev));
}
async function setDefault(itemsRef: Ref<string[]>) {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.resetAreYouSure,
});
if (canceled) return;
itemsRef.value = deepClone(defaultStore.def.reactions.default);
}
async function pickEmoji(itemsRef: Ref<string[]>, ev: MouseEvent) {
os.pickEmoji(getHTMLElement(ev), {
showPinned: false,
}).then(it => {
const emoji = it as string;
if (!itemsRef.value.includes(emoji)) {
itemsRef.value.push(emoji);
}
});
}
function getHTMLElement(ev: MouseEvent): HTMLElement {
const target = ev.currentTarget ?? ev.target;
return target as HTMLElement;
}
watch(pinnedEmojisForReaction, () => {
defaultStore.set('reactions', pinnedEmojisForReaction.value);
}, {
deep: true,
});
watch(pinnedEmojis, () => {
defaultStore.set('pinnedEmojis', pinnedEmojis.value);
}, {
deep: true,
});
definePageMetadata({
title: i18n.ts.emojiPicker,
icon: 'ti ti-mood-happy',
});
</script>
<style lang="scss" module>
.tab {
margin: calc(var(--margin) / 2) 0;
padding: calc(var(--margin) / 2) 0;
background: var(--bg);
}
.emojis {
padding: 12px;
font-size: 1.1em;
}
.emojisItem {
display: inline-block;
padding: 8px;
cursor: move;
}
.emojisAdd {
display: inline-block;
padding: 8px;
}
.editorCaption {
font-size: 0.85em;
padding: 8px 0 0 0;
color: var(--fgTransparentWeak);
}
</style>

View File

@@ -123,6 +123,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="useSystemFont">{{ i18n.ts.useSystemFont }}</MkSwitch>
<MkSwitch v-model="disableDrawer">{{ i18n.ts.disableDrawer }}</MkSwitch>
<MkSwitch v-model="forceShowAds">{{ i18n.ts.forceShowAds }}</MkSwitch>
<MkSwitch v-model="enableSeasonalScreenEffect">{{ i18n.ts.seasonalScreenEffect }}</MkSwitch>
</div>
<div>
<MkRadios v-model="emojiStyle">
@@ -291,11 +292,12 @@ const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificati
const keepScreenOn = computed(defaultStore.makeGetterSetter('keepScreenOn'));
const disableStreamingTimeline = computed(defaultStore.makeGetterSetter('disableStreamingTimeline'));
const useGroupedNotifications = computed(defaultStore.makeGetterSetter('useGroupedNotifications'));
const enableSeasonalScreenEffect = computed(defaultStore.makeGetterSetter('enableSeasonalScreenEffect'));
watch(lang, () => {
miLocalStorage.setItem('lang', lang.value as string);
miLocalStorage.removeItem('locale');
miLocalStorage.removeItem('localeVersion');
miLocalStorage.removeItem('localeVersion');
});
watch(fontSize, () => {
@@ -331,6 +333,7 @@ watch([
keepScreenOn,
hideMutedNotes,
disableStreamingTimeline,
enableSeasonalScreenEffect,
], async () => {
await reloadAsk();
});

View File

@@ -74,9 +74,9 @@ const menuDef = computed(() => [{
active: currentPage.value?.route.name === 'privacy',
}, {
icon: 'ti ti-mood-happy',
text: i18n.ts.reaction,
to: '/settings/reaction',
active: currentPage.value?.route.name === 'reaction',
text: i18n.ts.emojiPicker,
to: '/settings/emoji-picker',
active: currentPage.value?.route.name === 'emojiPicker',
}, {
icon: 'ti ti-cloud',
text: i18n.ts.drive,
@@ -236,7 +236,7 @@ provideMetadataReceiver((info) => {
childInfo.value = null;
} else {
childInfo.value = info;
INFO.value.needWideArea = info.value?.needWideArea ?? undefined;
INFO.value.needWideArea = info.value.needWideArea ?? undefined;
}
});

View File

@@ -7,9 +7,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_m">
<FormInfo warn>{{ i18n.ts._plugin.installWarn }}</FormInfo>
<MkTextarea v-model="code" tall>
<MkCodeEditor v-model="code" lang="is">
<template #label>{{ i18n.ts.code }}</template>
</MkTextarea>
</MkCodeEditor>
<div>
<MkButton :disabled="code == null" primary inline @click="install"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton>
@@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { nextTick, ref, computed } from 'vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkCodeEditor from '@/components/MkCodeEditor.vue';
import MkButton from '@/components/MkButton.vue';
import FormInfo from '@/components/MkInfo.vue';
import * as os from '@/os.js';

View File

@@ -83,10 +83,10 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
'useReactionPickerForContextMenu',
'showGapBetweenNotesInTimeline',
'instanceTicker',
'reactionPickerSize',
'reactionPickerWidth',
'reactionPickerHeight',
'reactionPickerUseDrawerForMobile',
'emojiPickerScale',
'emojiPickerWidth',
'emojiPickerHeight',
'emojiPickerUseDrawerForMobile',
'defaultSideView',
'menuDisplay',
'reportError',
@@ -406,7 +406,7 @@ function menu(ev: MouseEvent, profileId: string) {
icon: 'ti ti-download',
href: URL.createObjectURL(new Blob([JSON.stringify(profiles.value[profileId], null, 2)], { type: 'application/json' })),
download: `${profiles.value[profileId].name}.json`,
}, null, {
}, { type: 'divider' }, {
text: ts.rename,
icon: 'ti ti-forms',
action: () => rename(profileId),
@@ -414,7 +414,7 @@ function menu(ev: MouseEvent, profileId: string) {
text: ts._preferencesBackups.save,
icon: 'ti ti-device-floppy',
action: () => save(profileId),
}, null, {
}, { type: 'divider' }, {
text: ts.delete,
icon: 'ti ti-trash',
action: () => deleteProfile(profileId),

View File

@@ -1,114 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialog"
:width="400"
:height="450"
@close="cancel"
@closed="emit('closed')"
>
<template #header>{{ i18n.ts.avatarDecorations }}</template>
<div>
<MkSpacer :marginMin="20" :marginMax="28">
<div style="text-align: center;">
<div :class="$style.name">{{ decoration.name }}</div>
<MkAvatar style="width: 64px; height: 64px; margin-bottom: 20px;" :user="$i" :decoration="{ url: decoration.url, angle, flipH }" forceShowDecoration/>
</div>
<div class="_gaps_s">
<MkRange v-model="angle" continuousUpdate :min="-0.5" :max="0.5" :step="0.025" :textConverter="(v) => `${Math.floor(v * 360)}°`">
<template #label>{{ i18n.ts.angle }}</template>
</MkRange>
<MkSwitch v-model="flipH">
<template #label>{{ i18n.ts.flip }}</template>
</MkSwitch>
</div>
</MkSpacer>
<div :class="$style.footer" class="_buttonsCenter">
<MkButton v-if="using" primary rounded @click="attach"><i class="ti ti-check"></i> {{ i18n.ts.update }}</MkButton>
<MkButton v-if="using" rounded @click="detach"><i class="ti ti-x"></i> {{ i18n.ts.detach }}</MkButton>
<MkButton v-else primary rounded @click="attach"><i class="ti ti-check"></i> {{ i18n.ts.attach }}</MkButton>
</div>
</div>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { shallowRef, ref, computed } from 'vue';
import MkButton from '@/components/MkButton.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import MkFolder from '@/components/MkFolder.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkRange from '@/components/MkRange.vue';
import { $i } from '@/account.js';
const props = defineProps<{
decoration: {
id: string;
url: string;
}
}>();
const emit = defineEmits<{
(ev: 'closed'): void;
}>();
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
const using = computed(() => $i.avatarDecorations.some(x => x.id === props.decoration.id));
const angle = ref(using.value ? $i.avatarDecorations.find(x => x.id === props.decoration.id).angle ?? 0 : 0);
const flipH = ref(using.value ? $i.avatarDecorations.find(x => x.id === props.decoration.id).flipH ?? false : false);
function cancel() {
dialog.value.close();
}
async function attach() {
const decoration = {
id: props.decoration.id,
angle: angle.value,
flipH: flipH.value,
};
await os.apiWithDialog('i/update', {
avatarDecorations: [decoration],
});
$i.avatarDecorations = [decoration];
dialog.value.close();
}
async function detach() {
await os.apiWithDialog('i/update', {
avatarDecorations: [],
});
$i.avatarDecorations = [];
dialog.value.close();
}
</script>
<style lang="scss" module>
.name {
position: relative;
z-index: 10;
font-weight: bold;
margin-bottom: 28px;
}
.footer {
position: sticky;
bottom: 0;
left: 0;
padding: 12px;
border-top: solid 0.5px var(--divider);
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
}
</style>

View File

@@ -0,0 +1,69 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div
:class="[$style.root, { [$style.active]: active }]"
@click="emit('click')"
>
<div :class="$style.name"><MkCondensedLine :minScale="0.5">{{ decoration.name }}</MkCondensedLine></div>
<MkAvatar style="width: 60px; height: 60px;" :user="$i" :decorations="[{ url: decoration.url, angle, flipH, offsetX, offsetY }]" forceShowDecoration/>
<i v-if="decoration.roleIdsThatCanBeUsedThisDecoration.length > 0 && !$i.roles.some(r => decoration.roleIdsThatCanBeUsedThisDecoration.includes(r.id))" :class="$style.lock" class="ti ti-lock"></i>
</div>
</template>
<script lang="ts" setup>
import { } from 'vue';
import { $i } from '@/account.js';
const props = defineProps<{
active?: boolean;
decoration: {
id: string;
url: string;
name: string;
roleIdsThatCanBeUsedThisDecoration: string[];
};
angle?: number;
flipH?: boolean;
offsetX?: number;
offsetY?: number;
}>();
const emit = defineEmits<{
(ev: 'click'): void;
}>();
</script>
<style lang="scss" module>
.root {
cursor: pointer;
padding: 16px 16px 28px 16px;
border: solid 2px var(--divider);
border-radius: 8px;
text-align: center;
font-size: 90%;
overflow: clip;
contain: content;
}
.active {
background-color: var(--accentedBg);
border-color: var(--accent);
}
.name {
position: relative;
z-index: 10;
font-weight: bold;
margin-bottom: 20px;
}
.lock {
position: absolute;
bottom: 12px;
right: 12px;
}
</style>

View File

@@ -0,0 +1,157 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialog"
:width="400"
:height="450"
@close="cancel"
@closed="emit('closed')"
>
<template #header>{{ i18n.ts.avatarDecorations }}</template>
<div>
<MkSpacer :marginMin="20" :marginMax="28">
<div style="text-align: center;">
<div :class="$style.name">{{ decoration.name }}</div>
<MkAvatar style="width: 64px; height: 64px; margin-bottom: 20px;" :user="$i" :decorations="decorationsForPreview" forceShowDecoration/>
</div>
<div class="_gaps_s">
<MkRange v-model="angle" continuousUpdate :min="-0.5" :max="0.5" :step="0.025" :textConverter="(v) => `${Math.floor(v * 360)}°`">
<template #label>{{ i18n.ts.angle }}</template>
</MkRange>
<MkRange v-model="offsetX" continuousUpdate :min="-0.25" :max="0.25" :step="0.025" :textConverter="(v) => `${Math.floor(v * 100)}%`">
<template #label>X {{ i18n.ts.position }}</template>
</MkRange>
<MkRange v-model="offsetY" continuousUpdate :min="-0.25" :max="0.25" :step="0.025" :textConverter="(v) => `${Math.floor(v * 100)}%`">
<template #label>Y {{ i18n.ts.position }}</template>
</MkRange>
<MkSwitch v-model="flipH">
<template #label>{{ i18n.ts.flip }}</template>
</MkSwitch>
</div>
</MkSpacer>
<div :class="$style.footer" class="_buttonsCenter">
<MkButton v-if="usingIndex != null" primary rounded @click="update"><i class="ti ti-check"></i> {{ i18n.ts.update }}</MkButton>
<MkButton v-if="usingIndex != null" rounded @click="detach"><i class="ti ti-x"></i> {{ i18n.ts.detach }}</MkButton>
<MkButton v-else :disabled="exceeded" primary rounded @click="attach"><i class="ti ti-check"></i> {{ i18n.ts.attach }}</MkButton>
</div>
</div>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { shallowRef, ref, computed } from 'vue';
import MkButton from '@/components/MkButton.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import MkFolder from '@/components/MkFolder.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkRange from '@/components/MkRange.vue';
import { $i } from '@/account.js';
const props = defineProps<{
usingIndex: number | null;
decoration: {
id: string;
url: string;
name: string;
};
}>();
const emit = defineEmits<{
(ev: 'closed'): void;
(ev: 'attach', payload: {
angle: number;
flipH: boolean;
offsetX: number;
offsetY: number;
}): void;
(ev: 'update', payload: {
angle: number;
flipH: boolean;
offsetX: number;
offsetY: number;
}): void;
(ev: 'detach'): void;
}>();
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
const exceeded = computed(() => ($i.policies.avatarDecorationLimit - $i.avatarDecorations.length) <= 0);
const angle = ref((props.usingIndex != null ? $i.avatarDecorations[props.usingIndex].angle : null) ?? 0);
const flipH = ref((props.usingIndex != null ? $i.avatarDecorations[props.usingIndex].flipH : null) ?? false);
const offsetX = ref((props.usingIndex != null ? $i.avatarDecorations[props.usingIndex].offsetX : null) ?? 0);
const offsetY = ref((props.usingIndex != null ? $i.avatarDecorations[props.usingIndex].offsetY : null) ?? 0);
const decorationsForPreview = computed(() => {
const decoration = {
id: props.decoration.id,
url: props.decoration.url,
angle: angle.value,
flipH: flipH.value,
offsetX: offsetX.value,
offsetY: offsetY.value,
};
const decorations = [...$i.avatarDecorations];
if (props.usingIndex != null) {
decorations[props.usingIndex] = decoration;
} else {
decorations.push(decoration);
}
return decorations;
});
function cancel() {
dialog.value.close();
}
async function update() {
emit('update', {
angle: angle.value,
flipH: flipH.value,
offsetX: offsetX.value,
offsetY: offsetY.value,
});
dialog.value.close();
}
async function attach() {
emit('attach', {
angle: angle.value,
flipH: flipH.value,
offsetX: offsetX.value,
offsetY: offsetY.value,
});
dialog.value.close();
}
async function detach() {
emit('detach');
dialog.value.close();
}
</script>
<style lang="scss" module>
.name {
position: relative;
z-index: 10;
font-weight: bold;
margin-bottom: 28px;
}
.footer {
position: sticky;
bottom: 0;
left: 0;
padding: 12px;
border-top: solid 0.5px var(--divider);
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
}
</style>

View File

@@ -0,0 +1,131 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div v-if="!loading" class="_gaps">
<MkInfo>{{ i18n.t('_profile.avatarDecorationMax', { max: $i.policies.avatarDecorationLimit }) }} ({{ i18n.t('remainingN', { n: $i.policies.avatarDecorationLimit - $i.avatarDecorations.length }) }})</MkInfo>
<div v-if="$i.avatarDecorations.length > 0" v-panel :class="$style.current" class="_gaps_s">
<div>{{ i18n.ts.inUse }}</div>
<div :class="$style.decorations">
<XDecoration
v-for="(avatarDecoration, i) in $i.avatarDecorations"
:decoration="avatarDecorations.find(d => d.id === avatarDecoration.id)"
:angle="avatarDecoration.angle"
:flipH="avatarDecoration.flipH"
:offsetX="avatarDecoration.offsetX"
:offsetY="avatarDecoration.offsetY"
:active="true"
@click="openDecoration(avatarDecoration, i)"
/>
</div>
<MkButton danger @click="detachAllDecorations">{{ i18n.ts.detachAll }}</MkButton>
</div>
<div :class="$style.decorations">
<XDecoration
v-for="avatarDecoration in avatarDecorations"
:key="avatarDecoration.id"
:decoration="avatarDecoration"
@click="openDecoration(avatarDecoration)"
/>
</div>
</div>
<div v-else>
<MkLoading/>
</div>
</template>
<script lang="ts" setup>
import { ref, defineAsyncComponent } from 'vue';
import * as Misskey from 'misskey-js';
import XDecoration from './profile.avatar-decoration.decoration.vue';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/account.js';
import MkInfo from '@/components/MkInfo.vue';
const loading = ref(true);
const avatarDecorations = ref<Misskey.entities.GetAvatarDecorationsResponse>([]);
os.api('get-avatar-decorations').then(_avatarDecorations => {
avatarDecorations.value = _avatarDecorations;
loading.value = false;
});
function openDecoration(avatarDecoration, index?: number) {
os.popup(defineAsyncComponent(() => import('./profile.avatar-decoration.dialog.vue')), {
decoration: avatarDecoration,
usingIndex: index,
}, {
'attach': async (payload) => {
const decoration = {
id: avatarDecoration.id,
angle: payload.angle,
flipH: payload.flipH,
offsetX: payload.offsetX,
offsetY: payload.offsetY,
};
const update = [...$i.avatarDecorations, decoration];
await os.apiWithDialog('i/update', {
avatarDecorations: update,
});
$i.avatarDecorations = update;
},
'update': async (payload) => {
const decoration = {
id: avatarDecoration.id,
angle: payload.angle,
flipH: payload.flipH,
offsetX: payload.offsetX,
offsetY: payload.offsetY,
};
const update = [...$i.avatarDecorations];
update[index] = decoration;
await os.apiWithDialog('i/update', {
avatarDecorations: update,
});
$i.avatarDecorations = update;
},
'detach': async () => {
const update = [...$i.avatarDecorations];
update.splice(index, 1);
await os.apiWithDialog('i/update', {
avatarDecorations: update,
});
$i.avatarDecorations = update;
},
}, 'closed');
}
function detachAllDecorations() {
os.confirm({
type: 'warning',
text: i18n.ts.areYouSure,
}).then(async ({ canceled }) => {
if (canceled) return;
await os.apiWithDialog('i/update', {
avatarDecorations: [],
});
$i.avatarDecorations = [];
});
}
</script>
<style lang="scss" module>
.current {
padding: 16px;
border-radius: var(--radius);
}
.decorations {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
grid-gap: 12px;
}
</style>

View File

@@ -13,11 +13,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton primary rounded :class="$style.bannerEdit" @click="changeBanner">{{ i18n.ts._profile.changeBanner }}</MkButton>
</div>
<MkInput v-model="profile.name" :max="30" manualSave>
<MkInput v-model="profile.name" :max="30" manualSave :mfmAutocomplete="['emoji']">
<template #label>{{ i18n.ts._profile.name }}</template>
</MkInput>
<MkTextarea v-model="profile.description" :max="500" tall manualSave>
<MkTextarea v-model="profile.description" :max="500" tall manualSave mfmAutocomplete :mfmPreview="true">
<template #label>{{ i18n.ts._profile.description }}</template>
<template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template>
</MkTextarea>
@@ -87,18 +87,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #icon><i class="ti ti-sparkles"></i></template>
<template #label>{{ i18n.ts.avatarDecorations }}</template>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); grid-gap: 12px;">
<div
v-for="avatarDecoration in avatarDecorations"
:key="avatarDecoration.id"
:class="[$style.avatarDecoration, { [$style.avatarDecorationActive]: $i.avatarDecorations.some(x => x.id === avatarDecoration.id) }]"
@click="openDecoration(avatarDecoration)"
>
<div :class="$style.avatarDecorationName"><MkCondensedLine :minScale="0.5">{{ avatarDecoration.name }}</MkCondensedLine></div>
<MkAvatar style="width: 60px; height: 60px;" :user="$i" :decoration="{ url: avatarDecoration.url }" forceShowDecoration/>
<i v-if="avatarDecoration.roleIdsThatCanBeUsedThisDecoration.length > 0 && !$i.roles.some(r => avatarDecoration.roleIdsThatCanBeUsedThisDecoration.includes(r.id))" :class="$style.avatarDecorationLock" class="ti ti-lock"></i>
</div>
</div>
<XAvatarDecoration/>
</MkFolder>
<MkFolder>
@@ -122,10 +111,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch, defineAsyncComponent, onMounted, onUnmounted } from 'vue';
import { computed, reactive, ref, watch, defineAsyncComponent } from 'vue';
import Misskey from 'misskey-js';
import XAvatarDecoration from './profile.avatar-decoration.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkSelect from '@/components/MkSelect.vue';
import FormSplit from '@/components/form/split.vue';
@@ -140,11 +130,11 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import { claimAchievement } from '@/scripts/achievements.js';
import { defaultStore } from '@/store.js';
import MkInfo from '@/components/MkInfo.vue';
import MkTextarea from '@/components/MkTextarea.vue';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
const reactionAcceptance = computed(defaultStore.makeGetterSetter('reactionAcceptance'));
const avatarDecorations = ref<any[]>([]);
const profile = reactive({
name: $i.name,
@@ -165,10 +155,6 @@ watch(() => profile, () => {
const fields = ref($i?.fields.map(field => ({ id: Math.random().toString(), name: field.name, value: field.value })) ?? []);
const fieldEditMode = ref(false);
os.api('get-avatar-decorations').then(_avatarDecorations => {
avatarDecorations.value = _avatarDecorations;
});
function addField() {
fields.value.push({
id: Math.random().toString(),
@@ -267,12 +253,6 @@ function changeBanner(ev) {
});
}
function openDecoration(avatarDecoration) {
os.popup(defineAsyncComponent(() => import('./profile.avatar-decoration-dialog.vue')), {
decoration: avatarDecoration,
}, {}, 'closed');
}
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
@@ -367,33 +347,4 @@ definePageMetadata({
.dragItemForm {
flex-grow: 1;
}
.avatarDecoration {
cursor: pointer;
padding: 16px 16px 28px 16px;
border: solid 2px var(--divider);
border-radius: 8px;
text-align: center;
font-size: 90%;
overflow: clip;
contain: content;
}
.avatarDecorationActive {
background-color: var(--accentedBg);
border-color: var(--accent);
}
.avatarDecorationName {
position: relative;
z-index: 10;
font-weight: bold;
margin-bottom: 20px;
}
.avatarDecorationLock {
position: absolute;
bottom: 12px;
right: 12px;
}
</style>

View File

@@ -1,159 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps_m">
<FromSlot>
<template #label>{{ i18n.ts.reactionSettingDescription }}</template>
<div v-panel style="border-radius: 6px;">
<Sortable v-model="reactions" :class="$style.reactions" :itemKey="item => item" :animation="150" :delay="100" :delayOnTouchOnly="true">
<template #item="{element}">
<button class="_button" :class="$style.reactionsItem" @click="remove(element, $event)">
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true"/>
<MkEmoji v-else :emoji="element" :normal="true"/>
</button>
</template>
<template #footer>
<button class="_button" :class="$style.reactionsAdd" @click="chooseEmoji"><i class="ti ti-plus"></i></button>
</template>
</Sortable>
</div>
<template #caption>{{ i18n.ts.reactionSettingDescription2 }} <button class="_textButton" @click="preview">{{ i18n.ts.preview }}</button></template>
</FromSlot>
<MkRadios v-model="reactionPickerSize">
<template #label>{{ i18n.ts.size }}</template>
<option :value="1">{{ i18n.ts.small }}</option>
<option :value="2">{{ i18n.ts.medium }}</option>
<option :value="3">{{ i18n.ts.large }}</option>
</MkRadios>
<MkRadios v-model="reactionPickerWidth">
<template #label>{{ i18n.ts.numberOfColumn }}</template>
<option :value="1">5</option>
<option :value="2">6</option>
<option :value="3">7</option>
<option :value="4">8</option>
<option :value="5">9</option>
</MkRadios>
<MkRadios v-model="reactionPickerHeight">
<template #label>{{ i18n.ts.height }}</template>
<option :value="1">{{ i18n.ts.small }}</option>
<option :value="2">{{ i18n.ts.medium }}</option>
<option :value="3">{{ i18n.ts.large }}</option>
<option :value="4">{{ i18n.ts.large }}+</option>
</MkRadios>
<MkSwitch v-model="reactionPickerUseDrawerForMobile">
{{ i18n.ts.useDrawerReactionPickerForMobile }}
<template #caption>{{ i18n.ts.needReloadToApply }}</template>
</MkSwitch>
<FormSection>
<div class="_buttons">
<MkButton inline @click="preview"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton>
<MkButton inline danger @click="setDefault"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton>
</div>
</FormSection>
</div>
</template>
<script lang="ts" setup>
import { defineAsyncComponent, watch, ref, computed } from 'vue';
import Sortable from 'vuedraggable';
import MkRadios from '@/components/MkRadios.vue';
import FromSlot from '@/components/form/slot.vue';
import MkButton from '@/components/MkButton.vue';
import FormSection from '@/components/form/section.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import * as os from '@/os.js';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { deepClone } from '@/scripts/clone.js';
const reactions = ref(deepClone(defaultStore.state.reactions));
const reactionPickerSize = computed(defaultStore.makeGetterSetter('reactionPickerSize'));
const reactionPickerWidth = computed(defaultStore.makeGetterSetter('reactionPickerWidth'));
const reactionPickerHeight = computed(defaultStore.makeGetterSetter('reactionPickerHeight'));
const reactionPickerUseDrawerForMobile = computed(defaultStore.makeGetterSetter('reactionPickerUseDrawerForMobile'));
function save() {
defaultStore.set('reactions', reactions.value);
}
function remove(reaction, ev: MouseEvent) {
os.popupMenu([{
text: i18n.ts.remove,
action: () => {
reactions.value = reactions.value.filter(x => x !== reaction);
},
}], ev.currentTarget ?? ev.target);
}
function preview(ev: MouseEvent) {
os.popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
asReactionPicker: true,
src: ev.currentTarget ?? ev.target,
}, {}, 'closed');
}
async function setDefault() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.resetAreYouSure,
});
if (canceled) return;
reactions.value = deepClone(defaultStore.def.reactions.default);
}
function chooseEmoji(ev: MouseEvent) {
os.pickEmoji(ev.currentTarget ?? ev.target, {
showPinned: false,
}).then(emoji => {
if (!reactions.value.includes(emoji)) {
reactions.value.push(emoji);
}
});
}
watch(reactions, () => {
save();
}, {
deep: true,
});
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata({
title: i18n.ts.reaction,
icon: 'ti ti-mood-happy',
action: {
icon: 'ti ti-eye',
handler: preview,
},
});
</script>
<style lang="scss" module>
.reactions {
padding: 12px;
font-size: 1.1em;
}
.reactionsItem {
display: inline-block;
padding: 8px;
cursor: move;
}
.reactionsAdd {
display: inline-block;
padding: 8px;
}
</style>

View File

@@ -5,9 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div class="_gaps_m">
<MkTextarea v-model="installThemeCode">
<MkCodeEditor v-model="installThemeCode" lang="json5">
<template #label>{{ i18n.ts._theme.code }}</template>
</MkTextarea>
</MkCodeEditor>
<div class="_buttons">
<MkButton :disabled="installThemeCode == null" inline @click="() => previewTheme(installThemeCode)"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton>
@@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref, computed } from 'vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkCodeEditor from '@/components/MkCodeEditor.vue';
import MkButton from '@/components/MkButton.vue';
import { parseThemeCode, previewTheme, installTheme } from '@/scripts/install-theme.js';
import * as os from '@/os.js';

View File

@@ -51,9 +51,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.editCode }}</template>
<div class="_gaps_m">
<MkTextarea v-model="themeCode" tall>
<MkCodeEditor v-model="themeCode" lang="json5">
<template #label>{{ i18n.ts._theme.code }}</template>
</MkTextarea>
</MkCodeEditor>
<MkButton primary @click="applyThemeCode">{{ i18n.ts.apply }}</MkButton>
</div>
</MkFolder>
@@ -80,6 +80,7 @@ import { v4 as uuid } from 'uuid';
import JSON5 from 'json5';
import MkButton from '@/components/MkButton.vue';
import MkCodeEditor from '@/components/MkCodeEditor.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkFolder from '@/components/MkFolder.vue';

View File

@@ -48,6 +48,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import { miLocalStorage } from '@/local-storage.js';
import { antennasCache, userListsCache } from '@/cache.js';
import { deviceKind } from '@/scripts/device-kind.js';
import { MenuItem } from '@/types/menu.js';
provide('shouldOmitHeaderTitle', true);
@@ -83,22 +84,40 @@ function top(): void {
async function chooseList(ev: MouseEvent): Promise<void> {
const lists = await userListsCache.fetch();
const items = lists.map(list => ({
type: 'link' as const,
text: list.name,
to: `/timeline/list/${list.id}`,
}));
const items: MenuItem[] = [
...lists.map(list => ({
type: 'link' as const,
text: list.name,
to: `/timeline/list/${list.id}`,
})),
(lists.length === 0 ? undefined : { type: 'divider' }),
{
type: 'link' as const,
icon: 'ti ti-plus',
text: i18n.ts.createNew,
to: '/my/lists',
},
];
os.popupMenu(items, ev.currentTarget ?? ev.target);
}
async function chooseAntenna(ev: MouseEvent): Promise<void> {
const antennas = await antennasCache.fetch();
const items = antennas.map(antenna => ({
type: 'link' as const,
text: antenna.name,
indicate: antenna.hasUnreadNote,
to: `/timeline/antenna/${antenna.id}`,
}));
const items: MenuItem[] = [
...antennas.map(antenna => ({
type: 'link' as const,
text: antenna.name,
indicate: antenna.hasUnreadNote,
to: `/timeline/antenna/${antenna.id}`,
})),
(antennas.length === 0 ? undefined : { type: 'divider' }),
{
type: 'link' as const,
icon: 'ti ti-plus',
text: i18n.ts.createNew,
to: '/my/antennas',
},
];
os.popupMenu(items, ev.currentTarget ?? ev.target);
}
@@ -106,12 +125,21 @@ async function chooseChannel(ev: MouseEvent): Promise<void> {
const channels = await os.api('channels/my-favorites', {
limit: 100,
});
const items = channels.map(channel => ({
type: 'link' as const,
text: channel.name,
indicate: channel.hasUnreadNote,
to: `/channels/${channel.id}`,
}));
const items: MenuItem[] = [
...channels.map(channel => ({
type: 'link' as const,
text: channel.name,
indicate: channel.hasUnreadNote,
to: `/channels/${channel.id}`,
})),
(channels.length === 0 ? undefined : { type: 'divider' }),
{
type: 'link' as const,
icon: 'ti ti-plus',
text: i18n.ts.createNew,
to: '/channels',
},
];
os.popupMenu(items, ev.currentTarget ?? ev.target);
}

View File

@@ -128,12 +128,17 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo>
<template v-if="narrow">
<XFiles :key="user.id" :user="user"/>
<XActivity :key="user.id" :user="user"/>
<MkLazy>
<XFiles :key="user.id" :user="user"/>
</MkLazy>
<MkLazy>
<XActivity :key="user.id" :user="user"/>
</MkLazy>
</template>
<div v-if="!disableNotes">
<div style="margin-bottom: 8px;">{{ i18n.ts.featured }}</div>
<MkNotes :class="$style.tl" :noGap="true" :pagination="pagination"/>
<MkLazy>
<XTimeline :user="user"/>
</MkLazy>
</div>
</div>
</div>
@@ -187,6 +192,7 @@ function calcAge(birthdate: string): number {
const XFiles = defineAsyncComponent(() => import('./index.files.vue'));
const XActivity = defineAsyncComponent(() => import('./index.activity.vue'));
const XTimeline = defineAsyncComponent(() => import('./index.timeline.vue'));
const props = withDefaults(defineProps<{
user: Misskey.entities.UserDetailed;
@@ -213,14 +219,6 @@ watch(moderationNote, async () => {
await os.api('admin/update-user-note', { userId: props.user.id, text: moderationNote.value });
});
const pagination = {
endpoint: 'users/featured-notes' as const,
limit: 10,
params: computed(() => ({
userId: props.user.id,
})),
};
const style = computed(() => {
if (props.user.bannerUrl == null) return {};
return {
@@ -232,7 +230,7 @@ const age = computed(() => {
return calcAge(props.user.birthday);
});
function menu(ev) {
function menu(ev: MouseEvent) {
const { menu, cleanup } = getUserMenu(user.value, router);
os.popupMenu(menu, ev.currentTarget ?? ev.target).finally(cleanup);
}

View File

@@ -4,18 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkSpacer :contentMax="800" style="padding-top: 0">
<MkStickyContainer>
<template #header>
<MkTab v-model="include" :class="$style.tab">
<option :value="null">{{ i18n.ts.notes }}</option>
<option value="all">{{ i18n.ts.all }}</option>
<option value="files">{{ i18n.ts.withFiles }}</option>
</MkTab>
</template>
<MkNotes :noGap="true" :pagination="pagination" :class="$style.tl"/>
</MkStickyContainer>
</MkSpacer>
<MkStickyContainer>
<template #header>
<MkTab v-model="tab" :class="$style.tab">
<option value="featured">{{ i18n.ts.featured }}</option>
<option :value="null">{{ i18n.ts.notes }}</option>
<option value="all">{{ i18n.ts.all }}</option>
<option value="files">{{ i18n.ts.withFiles }}</option>
</MkTab>
</template>
<MkNotes :noGap="true" :pagination="pagination" :class="$style.tl"/>
</MkStickyContainer>
</template>
<script lang="ts" setup>
@@ -29,24 +28,29 @@ const props = defineProps<{
user: Misskey.entities.UserDetailed;
}>();
const include = ref<string | null>('all');
const tab = ref<string | null>('all');
const pagination = {
const pagination = computed(() => tab.value === 'featured' ? {
endpoint: 'users/featured-notes' as const,
limit: 10,
params: {
userId: props.user.id,
},
} : {
endpoint: 'users/notes' as const,
limit: 10,
params: computed(() => ({
params: {
userId: props.user.id,
withRenotes: include.value === 'all',
withReplies: include.value === 'all',
withFiles: include.value === 'files',
withRenotes: tab.value === 'all',
withReplies: tab.value === 'all',
withFiles: tab.value === 'files',
withChannelNotes: true,
})),
};
},
});
</script>
<style lang="scss" module>
.tab {
margin: calc(var(--margin) / 2) 0;
padding: calc(var(--margin) / 2) 0;
background: var(--bg);
}

View File

@@ -9,7 +9,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<div>
<div v-if="user">
<XHome v-if="tab === 'home'" :user="user"/>
<XTimeline v-else-if="tab === 'notes'" :user="user"/>
<MkSpacer v-else-if="tab === 'notes'" :contentMax="800" style="padding-top: 0">
<XTimeline :user="user"/>
</MkSpacer>
<XActivity v-else-if="tab === 'activity'" :user="user"/>
<XAchievements v-else-if="tab === 'achievements'" :user="user"/>
<XReactions v-else-if="tab === 'reactions'" :user="user"/>

View File

@@ -63,9 +63,9 @@ export const routes = [{
name: 'privacy',
component: page(() => import('./pages/settings/privacy.vue')),
}, {
path: '/reaction',
name: 'reaction',
component: page(() => import('./pages/settings/reaction.vue')),
path: '/emoji-picker',
name: 'emojiPicker',
component: page(() => import('./pages/settings/emoji-picker.vue')),
}, {
path: '/drive',
name: 'drive',

View File

@@ -8,6 +8,8 @@ import getCaretCoordinates from 'textarea-caret';
import { toASCII } from 'punycode/';
import { popup } from '@/os.js';
export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag';
export class Autocomplete {
private suggestion: {
x: Ref<number>;
@@ -19,6 +21,7 @@ export class Autocomplete {
private currentType: string;
private textRef: Ref<string>;
private opening: boolean;
private onlyType: SuggestionType[];
private get text(): string {
// Use raw .value to get the latest value
@@ -35,7 +38,7 @@ export class Autocomplete {
/**
* 対象のテキストエリアを与えてインスタンスを初期化します。
*/
constructor(textarea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>) {
constructor(textarea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>, onlyType?: SuggestionType[]) {
//#region BIND
this.onInput = this.onInput.bind(this);
this.complete = this.complete.bind(this);
@@ -46,6 +49,7 @@ export class Autocomplete {
this.textarea = textarea;
this.textRef = textRef;
this.opening = false;
this.onlyType = onlyType ?? ['user', 'hashtag', 'emoji', 'mfmTag'];
this.attach();
}
@@ -95,7 +99,7 @@ export class Autocomplete {
let opened = false;
if (isMention) {
if (isMention && this.onlyType.includes('user')) {
const username = text.substring(mentionIndex + 1);
if (username !== '' && username.match(/^[a-zA-Z0-9_]+$/)) {
this.open('user', username);
@@ -106,7 +110,7 @@ export class Autocomplete {
}
}
if (isHashtag && !opened) {
if (isHashtag && !opened && this.onlyType.includes('hashtag')) {
const hashtag = text.substring(hashtagIndex + 1);
if (!hashtag.includes(' ')) {
this.open('hashtag', hashtag);
@@ -114,7 +118,7 @@ export class Autocomplete {
}
}
if (isEmoji && !opened) {
if (isEmoji && !opened && this.onlyType.includes('emoji')) {
const emoji = text.substring(emojiIndex + 1);
if (!emoji.includes(' ')) {
this.open('emoji', emoji);
@@ -122,7 +126,7 @@ export class Autocomplete {
}
}
if (isMfmTag && !opened) {
if (isMfmTag && !opened && this.onlyType.includes('mfmTag')) {
const mfmTag = text.substring(mfmTagIndex + 1);
if (!mfmTag.includes(' ')) {
this.open('mfmTag', mfmTag.replace('[', ''));

View File

@@ -6,6 +6,7 @@ import { fetchCustomEmojis } from '@/custom-emojis.js';
export async function clearCache() {
os.waiting();
miLocalStorage.removeItem('locale');
miLocalStorage.removeItem('localeVersion');
miLocalStorage.removeItem('theme');
miLocalStorage.removeItem('emojis');
miLocalStorage.removeItem('lastEmojisFetchedAt');

View File

@@ -3,8 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineAsyncComponent, Ref, ref } from 'vue';
import { defineAsyncComponent, Ref, ref, computed, ComputedRef } from 'vue';
import { popup } from '@/os.js';
import { defaultStore } from '@/store.js';
/**
* 絵文字ピッカーを表示する。
@@ -23,8 +24,10 @@ class EmojiPicker {
}
public async init() {
const emojisRef = defaultStore.reactiveState.pinnedEmojis;
await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
src: this.src,
pinnedEmojis: emojisRef,
asReactionPicker: false,
manualShowing: this.manualShowing,
choseAndClose: false,
@@ -44,8 +47,8 @@ class EmojiPicker {
public show(
src: HTMLElement,
onChosen: EmojiPicker['onChosen'],
onClosed: EmojiPicker['onClosed'],
onChosen?: EmojiPicker['onChosen'],
onClosed?: EmojiPicker['onClosed'],
) {
this.src.value = src;
this.manualShowing.value = true;

View File

@@ -82,7 +82,7 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss
to: `/my/drive/file/${file.id}`,
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
}, null, {
}, { type: 'divider' }, {
text: i18n.ts.rename,
icon: 'ti ti-forms',
action: () => rename(file),
@@ -101,7 +101,7 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss
aspectRatio: NaN,
uploadFolder: folder ? folder.id : folder,
}),
}] : [], null, {
}] : [], { type: 'divider' }, {
text: i18n.ts.createNoteFromTheFile,
icon: 'ti ti-pencil',
action: () => os.post({
@@ -118,7 +118,7 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss
text: i18n.ts.download,
icon: 'ti ti-download',
download: file.name,
}, null, {
}, { type: 'divider' }, {
text: i18n.ts.delete,
icon: 'ti ti-trash',
danger: true,
@@ -126,7 +126,7 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss
}];
if (defaultStore.state.devMode) {
menu = menu.concat([null, {
menu = menu.concat([{ type: 'divider' }, {
icon: 'ti ti-id',
text: i18n.ts.copyFileId,
action: () => {

View File

@@ -61,7 +61,7 @@ export async function getNoteClipMenu(props: {
},
);
},
})), null, {
})), { type: 'divider' }, {
icon: 'ti ti-plus',
text: i18n.ts.createNew,
action: async () => {
@@ -94,7 +94,7 @@ export async function getNoteClipMenu(props: {
}];
}
export function getAbuseNoteMenu(note: misskey.entities.Note, text: string): MenuItem {
export function getAbuseNoteMenu(note: Misskey.entities.Note, text: string): MenuItem {
return {
icon: 'ti ti-exclamation-circle',
text,
@@ -108,7 +108,7 @@ export function getAbuseNoteMenu(note: misskey.entities.Note, text: string): Men
};
}
export function getCopyNoteLinkMenu(note: misskey.entities.Note, text: string): MenuItem {
export function getCopyNoteLinkMenu(note: Misskey.entities.Note, text: string): MenuItem {
return {
icon: 'ti ti-link',
text,
@@ -264,7 +264,7 @@ export function getNoteMenu(props: {
text: i18n.ts.unclip,
danger: true,
action: unclip,
}, null] : []
}, { type: 'divider' }] : []
), {
icon: 'ti ti-info-circle',
text: i18n.ts.details,
@@ -291,7 +291,7 @@ export function getNoteMenu(props: {
text: i18n.ts.translate,
action: translate,
} : undefined,
null,
{ type: 'divider' },
statePromise.then(state => state.isFavorited ? {
icon: 'ti ti-star-off',
text: i18n.ts.unfavorite,
@@ -338,7 +338,7 @@ export function getNoteMenu(props: {
},
/*
...($i.isModerator || $i.isAdmin ? [
null,
{ type: 'divider' },
{
icon: 'ti ti-speakerphone',
text: i18n.ts.promote,
@@ -347,13 +347,13 @@ export function getNoteMenu(props: {
: []
),*/
...(appearNote.userId !== $i.id ? [
null,
{ type: 'divider' },
appearNote.userId !== $i.id ? getAbuseNoteMenu(appearNote, i18n.ts.reportAbuse) : undefined,
]
: []
),
...(appearNote.userId === $i.id || $i.isModerator || $i.isAdmin ? [
null,
{ type: 'divider' },
appearNote.userId === $i.id ? {
icon: 'ti ti-edit',
text: i18n.ts.deleteAndEdit,
@@ -389,7 +389,7 @@ export function getNoteMenu(props: {
}
if (noteActions.length > 0) {
menu = menu.concat([null, ...noteActions.map(action => ({
menu = menu.concat([{ type: "divider" }, ...noteActions.map(action => ({
icon: 'ti ti-plug',
text: action.title,
action: () => {
@@ -399,7 +399,7 @@ export function getNoteMenu(props: {
}
if (defaultStore.state.devMode) {
menu = menu.concat([null, {
menu = menu.concat([{ type: "divider" }, {
icon: 'ti ti-id',
text: i18n.ts.copyNoteId,
action: () => {
@@ -528,10 +528,9 @@ export function getRenoteMenu(props: {
}]);
}
// nullを挟むことで区切り線を出せる
const renoteItems = [
...normalRenoteItems,
...(channelRenoteItems.length > 0 && normalRenoteItems.length > 0) ? [null] : [],
...(channelRenoteItems.length > 0 && normalRenoteItems.length > 0) ? [{ type: 'divider' }] : [],
...channelRenoteItems,
];

View File

@@ -119,7 +119,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
userId: user.id,
});
}
async function invalidateFollow() {
if (!await getConfirmed(i18n.ts.breakFollowConfirm)) return;
@@ -183,7 +183,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`;
os.post({ specified: user, initialText: `${canonical} ` });
},
}, null, {
}, { type: 'divider' }, {
icon: 'ti ti-pencil',
text: i18n.ts.editMemo,
action: () => {
@@ -307,7 +307,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
}]);
//}
menu = menu.concat([null, {
menu = menu.concat([{ type: 'divider' }, {
icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off',
text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute,
action: toggleMute,
@@ -329,7 +329,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
}]);
}
menu = menu.concat([null, {
menu = menu.concat([{ type: 'divider' }, {
icon: 'ti ti-exclamation-circle',
text: i18n.ts.reportAbuse,
action: reportAbuse,
@@ -337,15 +337,15 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
}
if (user.host !== null) {
menu = menu.concat([null, {
menu = menu.concat([{ type: 'divider' }, {
icon: 'ti ti-refresh',
text: i18n.ts.updateRemoteUser,
action: userInfoUpdate,
}]);
}
if (defaultStore.state.devMode) {
menu = menu.concat([null, {
menu = menu.concat([{ type: 'divider' }, {
icon: 'ti ti-id',
text: i18n.ts.copyUserId,
action: () => {
@@ -355,7 +355,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
}
if ($i && meId === user.id) {
menu = menu.concat([null, {
menu = menu.concat([{ type: 'divider' }, {
icon: 'ti ti-pencil',
text: i18n.ts.editProfile,
action: () => {
@@ -365,7 +365,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
}
if (userActions.length > 0) {
menu = menu.concat([null, ...userActions.map(action => ({
menu = menu.concat([{ type: 'divider' }, ...userActions.map(action => ({
icon: 'ti ti-plug',
text: action.title,
action: () => {

View File

@@ -5,6 +5,7 @@
import { defineAsyncComponent, Ref, ref } from 'vue';
import { popup } from '@/os.js';
import { defaultStore } from '@/store.js';
class ReactionPicker {
private src: Ref<HTMLElement | null> = ref(null);
@@ -17,25 +18,27 @@ class ReactionPicker {
}
public async init() {
const reactionsRef = defaultStore.reactiveState.reactions;
await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
src: this.src,
pinnedEmojis: reactionsRef,
asReactionPicker: true,
manualShowing: this.manualShowing,
}, {
done: reaction => {
this.onChosen!(reaction);
if (this.onChosen) this.onChosen(reaction);
},
close: () => {
this.manualShowing.value = false;
},
closed: () => {
this.src.value = null;
this.onClosed!();
if (this.onClosed) this.onClosed();
},
});
}
public show(src: HTMLElement, onChosen: ReactionPicker['onChosen'], onClosed: ReactionPicker['onClosed']) {
public show(src: HTMLElement, onChosen?: ReactionPicker['onChosen'], onClosed?: ReactionPicker['onClosed']) {
this.src.value = src;
this.manualShowing.value = true;
this.onChosen = onChosen;

File diff suppressed because one or more lines are too long

View File

@@ -119,6 +119,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'account',
default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'],
},
pinnedEmojis: {
where: 'account',
default: [],
},
reactionAcceptance: {
where: 'account',
default: 'nonSensitiveOnly' as 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null,
@@ -271,19 +275,19 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: 'remote' as 'none' | 'remote' | 'always',
},
reactionPickerSize: {
emojiPickerScale: {
where: 'device',
default: 1,
},
reactionPickerWidth: {
emojiPickerWidth: {
where: 'device',
default: 1,
},
reactionPickerHeight: {
emojiPickerHeight: {
where: 'device',
default: 2,
},
reactionPickerUseDrawerForMobile: {
emojiPickerUseDrawerForMobile: {
where: 'device',
default: true,
},
@@ -412,6 +416,10 @@ export const defaultStore = markRaw(new Storage('base', {
code: false,
} as Record<string, boolean>,
},
enableSeasonalScreenEffect: {
where: 'device',
default: false,
},
sound_masterVolume: {
where: 'device',

View File

@@ -8,7 +8,7 @@ import { Ref } from 'vue';
export type MenuAction = (ev: MouseEvent) => void;
export type MenuDivider = null;
export type MenuDivider = { type: 'divider' };
export type MenuNull = undefined;
export type MenuLabel = { type: 'label', text: string };
export type MenuLink = { type: 'link', to: string, text: string, icon?: string, indicate?: boolean, avatar?: Misskey.entities.User };

View File

@@ -64,7 +64,7 @@ export function openInstanceMenu(ev: MouseEvent) {
text: i18n.ts.charts,
icon: 'ti ti-chart-line',
to: '/about#charts',
}, null, {
}, { type: 'divider' }, {
type: 'link',
text: i18n.ts.ads,
icon: 'ti ti-ad',
@@ -79,7 +79,7 @@ export function openInstanceMenu(ev: MouseEvent) {
text: i18n.ts.tools,
icon: 'ti ti-tool',
children: toolsMenuItems(),
}, null, (instance.impressumUrl) ? {
}, { type: 'divider' }, (instance.impressumUrl) ? {
text: i18n.ts.impressum,
icon: 'ti ti-file-invoice',
action: () => {
@@ -97,7 +97,7 @@ export function openInstanceMenu(ev: MouseEvent) {
action: () => {
window.open(instance.privacyPolicyUrl, '_blank', 'noopener');
},
} : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl) ? undefined : null, {
} : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl) ? undefined : { type: 'divider' }, {
text: i18n.ts.help,
icon: 'ti ti-help-circle',
action: () => {

View File

@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<XSidebar v-if="!isMobile"/>
<div :class="$style.main">
<XAnnouncements v-if="$i" :class="$style.announcements"/>
<XAnnouncements v-if="$i"/>
<XStatusBars/>
<div ref="columnsEl" :class="[$style.sections, { [$style.center]: deckStore.reactiveState.columnAlign.value === 'center', [$style.snapScroll]: snapScroll }]" @contextmenu.self.prevent="onContextmenu" @wheel.self="onWheel">
<!-- sectionを利用しているのはdeck.vue側でcolumnに対してfirst-of-typeを効かせるため -->
@@ -236,7 +236,7 @@ function changeProfile(ev: MouseEvent) {
deckStore.set('profile', k);
unisonReload();
},
}))), null, {
}))), { type: 'divider' }, {
text: i18n.ts._deck.newProfile,
icon: 'ti ti-plus',
action: async () => {

View File

@@ -104,7 +104,7 @@ function toggleActive() {
}
function getMenu() {
let items = [{
let items: MenuItem[] = [{
icon: 'ti ti-settings',
text: i18n.ts._deck.configureColumn,
action: async () => {
@@ -170,7 +170,7 @@ function getMenu() {
action: () => {
popRightColumn(props.column.id);
},
} : undefined, null, {
} : undefined, { type: 'divider' }, {
icon: 'ti ti-trash',
text: i18n.ts.remove,
danger: true,
@@ -180,7 +180,7 @@ function getMenu() {
}];
if (props.menu) {
items.unshift(null);
items.unshift({ type: 'divider' });
items = props.menu.concat(items);
}

View File

@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer ref="contents" :class="$style.contents" style="container-type: inline-size;" @contextmenu.stop="onContextmenu">
<template #header>
<div>
<XAnnouncements v-if="$i" :class="$style.announcements"/>
<XAnnouncements v-if="$i"/>
<XStatusBars :class="$style.statusbars"/>
</div>
</template>

View File

@@ -72,5 +72,6 @@ defineExpose<WidgetComponentExpose>({
height: 350px;
border: none;
pointer-events: none;
color-scheme: light;
}
</style>

View File

@@ -134,7 +134,7 @@ const choose = async (ev) => {
text: i18n.ts._timelines.global,
icon: 'ti ti-whirl',
action: () => { setSrc('global'); },
}, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems], ev.currentTarget ?? ev.target).then(() => {
}, antennaItems.length > 0 ? { type: 'divider' } : undefined, ...antennaItems, listItems.length > 0 ? { type: 'divider' } : undefined, ...listItems], ev.currentTarget ?? ev.target).then(() => {
menuOpened.value = false;
});
};

View File

@@ -3,13 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, test, assert, afterEach } from 'vitest';
import { render, cleanup, type RenderResult } from '@testing-library/vue';
import { afterEach, assert, describe, test } from 'vitest';
import { cleanup, render, type RenderResult } from '@testing-library/vue';
import './init';
import type * as Misskey from 'misskey-js';
import { directives } from '@/directives/index.js';
import { components } from '@/components/index.js';
import XHome from '@/pages/user/home.vue';
import 'intersection-observer';
describe('XHome', () => {
const renderHome = (user: Partial<Misskey.entities.UserDetailed>): RenderResult => {

View File

@@ -1,5 +1,6 @@
import dns from 'dns';
import { defineConfig } from 'vite';
import locales from '../../locales';
import { getConfig } from './vite.config.js';
dns.setDefaultResultOrder('ipv4first');
@@ -49,6 +50,11 @@ const devConfig = {
input: 'index.html',
},
},
define: {
...defaultConfig.define,
_LANGS_FULL_: JSON.stringify(Object.entries(locales)),
},
};
export default defineConfig(({ command, mode }) => devConfig);