Merge branch 'develop' into mahjong

This commit is contained in:
syuilo
2024-07-02 10:10:01 +09:00
840 changed files with 44657 additions and 21435 deletions

View File

@@ -55,7 +55,7 @@ module.exports = {
'vue/no-dupe-keys': 'warn',
'vue/valid-v-for': 'warn',
'vue/return-in-computed-property': 'warn',
'vue/no-setup-props-destructure': 'warn',
'vue/no-setup-props-reactivity-loss': 'warn',
'vue/max-attributes-per-line': 'off',
'vue/html-self-closing': 'off',
'vue/singleline-html-element-content-newline': 'off',

View File

@@ -0,0 +1,48 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { DefaultBodyType, HttpResponse, HttpResponseResolver, JsonBodyType, PathParams, http } from 'msw';
import seedrandom from 'seedrandom';
import { action } from '@storybook/addon-actions';
function getChartArray(seed: string, limit: number, option?: { accumulate?: boolean, mul?: number }): number[] {
const rng = seedrandom(seed);
const max = Math.floor(option?.mul ?? 250 * rng());
let accumulation = 0;
const array: number[] = [];
for (let i = 0; i < limit; i++) {
const num = Math.floor((max + 1) * rng());
if (option?.accumulate) {
accumulation += num;
array.unshift(accumulation);
} else {
array.push(num);
}
}
return array;
}
export function getChartResolver(fields: string[], option?: { accumulate?: boolean, mulMap?: Record<string, number> }): HttpResponseResolver<PathParams, DefaultBodyType, JsonBodyType> {
return ({ request }) => {
action(`GET ${request.url}`)();
const limitParam = new URL(request.url).searchParams.get('limit');
const limit = limitParam ? parseInt(limitParam) : 30;
const res = {};
for (const field of fields) {
const layers = field.split('.');
let current = res;
while (layers.length > 1) {
const currentKey = layers.shift()!;
if (current[currentKey] == null) current[currentKey] = {};
current = current[currentKey];
}
current[layers[0]] = getChartArray(field, limit, {
accumulate: option?.accumulate,
mul: option?.mulMap != null && field in option.mulMap ? option.mulMap[field] : undefined,
});
}
return HttpResponse.json(res);
};
}

View File

@@ -22,12 +22,72 @@ export function abuseUserReport() {
};
}
export function channel(id = 'somechannelid', name = 'Some Channel', bannerUrl: string | null = 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true'): entities.Channel {
return {
id,
createdAt: '2016-12-28T22:49:51.000Z',
lastNotedAt: '2016-12-28T22:49:51.000Z',
name,
description: null,
userId: null,
bannerUrl,
pinnedNoteIds: [],
color: '#000',
isArchived: false,
usersCount: 1,
notesCount: 1,
isSensitive: false,
allowRenoteToExternal: false,
};
}
export function clip(id = 'someclipid', name = 'Some Clip'): entities.Clip {
return {
id,
createdAt: '2016-12-28T22:49:51.000Z',
lastClippedAt: null,
userId: 'someuserid',
user: {
id: 'someuserid',
name: 'Misskey User',
username: 'miskist',
host: 'misskey-hub.net',
avatarUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true',
avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay',
avatarDecorations: [],
emojis: {},
badgeRoles: [],
onlineStatus: 'unknown',
},
notesCount: undefined,
name,
description: 'Some clip description',
isPublic: false,
favoritedCount: 0,
};
}
export function emojiDetailed(id = 'someemojiid', name = 'some_emoji'): entities.EmojiDetailed {
return {
id,
aliases: ['alias1', 'alias2'],
name,
category: 'emojiCategory',
host: null,
url: '/client-assets/about-icon.png',
license: null,
isSensitive: false,
localOnly: false,
roleIdsThatCanBeUsedThisEmojiAsReaction: ['roleId1', 'roleId2'],
};
}
export function galleryPost(isSensitive = false) {
return {
id: 'somepostid',
createdAt: '2016-12-28T22:49:51.000Z',
updatedAt: '2016-12-28T22:49:51.000Z',
userid: 'someuserid',
userId: 'someuserid',
user: userDetailed(),
title: 'Some post title',
description: 'Some post description',
@@ -65,6 +125,35 @@ export function file(isSensitive = false) {
};
}
export function federationInstance(): entities.FederationInstance {
return {
id: 'someinstanceid',
firstRetrievedAt: '2021-01-01T00:00:00.000Z',
host: 'misskey-hub.net',
usersCount: 10,
notesCount: 20,
followingCount: 5,
followersCount: 15,
isNotResponding: false,
isSuspended: false,
suspensionState: 'none',
isBlocked: false,
softwareName: 'misskey',
softwareVersion: '2024.5.0',
openRegistrations: false,
name: 'Misskey Hub',
description: '',
maintainerName: '',
maintainerEmail: '',
isSilenced: false,
iconUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true',
faviconUrl: '',
themeColor: '',
infoUpdatedAt: '',
latestRequestReceivedAt: '',
};
}
export function userDetailed(id = 'someuserid', username = 'miskist', host = 'misskey-hub.net', name = 'Misskey User'): entities.UserDetailed {
return {
id,
@@ -75,9 +164,8 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi
avatarUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true',
avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay',
avatarDecorations: [],
emojis: [],
emojis: {},
bannerBlurhash: 'eQA^IW^-MH8w9tE8I=S^o{$*R4RikXtSxutRozjEnNR.RQadoyozog',
bannerColor: '#000000',
bannerUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true',
birthday: '2014-06-20',
createdAt: '2016-12-28T22:49:51.000Z',
@@ -118,11 +206,16 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi
publicReactions: false,
securityKeys: false,
twoFactorEnabled: false,
usePasswordLessLogin: false,
twoFactorBackupCodesStock: 'none',
updatedAt: null,
lastFetchedAt: null,
uri: null,
url: null,
movedTo: null,
alsoKnownAs: null,
notify: 'none',
memo: null
};
}

View File

@@ -82,23 +82,16 @@ function h<T extends estree.Node>(
return Object.assign(props || {}, { type }) as T;
}
declare global {
namespace JSX {
type Element = estree.Node;
type ElementClass = never;
type ElementAttributesProperty = never;
type ElementChildrenAttribute = never;
type IntrinsicAttributes = never;
type IntrinsicClassAttributes<T> = never;
type IntrinsicElements = {
[T in keyof typeof generator as ToKebab<SplitCamel<Uncapitalize<T>>>]: {
[K in keyof Omit<
Parameters<(typeof generator)[T]>[0],
'type'
>]?: Parameters<(typeof generator)[T]>[0][K];
};
declare namespace h.JSX {
type Element = estree.Node;
type IntrinsicElements = {
[T in keyof typeof generator as ToKebab<SplitCamel<Uncapitalize<T>>>]: {
[K in keyof Omit<
Parameters<(typeof generator)[T]>[0],
'type'
>]?: Parameters<(typeof generator)[T]>[0][K];
};
}
};
}
function toStories(component: string): Promise<string> {
@@ -388,6 +381,7 @@ function toStories(component: string): Promise<string> {
'/* eslint-disable @typescript-eslint/explicit-function-return-type */\n' +
'/* eslint-disable import/no-default-export */\n' +
'/* eslint-disable import/no-duplicates */\n' +
'/* eslint-disable import/order */\n' +
generate(program, { generator }) +
(hasImplStories ? readFileSync(`${implStories}.ts`, 'utf-8') : ''),
{
@@ -401,13 +395,15 @@ function toStories(component: string): Promise<string> {
// glob('src/{components,pages,ui,widgets}/**/*.vue')
(async () => {
const globs = await Promise.all([
glob('src/components/global/*.vue'),
glob('src/components/Mk{A,B}*.vue'),
glob('src/components/global/Mk*.vue'),
glob('src/components/global/RouterView.vue'),
glob('src/components/Mk[A-C]*.vue'),
glob('src/components/MkDigitalClock.vue'),
glob('src/components/MkGalleryPostPreview.vue'),
glob('src/components/MkSignupServerRules.vue'),
glob('src/components/MkUserSetupDialog.vue'),
glob('src/components/MkUserSetupDialog.*.vue'),
glob('src/components/MkInstanceCardMini.vue'),
glob('src/components/MkInviteCode.vue'),
glob('src/pages/user/home.vue'),
]);

View File

@@ -15,6 +15,7 @@ const _dirname = fileURLToPath(new URL('.', import.meta.url));
const config = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
staticDirs: [{ from: '../assets', to: '/client-assets' }],
addons: [
getAbsolutePath('@storybook/addon-essentials'),
getAbsolutePath('@storybook/addon-interactions'),
@@ -34,7 +35,7 @@ const config = {
disableTelemetry: true,
},
async viteFinal(config) {
const replacePluginForIsChromatic = config.plugins?.findIndex((plugin) => plugin && (plugin as Partial<Plugin>)?.name === 'replace') ?? -1;
const replacePluginForIsChromatic = config.plugins?.findIndex((plugin: Plugin) => plugin && plugin.name === 'replace') ?? -1;
if (~replacePluginForIsChromatic) {
config.plugins?.splice(replacePluginForIsChromatic, 1);
}

View File

@@ -6,7 +6,8 @@
import { type SharedOptions, http, HttpResponse } from 'msw';
export const onUnhandledRequest = ((req, print) => {
if (req.url.hostname !== 'localhost' || /^\/(?:client-assets\/|fluent-emojis?\/|iframe.html$|node_modules\/|src\/|sb-|static-assets\/|vite\/)/.test(req.url.pathname)) {
const url = new URL(req.url);
if (url.hostname !== 'localhost' || /^\/(?:client-assets\/|fluent-emojis?\/|iframe.html$|node_modules\/|src\/|sb-|static-assets\/|vite\/)/.test(url.pathname)) {
return
}
print.warning()

View File

@@ -1,6 +1,11 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<link rel="preload" href="https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true" as="image" type="image/png" crossorigin="anonymous">
<link rel="preload" href="https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true" as="image" type="image/jpeg" crossorigin="anonymous">
<link rel="stylesheet" href="https://unpkg.com/@tabler/icons-webfont@2.44.0/tabler-icons.min.css">
<link rel="stylesheet" href="https://unpkg.com/@tabler/icons-webfont@3.3.0/dist/tabler-icons.min.css">
<link rel="stylesheet" href="https://unpkg.com/@fontsource/m-plus-rounded-1c/index.css">
<style>
html {

View File

@@ -7,7 +7,7 @@ import { FORCE_REMOUNT } from '@storybook/core-events';
import { addons } from '@storybook/preview-api';
import { type Preview, setup } from '@storybook/vue3';
import isChromatic from 'chromatic/isChromatic';
import { initialize, mswDecorator } from 'msw-storybook-addon';
import { initialize, mswLoader } from 'msw-storybook-addon';
import { userDetailed } from './fakes.js';
import locale from './locale.js';
import { commonHandlers, onUnhandledRequest } from './mocks.js';
@@ -122,7 +122,6 @@ const preview = {
}
return story;
},
mswDecorator,
(Story, context) => {
return {
setup() {
@@ -137,6 +136,7 @@ const preview = {
};
},
],
loaders: [mswLoader],
parameters: {
controls: {
exclude: /^__/,

View File

@@ -17,31 +17,31 @@
"lint": "pnpm typecheck && pnpm eslint"
},
"dependencies": {
"@discordapp/twemoji": "15.0.2",
"@discordapp/twemoji": "15.0.3",
"@github/webauthn-json": "2.1.1",
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
"@misskey-dev/browser-image-resizer": "2024.1.0",
"@rollup/plugin-json": "6.1.0",
"@rollup/plugin-replace": "5.0.5",
"@rollup/pluginutils": "5.1.0",
"@syuilo/aiscript": "0.17.0",
"@tabler/icons-webfont": "2.44.0",
"@twemoji/parser": "15.0.0",
"@vitejs/plugin-vue": "5.0.3",
"@vue/compiler-sfc": "3.4.15",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.2",
"@syuilo/aiscript": "0.18.0",
"@tabler/icons-webfont": "3.3.0",
"@twemoji/parser": "15.1.1",
"@vitejs/plugin-vue": "5.0.4",
"@vue/compiler-sfc": "3.4.26",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.9",
"astring": "1.8.6",
"broadcast-channel": "7.0.0",
"buraha": "0.0.1",
"canvas-confetti": "1.6.1",
"chart.js": "4.4.1",
"canvas-confetti": "1.9.3",
"chart.js": "4.4.2",
"chartjs-adapter-date-fns": "3.0.0",
"chartjs-chart-matrix": "2.0.1",
"chartjs-plugin-gradient": "0.6.1",
"chartjs-plugin-zoom": "2.0.1",
"chromatic": "10.6.1",
"chromatic": "11.3.0",
"compare-versions": "6.1.0",
"cropperjs": "2.0.0-beta.4",
"cropperjs": "2.0.0-beta.5",
"date-fns": "2.30.0",
"escape-regexp": "0.0.1",
"estree-walker": "3.0.3",
@@ -58,85 +58,87 @@
"misskey-reversi": "workspace:*",
"photoswipe": "5.4.3",
"punycode": "2.3.1",
"rollup": "4.9.6",
"sanitize-html": "2.11.0",
"sass": "1.70.0",
"shiki": "1.0.0-beta.3",
"rollup": "4.17.2",
"sanitize-html": "2.13.0",
"sass": "1.76.0",
"shiki": "1.4.0",
"strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0",
"three": "0.160.1",
"three": "0.164.1",
"throttle-debounce": "5.0.0",
"tinycolor2": "1.6.0",
"tsc-alias": "1.8.8",
"tsconfig-paths": "4.2.0",
"typescript": "5.3.3",
"typescript": "5.5.2",
"uuid": "9.0.1",
"v-code-diff": "1.7.2",
"vite": "5.1.0",
"vue": "3.4.15",
"v-code-diff": "1.11.0",
"vite": "5.2.11",
"vue": "3.4.26",
"vuedraggable": "next"
},
"devDependencies": {
"@misskey-dev/eslint-plugin": "1.0.0",
"@misskey-dev/summaly": "5.0.3",
"@storybook/addon-actions": "8.0.0-beta.2",
"@storybook/addon-essentials": "8.0.0-beta.2",
"@storybook/addon-interactions": "8.0.0-beta.2",
"@storybook/addon-links": "8.0.0-beta.2",
"@storybook/addon-mdx-gfm": "8.0.0-beta.2",
"@storybook/addon-storysource": "8.0.0-beta.2",
"@storybook/blocks": "8.0.0-beta.2",
"@storybook/components": "8.0.0-beta.2",
"@storybook/core-events": "8.0.0-beta.2",
"@storybook/manager-api": "8.0.0-beta.2",
"@storybook/preview-api": "8.0.0-beta.2",
"@storybook/react": "8.0.0-beta.2",
"@storybook/react-vite": "8.0.0-beta.2",
"@storybook/test": "8.0.0-beta.2",
"@storybook/theming": "8.0.0-beta.2",
"@storybook/types": "8.0.0-beta.2",
"@storybook/vue3": "8.0.0-beta.2",
"@storybook/vue3-vite": "8.0.0-beta.2",
"@testing-library/vue": "8.0.2",
"@misskey-dev/summaly": "5.1.0",
"@storybook/addon-actions": "8.0.9",
"@storybook/addon-essentials": "8.0.9",
"@storybook/addon-interactions": "8.0.9",
"@storybook/addon-links": "8.0.9",
"@storybook/addon-mdx-gfm": "8.0.9",
"@storybook/addon-storysource": "8.0.9",
"@storybook/blocks": "8.0.9",
"@storybook/components": "8.0.9",
"@storybook/core-events": "8.0.9",
"@storybook/manager-api": "8.0.9",
"@storybook/preview-api": "8.0.9",
"@storybook/react": "8.0.9",
"@storybook/react-vite": "8.0.9",
"@storybook/test": "8.0.9",
"@storybook/theming": "8.0.9",
"@storybook/types": "8.0.9",
"@storybook/vue3": "8.0.9",
"@storybook/vue3-vite": "8.0.9",
"@testing-library/vue": "8.0.3",
"@types/escape-regexp": "0.0.3",
"@types/estree": "1.0.5",
"@types/matter-js": "0.19.6",
"@types/micromatch": "4.0.6",
"@types/node": "20.11.17",
"@types/punycode": "2.1.3",
"@types/sanitize-html": "2.9.5",
"@types/micromatch": "4.0.7",
"@types/node": "20.12.7",
"@types/punycode": "2.1.4",
"@types/sanitize-html": "2.11.0",
"@types/seedrandom": "3.0.8",
"@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6",
"@types/uuid": "9.0.8",
"@types/ws": "8.5.10",
"@typescript-eslint/eslint-plugin": "6.18.1",
"@typescript-eslint/parser": "6.18.1",
"@typescript-eslint/eslint-plugin": "7.7.1",
"@typescript-eslint/parser": "7.7.1",
"@vitest/coverage-v8": "0.34.6",
"@vue/runtime-core": "3.4.18",
"@vue/runtime-core": "3.4.26",
"acorn": "8.11.3",
"cross-env": "7.0.3",
"cypress": "13.6.4",
"eslint": "8.56.0",
"cypress": "13.8.1",
"eslint": "8.57.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-vue": "9.20.1",
"eslint-plugin-vue": "9.25.0",
"fast-glob": "3.3.2",
"happy-dom": "10.0.3",
"intersection-observer": "0.12.2",
"micromatch": "4.0.5",
"msw": "2.1.7",
"msw-storybook-addon": "2.0.0-beta.1",
"nodemon": "3.0.3",
"msw": "2.2.14",
"msw-storybook-addon": "2.0.1",
"nodemon": "3.1.0",
"prettier": "3.2.5",
"react": "18.2.0",
"react-dom": "18.2.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"seedrandom": "3.0.5",
"start-server-and-test": "2.0.3",
"storybook": "8.0.0-beta.2",
"storybook": "8.0.9",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"vite-plugin-turbosnap": "1.0.3",
"vitest": "0.34.6",
"vitest-fetch-mock": "0.2.2",
"vue-component-type-helpers": "1.8.27",
"vue-component-type-helpers": "2.0.16",
"vue-eslint-parser": "9.4.2",
"vue-tsc": "1.8.27"
"vue-tsc": "2.0.16"
}
}

View File

@@ -6,7 +6,7 @@
// devモードで起動される際index.htmlを使うときはrouterが暴発してしまってうまく読み込めない。
// よって、devモードとして起動されるときはビルド時に組み込む形としておく。
// (pnpm start時はpugファイルの中で静的リソースとして読み込むようになっており、この問題は起こっていない)
import '@tabler/icons-webfont/tabler-icons.scss';
import '@tabler/icons-webfont/dist/tabler-icons.scss';
await main();

View File

@@ -120,7 +120,7 @@ function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Pr
res.json().then(done2, fail2);
}))
.then(async res => {
if (res.error) {
if ('error' in res) {
if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') {
// SUSPENDED
if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
@@ -290,7 +290,7 @@ export async function openAccountMenu(opts: {
text: i18n.ts.profile,
to: `/@${ $i.username }`,
avatar: $i,
}, { type: 'divider' }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
}, { type: 'divider' as const }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
type: 'parent' as const,
icon: 'ti ti-plus',
text: i18n.ts.addAccount,

View File

@@ -145,8 +145,11 @@ export async function common(createVue: () => App<Element>) {
// NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため)
watch(defaultStore.reactiveState.darkMode, (darkMode) => {
applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme'));
document.documentElement.dataset.colorMode = darkMode ? 'dark' : 'light';
}, { immediate: miLocalStorage.getItem('theme') == null });
document.documentElement.dataset.colorMode = defaultStore.state.darkMode ? 'dark' : 'light';
const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme'));
const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme'));

View File

@@ -11,6 +11,7 @@ import { alert, confirm, popup, post, toast } from '@/os.js';
import { useStream } from '@/stream.js';
import * as sound from '@/scripts/sound.js';
import { $i, signout, updateAccount } from '@/account.js';
import { instance } from '@/instance.js';
import { ColdDeviceStorage, defaultStore } from '@/store.js';
import { makeHotkey } from '@/scripts/hotkey.js';
import { reactionPicker } from '@/scripts/reaction-picker.js';
@@ -74,27 +75,31 @@ export async function mainBoot() {
mainRouter.push('/search');
},
};
if (defaultStore.state.enableSeasonalScreenEffect) {
const month = new Date().getMonth() + 1;
if (defaultStore.state.hemisphere === 'S') {
// ▼南半球
if (month === 7 || month === 8) {
const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect;
new SnowfallEffect({}).render();
try {
if (defaultStore.state.enableSeasonalScreenEffect) {
const month = new Date().getMonth() + 1;
if (defaultStore.state.hemisphere === 'S') {
// ▼南半球
if (month === 7 || month === 8) {
const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect;
new SnowfallEffect({}).render();
}
} else {
// ▼北半球
if (month === 12 || month === 1) {
const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect;
new SnowfallEffect({}).render();
} else if (month === 3 || month === 4) {
const SakuraEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect;
new SakuraEffect({
sakura: true,
}).render();
}
}
} else {
// ▼北半球
if (month === 12 || month === 1) {
const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect;
new SnowfallEffect({}).render();
} else if (month === 3 || month === 4) {
const SakuraEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect;
new SakuraEffect({
sakura: true,
}).render();
}
}
}
} catch (error) {
// console.error(error);
console.error('Failed to initialise the seasonal screen effect canvas context:', error);
}
if ($i) {
@@ -186,14 +191,26 @@ export async function mainBoot() {
if ($i.followersCount >= 500) claimAchievement('followers500');
if ($i.followersCount >= 1000) claimAchievement('followers1000');
if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365) {
claimAchievement('passedSinceAccountCreated1');
}
if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 2) {
claimAchievement('passedSinceAccountCreated2');
}
if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 3) {
const createdAt = new Date($i.createdAt);
const createdAtThreeYearsLater = new Date($i.createdAt);
createdAtThreeYearsLater.setFullYear(createdAtThreeYearsLater.getFullYear() + 3);
if (now >= createdAtThreeYearsLater) {
claimAchievement('passedSinceAccountCreated3');
claimAchievement('passedSinceAccountCreated2');
claimAchievement('passedSinceAccountCreated1');
} else {
const createdAtTwoYearsLater = new Date($i.createdAt);
createdAtTwoYearsLater.setFullYear(createdAtTwoYearsLater.getFullYear() + 2);
if (now >= createdAtTwoYearsLater) {
claimAchievement('passedSinceAccountCreated2');
claimAchievement('passedSinceAccountCreated1');
} else {
const createdAtOneYearLater = new Date($i.createdAt);
createdAtOneYearLater.setFullYear(createdAtOneYearLater.getFullYear() + 1);
if (now >= createdAtOneYearLater) {
claimAchievement('passedSinceAccountCreated1');
}
}
}
if (claimedAchievements.length >= 30) {
@@ -228,12 +245,17 @@ export async function mainBoot() {
const latestDonationInfoShownAt = miLocalStorage.getItem('latestDonationInfoShownAt');
const neverShowDonationInfo = miLocalStorage.getItem('neverShowDonationInfo');
if (neverShowDonationInfo !== 'true' && (new Date($i.createdAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3))) && !location.pathname.startsWith('/miauth')) {
if (neverShowDonationInfo !== 'true' && (createdAt.getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3))) && !location.pathname.startsWith('/miauth')) {
if (latestDonationInfoShownAt == null || (new Date(latestDonationInfoShownAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 30)))) {
popup(defineAsyncComponent(() => import('@/components/MkDonation.vue')), {}, {}, 'closed');
}
}
const modifiedVersionMustProminentlyOfferInAgplV3Section13Read = miLocalStorage.getItem('modifiedVersionMustProminentlyOfferInAgplV3Section13Read');
if (modifiedVersionMustProminentlyOfferInAgplV3Section13Read !== 'true' && instance.repositoryUrl !== 'https://github.com/misskey-dev/misskey') {
popup(defineAsyncComponent(() => import('@/components/MkSourceCodeAvailablePopup.vue')), {}, {}, 'closed');
}
if ('Notification' in window) {
// 許可を得ていなかったらリクエスト
if (Notification.permission === 'default') {

View File

@@ -11,3 +11,4 @@ export const clipsCache = new Cache<Misskey.entities.Clip[]>(1000 * 60 * 30, ()
export const rolesCache = new Cache(1000 * 60 * 30, () => misskeyApi('admin/roles/list'));
export const userListsCache = new Cache<Misskey.entities.UserList[]>(1000 * 60 * 30, () => misskeyApi('users/lists/list'));
export const antennasCache = new Cache<Misskey.entities.Antenna[]>(1000 * 60 * 30, () => misskeyApi('antennas/list'));
export const favoritedChannelsCache = new Cache<Misskey.entities.Channel[]>(1000 * 60 * 30, () => misskeyApi('channels/my-favorites', { limit: 100 }));

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div class="bcekxzvu _margin _panel">
<div class="target">
<MkA v-user-preview="report.targetUserId" class="info" :to="`/admin/user/${report.targetUserId}`">
<MkA v-user-preview="report.targetUserId" class="info" :to="`/admin/user/${report.targetUserId}`" :behavior="'window'">
<MkAvatar class="avatar" :user="report.targetUser" indicator/>
<div class="names">
<MkUserName class="name" :user="report.targetUser"/>
@@ -20,10 +20,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div class="detail">
<div>
<Mfm :text="report.comment"/>
<Mfm :text="report.comment" :linkNavigationBehavior="'window'"/>
</div>
<hr/>
<div>{{ i18n.ts.reporter }}: <MkA :to="`/admin/user/${report.reporter.id}`" class="_link">@{{ report.reporter.username }}</MkA></div>
<div>{{ i18n.ts.reporter }}: <MkA :to="`/admin/user/${report.reporter.id}`" class="_link" :behavior="'window'">@{{ report.reporter.username }}</MkA></div>
<div v-if="report.assignee">
{{ i18n.ts.moderator }}:
<MkAcct :user="report.assignee"/>

View File

@@ -4,7 +4,10 @@
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { action } from '@storybook/addon-actions';
import { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { commonHandlers } from '../../.storybook/mocks.js';
import { userDetailed } from '../../.storybook/fakes.js';
import MkAccountMoved from './MkAccountMoved.vue';
export const Default = {
@@ -29,10 +32,18 @@ export const Default = {
};
},
args: {
username: userDetailed().username,
host: userDetailed().host,
movedTo: userDetailed().id,
},
parameters: {
layout: 'centered',
msw: {
handlers: [
...commonHandlers,
http.post('/api/users/show', async ({ request }) => {
action('POST /api/users/show')(await request.json());
return HttpResponse.json(userDetailed());
}),
],
},
},
} satisfies StoryObj<typeof MkAccountMoved>;

View File

@@ -4,7 +4,10 @@
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { action } from '@storybook/addon-actions';
import { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkAnnouncementDialog from './MkAnnouncementDialog.vue';
export const Default = {
render(args) {
@@ -23,8 +26,13 @@ export const Default = {
...this.args,
};
},
events() {
return {
closed: action('closed'),
};
},
},
template: '<MkAnnouncementDialog v-bind="props" />',
template: '<MkAnnouncementDialog v-bind="props" v-on="events" />',
};
},
args: {
@@ -38,10 +46,20 @@ export const Default = {
imageUrl: null,
display: 'dialog',
needConfirmationToRead: false,
silence: false,
forYou: true,
},
},
parameters: {
layout: 'centered',
msw: {
handlers: [
...commonHandlers,
http.post('/api/i/read-announcement', async ({ request }) => {
action('POST /api/i/read-announcement')(await request.json());
return HttpResponse.json();
}),
],
},
},
} satisfies StoryObj<typeof MkAnnouncementDialog>;

View File

@@ -44,6 +44,8 @@ SPDX-License-Identifier: AGPL-3.0-only
:instant="true"
:initialText="c.form?.text"
:initialCw="c.form?.cw"
:initialVisibility="c.form?.visibility"
:initialLocalOnly="c.form?.localOnly"
/>
</div>
<MkFolder v-else-if="c.type === 'folder'" :defaultOpen="c.opened">
@@ -111,6 +113,8 @@ function openPostForm() {
os.post({
initialText: form.text,
initialCw: form.cw,
initialVisibility: form.visibility,
initialLocalOnly: form.localOnly,
instant: true,
});
}

View File

@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</ol>
<ol v-else-if="emojis.length > 0" ref="suggests" :class="$style.list">
<li v-for="emoji in emojis" :key="emoji.emoji" :class="$style.item" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown">
<MkCustomEmoji v-if="'isCustomEmoji' in emoji && emoji.isCustomEmoji" :name="emoji.emoji" :class="$style.emoji"/>
<MkCustomEmoji v-if="'isCustomEmoji' in emoji && emoji.isCustomEmoji" :name="emoji.emoji" :class="$style.emoji" :fallbackToImage="true"/>
<MkEmoji v-else :emoji="emoji.emoji" :class="$style.emoji"/>
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-if="q" :class="$style.emojiName" v-html="sanitizeHtml(emoji.name.replace(q, `<b>${q}</b>`))"></span>
@@ -57,18 +57,7 @@ import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js';
import { customEmojis } from '@/custom-emojis.js';
import { MFM_TAGS, MFM_PARAMS } from '@/const.js';
type EmojiDef = {
emoji: string;
name: string;
url: string;
aliasOf?: string;
} | {
emoji: string;
name: string;
aliasOf?: string;
isCustomEmoji?: true;
};
import { searchEmoji, EmojiDef } from '@/scripts/search-emoji.js';
const lib = emojilist.filter(x => x.category !== 'flags');
@@ -88,7 +77,7 @@ const emojiDb = computed(() => {
unicodeEmojiDB.push({
emoji: emoji,
name: k,
aliasOf: getEmojiName(emoji)!,
aliasOf: getEmojiName(emoji),
url: char2path(emoji),
});
}
@@ -249,7 +238,7 @@ function exec() {
return;
}
emojis.value = emojiAutoComplete(props.q, emojiDb.value);
emojis.value = searchEmoji(props.q, emojiDb.value);
} else if (props.type === 'mfmTag') {
if (!props.q || props.q === '') {
mfmTags.value = MFM_TAGS;
@@ -267,87 +256,6 @@ function exec() {
}
}
type EmojiScore = { emoji: EmojiDef, score: number };
function emojiAutoComplete(query: string | null, emojiDb: EmojiDef[], max = 30): EmojiDef[] {
if (!query) {
return [];
}
const matched = new Map<string, EmojiScore>();
// 完全一致(エイリアス込み)
emojiDb.some(x => {
if (x.name === query && !matched.has(x.aliasOf ?? x.name)) {
matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length + 2 });
}
return matched.size === max;
});
// 前方一致(エイリアスなし)
if (matched.size < max) {
emojiDb.some(x => {
if (x.name.startsWith(query) && !x.aliasOf) {
matched.set(x.name, { emoji: x, score: query.length + 1 });
}
return matched.size === max;
});
}
// 前方一致(エイリアス込み)
if (matched.size < max) {
emojiDb.some(x => {
if (x.name.startsWith(query) && !matched.has(x.aliasOf ?? x.name)) {
matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length });
}
return matched.size === max;
});
}
// 部分一致(エイリアス込み)
if (matched.size < max) {
emojiDb.some(x => {
if (x.name.includes(query) && !matched.has(x.aliasOf ?? x.name)) {
matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length - 1 });
}
return matched.size === max;
});
}
// 簡易あいまい検索3文字以上
if (matched.size < max && query.length > 3) {
const queryChars = [...query];
const hitEmojis = new Map<string, EmojiScore>();
for (const x of emojiDb) {
// 文字列の位置を進めながら、クエリの文字を順番に探す
let pos = 0;
let hit = 0;
for (const c of queryChars) {
pos = x.name.indexOf(c, pos);
if (pos <= -1) break;
hit++;
}
// 半分以上の文字が含まれていればヒットとする
if (hit > Math.ceil(queryChars.length / 2) && hit - 2 > (matched.get(x.aliasOf ?? x.name)?.score ?? 0)) {
hitEmojis.set(x.aliasOf ?? x.name, { emoji: x, score: hit - 2 });
}
}
// ヒットしたものを全部追加すると雑多になるので、先頭の6件程度だけにしておく6件オートコンプリートのポップアップのサイズ分
[...hitEmojis.values()]
.sort((x, y) => y.score - x.score)
.slice(0, 6)
.forEach(it => matched.set(it.emoji.name, it));
}
return [...matched.values()]
.sort((x, y) => y.score - x.score)
.slice(0, max)
.map(it => it.emoji);
}
function onMousedown(event: Event) {
if (!contains(rootEl.value, event.target) && (rootEl.value !== event.target)) props.close();
}

View File

@@ -11,6 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:type="type"
:name="name"
:value="value"
:disabled="disabled"
@click="emit('click', $event)"
@mousedown="onMousedown"
>
@@ -23,6 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
v-else class="_button"
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]"
:to="to ?? '#'"
:behavior="linkBehavior"
@mousedown="onMousedown"
>
<div ref="ripples" :class="$style.ripples" :data-children-class="$style.ripple"></div>
@@ -43,6 +45,7 @@ const props = defineProps<{
inline?: boolean;
link?: boolean;
to?: string;
linkBehavior?: null | 'window' | 'browser';
autofocus?: boolean;
wait?: boolean;
danger?: boolean;
@@ -53,6 +56,7 @@ const props = defineProps<{
asLike?: boolean;
name?: string;
value?: string;
disabled?: boolean;
}>();
const emit = defineEmits<{

View File

@@ -104,7 +104,6 @@ async function requestRender() {
});
} else if (props.provider === 'mcaptcha' && props.instanceUrl && props.sitekey) {
const { default: Widget } = await import('@mcaptcha/vanilla-glue');
// @ts-expect-error avoid typecheck error
new Widget({
siteKey: {
instanceUrl: new URL(props.instanceUrl),

View File

@@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { action } from '@storybook/addon-actions';
import { expect, userEvent, within } from '@storybook/test';
import { channel } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkChannelFollowButton from './MkChannelFollowButton.vue';
import { semaphore } from '@/scripts/test-utils.js';
import { i18n } from '@/i18n.js';
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
const s = semaphore();
export const Default = {
render(args) {
return {
components: {
MkChannelFollowButton,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkChannelFollowButton v-bind="props" />',
};
},
args: {
channel: channel(),
full: true,
},
async play({ canvasElement }) {
await s.acquire();
await sleep(1000);
const canvas = within(canvasElement);
const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
await expect(buttonElement).toHaveTextContent(i18n.ts.follow);
await userEvent.click(buttonElement);
await sleep(1000);
await expect(buttonElement).toHaveTextContent(i18n.ts.unfollow);
await sleep(100);
await userEvent.click(buttonElement);
s.release();
},
parameters: {
layout: 'centered',
msw: {
handlers: [
...commonHandlers,
http.post('/api/channels/follow', async ({ request }) => {
action('POST /api/channels/follow')(await request.json());
return HttpResponse.json({});
}),
http.post('/api/channels/unfollow', async ({ request }) => {
action('POST /api/channels/unfollow')(await request.json());
return HttpResponse.json({});
}),
],
},
},
} satisfies StoryObj<typeof MkChannelFollowButton>;

View File

@@ -26,17 +26,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
const props = withDefaults(defineProps<{
channel: Record<string, any>;
channel: Misskey.entities.Channel;
full?: boolean;
}>(), {
full: false,
});
const isFollowing = ref<boolean>(props.channel.isFollowing);
const isFollowing = ref(props.channel.isFollowing);
const wait = ref(false);
async function onClick() {

View File

@@ -0,0 +1,65 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { action } from '@storybook/addon-actions';
import { channel } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkChannelList from './MkChannelList.vue';
export const Default = {
render(args) {
return {
components: {
MkChannelList,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkChannelList v-bind="props" />',
};
},
args: {
pagination: {
endpoint: 'channels/search',
limit: 10,
},
},
parameters: {
chromatic: {
// NOTE: ロードが終わるまで待つ
delay: 3000,
},
layout: 'fullscreen',
msw: {
handlers: [
...commonHandlers,
http.post('/api/channels/search', async ({ request, params }) => {
action('POST /api/channels/search')(await request.json());
return HttpResponse.json(params.untilId === 'lastchannel' ? [] : [
channel(),
channel('lastchannel', 'Last Channel', null),
]);
}),
],
},
},
decorators: [
() => ({
template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 700px; width: 100%; margin: 3rem"><story/></div></div>',
}),
],
} satisfies StoryObj<typeof MkChannelList>;

View File

@@ -0,0 +1,43 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { channel } from '../../.storybook/fakes.js';
import MkChannelPreview from './MkChannelPreview.vue';
export const Default = {
render(args) {
return {
components: {
MkChannelPreview,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkChannelPreview v-bind="props" />',
};
},
args: {
channel: channel(),
},
parameters: {
layout: 'fullscreen',
},
decorators: [
() => ({
template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 700px; width: 100%; margin: 3rem"><story/></div></div>',
}),
],
} satisfies StoryObj<typeof MkChannelPreview>;

View File

@@ -0,0 +1,80 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { http } from 'msw';
import { commonHandlers } from '../../.storybook/mocks.js';
import { getChartResolver } from '../../.storybook/charts.js';
import MkChart from './MkChart.vue';
const Base = {
render(args) {
return {
components: {
MkChart,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkChart v-bind="props" />',
};
},
args: {
src: 'federation',
span: 'hour',
nowForChromatic: 1716263640000,
},
parameters: {
layout: 'centered',
msw: {
handlers: [
...commonHandlers,
http.get('/api/charts/federation', getChartResolver(
['deliveredInstances', 'inboxInstances', 'stalled', 'sub', 'pub', 'pubsub', 'subActive', 'pubActive'],
)),
http.get('/api/charts/notes', getChartResolver(
['local.total', 'remote.total'],
{ accumulate: true },
)),
http.get('/api/charts/drive', getChartResolver(
['local.incSize', 'local.decSize', 'remote.incSize', 'remote.decSize'],
{ mulMap: { 'local.incSize': 1e7, 'local.decSize': 5e6, 'remote.incSize': 1e6, 'remote.decSize': 5e5 } },
)),
],
},
},
} satisfies StoryObj<typeof MkChart>;
export const FederationChart = {
...Base,
args: {
...Base.args,
src: 'federation',
},
} satisfies StoryObj<typeof MkChart>;
export const NotesTotalChart = {
...Base,
args: {
...Base.args,
src: 'notes-total',
},
} satisfies StoryObj<typeof MkChart>;
export const DriveChart = {
...Base,
args: {
...Base.args,
src: 'drive',
},
} satisfies StoryObj<typeof MkChart>;

View File

@@ -19,8 +19,9 @@ SPDX-License-Identifier: AGPL-3.0-only
id-denylist violation when setting it. This is causing about 60+ lint issues.
As this is part of Chart.js's API it makes sense to disable the check here.
*/
import { onMounted, ref, shallowRef, watch, PropType } from 'vue';
import { onMounted, ref, shallowRef, watch } from 'vue';
import { Chart } from 'chart.js';
import * as Misskey from 'misskey-js';
import { misskeyApiGet } from '@/scripts/misskey-api.js';
import { defaultStore } from '@/store.js';
import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
@@ -34,44 +35,63 @@ import MkChartLegend from '@/components/MkChartLegend.vue';
initChart();
const props = defineProps({
src: {
type: String,
required: true,
},
args: {
type: Object,
required: false,
},
limit: {
type: Number,
required: false,
default: 90,
},
span: {
type: String as PropType<'hour' | 'day'>,
required: true,
},
detailed: {
type: Boolean,
required: false,
default: false,
},
stacked: {
type: Boolean,
required: false,
default: false,
},
bar: {
type: Boolean,
required: false,
default: false,
},
aspectRatio: {
type: Number,
required: false,
default: null,
},
type ChartSrc =
| 'federation'
| 'ap-request'
| 'users'
| 'users-total'
| 'active-users'
| 'notes'
| 'local-notes'
| 'remote-notes'
| 'notes-total'
| 'drive'
| 'drive-files'
| 'instance-requests'
| 'instance-users'
| 'instance-users-total'
| 'instance-notes'
| 'instance-notes-total'
| 'instance-ff'
| 'instance-ff-total'
| 'instance-drive-usage'
| 'instance-drive-usage-total'
| 'instance-drive-files'
| 'instance-drive-files-total'
| 'per-user-notes'
| 'per-user-pv'
| 'per-user-following'
| 'per-user-followers'
| 'per-user-drive'
const props = withDefaults(defineProps<{
src: ChartSrc;
args?: {
host?: string;
user?: Misskey.entities.UserLite;
withoutAll?: boolean;
};
limit?: number;
span: 'hour' | 'day';
detailed?: boolean;
stacked?: boolean;
bar?: boolean;
aspectRatio?: number | null;
nowForChromatic?: number;
}>(), {
args: undefined,
limit: 90,
detailed: false,
stacked: false,
bar: false,
aspectRatio: null,
/**
* @desc Overwrites current date to fix background lines of chart.
* @ignore Only used for Chromatic. Don't use this for production.
* @see https://github.com/misskey-dev/misskey/pull/13830#issuecomment-2155886151
*/
nowForChromatic: undefined,
});
const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>();
@@ -94,7 +114,8 @@ const getColor = (i) => {
return colorSets[i % colorSets.length];
};
const now = new Date();
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
const now = props.nowForChromatic != null ? new Date(props.nowForChromatic) : new Date();
let chartInstance: Chart | null = null;
let chartData: {
series: {
@@ -240,7 +261,7 @@ const render = () => {
},
external: externalTooltipHandler,
callbacks: {
label: (item) => chartData?.bytes ? bytes(item.parsed.y * 1000, 1) : item.parsed.y.toString(),
label: (item) => `${item.dataset.label}: ${chartData?.bytes ? bytes(item.parsed.y * 1000, 1) : item.parsed.y.toString()}`,
},
},
zoom: props.detailed ? {

View File

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

View File

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

View File

@@ -0,0 +1,79 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { action } from '@storybook/addon-actions';
import { expect, within } from '@storybook/test';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkClickerGame from './MkClickerGame.vue';
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export const Default = {
render(args) {
return {
components: {
MkClickerGame,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkClickerGame v-bind="props" />',
};
},
async play({ canvasElement }) {
await sleep(1000);
const canvas = within(canvasElement);
const count = canvas.getByTestId('count');
// NOTE: flaky なので N/A も通しておく
await expect(count).toHaveTextContent(/^(0|N\/A)$/);
// FIXME: flaky
// const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
// await userEvent.click(buttonElement);
// await expect(count).toHaveTextContent('1');
},
parameters: {
layout: 'centered',
msw: {
handlers: [
...commonHandlers,
http.post('/api/i/registry/get', async ({ request }) => {
action('POST /api/i/registry/get')(await request.json());
return HttpResponse.json({
error: {
message: 'No such key.',
code: 'NO_SUCH_KEY',
id: 'ac3ed68a-62f0-422b-a7bc-d5e09e8f6a6a',
},
}, {
status: 400,
});
}),
http.post('/api/i/registry/set', async ({ request }) => {
action('POST /api/i/registry/set')(await request.json());
return HttpResponse.json(undefined, { status: 204 });
}),
http.post('/api/i/claim-achievement', async ({ request }) => {
action('POST /api/i/claim-achievement')(await request.json());
return HttpResponse.json(undefined, { status: 204 });
}),
],
},
},
} satisfies StoryObj<typeof MkClickerGame>;

View File

@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div>
<div v-if="game.ready" :class="$style.game">
<div :class="$style.cps" class="">{{ number(cps) }}cps</div>
<div :class="$style.count" class=""><i class="ti ti-cookie" style="font-size: 70%;"></i> {{ number(cookies) }}</div>
<div :class="$style.count" class="" data-testid="count"><i class="ti ti-cookie" style="font-size: 70%;"></i> {{ number(cookies) }}</div>
<button v-click-anime class="_button" @click="onClick">
<img src="/client-assets/cookie.png" :class="$style.img">
</button>

View File

@@ -0,0 +1,43 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { clip } from '../../.storybook/fakes.js';
import MkClipPreview from './MkClipPreview.vue';
export const Default = {
render(args) {
return {
components: {
MkClipPreview,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkClipPreview v-bind="props" />',
};
},
args: {
clip: clip(),
},
parameters: {
layout: 'fullscreen',
},
decorators: [
() => ({
template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 700px; width: 100%; margin: 3rem"><story/></div></div>',
}),
],
} satisfies StoryObj<typeof MkClipPreview>;

View File

@@ -4,37 +4,59 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root" class="_panel">
<b>{{ clip.name }}</b>
<div v-if="clip.description" :class="$style.description">{{ clip.description }}</div>
<div v-if="clip.lastClippedAt">{{ i18n.ts.updatedAt }}: <MkTime :time="clip.lastClippedAt" mode="detail"/></div>
<div :class="$style.user">
<MkAvatar :user="clip.user" :class="$style.userAvatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/>
<MkA :to="`/clips/${clip.id}`" :class="$style.link">
<div :class="$style.root" class="_panel _gaps_s">
<b>{{ clip.name }}</b>
<div :class="$style.description">
<div v-if="clip.description"><Mfm :text="clip.description" :plain="true" :nowrap="true"/></div>
<div v-if="clip.lastClippedAt">{{ i18n.ts.updatedAt }}: <MkTime :time="clip.lastClippedAt" mode="detail"/></div>
<div v-if="clip.notesCount != null">{{ i18n.ts.notesCount }}: {{ number(clip.notesCount) }} / {{ $i?.policies.noteEachClipsLimit }} ({{ i18n.tsx.remainingN({ n: remaining }) }})</div>
</div>
<div :class="$style.divider"></div>
<div>
<MkAvatar :user="clip.user" :class="$style.userAvatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/>
</div>
</div>
</div>
</MkA>
</template>
<script lang="ts" setup>
import * as Misskey from 'misskey-js';
import { computed } from 'vue';
import { i18n } from '@/i18n.js';
import { $i } from '@/account.js';
import number from '@/filters/number.js';
defineProps<{
clip: any;
const props = defineProps<{
clip: Misskey.entities.Clip;
}>();
const remaining = computed(() => {
return ($i?.policies && props.clip.notesCount != null) ? ($i.policies.noteEachClipsLimit - props.clip.notesCount) : i18n.ts.unknown;
});
</script>
<style lang="scss" module>
.root {
.link {
display: block;
&:hover {
text-decoration: none;
color: var(--accent);
}
}
.root {
padding: 16px;
}
.description {
padding: 8px 0;
.divider {
height: 1px;
background: var(--divider);
}
.user {
padding-top: 16px;
border-top: solid 0.5px var(--divider);
.description {
font-size: 90%;
}
.userAvatar {

View File

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

View File

@@ -9,9 +9,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { ref, computed, watch } from 'vue';
import { bundledLanguagesInfo } from 'shiki';
import type { BuiltinLanguage } from 'shiki';
import { computed, ref, watch } from 'vue';
import { bundledLanguagesInfo } from 'shiki/langs';
import type { BundledLanguage } from 'shiki/langs';
import { getHighlighter, getTheme } from '@/scripts/code-highlighter.js';
import { defaultStore } from '@/store.js';
@@ -23,7 +23,7 @@ const props = defineProps<{
const highlighter = await getHighlighter();
const darkMode = defaultStore.reactiveState.darkMode;
const codeLang = ref<BuiltinLanguage | 'aiscript'>('js');
const codeLang = ref<BundledLanguage | 'aiscript'>('js');
const [lightThemeName, darkThemeName] = await Promise.all([
getTheme('light', true),
@@ -42,7 +42,7 @@ const html = computed(() => highlighter.codeToHtml(props.code, {
}));
async function fetchLanguage(to: string): Promise<void> {
const language = to as BuiltinLanguage;
const language = to as BundledLanguage;
// Check for the loaded languages, and load the language if it's not loaded yet.
if (!highlighter.getLoadedLanguages().includes(language)) {
@@ -52,7 +52,7 @@ async function fetchLanguage(to: string): Promise<void> {
return bundle.id === language || bundle.aliases?.includes(language);
});
if (bundles.length > 0) {
console.log(`Loading language: ${language}`);
if (_DEV_) console.log(`Loading language: ${language}`);
await highlighter.loadLanguage(bundles[0].import);
codeLang.value = language;
} else {

View File

@@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import MkCode from './MkCode.vue';
const code = `for (let i, 100) {
<: if (i % 15 == 0) "FizzBuzz"
elif (i % 3 == 0) "Fizz"
elif (i % 5 == 0) "Buzz"
else i
}`;
export const Default = {
render(args) {
return {
components: {
MkCode,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkCode v-bind="props" />',
};
},
args: {
code,
lang: 'is',
},
parameters: {
layout: 'centered',
},
} satisfies StoryObj<typeof MkCode>;

View File

@@ -80,11 +80,9 @@ function copy() {
.codePlaceholderRoot {
display: block;
width: 100%;
background: none;
border: none;
outline: none;
font: inherit;
color: inherit;
cursor: pointer;
box-sizing: border-box;

View File

@@ -0,0 +1,62 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { action } from '@storybook/addon-actions';
import MkCodeEditor from './MkCodeEditor.vue';
const code = `for (let i, 100) {
<: if (i % 15 == 0) "FizzBuzz"
elif (i % 3 == 0) "Fizz"
elif (i % 5 == 0) "Buzz"
else i
}`;
export const Default = {
render(args) {
return {
components: {
MkCodeEditor,
},
data() {
return {
code,
};
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
events() {
return {
'change': action('change'),
'keydown': action('keydown'),
'enter': action('enter'),
'update:modelValue': action('update:modelValue'),
};
},
},
template: '<MkCodeEditor v-model="code" v-bind="props" v-on="events" />',
};
},
args: {
lang: 'aiscript',
},
parameters: {
layout: 'fullscreen',
},
decorators: [
() => ({
template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 800px; width: 100%; margin: 3rem"><Suspense><story/></Suspense></div></div>',
}),
],
} satisfies StoryObj<typeof MkCodeEditor>;

View File

@@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import MkCodeInline from './MkCodeInline.vue';
export const Default = {
render(args) {
return {
components: {
MkCodeInline,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkCodeInline v-bind="props"/>',
};
},
args: {
code: '<: "Hello, world!"',
},
parameters: {
layout: 'centered',
},
} satisfies StoryObj<typeof MkCodeInline>;

View File

@@ -0,0 +1,50 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { action } from '@storybook/addon-actions';
import MkColorInput from './MkColorInput.vue';
export const Default = {
render(args) {
return {
components: {
MkColorInput,
},
data() {
return {
color: '#cccccc',
};
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
events() {
return {
'update:modelValue': action('update:modelValue'),
};
},
},
template: '<MkColorInput v-model="color" v-bind="props" v-on="events" />',
};
},
parameters: {
layout: 'fullscreen',
},
decorators: [
() => ({
template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 800px; width: 100%; margin: 3rem"><story/></div></div>',
}),
],
} satisfies StoryObj<typeof MkColorInput>;

View File

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

View File

@@ -0,0 +1,58 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { userEvent, within } from '@storybook/test';
import MkContextMenu from './MkContextMenu.vue';
import * as os from '@/os.js';
export const Empty = {
render(args) {
return {
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
methods: {
onContextmenu(ev: MouseEvent) {
os.contextMenu(args.items, ev);
},
},
template: '<div @contextmenu.stop="onContextmenu">Right Click Here</div>',
};
},
args: {
items: [],
},
async play({ canvasElement }) {
const canvas = within(canvasElement);
const target = canvas.getByText('Right Click Here');
await userEvent.pointer({ keys: '[MouseRight>]', target });
},
parameters: {
layout: 'centered',
},
} satisfies StoryObj<typeof MkContextMenu>;
export const SomeTabs = {
...Empty,
args: {
items: [
{
text: 'Home',
icon: 'ti ti-home',
action() {},
},
],
},
} satisfies StoryObj<typeof MkContextMenu>;

View File

@@ -47,12 +47,12 @@ onMounted(() => {
const width = rootEl.value!.offsetWidth;
const height = rootEl.value!.offsetHeight;
if (left + width - window.pageXOffset >= (window.innerWidth - SCROLLBAR_THICKNESS)) {
left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.pageXOffset;
if (left + width - window.scrollX >= (window.innerWidth - SCROLLBAR_THICKNESS)) {
left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.scrollX;
}
if (top + height - window.pageYOffset >= (window.innerHeight - SCROLLBAR_THICKNESS)) {
top = (window.innerHeight - SCROLLBAR_THICKNESS) - height + window.pageYOffset;
if (top + height - window.scrollY >= (window.innerHeight - SCROLLBAR_THICKNESS)) {
top = (window.innerHeight - SCROLLBAR_THICKNESS) - height + window.scrollY;
}
if (top < 0) {

View File

@@ -0,0 +1,75 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { action } from '@storybook/addon-actions';
import { file } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkCropperDialog from './MkCropperDialog.vue';
export const Default = {
render(args) {
return {
components: {
MkCropperDialog,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
events() {
return {
'ok': action('ok'),
'cancel': action('cancel'),
'closed': action('closed'),
};
},
},
template: '<MkCropperDialog v-bind="props" v-on="events" />',
};
},
args: {
file: file(),
aspectRatio: NaN,
},
parameters: {
chromatic: {
// NOTE: ロードが終わるまで待つ
delay: 3000,
},
layout: 'centered',
msw: {
handlers: [
...commonHandlers,
http.get('/proxy/image.webp', async ({ request }) => {
const url = new URL(request.url).searchParams.get('url');
if (url === 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true') {
const image = await (await fetch('client-assets/fedi.jpg')).blob();
return new HttpResponse(image, {
headers: {
'Content-Type': 'image/jpeg',
},
});
} else {
return new HttpResponse(null, { status: 404 });
}
}),
http.post('/api/drive/files/create', async ({ request }) => {
action('POST /api/drive/files/create')(await request.formData());
return HttpResponse.json(file());
}),
],
},
},
} satisfies StoryObj<typeof MkCropperDialog>;

View File

@@ -0,0 +1,38 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { emojiDetailed } from '../../.storybook/fakes.js';
import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue';
export const Default = {
render(args) {
return {
components: {
MkCustomEmojiDetailedDialog,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkCustomEmojiDetailedDialog v-bind="props" />',
};
},
args: {
emoji: emojiDetailed(),
},
parameters: {
layout: 'centered',
},
} satisfies StoryObj<typeof MkCustomEmojiDetailedDialog>;

View File

@@ -4,77 +4,81 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow ref="dialogEl" @close="cancel()" @closed="$emit('closed')">
<template #header>:{{ emoji.name }}:</template>
<template #default>
<MkSpacer>
<div style="display: flex; flex-direction: column; gap: 1em;">
<div :class="$style.emojiImgWrapper">
<MkCustomEmoji :name="emoji.name" :normal="true" :useOriginalSize="true" style="height: 100%;"></MkCustomEmoji>
</div>
<MkKeyValue :copy="`:${emoji.name}:`">
<template #key>{{ i18n.ts.name }}</template>
<template #value>{{ emoji.name }}</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.tags }}</template>
<template #value>
<div v-if="emoji.aliases.length === 0">{{ i18n.ts.none }}</div>
<div v-else :class="$style.aliases">
<span v-for="alias in emoji.aliases" :key="alias" :class="$style.alias">
{{ alias }}
</span>
</div>
</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.category }}</template>
<template #value>{{ emoji.category ?? i18n.ts.none }}</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.sensitive }}</template>
<template #value>{{ emoji.isSensitive ? i18n.ts.yes : i18n.ts.no }}</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.localOnly }}</template>
<template #value>{{ emoji.localOnly ? i18n.ts.yes : i18n.ts.no }}</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.license }}</template>
<template #value><Mfm :text="emoji.license ?? i18n.ts.none" /></template>
</MkKeyValue>
<MkKeyValue :copy="emoji.url">
<template #key>{{ i18n.ts.emojiUrl }}</template>
<template #value>
<MkLink :url="emoji.url" target="_blank">{{ emoji.url }}</MkLink>
</template>
</MkKeyValue>
</div>
</MkSpacer>
</template>
</MkModalWindow>
<MkModalWindow ref="dialogEl" @close="cancel()" @closed="$emit('closed')">
<template #header>:{{ emoji.name }}:</template>
<template #default>
<MkSpacer>
<div style="display: flex; flex-direction: column; gap: 1em;">
<div :class="$style.emojiImgWrapper">
<MkCustomEmoji :name="emoji.name" :normal="true" :useOriginalSize="true" style="height: 100%;"></MkCustomEmoji>
</div>
<MkKeyValue :copy="`:${emoji.name}:`">
<template #key>{{ i18n.ts.name }}</template>
<template #value>{{ emoji.name }}</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.tags }}</template>
<template #value>
<div v-if="emoji.aliases.length === 0">{{ i18n.ts.none }}</div>
<div v-else :class="$style.aliases">
<span v-for="alias in emoji.aliases" :key="alias" :class="$style.alias">
{{ alias }}
</span>
</div>
</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.category }}</template>
<template #value>{{ emoji.category ?? i18n.ts.none }}</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.sensitive }}</template>
<template #value>{{ emoji.isSensitive ? i18n.ts.yes : i18n.ts.no }}</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.localOnly }}</template>
<template #value>{{ emoji.localOnly ? i18n.ts.yes : i18n.ts.no }}</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.license }}</template>
<template #value><Mfm :text="emoji.license ?? i18n.ts.none"/></template>
</MkKeyValue>
<MkKeyValue :copy="emoji.url">
<template #key>{{ i18n.ts.emojiUrl }}</template>
<template #value>
<MkLink :url="emoji.url" target="_blank">{{ emoji.url }}</MkLink>
</template>
</MkKeyValue>
</div>
</MkSpacer>
</template>
</MkModalWindow>
</template>
<script lang="ts" setup>
import * as Misskey from 'misskey-js';
import { defineProps, shallowRef } from 'vue';
import MkLink from '@/components/MkLink.vue';
import { i18n } from '@/i18n.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import MkLink from './MkLink.vue';
const props = defineProps<{
emoji: Misskey.entities.EmojiDetailed,
}>();
const emit = defineEmits<{
(ev: 'ok', cropped: Misskey.entities.DriveFile): void;
(ev: 'cancel'): void;
(ev: 'closed'): void;
}>();
const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
const cancel = () => {
function cancel() {
emit('cancel');
dialogEl.value!.close();
};
}
</script>
<style lang="scss" module>

View File

@@ -0,0 +1,89 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { action } from '@storybook/addon-actions';
import { expect, userEvent, within } from '@storybook/test';
import { file } from '../../.storybook/fakes.js';
import MkCwButton from './MkCwButton.vue';
import { i18n } from '@/i18n.js';
import { semaphore } from '@/scripts/test-utils.js';
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
const s = semaphore();
export const Default = {
render(args) {
return {
components: {
MkCwButton,
},
data() {
return {
showContent: false,
};
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
events() {
return {
'update:modelValue': action('update:modelValue'),
};
},
},
template: '<MkCwButton v-model="showContent" v-bind="props" v-on="events" />',
};
},
args: {
text: 'Some CW content',
},
async play({ canvasElement }) {
await s.acquire();
await sleep(1000);
const canvas = within(canvasElement);
const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
await expect(buttonElement).toHaveTextContent(i18n.ts._cw.show);
await expect(buttonElement).toHaveTextContent(i18n.tsx._cw.chars({ count: 15 }));
await userEvent.click(buttonElement);
await expect(buttonElement).toHaveTextContent(i18n.ts._cw.hide);
await userEvent.click(buttonElement);
s.release();
},
parameters: {
chromatic: {
// NOTE: テストが終わるまで待つ
delay: 5000,
},
layout: 'centered',
},
} satisfies StoryObj<typeof MkCwButton>;
export const IncludesTextAndDriveFile = {
...Default,
args: {
text: 'Some CW content',
files: [file()],
},
async play({ canvasElement }) {
const canvas = within(canvasElement);
const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
await expect(buttonElement).toHaveTextContent(i18n.tsx._cw.chars({ count: 15 }));
await expect(buttonElement).toHaveTextContent(' / ');
await expect(buttonElement).toHaveTextContent(i18n.tsx._cw.files({ count: 1 }));
},
} satisfies StoryObj<typeof MkCwButton>;

View File

@@ -130,7 +130,7 @@ export default defineComponent({
el.style.left = '';
}
// eslint-disable-next-line vue/no-setup-props-destructure
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
const classes = {
[$style['date-separated-list']]: true,
[$style['date-separated-list-nogap']]: props.noGap,

View File

@@ -38,11 +38,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-if="select.items">
<option v-for="item in select.items" :value="item.value">{{ item.text }}</option>
</template>
<template v-else>
<optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label">
<option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option>
</optgroup>
</template>
</MkSelect>
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
<MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabledReason" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
@@ -64,7 +59,7 @@ import MkSelect from '@/components/MkSelect.vue';
import { i18n } from '@/i18n.js';
type Input = {
type: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local';
type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local';
placeholder?: string | null;
autocomplete?: string;
default: string | number | null;
@@ -74,22 +69,17 @@ type Input = {
type Select = {
items: {
value: string;
value: any;
text: string;
}[];
groupedItems: {
label: string;
items: {
value: string;
text: string;
}[];
}[];
default: string | null;
};
type Result = string | number | true | null;
const props = withDefaults(defineProps<{
type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting';
title: string;
title?: string;
text?: string;
input?: Input;
select?: Select;
@@ -113,7 +103,7 @@ const props = withDefaults(defineProps<{
});
const emit = defineEmits<{
(ev: 'done', v: { canceled: boolean; result: any }): void;
(ev: 'done', v: { canceled: true } | { canceled: false, result: Result }): void;
(ev: 'closed'): void;
}>();
@@ -139,8 +129,11 @@ const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'character
return null;
});
function done(canceled: boolean, result?) {
emit('done', { canceled, result });
// overload function を使いたいので lint エラーを無視する
function done(canceled: true): void;
function done(canceled: false, result: Result): void; // eslint-disable-line no-redeclare
function done(canceled: boolean, result?: Result): void { // eslint-disable-line no-redeclare
emit('done', { canceled, result } as { canceled: true } | { canceled: false, result: Result });
modal.value?.close();
}
@@ -168,7 +161,7 @@ function onKeydown(evt: KeyboardEvent) {
}
function onInputKeydown(evt: KeyboardEvent) {
if (evt.key === 'Enter') {
if (evt.key === 'Enter' && okButtonDisabledReason.value === null) {
evt.preventDefault();
evt.stopPropagation();
ok();

View File

@@ -0,0 +1,32 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div
class="default" :style="[
marginTopBottom ? { marginTop: marginTopBottom, marginBottom: marginTopBottom } : {},
marginLeftRight ? { marginLeft: marginLeftRight, marginRight: marginLeftRight } : {},
borderStyle ? { borderStyle: borderStyle } : {},
borderWidth ? { borderWidth: borderWidth } : {},
borderColor ? { borderColor: borderColor } : {},
]"
/>
</template>
<script setup lang="ts">
defineProps<{
marginTopBottom?: string;
marginLeftRight?: string;
borderStyle?: string;
borderWidth?: string;
borderColor?: string;
}>();
</script>
<style scoped lang="scss">
.default {
border-top: solid 0.5px var(--divider);
}
</style>

View File

@@ -39,13 +39,13 @@ withDefaults(defineProps<{
});
const emit = defineEmits<{
(ev: 'done', r?: Misskey.entities.DriveFile[]): void;
(ev: 'done', r?: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]): void;
(ev: 'closed'): void;
}>();
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
const selected = ref<Misskey.entities.DriveFile[]>([]);
const selected = ref<Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]>([]);
function ok() {
emit('done', selected.value);
@@ -57,7 +57,7 @@ function cancel() {
dialog.value?.close();
}
function onChangeSelection(files: Misskey.entities.DriveFile[]) {
selected.value = files;
function onChangeSelection(v: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]) {
selected.value = v;
}
</script>

View File

@@ -16,10 +16,11 @@ SPDX-License-Identifier: AGPL-3.0-only
:key="emoji"
:data-emoji="emoji"
class="_button item"
:disabled="disabledEmojis?.value.includes(emoji)"
@pointerenter="computeButtonTitle"
@click="emit('chosen', emoji, $event)"
>
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/>
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true" :fallbackToImage="true"/>
<MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/>
</button>
</div>
@@ -48,6 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:key="emoji"
:data-emoji="emoji"
class="_button item"
:disabled="disabledEmojis?.value.includes(emoji)"
@pointerenter="computeButtonTitle"
@click="emit('chosen', emoji, $event)"
>
@@ -67,6 +69,7 @@ import MkEmojiPickerSection from '@/components/MkEmojiPicker.section.vue';
const props = defineProps<{
emojis: string[] | Ref<string[]>;
disabledEmojis?: Ref<string[]>;
initialShown?: boolean;
hasChildSection?: boolean;
customEmojiTree?: CustomEmojiFolderTree[];
@@ -84,7 +87,7 @@ const shown = ref(!!props.initialShown);
function computeButtonTitle(ev: MouseEvent): void {
const elm = ev.target as HTMLElement;
const emoji = elm.dataset.emoji as string;
elm.title = getEmojiName(emoji) ?? emoji;
elm.title = getEmojiName(emoji);
}
function nestedChosen(emoji: any, ev: MouseEvent) {

View File

@@ -14,11 +14,12 @@ SPDX-License-Identifier: AGPL-3.0-only
v-for="emoji in searchResultCustom"
:key="emoji.name"
class="_button item"
:disabled="!canReact(emoji)"
:title="emoji.name"
tabindex="0"
@click="chosen(emoji, $event)"
>
<MkCustomEmoji class="emoji" :name="emoji.name"/>
<MkCustomEmoji class="emoji" :name="emoji.name" :fallbackToImage="true"/>
</button>
</div>
<div v-if="searchResultUnicode.length > 0" class="body">
@@ -39,16 +40,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<section v-if="showPinned && (pinned && pinned.length > 0)">
<div class="body">
<button
v-for="emoji in pinned"
:key="emoji"
:data-emoji="emoji"
v-for="emoji in pinnedEmojisDef"
:key="getKey(emoji)"
:data-emoji="getKey(emoji)"
class="_button item"
:disabled="!canReact(emoji)"
tabindex="0"
@pointerenter="computeButtonTitle"
@click="chosen(emoji, $event)"
>
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/>
<MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/>
<MkCustomEmoji v-if="!emoji.hasOwnProperty('char')" class="emoji" :name="getKey(emoji)" :normal="true"/>
<MkEmoji v-else class="emoji" :emoji="getKey(emoji)" :normal="true"/>
</button>
</div>
</section>
@@ -57,15 +59,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<header class="_acrylic"><i class="ti ti-clock ti-fw"></i> {{ i18n.ts.recentUsed }}</header>
<div class="body">
<button
v-for="emoji in recentlyUsedEmojis"
:key="emoji"
v-for="emoji in recentlyUsedEmojisDef"
:key="getKey(emoji)"
class="_button item"
:data-emoji="emoji"
:disabled="!canReact(emoji)"
:data-emoji="getKey(emoji)"
@pointerenter="computeButtonTitle"
@click="chosen(emoji, $event)"
>
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/>
<MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/>
<MkCustomEmoji v-if="!emoji.hasOwnProperty('char')" class="emoji" :name="getKey(emoji)" :normal="true"/>
<MkEmoji v-else class="emoji" :emoji="getKey(emoji)" :normal="true"/>
</button>
</div>
</section>
@@ -76,7 +79,8 @@ SPDX-License-Identifier: AGPL-3.0-only
v-for="child in customEmojiFolderRoot.children"
:key="`custom:${child.value}`"
:initialShown="false"
:emojis="computed(() => customEmojis.filter(e => child.value === '' ? (e.category === 'null' || !e.category) : e.category === child.value).filter(filterAvailable).map(e => `:${e.name}:`))"
:emojis="computed(() => customEmojis.filter(e => filterCategory(e, child.value)).map(e => `:${e.name}:`))"
:disabledEmojis="computed(() => customEmojis.filter(e => filterCategory(e, child.value)).filter(e => !canReact(e)).map(e => `:${e.name}:`))"
:hasChildSection="child.children.length !== 0"
:customEmojiTree="child.children"
@chosen="chosen"
@@ -109,6 +113,7 @@ import {
unicodeEmojiCategories as categories,
getEmojiName,
CustomEmojiFolderTree,
getUnicodeEmoji,
} from '@/scripts/emojilist.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import * as os from '@/os.js';
@@ -146,6 +151,13 @@ const {
recentlyUsedEmojis,
} = defaultStore.reactiveState;
const recentlyUsedEmojisDef = computed(() => {
return recentlyUsedEmojis.value.map(getDef);
});
const pinnedEmojisDef = computed(() => {
return pinned.value?.map(getDef);
});
const pinned = computed(() => props.pinnedEmojis);
const size = computed(() => emojiPickerScale.value);
const width = computed(() => emojiPickerWidth.value);
@@ -337,14 +349,18 @@ watch(q, () => {
return matches;
};
searchResultCustom.value = Array.from(searchCustom()).filter(filterAvailable);
searchResultCustom.value = Array.from(searchCustom());
searchResultUnicode.value = Array.from(searchUnicode());
});
function filterAvailable(emoji: Misskey.entities.EmojiSimple): boolean {
function canReact(emoji: Misskey.entities.EmojiSimple | UnicodeEmojiDef | string): boolean {
return !props.targetNote || checkReactionPermissions($i!, props.targetNote, emoji);
}
function filterCategory(emoji: Misskey.entities.EmojiSimple, category: string): boolean {
return category === '' ? (emoji.category === 'null' || !emoji.category) : emoji.category === category;
}
function focus() {
if (!['smartphone', 'tablet'].includes(deviceKind) && !isTouchUsing) {
searchEl.value?.focus({
@@ -362,11 +378,22 @@ function getKey(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef):
return typeof emoji === 'string' ? emoji : 'char' in emoji ? emoji.char : `:${emoji.name}:`;
}
function getDef(emoji: string): string | Misskey.entities.EmojiSimple | UnicodeEmojiDef {
if (emoji.includes(':')) {
// カスタム絵文字が存在する場合はその情報を持つオブジェクトを返し、
// サーバの管理画面から削除された等で情報が見つからない場合は名前の文字列をそのまま返しておくundefinedを返すとエラーになるため
const name = emoji.replaceAll(':', '');
return customEmojisMap.get(name) ?? emoji;
} else {
return getUnicodeEmoji(emoji);
}
}
/** @see MkEmojiPicker.section.vue */
function computeButtonTitle(ev: MouseEvent): void {
const elm = ev.target as HTMLElement;
const emoji = elm.dataset.emoji as string;
elm.title = getEmojiName(emoji) ?? emoji;
elm.title = getEmojiName(emoji);
}
function chosen(emoji: any, ev?: MouseEvent) {
@@ -526,6 +553,18 @@ defineExpose({
width: auto;
height: auto;
min-width: 0;
&:disabled {
cursor: not-allowed;
background: linear-gradient(-45deg, transparent 0% 48%, var(--X6) 48% 52%, transparent 52% 100%);
opacity: 1;
> .emoji {
filter: grayscale(1);
mix-blend-mode: exclusion;
opacity: 0.8;
}
}
}
}
}
@@ -548,6 +587,18 @@ defineExpose({
width: auto;
height: auto;
min-width: 0;
&:disabled {
cursor: not-allowed;
background: linear-gradient(-45deg, transparent 0% 48%, var(--X6) 48% 52%, transparent 52% 100%);
opacity: 1;
> .emoji {
filter: grayscale(1);
mix-blend-mode: exclusion;
opacity: 0.8;
}
}
}
}
}
@@ -663,6 +714,18 @@ defineExpose({
box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15);
}
&:disabled {
cursor: not-allowed;
background: linear-gradient(-45deg, transparent 0% 48%, var(--X6) 48% 52%, transparent 52% 100%);
opacity: 1;
> .emoji {
filter: grayscale(1);
mix-blend-mode: exclusion;
opacity: 0.8;
}
}
> .emoji {
height: 1.25em;
vertical-align: -.25em;

View File

@@ -56,7 +56,7 @@ const props = withDefaults(defineProps<{
});
const emit = defineEmits<{
(ev: 'done', v: any): void;
(ev: 'done', v: string): void;
(ev: 'close'): void;
(ev: 'closed'): void;
}>();
@@ -64,7 +64,7 @@ const emit = defineEmits<{
const modal = shallowRef<InstanceType<typeof MkModal>>();
const picker = shallowRef<InstanceType<typeof MkEmojiPicker>>();
function chosen(emoji: any) {
function chosen(emoji: string) {
emit('done', emoji);
if (props.choseAndClose) {
modal.value?.close();

View File

@@ -1,49 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkWindow
ref="window"
:initialWidth="300"
:initialHeight="290"
:canResize="true"
:mini="true"
:front="true"
@closed="emit('closed')"
>
<MkEmojiPicker :showPinned="showPinned" :asReactionPicker="asReactionPicker" :targetNote="targetNote" asWindow :class="$style.picker" @chosen="chosen"/>
</MkWindow>
</template>
<script lang="ts" setup>
import { } from 'vue';
import * as Misskey from 'misskey-js';
import MkWindow from '@/components/MkWindow.vue';
import MkEmojiPicker from '@/components/MkEmojiPicker.vue';
withDefaults(defineProps<{
src?: HTMLElement;
showPinned?: boolean;
asReactionPicker?: boolean;
targetNote?: Misskey.entities.Note
}>(), {
showPinned: true,
});
const emit = defineEmits<{
(ev: 'chosen', v: any): void;
(ev: 'closed'): void;
}>();
function chosen(emoji: any) {
emit('chosen', emoji);
}
</script>
<style lang="scss" module>
.picker {
height: 100%;
}
</style>

View File

@@ -4,19 +4,11 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div v-if="meta" :class="$style.root" :style="{ backgroundImage: `url(${ meta.backgroundImageUrl })` }"></div>
<div v-if="instance" :class="$style.root" :style="{ backgroundImage: `url(${ instance.backgroundImageUrl })` }"></div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import { misskeyApi } from '@/scripts/misskey-api.js';
const meta = ref<Misskey.entities.MetaResponse>();
misskeyApi('meta', { detail: true }).then(gotMeta => {
meta.value = gotMeta;
});
import { instance } from '@/instance.js';
</script>
<style lang="scss" module>

View File

@@ -93,6 +93,18 @@ async function onClick() {
userId: props.user.id,
});
} else {
if (defaultStore.state.alwaysConfirmFollow) {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.tsx.followConfirm({ name: props.user.name || props.user.username }),
});
if (canceled) {
wait.value = false;
return;
}
}
if (hasPendingFollowRequestFromYou.value) {
await misskeyApi('following/requests/cancel', {
userId: props.user.id,
@@ -109,6 +121,8 @@ async function onClick() {
});
hasPendingFollowRequestFromYou.value = true;
if ($i == null) return;
claimAchievement('following1');
if ($i.followingCount >= 10) {

View File

@@ -0,0 +1,71 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div>
<MkButton inline rounded primary @click="selectButton($event)">{{ i18n.ts.selectFile }}</MkButton>
<div :class="['_nowrap', !fileName && $style.fileNotSelected]">{{ friendlyFileName }}</div>
</div>
</template>
<script setup lang="ts">
import * as Misskey from 'misskey-js';
import { computed, ref } from 'vue';
import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue';
import { selectFile } from '@/scripts/select-file.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
const props = defineProps<{
fileId?: string | null;
validate?: (file: Misskey.entities.DriveFile) => Promise<boolean>;
}>();
const emit = defineEmits<{
(ev: 'update', result: Misskey.entities.DriveFile): void;
}>();
const fileUrl = ref('');
const fileName = ref<string>('');
const friendlyFileName = computed<string>(() => {
if (fileName.value) {
return fileName.value;
}
if (fileUrl.value) {
return fileUrl.value;
}
return i18n.ts.fileNotSelected;
});
if (props.fileId) {
misskeyApi('drive/files/show', {
fileId: props.fileId,
}).then((apiRes) => {
fileName.value = apiRes.name;
fileUrl.value = apiRes.url;
});
}
function selectButton(ev: MouseEvent) {
selectFile(ev.currentTarget ?? ev.target).then(async (file) => {
if (!file) return;
if (props.validate && !await props.validate(file)) return;
emit('update', file);
fileName.value = file.name;
fileUrl.value = file.url;
});
}
</script>
<style module>
.fileNotSelected {
font-weight: 700;
color: var(--infoWarnFg);
}
</style>

View File

@@ -21,38 +21,45 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :marginMin="20" :marginMax="32">
<div v-if="Object.keys(form).filter(item => !form[item].hidden).length > 0" class="_gaps_m">
<template v-for="item in Object.keys(form).filter(item => !form[item].hidden)">
<MkInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1">
<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>
<template v-for="(v, k) in Object.fromEntries(Object.entries(form))">
<template v-if="typeof v.hidden == 'function' ? v.hidden(values) : v.hidden"></template>
<MkInput v-else-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkInput>
<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 v-else-if="v.type === 'string' && !v.multiline" v-model="values[k]" type="text" :mfmAutocomplete="v.treatAsMfm">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkInput>
<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 v-else-if="v.type === 'string' && v.multiline" v-model="values[k]" :mfmAutocomplete="v.treatAsMfm" :mfmPreview="v.treatAsMfm">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkTextarea>
<MkSwitch v-else-if="form[item].type === 'boolean'" v-model="values[item]">
<span v-text="form[item].label || item"></span>
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
<MkSwitch v-else-if="v.type === 'boolean'" v-model="values[k]">
<span v-text="v.label || k"></span>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkSwitch>
<MkSelect v-else-if="form[item].type === 'enum'" v-model="values[item]">
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
<option v-for="option in form[item].enum" :key="option.value" :value="option.value">{{ option.label }}</option>
<MkSelect v-else-if="v.type === 'enum'" v-model="values[k]">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<option v-for="option in v.enum" :key="option.value" :value="option.value">{{ option.label }}</option>
</MkSelect>
<MkRadios v-else-if="form[item].type === 'radio'" v-model="values[item]">
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
<option v-for="option in form[item].options" :key="option.value" :value="option.value">{{ option.label }}</option>
<MkRadios v-else-if="v.type === 'radio'" v-model="values[k]">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<option v-for="option in v.options" :key="option.value" :value="option.value">{{ option.label }}</option>
</MkRadios>
<MkRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].min" :max="form[item].max" :step="form[item].step" :textConverter="form[item].textConverter">
<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>
<MkRange v-else-if="v.type === 'range'" v-model="values[k]" :min="v.min" :max="v.max" :step="v.step" :textConverter="v.textConverter">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkRange>
<MkButton v-else-if="form[item].type === 'button'" @click="form[item].action($event, values)">
<span v-text="form[item].content || item"></span>
<MkButton v-else-if="v.type === 'button'" @click="v.action($event, values)">
<span v-text="v.content || k"></span>
</MkButton>
<XFile
v-else-if="v.type === 'drive-file'"
:fileId="v.defaultFileId"
:validate="async f => !v.validate || await v.validate(f)"
@update="f => values[k] = f"
/>
</template>
</div>
<div v-else class="_fullinfo">
@@ -72,19 +79,22 @@ import MkSelect from './MkSelect.vue';
import MkRange from './MkRange.vue';
import MkButton from './MkButton.vue';
import MkRadios from './MkRadios.vue';
import XFile from './MkFormDialog.file.vue';
import type { Form } from '@/scripts/form.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js';
const props = defineProps<{
title: string;
form: any;
form: Form;
}>();
const emit = defineEmits<{
(ev: 'done', v: {
canceled?: boolean;
result?: any;
canceled: true;
} | {
result: Record<string, any>;
}): void;
(ev: 'closed'): void;
}>();

View File

@@ -22,6 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:autocomplete="autocomplete"
:autocapitalize="autocapitalize"
:spellcheck="spellcheck"
:inputmode="inputmode"
:step="step"
:list="id"
:min="min"
@@ -63,6 +64,7 @@ const props = defineProps<{
mfmAutocomplete?: boolean | SuggestionType[],
autocapitalize?: string;
spellcheck?: boolean;
inputmode?: 'none' | 'text' | 'search' | 'email' | 'url' | 'numeric' | 'tel' | 'decimal';
step?: any;
datalist?: string[];
min?: number;

View File

@@ -0,0 +1,65 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { federationInstance } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js';
import { getChartResolver } from '../../.storybook/charts.js';
import MkInstanceCardMini from './MkInstanceCardMini.vue';
export const Default = {
render(args) {
return {
components: {
MkInstanceCardMini,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkInstanceCardMini v-bind="props" />',
};
},
args: {
instance: federationInstance(),
},
parameters: {
layout: 'centered',
msw: {
handlers: [
...commonHandlers,
http.get('/undefined/preview.webp', async ({ request }) => {
const urlStr = new URL(request.url).searchParams.get('url');
if (urlStr == null) {
return new HttpResponse(null, { status: 404 });
}
const url = new URL(urlStr);
if (url.href.startsWith('https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/')) {
const image = await (await fetch(`client-assets/${url.pathname.split('/').pop()}`)).blob();
return new HttpResponse(image, {
headers: {
'Content-Type': 'image/jpeg',
},
});
} else {
return new HttpResponse(null, { status: 404 });
}
}),
http.get('/api/charts/instance', getChartResolver(['requests.received'])),
],
},
},
} satisfies StoryObj<typeof MkInstanceCardMini>;

View File

@@ -29,8 +29,8 @@ const chartValues = ref<number[] | null>(null);
misskeyApiGet('charts/instance', { host: props.instance.host, limit: 16 + 1, span: 'day' }).then(res => {
// 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く
res['requests.received'].splice(0, 1);
chartValues.value = res['requests.received'];
res.requests.received.splice(0, 1);
chartValues.value = res.requests.received;
});
function getInstanceIcon(instance): string {

View File

@@ -6,6 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<component
:is="self ? 'MkA' : 'a'" ref="el" style="word-break: break-all;" class="_link" :[attr]="self ? url.substring(local.length) : url" :rel="rel ?? 'nofollow noopener'" :target="target"
:behavior="props.navigationBehavior"
:title="url"
>
<slot></slot>
@@ -18,10 +19,13 @@ import { defineAsyncComponent, ref } from 'vue';
import { url as local } from '@/config.js';
import { useTooltip } from '@/scripts/use-tooltip.js';
import * as os from '@/os.js';
import { isEnabledUrlPreview } from '@/instance.js';
import { MkABehavior } from '@/components/global/MkA.vue';
const props = withDefaults(defineProps<{
url: string;
rel?: null | string;
navigationBehavior?: MkABehavior;
}>(), {
});
@@ -29,15 +33,17 @@ const self = props.url.startsWith(local);
const attr = self ? 'to' : 'href';
const target = self ? null : '_blank';
const el = ref<HTMLElement>();
const el = ref<HTMLElement | { $el: HTMLElement }>();
useTooltip(el, (showing) => {
os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
showing,
url: props.url,
source: el.value,
}, {}, 'closed');
});
if (isEnabledUrlPreview.value) {
useTooltip(el, (showing) => {
os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
showing,
url: props.url,
source: el.value instanceof HTMLElement ? el.value : el.value?.$el,
}, {}, 'closed');
});
}
</script>
<style lang="scss" module>

View File

@@ -5,11 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div
ref="playerEl"
v-hotkey="keymap"
tabindex="0"
:class="[
$style.audioContainer,
(audio.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive,
]"
@contextmenu.stop
@keydown.stop
>
<button v-if="hide" :class="$style.hidden" @click="hide = false">
<div :class="$style.hiddenTextWrapper">
@@ -18,6 +22,19 @@ SPDX-License-Identifier: AGPL-3.0-only
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
</div>
</button>
<div v-else-if="defaultStore.reactiveState.useNativeUIForVideoAudioPlayer.value" :class="$style.nativeAudioContainer">
<audio
ref="audioEl"
preload="metadata"
controls
:class="$style.nativeAudio"
@keydown.prevent
>
<source :src="audio.url">
</audio>
</div>
<div v-else :class="$style.audioControls">
<audio
ref="audioEl"
@@ -66,15 +83,50 @@ import * as os from '@/os.js';
import bytes from '@/filters/bytes.js';
import { hms } from '@/filters/hms.js';
import MkMediaRange from '@/components/MkMediaRange.vue';
import { iAmModerator } from '@/account.js';
import { $i, iAmModerator } from '@/account.js';
const props = defineProps<{
audio: Misskey.entities.DriveFile;
}>();
const keymap = {
'up': () => {
if (hasFocus() && audioEl.value) {
volume.value = Math.min(volume.value + 0.1, 1);
}
},
'down': () => {
if (hasFocus() && audioEl.value) {
volume.value = Math.max(volume.value - 0.1, 0);
}
},
'left': () => {
if (hasFocus() && audioEl.value) {
audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0);
}
},
'right': () => {
if (hasFocus() && audioEl.value) {
audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, audioEl.value.duration);
}
},
'space': () => {
if (hasFocus()) {
togglePlayPause();
}
},
};
// PlayerElもしくはその子要素にフォーカスがあるかどうか
function hasFocus() {
if (!playerEl.value) return false;
return playerEl.value === document.activeElement || playerEl.value.contains(document.activeElement);
}
const playerEl = shallowRef<HTMLDivElement>();
const audioEl = shallowRef<HTMLAudioElement>();
// eslint-disable-next-line vue/no-setup-props-destructure
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore'));
// Menu
@@ -85,6 +137,30 @@ function showMenu(ev: MouseEvent) {
menu = [
// TODO: 再生キューに追加
{
type: 'switch',
text: i18n.ts._mediaControls.loop,
icon: 'ti ti-repeat',
ref: loop,
},
{
type: 'radio',
text: i18n.ts._mediaControls.playbackRate,
icon: 'ti ti-clock-play',
ref: speed,
options: {
'0.25x': 0.25,
'0.5x': 0.5,
'0.75x': 0.75,
'1.0x': 1,
'1.25x': 1.25,
'1.5x': 1.5,
'2.0x': 2,
},
},
{
type: 'divider',
},
{
text: i18n.ts.hide,
icon: 'ti ti-eye-off',
@@ -96,8 +172,6 @@ function showMenu(ev: MouseEvent) {
if (iAmModerator) {
menu.push({
type: 'divider',
}, {
text: props.audio.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
icon: props.audio.isSensitive ? 'ti ti-eye' : 'ti ti-eye-exclamation',
danger: true,
@@ -105,6 +179,17 @@ function showMenu(ev: MouseEvent) {
});
}
if ($i?.id === props.audio.userId) {
menu.push({
type: 'divider',
}, {
type: 'link' as const,
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
to: `/my/drive/file/${props.audio.id}`,
});
}
menuShowing.value = true;
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
align: 'right',
@@ -138,6 +223,8 @@ const rangePercent = computed({
},
});
const volume = ref(.25);
const speed = ref(1);
const loop = ref(false); // TODO: ドライブファイルのフラグに置き換える
const bufferedEnd = ref(0);
const bufferedDataRatio = computed(() => {
if (!audioEl.value) return 0;
@@ -167,6 +254,7 @@ function toggleMute() {
}
let onceInit = false;
let mediaTickFrameId: number | null = null;
let stopAudioElWatch: () => void;
function init() {
@@ -186,8 +274,12 @@ function init() {
}
elapsedTimeMs.value = audioEl.value.currentTime * 1000;
if (audioEl.value.loop !== loop.value) {
loop.value = audioEl.value.loop;
}
}
window.requestAnimationFrame(updateMediaTick);
mediaTickFrameId = window.requestAnimationFrame(updateMediaTick);
}
updateMediaTick();
@@ -225,6 +317,14 @@ watch(volume, (to) => {
if (audioEl.value) audioEl.value.volume = to;
});
watch(speed, (to) => {
if (audioEl.value) audioEl.value.playbackRate = to;
});
watch(loop, (to) => {
if (audioEl.value) audioEl.value.loop = to;
});
onMounted(() => {
init();
});
@@ -243,6 +343,10 @@ onDeactivated(() => {
hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore');
stopAudioElWatch();
onceInit = false;
if (mediaTickFrameId) {
window.cancelAnimationFrame(mediaTickFrameId);
mediaTickFrameId = null;
}
});
</script>
@@ -253,6 +357,10 @@ onDeactivated(() => {
border: .5px solid var(--divider);
border-radius: var(--radius);
overflow: clip;
&:focus {
outline: none;
}
}
.sensitive {
@@ -358,4 +466,15 @@ onDeactivated(() => {
}
}
}
.nativeAudioContainer {
display: flex;
align-items: center;
padding: 6px;
}
.nativeAudio {
display: block;
width: 100%;
}
</style>

View File

@@ -59,7 +59,7 @@ import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { iAmModerator } from '@/account.js';
import { $i, iAmModerator } from '@/account.js';
const props = withDefaults(defineProps<{
image: Misskey.entities.DriveFile;
@@ -114,6 +114,13 @@ function showMenu(ev: MouseEvent) {
action: () => {
os.apiWithDialog('drive/files/update', { fileId: props.image.id, isSensitive: true });
},
}] : []), ...($i?.id === props.image.userId ? [{
type: 'divider' as const,
}, {
type: 'link' as const,
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
to: `/my/drive/file/${props.image.id}`,
}] : [])], ev.currentTarget ?? ev.target);
}

View File

@@ -6,6 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div
ref="playerEl"
v-hotkey="keymap"
tabindex="0"
:class="[
$style.videoContainer,
controlsShowing && $style.active,
@@ -14,15 +16,37 @@ SPDX-License-Identifier: AGPL-3.0-only
@mouseover="onMouseOver"
@mouseleave="onMouseLeave"
@contextmenu.stop
@keydown.stop
>
<button v-if="hide" :class="$style.hidden" @click="hide = false">
<div :class="$style.hiddenTextWrapper">
<b v-if="video.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b>
<b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b>
<b v-else style="display: block;"><i class="ti ti-movie"></i> {{ defaultStore.state.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b>
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
</div>
</button>
<div v-else :class="$style.videoRoot" @click.self="togglePlayPause">
<div v-else-if="defaultStore.reactiveState.useNativeUIForVideoAudioPlayer.value" :class="$style.videoRoot">
<video
ref="videoEl"
:class="$style.video"
:poster="video.thumbnailUrl ?? undefined"
:title="video.comment ?? undefined"
:alt="video.comment"
preload="metadata"
controls
@keydown.prevent
>
<source :src="video.url">
</video>
<i class="ti ti-eye-off" :class="$style.hide" @click="hide = true"></i>
<div :class="$style.indicators">
<div v-if="video.comment" :class="$style.indicator">ALT</div>
<div v-if="video.isSensitive" :class="$style.indicator" style="color: var(--warn);" :title="i18n.ts.sensitive"><i class="ti ti-eye-exclamation"></i></div>
</div>
</div>
<div v-else :class="$style.videoRoot">
<video
ref="videoEl"
:class="$style.video"
@@ -31,6 +55,8 @@ SPDX-License-Identifier: AGPL-3.0-only
:alt="video.comment"
preload="metadata"
playsinline
@keydown.prevent
@click.self="togglePlayPause"
>
<source :src="video.url">
</video>
@@ -94,13 +120,47 @@ import * as os from '@/os.js';
import { isFullscreenNotSupported } from '@/scripts/device-kind.js';
import hasAudio from '@/scripts/media-has-audio.js';
import MkMediaRange from '@/components/MkMediaRange.vue';
import { iAmModerator } from '@/account.js';
import { $i, iAmModerator } from '@/account.js';
const props = defineProps<{
video: Misskey.entities.DriveFile;
}>();
// eslint-disable-next-line vue/no-setup-props-destructure
const keymap = {
'up': () => {
if (hasFocus() && videoEl.value) {
volume.value = Math.min(volume.value + 0.1, 1);
}
},
'down': () => {
if (hasFocus() && videoEl.value) {
volume.value = Math.max(volume.value - 0.1, 0);
}
},
'left': () => {
if (hasFocus() && videoEl.value) {
videoEl.value.currentTime = Math.max(videoEl.value.currentTime - 5, 0);
}
},
'right': () => {
if (hasFocus() && videoEl.value) {
videoEl.value.currentTime = Math.min(videoEl.value.currentTime + 5, videoEl.value.duration);
}
},
'space': () => {
if (hasFocus()) {
togglePlayPause();
}
},
};
// PlayerElもしくはその子要素にフォーカスがあるかどうか
function hasFocus() {
if (!playerEl.value) return false;
return playerEl.value === document.activeElement || playerEl.value.contains(document.activeElement);
}
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore'));
// Menu
@@ -111,6 +171,35 @@ function showMenu(ev: MouseEvent) {
menu = [
// TODO: 再生キューに追加
{
type: 'switch',
text: i18n.ts._mediaControls.loop,
icon: 'ti ti-repeat',
ref: loop,
},
{
type: 'radio',
text: i18n.ts._mediaControls.playbackRate,
icon: 'ti ti-clock-play',
ref: speed,
options: {
'0.25x': 0.25,
'0.5x': 0.5,
'0.75x': 0.75,
'1.0x': 1,
'1.25x': 1.25,
'1.5x': 1.5,
'2.0x': 2,
},
},
...(document.pictureInPictureEnabled ? [{
text: i18n.ts._mediaControls.pip,
icon: 'ti ti-picture-in-picture',
action: togglePictureInPicture,
}] : []),
{
type: 'divider',
},
{
text: i18n.ts.hide,
icon: 'ti ti-eye-off',
@@ -122,8 +211,6 @@ function showMenu(ev: MouseEvent) {
if (iAmModerator) {
menu.push({
type: 'divider',
}, {
text: props.video.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
icon: props.video.isSensitive ? 'ti ti-eye' : 'ti ti-eye-exclamation',
danger: true,
@@ -131,6 +218,17 @@ function showMenu(ev: MouseEvent) {
});
}
if ($i?.id === props.video.userId) {
menu.push({
type: 'divider',
}, {
type: 'link' as const,
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
to: `/my/drive/file/${props.video.id}`,
});
}
menuShowing.value = true;
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
align: 'right',
@@ -177,6 +275,8 @@ const rangePercent = computed({
},
});
const volume = ref(.25);
const speed = ref(1);
const loop = ref(false); // TODO: ドライブファイルのフラグに置き換える
const bufferedEnd = ref(0);
const bufferedDataRatio = computed(() => {
if (!videoEl.value) return 0;
@@ -234,6 +334,16 @@ function toggleFullscreen() {
}
}
function togglePictureInPicture() {
if (videoEl.value) {
if (document.pictureInPictureElement) {
document.exitPictureInPicture();
} else {
videoEl.value.requestPictureInPicture();
}
}
}
function toggleMute() {
if (volume.value === 0) {
volume.value = .25;
@@ -243,6 +353,7 @@ function toggleMute() {
}
let onceInit = false;
let mediaTickFrameId: number | null = null;
let stopVideoElWatch: () => void;
function init() {
@@ -262,8 +373,12 @@ function init() {
}
elapsedTimeMs.value = videoEl.value.currentTime * 1000;
if (videoEl.value.loop !== loop.value) {
loop.value = videoEl.value.loop;
}
}
window.requestAnimationFrame(updateMediaTick);
mediaTickFrameId = window.requestAnimationFrame(updateMediaTick);
}
updateMediaTick();
@@ -307,6 +422,14 @@ watch(volume, (to) => {
if (videoEl.value) videoEl.value.volume = to;
});
watch(speed, (to) => {
if (videoEl.value) videoEl.value.playbackRate = to;
});
watch(loop, (to) => {
if (videoEl.value) videoEl.value.loop = to;
});
watch(hide, (to) => {
if (to && isFullscreen.value) {
document.exitFullscreen();
@@ -332,6 +455,10 @@ onDeactivated(() => {
hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore');
stopVideoElWatch();
onceInit = false;
if (mediaTickFrameId) {
window.cancelAnimationFrame(mediaTickFrameId);
mediaTickFrameId = null;
}
});
</script>
@@ -340,6 +467,10 @@ onDeactivated(() => {
container-type: inline-size;
position: relative;
overflow: clip;
&:focus {
outline: none;
}
}
.sensitive {
@@ -403,7 +534,7 @@ onDeactivated(() => {
font: inherit;
color: inherit;
cursor: pointer;
padding: 120px 0;
padding: 60px 0;
display: flex;
align-items: center;
justify-content: center;
@@ -427,7 +558,6 @@ onDeactivated(() => {
display: block;
height: 100%;
width: 100%;
pointer-events: none;
}
.videoOverlayPlayButton {

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkA v-user-preview="canonical" :class="[$style.root, { [$style.isMe]: isMe }]" :to="url" :style="{ background: bgCss }">
<MkA v-user-preview="canonical" :class="[$style.root, { [$style.isMe]: isMe }]" :to="url" :style="{ background: bgCss }" :behavior="navigationBehavior">
<img :class="$style.icon" :src="avatarUrl" alt="">
<span>
<span>@{{ username }}</span>
@@ -21,10 +21,12 @@ import { host as localHost } from '@/config.js';
import { $i } from '@/account.js';
import { defaultStore } from '@/store.js';
import { getStaticImageUrl } from '@/scripts/media-proxy.js';
import { MkABehavior } from '@/components/global/MkA.vue';
const props = defineProps<{
username: string;
host: string;
navigationBehavior?: MkABehavior;
}>();
const canonical = props.host === localHost ? `@${props.username}` : `@${props.username}@${toUnicode(props.host)}`;

View File

@@ -42,9 +42,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</button>
<button v-else-if="item.type === 'switch'" role="menuitemcheckbox" :tabindex="i" class="_button" :class="[$style.item, $style.switch, { [$style.switchDisabled]: item.disabled } ]" @click="switchItem(item)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<MkSwitchButton :class="$style.switchButton" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/>
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<MkSwitchButton v-else :class="$style.switchButton" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/>
<div :class="$style.item_content">
<span :class="[$style.item_content_text, $style.switchText]">{{ item.text }}</span>
<span :class="[$style.item_content_text, { [$style.switchText]: !item.icon }]">{{ item.text }}</span>
<MkSwitchButton v-if="item.icon" :class="[$style.switchButton, $style.caret]" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/>
</div>
</button>
<button v-else-if="item.type === 'radio'" class="_button" role="menuitem" :tabindex="i" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="preferClick ? null : showRadioOptions(item, $event)" @click="!preferClick ? null : showRadioOptions(item, $event)">
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i>
<div :class="$style.item_content">
<span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span>
<span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span>
</div>
</button>
<button v-else-if="item.type === 'radioOption'" :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.radioActive]: item.active }]" @click="clicked(item.action, $event, false)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<div :class="$style.icon">
<span :class="[$style.radio, { [$style.radioChecked]: item.active }]"></span>
</div>
<div :class="$style.item_content">
<span :class="$style.item_content_text">{{ item.text }}</span>
</div>
</button>
<button v-else-if="item.type === 'parent'" class="_button" role="menuitem" :tabindex="i" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="preferClick ? null : showChildren(item, $event)" @click="!preferClick ? null : showChildren(item, $event)">
@@ -77,7 +94,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ComputedRef, computed, defineAsyncComponent, isRef, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
import { focusPrev, focusNext } from '@/scripts/focus.js';
import MkSwitchButton from '@/components/MkSwitch.button.vue';
import { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuParent } from '@/types/menu.js';
import { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio, MenuRadioOption, MenuParent } from '@/types/menu.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { isTouchUsing } from '@/scripts/touch.js';
@@ -168,6 +185,31 @@ function onItemMouseLeave(item) {
if (childCloseTimer) window.clearTimeout(childCloseTimer);
}
async function showRadioOptions(item: MenuRadio, ev: MouseEvent) {
const children: MenuItem[] = Object.keys(item.options).map<MenuRadioOption>(key => {
const value = item.options[key];
return {
type: 'radioOption',
text: key,
action: () => {
item.ref = value;
},
active: computed(() => item.ref === value),
};
});
if (props.asDrawer) {
os.popupMenu(children, ev.currentTarget ?? ev.target).finally(() => {
emit('close');
});
emit('hide');
} else {
childTarget.value = (ev.currentTarget ?? ev.target) as HTMLElement;
childMenu.value = children;
childShowingItem.value = item;
}
}
async function showChildren(item: MenuParent, ev: MouseEvent) {
const children: MenuItem[] = await (async () => {
if (childrenCache.has(item)) {
@@ -196,8 +238,10 @@ async function showChildren(item: MenuParent, ev: MouseEvent) {
}
}
function clicked(fn: MenuAction, ev: MouseEvent) {
function clicked(fn: MenuAction, ev: MouseEvent, doClose = true) {
fn(ev);
if (!doClose) return;
close(true);
}
@@ -350,6 +394,15 @@ onBeforeUnmount(() => {
}
}
&.radioActive {
color: var(--accent) !important;
opacity: 1;
&:before {
background-color: var(--accentedBg) !important;
}
}
&:not(:active):focus-visible {
box-shadow: 0 0 0 2px var(--focus) inset;
}
@@ -417,11 +470,11 @@ onBeforeUnmount(() => {
.switchButton {
margin-left: -2px;
--height: 1.35em;
}
.switchText {
margin-left: 8px;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
}
@@ -461,4 +514,32 @@ onBeforeUnmount(() => {
margin: 8px 0;
border-top: solid 0.5px var(--divider);
}
.radio {
display: inline-block;
position: relative;
width: 1em;
height: 1em;
vertical-align: -.125em;
border-radius: 50%;
border: solid 2px var(--divider);
background-color: var(--panel);
&.radioChecked {
border-color: var(--accent);
&::after {
content: "";
display: block;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 50%;
height: 50%;
border-radius: 50%;
background-color: var(--accent);
}
}
}
</style>

View File

@@ -175,8 +175,8 @@ const align = () => {
let left;
let top;
const x = srcRect.left + (fixed.value ? 0 : window.pageXOffset);
const y = srcRect.top + (fixed.value ? 0 : window.pageYOffset);
const x = srcRect.left + (fixed.value ? 0 : window.scrollX);
const y = srcRect.top + (fixed.value ? 0 : window.scrollY);
if (props.anchor.x === 'center') {
left = x + (props.src.offsetWidth / 2) - (width / 2);
@@ -220,24 +220,24 @@ const align = () => {
}
} else {
// 画面から横にはみ出る場合
if (left + width - window.pageXOffset > (window.innerWidth - SCROLLBAR_THICKNESS)) {
left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.pageXOffset - 1;
if (left + width - window.scrollX > (window.innerWidth - SCROLLBAR_THICKNESS)) {
left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.scrollX - 1;
}
const underSpace = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - (top - window.pageYOffset);
const underSpace = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - (top - window.scrollY);
const upperSpace = (srcRect.top - MARGIN);
// 画面から縦にはみ出る場合
if (top + height - window.pageYOffset > ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN)) {
if (top + height - window.scrollY > ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN)) {
if (props.noOverlap && props.anchor.x === 'center') {
if (underSpace >= (upperSpace / 3)) {
maxHeight.value = underSpace;
} else {
maxHeight.value = upperSpace;
top = window.pageYOffset + ((upperSpace + MARGIN) - height);
top = window.scrollY + ((upperSpace + MARGIN) - height);
}
} else {
top = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - height + window.pageYOffset - 1;
top = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - height + window.scrollY - 1;
}
} else {
maxHeight.value = underSpace;
@@ -255,15 +255,15 @@ const align = () => {
let transformOriginX = 'center';
let transformOriginY = 'center';
if (top >= srcRect.top + props.src.offsetHeight + (fixed.value ? 0 : window.pageYOffset)) {
if (top >= srcRect.top + props.src.offsetHeight + (fixed.value ? 0 : window.scrollY)) {
transformOriginY = 'top';
} else if ((top + height) <= srcRect.top + (fixed.value ? 0 : window.pageYOffset)) {
} else if ((top + height) <= srcRect.top + (fixed.value ? 0 : window.scrollY)) {
transformOriginY = 'bottom';
}
if (left >= srcRect.left + props.src.offsetWidth + (fixed.value ? 0 : window.pageXOffset)) {
if (left >= srcRect.left + props.src.offsetWidth + (fixed.value ? 0 : window.scrollX)) {
transformOriginX = 'left';
} else if ((left + width) <= srcRect.left + (fixed.value ? 0 : window.pageXOffset)) {
} else if ((left + width) <= srcRect.left + (fixed.value ? 0 : window.scrollX)) {
transformOriginX = 'right';
}
@@ -276,8 +276,11 @@ const align = () => {
const onOpened = () => {
emit('opened');
// NOTE: Chromatic テストの際に undefined になる場合がある
if (content.value == null) return;
// モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する
const el = content.value!.children[0];
const el = content.value.children[0];
el.addEventListener('mousedown', ev => {
contentClicking = true;
window.addEventListener('mouseup', ev => {

View File

@@ -82,7 +82,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkMediaList :mediaList="appearNote.files"/>
</div>
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/>
<div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/>
</div>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false">
<span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span>
@@ -93,15 +95,15 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
</div>
<MkReactionsViewer :note="appearNote" :maxNumber="16" @mockUpdateMyReaction="emitUpdReaction">
<MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" :note="appearNote" :maxNumber="16" @mockUpdateMyReaction="emitUpdReaction">
<template #more>
<div :class="$style.reactionOmitted">{{ i18n.ts.more }}</div>
<MkA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</MkA>
</template>
</MkReactionsViewer>
<footer :class="$style.footer">
<button :class="$style.footerButton" class="_button" @click="reply()">
<i class="ti ti-arrow-back-up"></i>
<p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ appearNote.repliesCount }}</p>
<p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.repliesCount) }}</p>
</button>
<button
v-if="canRenote"
@@ -111,17 +113,17 @@ SPDX-License-Identifier: AGPL-3.0-only
@mousedown="renote()"
>
<i class="ti ti-repeat"></i>
<p v-if="appearNote.renoteCount > 0" :class="$style.footerButtonCount">{{ appearNote.renoteCount }}</p>
<p v-if="appearNote.renoteCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.renoteCount) }}</p>
</button>
<button v-else :class="$style.footerButton" class="_button" disabled>
<i class="ti ti-ban"></i>
</button>
<button v-if="appearNote.myReaction == null" ref="reactButton" :class="$style.footerButton" class="_button" @mousedown="react()">
<i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
<button ref="reactButton" :class="$style.footerButton" class="_button" @click="toggleReact()">
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--eventReactionHeart);"></i>
<i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--accent);"></i>
<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
<i v-else class="ti ti-plus"></i>
</button>
<button v-if="appearNote.myReaction != null" ref="reactButton" :class="$style.footerButton" class="_button" @click="undoReact(appearNote)">
<i class="ti ti-minus"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p>
</button>
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown="clip()">
<i class="ti ti-paperclip"></i>
@@ -165,6 +167,7 @@ import MkNoteSub from '@/components/MkNoteSub.vue';
import MkNoteHeader from '@/components/MkNoteHeader.vue';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
import MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue';
import MkMediaList from '@/components/MkMediaList.vue';
import MkCwButton from '@/components/MkCwButton.vue';
import MkPoll from '@/components/MkPoll.vue';
@@ -175,9 +178,10 @@ import { pleaseLogin } from '@/scripts/please-login.js';
import { focusPrev, focusNext } from '@/scripts/focus.js';
import { checkWordMute } from '@/scripts/check-word-mute.js';
import { userPage } from '@/filters/user.js';
import number from '@/filters/number.js';
import * as os from '@/os.js';
import * as sound from '@/scripts/sound.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
import { defaultStore, noteViewInterruptors } from '@/store.js';
import { reactionPicker } from '@/scripts/reaction-picker.js';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
@@ -193,6 +197,7 @@ import { MenuItem } from '@/types/menu.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
import { shouldCollapsed } from '@/scripts/collapsed.js';
import { isEnabledUrlPreview } from '@/instance.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@@ -237,6 +242,7 @@ if (noteViewInterruptors.length > 0) {
const isRenote = (
note.value.renote != null &&
note.value.reply == null &&
note.value.text == null &&
note.value.cw == null &&
note.value.fileIds && note.value.fileIds.length === 0 &&
@@ -267,7 +273,7 @@ const renoteCollapsed = ref(
defaultStore.state.collapseRenotes && isRenote && (
($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131
(appearNote.value.myReaction != null)
)
),
);
/* Overload FunctionにLintが対応していないのでコメントアウト
@@ -336,6 +342,28 @@ if (!props.mock) {
targetElement: renoteButton.value,
}, {}, 'closed');
});
if (appearNote.value.reactionAcceptance === 'likeOnly') {
useTooltip(reactButton, async (showing) => {
const reactions = await misskeyApiGet('notes/reactions', {
noteId: appearNote.value.id,
limit: 10,
_cacheKey_: appearNote.value.reactionCount,
});
const users = reactions.map(x => x.user);
if (users.length < 1) return;
os.popup(MkReactionsViewerDetails, {
showing,
reaction: '❤️',
users,
count: appearNote.value.reactionCount,
targetElement: reactButton.value!,
}, {}, 'closed');
});
}
}
function renote(viaKeyboard = false) {
@@ -420,6 +448,14 @@ function undoReact(targetNote: Misskey.entities.Note): void {
});
}
function toggleReact() {
if (appearNote.value.myReaction == null) {
react();
} else {
undoReact(appearNote.value);
}
}
function onContextmenu(ev: MouseEvent): void {
if (props.mock) {
return;
@@ -985,9 +1021,8 @@ function emitUpdReaction(emoji: string, delta: number) {
.reactionOmitted {
display: inline-block;
height: 32px;
margin: 2px;
padding: 0 6px;
margin-left: 8px;
opacity: .8;
font-size: 95%;
}
</style>

View File

@@ -68,7 +68,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.noteContent">
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/>
<MkCwButton v-model="showContent" :text="appearNote.text" :files="appearNote.files" :poll="appearNote.poll"/>
<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll"/>
</p>
<div v-show="appearNote.cw == null || showContent">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
@@ -95,7 +95,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkMediaList :mediaList="appearNote.files"/>
</div>
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
<div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
</div>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
</div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
@@ -106,10 +108,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkTime :time="appearNote.createdAt" mode="detail" colored/>
</MkA>
</div>
<MkReactionsViewer ref="reactionsViewer" :note="appearNote"/>
<MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" ref="reactionsViewer" :note="appearNote"/>
<button class="_button" :class="$style.noteFooterButton" @click="reply()">
<i class="ti ti-arrow-back-up"></i>
<p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ appearNote.repliesCount }}</p>
<p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.repliesCount) }}</p>
</button>
<button
v-if="canRenote"
@@ -119,17 +121,17 @@ SPDX-License-Identifier: AGPL-3.0-only
@mousedown="renote()"
>
<i class="ti ti-repeat"></i>
<p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ appearNote.renoteCount }}</p>
<p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.renoteCount) }}</p>
</button>
<button v-else class="_button" :class="$style.noteFooterButton" disabled>
<i class="ti ti-ban"></i>
</button>
<button v-if="appearNote.myReaction == null" ref="reactButton" :class="$style.noteFooterButton" class="_button" @mousedown="react()">
<i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
<button ref="reactButton" :class="$style.noteFooterButton" class="_button" @click="toggleReact()">
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--eventReactionHeart);"></i>
<i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--accent);"></i>
<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
<i v-else class="ti ti-plus"></i>
</button>
<button v-if="appearNote.myReaction != null" ref="reactButton" class="_button" :class="[$style.noteFooterButton, $style.reacted]" @click="undoReact(appearNote)">
<i class="ti ti-minus"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p>
</button>
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown="clip()">
<i class="ti ti-paperclip"></i>
@@ -199,6 +201,7 @@ import * as Misskey from 'misskey-js';
import MkNoteSub from '@/components/MkNoteSub.vue';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
import MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue';
import MkMediaList from '@/components/MkMediaList.vue';
import MkCwButton from '@/components/MkCwButton.vue';
import MkPoll from '@/components/MkPoll.vue';
@@ -209,8 +212,9 @@ import { pleaseLogin } from '@/scripts/please-login.js';
import { checkWordMute } from '@/scripts/check-word-mute.js';
import { userPage } from '@/filters/user.js';
import { notePage } from '@/filters/note.js';
import number from '@/filters/number.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
import * as sound from '@/scripts/sound.js';
import { defaultStore, noteViewInterruptors } from '@/store.js';
import { reactionPicker } from '@/scripts/reaction-picker.js';
@@ -228,10 +232,14 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue';
import MkPagination, { type Paging } from '@/components/MkPagination.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
import MkButton from '@/components/MkButton.vue';
import { isEnabledUrlPreview } from '@/instance.js';
const props = defineProps<{
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
}>();
initialTab: string;
}>(), {
initialTab: 'replies',
});
const inChannel = inject('inChannel', null);
@@ -258,7 +266,9 @@ if (noteViewInterruptors.length > 0) {
const isRenote = (
note.value.renote != null &&
note.value.reply == null &&
note.value.text == null &&
note.value.cw == null &&
note.value.fileIds && note.value.fileIds.length === 0 &&
note.value.poll == null
);
@@ -299,7 +309,7 @@ provide('react', (reaction: string) => {
});
});
const tab = ref('replies');
const tab = ref(props.initialTab);
const reactionTabType = ref<string | null>(null);
const renotesPagination = computed<Paging>(() => ({
@@ -344,6 +354,28 @@ useTooltip(renoteButton, async (showing) => {
}, {}, 'closed');
});
if (appearNote.value.reactionAcceptance === 'likeOnly') {
useTooltip(reactButton, async (showing) => {
const reactions = await misskeyApiGet('notes/reactions', {
noteId: appearNote.value.id,
limit: 10,
_cacheKey_: appearNote.value.reactionCount,
});
const users = reactions.map(x => x.user);
if (users.length < 1) return;
os.popup(MkReactionsViewerDetails, {
showing,
reaction: '❤️',
users,
count: appearNote.value.reactionCount,
targetElement: reactButton.value!,
}, {}, 'closed');
});
}
function renote(viaKeyboard = false) {
pleaseLogin();
showMovedDialog();
@@ -401,14 +433,22 @@ function react(viaKeyboard = false): void {
}
}
function undoReact(note): void {
const oldReaction = note.myReaction;
function undoReact(targetNote: Misskey.entities.Note): void {
const oldReaction = targetNote.myReaction;
if (!oldReaction) return;
misskeyApi('notes/reactions/delete', {
noteId: note.id,
noteId: targetNote.id,
});
}
function toggleReact() {
if (appearNote.value.myReaction == null) {
react();
} else {
undoReact(appearNote.value);
}
}
function onContextmenu(ev: MouseEvent): void {
const isLink = (el: HTMLElement): boolean => {
if (el.tagName === 'A') return true;

View File

@@ -6,13 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="$style.root">
<div :class="$style.head">
<MkAvatar v-if="['pollEnded', 'note'].includes(notification.type) && notification.note" :class="$style.icon" :user="notification.note.user" link preview/>
<MkAvatar v-if="['pollEnded', 'note'].includes(notification.type) && 'note' in notification" :class="$style.icon" :user="notification.note.user" link preview/>
<MkAvatar v-else-if="['roleAssigned', 'achievementEarned'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/>
<div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ti ti-heart" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div>
<img v-else-if="notification.type === 'test'" :class="$style.icon" :src="infoImageUrl"/>
<MkAvatar v-else-if="notification.user" :class="$style.icon" :user="notification.user" link preview/>
<img v-else-if="notification.icon" :class="[$style.icon, $style.icon_app]" :src="notification.icon" alt=""/>
<MkAvatar v-else-if="'user' in notification" :class="$style.icon" :user="notification.user" link preview/>
<img v-else-if="'icon' in notification" :class="[$style.icon, $style.icon_app]" :src="notification.icon" alt=""/>
<div
:class="[$style.subIcon, {
[$style.t_follow]: notification.type === 'follow',
@@ -57,7 +58,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span>
<span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span>
<MkA v-else-if="notification.type === 'follow' || notification.type === 'mention' || notification.type === 'reply' || notification.type === 'renote' || notification.type === 'quote' || notification.type === 'reaction' || notification.type === 'receiveFollowRequest' || notification.type === 'followRequestAccepted'" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
<span v-else-if="notification.type === 'reaction:grouped'">{{ i18n.tsx._notification.reactedBySomeUsers({ n: notification.reactions.length }) }}</span>
<span v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'">{{ i18n.tsx._notification.likedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span>
<span v-else-if="notification.type === 'reaction:grouped'">{{ i18n.tsx._notification.reactedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span>
<span v-else-if="notification.type === 'renote:grouped'">{{ i18n.tsx._notification.renotedBySomeUsers({ n: notification.users.length }) }}</span>
<span v-else-if="notification.type === 'app'">{{ notification.header }}</span>
<MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/>
@@ -70,7 +72,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkA>
<MkA v-else-if="notification.type === 'renote' || notification.type === 'renote:grouped'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note.renote)">
<i class="ti ti-quote" :class="$style.quote"></i>
<Mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="true" :author="notification.note.renote.user"/>
<Mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="true" :author="notification.note.renote?.user"/>
<i class="ti ti-quote" :class="$style.quote"></i>
</MkA>
<MkA v-else-if="notification.type === 'reply'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
@@ -162,16 +164,21 @@ const props = withDefaults(defineProps<{
const followRequestDone = ref(false);
const acceptFollowRequest = () => {
if (props.notification.user == null) return;
if (!('user' in props.notification)) return;
followRequestDone.value = true;
misskeyApi('following/requests/accept', { userId: props.notification.user.id });
};
const rejectFollowRequest = () => {
if (props.notification.user == null) return;
if (!('user' in props.notification)) return;
followRequestDone.value = true;
misskeyApi('following/requests/reject', { userId: props.notification.user.id });
};
function getActualReactedUsersCount(notification: Misskey.entities.Notification) {
if (notification.type !== 'reaction:grouped') return 0;
return new Set(notification.reactions.map((reaction) => reaction.user.id)).size;
}
</script>
<style lang="scss" module>
@@ -201,6 +208,7 @@ const rejectFollowRequest = () => {
}
.icon_reactionGroup,
.icon_reactionGroupHeart,
.icon_renoteGroup {
display: grid;
align-items: center;
@@ -213,11 +221,15 @@ const rejectFollowRequest = () => {
}
.icon_reactionGroup {
background: #e99a0b;
background: var(--eventReaction);
}
.icon_reactionGroupHeart {
background: var(--eventReactionHeart);
}
.icon_renoteGroup {
background: #36d298;
background: var(--eventRenote);
}
.icon_app {
@@ -246,49 +258,49 @@ const rejectFollowRequest = () => {
.t_follow, .t_followRequestAccepted, .t_receiveFollowRequest {
padding: 3px;
background: #36aed2;
background: var(--eventFollow);
pointer-events: none;
}
.t_renote {
padding: 3px;
background: #36d298;
background: var(--eventRenote);
pointer-events: none;
}
.t_quote {
padding: 3px;
background: #36d298;
background: var(--eventRenote);
pointer-events: none;
}
.t_reply {
padding: 3px;
background: #007aff;
background: var(--eventReply);
pointer-events: none;
}
.t_mention {
padding: 3px;
background: #88a6b7;
background: var(--eventOther);
pointer-events: none;
}
.t_pollEnded {
padding: 3px;
background: #88a6b7;
background: var(--eventOther);
pointer-events: none;
}
.t_achievementEarned {
padding: 3px;
background: #cb9a11;
background: var(--eventAchievement);
pointer-events: none;
}
.t_roleAssigned {
padding: 3px;
background: #88a6b7;
background: var(--eventOther);
pointer-events: none;
}

View File

@@ -35,6 +35,7 @@ import { notificationTypes } from '@/const.js';
import { infoImageUrl } from '@/instance.js';
import { defaultStore } from '@/store.js';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import * as Misskey from 'misskey-js';
const props = defineProps<{
excludeTypes?: typeof notificationTypes[number][];
@@ -75,17 +76,19 @@ function reload() {
});
}
let connection;
let connection: Misskey.ChannelConnection<Misskey.Channels['main']>;
onMounted(() => {
connection = useStream().useChannel('main');
connection.on('notification', onNotification);
connection.on('notificationFlushed', reload);
});
onActivated(() => {
pagingComponent.value?.reload();
connection = useStream().useChannel('main');
connection.on('notification', onNotification);
connection.on('notificationFlushed', reload);
});
onUnmounted(() => {

View File

@@ -16,9 +16,9 @@ SPDX-License-Identifier: AGPL-3.0-only
@closed="$emit('closed')"
>
<template #header>
<template v-if="pageMetadata?.value">
<i v-if="pageMetadata.value.icon" :class="pageMetadata.value.icon" style="margin-right: 0.5em;"></i>
<span>{{ pageMetadata.value.title }}</span>
<template v-if="pageMetadata">
<i v-if="pageMetadata.icon" :class="pageMetadata.icon" style="margin-right: 0.5em;"></i>
<span>{{ pageMetadata.title }}</span>
</template>
</template>
@@ -29,7 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, ComputedRef, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue';
import { computed, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue';
import RouterView from '@/components/global/RouterView.vue';
import MkWindow from '@/components/MkWindow.vue';
import { popout as _popout } from '@/scripts/popout.js';
@@ -37,7 +37,7 @@ import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { url } from '@/config.js';
import { useScrollPositionManager } from '@/nirax.js';
import { i18n } from '@/i18n.js';
import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js';
import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
import { openingWindowsCount } from '@/os.js';
import { claimAchievement } from '@/scripts/achievements.js';
import { getScrollContainer } from '@/scripts/scroll.js';
@@ -56,7 +56,7 @@ const routerFactory = useRouterFactory();
const windowRouter = routerFactory(props.initialPath);
const contents = shallowRef<HTMLElement | null>(null);
const pageMetadata = ref<null | ComputedRef<PageMetadata>>();
const pageMetadata = ref<null | PageMetadata>(null);
const windowEl = shallowRef<InstanceType<typeof MkWindow>>();
const history = ref<{ path: string; key: any; }[]>([{
path: windowRouter.getCurrentPath(),
@@ -101,9 +101,11 @@ windowRouter.addListener('replace', ctx => {
windowRouter.init();
provide('router', windowRouter);
provideMetadataReceiver((info) => {
provideMetadataReceiver((metadataGetter) => {
const info = metadataGetter();
pageMetadata.value = info;
});
provideReactiveMetadata(pageMetadata);
provide('shouldOmitHeaderTitle', true);
provide('shouldHeaderThin', true);
provide('forceSpacerMin', true);

View File

@@ -19,18 +19,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<div style="margin-top: 16px;">{{ i18n.ts.authenticationRequiredToContinue }}</div>
</div>
<div class="_gaps">
<MkInput ref="passwordInput" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" :withPasswordToggle="true">
<template #prefix><i class="ti ti-password"></i></template>
</MkInput>
<form @submit.prevent="done">
<div class="_gaps">
<MkInput ref="passwordInput" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" required :withPasswordToggle="true">
<template #prefix><i class="ti ti-password"></i></template>
</MkInput>
<MkInput v-if="$i.twoFactorEnabled" v-model="token" type="text" pattern="^([0-9]{6}|[A-Z0-9]{32})$" autocomplete="one-time-code" :spellcheck="false">
<template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template>
<template #prefix><i class="ti ti-123"></i></template>
</MkInput>
<MkInput v-if="$i.twoFactorEnabled" v-model="token" type="text" :pattern="isBackupCode ? '^[A-Z0-9]{32}$' :'^[0-9]{6}$'" autocomplete="one-time-code" required :spellcheck="false" :inputmode="isBackupCode ? undefined : 'numeric'">
<template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template>
<template #prefix><i v-if="isBackupCode" class="ti ti-key"></i><i v-else class="ti ti-123"></i></template>
<template #caption><button class="_textButton" type="button" @click="isBackupCode = !isBackupCode">{{ isBackupCode ? i18n.ts.useTotp : i18n.ts.useBackupCode }}</button></template>
</MkInput>
<MkButton :disabled="(password ?? '') == '' || ($i.twoFactorEnabled && (token ?? '') == '')" primary rounded style="margin: 0 auto;" @click="done"><i class="ti ti-lock-open"></i> {{ i18n.ts.continue }}</MkButton>
</div>
<MkButton :disabled="(password ?? '') == '' || ($i.twoFactorEnabled && (token ?? '') == '')" type="submit" primary rounded style="margin: 0 auto;"><i class="ti ti-lock-open"></i> {{ i18n.ts.continue }}</MkButton>
</div>
</form>
</MkSpacer>
</MkModalWindow>
</template>
@@ -54,6 +57,7 @@ const emit = defineEmits<{
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
const passwordInput = shallowRef<InstanceType<typeof MkInput>>();
const password = ref('');
const isBackupCode = ref(false);
const token = ref<string | null>(null);
function onClose() {
@@ -61,7 +65,7 @@ function onClose() {
if (dialog.value) dialog.value.close();
}
function done(res) {
function done() {
emit('done', { password: password.value, token: token.value });
if (dialog.value) dialog.value.close();
}

View File

@@ -156,6 +156,7 @@ const props = withDefaults(defineProps<{
initialVisibleUsers: () => [],
autofocus: true,
mock: false,
initialLocalOnly: undefined,
});
provide('mock', props.mock);
@@ -172,7 +173,7 @@ const emit = defineEmits<{
const textareaEl = shallowRef<HTMLTextAreaElement | null>(null);
const cwInputEl = shallowRef<HTMLInputElement | null>(null);
const hashtagsInputEl = shallowRef<HTMLInputElement | null>(null);
const visibilityButton = shallowRef<HTMLElement | null>(null);
const visibilityButton = shallowRef<HTMLElement>();
const posting = ref(false);
const posted = ref(false);
@@ -185,11 +186,11 @@ watch(showPreview, () => defaultStore.set('showPreview', showPreview.value));
const showAddMfmFunction = ref(defaultStore.state.enableQuickAddMfmFunction);
watch(showAddMfmFunction, () => defaultStore.set('enableQuickAddMfmFunction', showAddMfmFunction.value));
const cw = ref<string | null>(props.initialCw ?? null);
const localOnly = ref<boolean>(props.initialLocalOnly ?? defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly);
const visibility = ref(props.initialVisibility ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility) as typeof Misskey.noteVisibilities[number]);
const localOnly = ref(props.initialLocalOnly ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly));
const visibility = ref(props.initialVisibility ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility));
const visibleUsers = ref<Misskey.entities.UserDetailed[]>([]);
if (props.initialVisibleUsers) {
props.initialVisibleUsers.forEach(pushVisibleUser);
props.initialVisibleUsers.forEach(u => pushVisibleUser(u));
}
const reactionAcceptance = ref(defaultStore.state.reactionAcceptance);
const autocomplete = ref(null);
@@ -253,7 +254,13 @@ const maxTextLength = computed((): number => {
const canPost = computed((): boolean => {
return !props.mock && !posting.value && !posted.value &&
(1 <= textLength.value || 1 <= files.value.length || !!poll.value || !!props.renote) &&
(
1 <= textLength.value ||
1 <= files.value.length ||
poll.value != null ||
props.renote != null ||
(props.reply != null && quoteId.value != null)
) &&
(textLength.value <= maxTextLength.value) &&
(!poll.value || poll.value.choices.length >= 2);
});
@@ -329,7 +336,7 @@ if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visib
misskeyApi('users/show', {
userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply?.userId),
}).then(users => {
users.forEach(pushVisibleUser);
users.forEach(u => pushVisibleUser(u));
});
}
@@ -382,7 +389,7 @@ function addMissingMention() {
for (const x of extractMentions(ast)) {
if (!visibleUsers.value.some(u => (u.username === x.username) && (u.host === x.host))) {
misskeyApi('users/show', { username: x.username, host: x.host }).then(user => {
visibleUsers.value.push(user);
pushVisibleUser(user);
});
}
}
@@ -461,6 +468,7 @@ function setVisibility() {
isSilenced: $i.isSilenced,
localOnly: localOnly.value,
src: visibilityButton.value,
...(props.reply ? { isReplyVisibilitySpecified: props.reply.visibility === 'specified' } : {}),
}, {
changeVisibility: v => {
visibility.value = v;
@@ -511,6 +519,9 @@ async function toggleLocalOnly() {
}
localOnly.value = !localOnly.value;
if (defaultStore.state.rememberNoteVisibility) {
defaultStore.set('localOnly', localOnly.value);
}
}
async function toggleReactionAcceptance() {
@@ -601,6 +612,23 @@ async function onPaste(ev: ClipboardEvent) {
quoteId.value = paste.substring(url.length).match(/^\/notes\/(.+?)\/?$/)?.[1] ?? null;
});
}
if (paste.length > 1000) {
ev.preventDefault();
os.confirm({
type: 'info',
text: i18n.ts.attachAsFileQuestion,
}).then(({ canceled }) => {
if (canceled) {
insertTextAtCursor(textareaEl.value, paste);
return;
}
const fileName = formatTimeString(new Date(), defaultStore.state.pastedFileName).replace(/{{number}}/g, "0");
const file = new File([paste], `${fileName}.txt`, { type: "text/plain" });
upload(file, `${fileName}.txt`);
});
}
}
function onDragover(ev) {
@@ -672,6 +700,7 @@ function saveDraft() {
localOnly: localOnly.value,
files: files.value,
poll: poll.value,
visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(x => x.id) : undefined,
},
};
@@ -953,6 +982,11 @@ onMounted(() => {
if (draft.data.poll) {
poll.value = draft.data.poll;
}
if (draft.data.visibleUserIds) {
misskeyApi('users/show', { userIds: draft.data.visibleUserIds }).then(users => {
users.forEach(u => pushVisibleUser(u));
});
}
}
}

View File

@@ -152,11 +152,11 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent): void {
icon: 'ti ti-crop',
action: () : void => { crop(file); },
}] : [], {
type: 'divider',
}, {
text: i18n.ts.attachCancel,
icon: 'ti ti-circle-x',
action: () => { detachMedia(file.id); },
}, {
type: 'divider',
}, {
text: i18n.ts.deleteFile,
icon: 'ti ti-trash',

View File

@@ -15,7 +15,7 @@ import * as Misskey from 'misskey-js';
import MkModal from '@/components/MkModal.vue';
import MkPostForm from '@/components/MkPostForm.vue';
const props = defineProps<{
const props = withDefaults(defineProps<{
reply?: Misskey.entities.Note;
renote?: Misskey.entities.Note;
channel?: any; // TODO
@@ -31,7 +31,9 @@ const props = defineProps<{
instant?: boolean;
fixed?: boolean;
autofocus?: boolean;
}>();
}>(), {
initialLocalOnly: undefined,
});
const emit = defineEmits<{
(ev: 'closed'): void;

View File

@@ -0,0 +1,150 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.preview">
<div :class="$style.preview__content1">
<MkInput v-model="text">
<template #label>Text</template>
</MkInput>
<MkSwitch v-model="flag" :class="$style.preview__content1__switch_button">
<span>Switch is now {{ flag ? 'on' : 'off' }}</span>
</MkSwitch>
<div :class="$style.preview__content1__input">
<MkRadio v-model="radio" value="misskey">Misskey</MkRadio>
<MkRadio v-model="radio" value="mastodon">Mastodon</MkRadio>
<MkRadio v-model="radio" value="pleroma">Pleroma</MkRadio>
</div>
<div :class="$style.preview__content1__button">
<MkButton inline>This is</MkButton>
<MkButton inline primary>the button</MkButton>
</div>
</div>
<div :class="$style.preview__content2" style="pointer-events: none;">
<Mfm :text="mfm"/>
</div>
<div :class="$style.preview__content3">
<MkButton inline primary @click="openMenu">Open menu</MkButton>
<MkButton inline primary @click="openDialog">Open dialog</MkButton>
<MkButton inline primary @click="openForm">Open form</MkButton>
<MkButton inline primary @click="openDrive">Open drive</MkButton>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkRadio from '@/components/MkRadio.vue';
import * as os from '@/os.js';
import * as config from '@/config.js';
import { $i } from '@/account.js';
const text = ref('');
const flag = ref(true);
const radio = ref('misskey');
const mfm = ref(`Hello world! This is an @example mention. BTW you are @${$i ? $i.username : 'guest'}.\nAlso, here is ${config.url} and [example link](${config.url}). for more details, see https://example.com.\nAs you know #misskey is open-source software.`);
const openDialog = async () => {
await os.alert({
type: 'warning',
title: 'Oh my Aichan',
text: 'Lorem ipsum dolor sit amet, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
});
};
const openForm = async () => {
await os.form('Example form', {
foo: {
type: 'boolean',
default: true,
label: 'This is a boolean property',
},
bar: {
type: 'number',
default: 300,
label: 'This is a number property',
},
baz: {
type: 'string',
default: 'Misskey makes you happy.',
label: 'This is a string property',
},
});
};
const openDrive = async () => {
await os.selectDriveFile(false);
};
const selectUser = async () => {
await os.selectUser();
};
const openMenu = async (ev: Event) => {
os.popupMenu([{
type: 'label',
text: 'Fruits',
}, {
text: 'Create some apples',
action: () => {},
}, {
text: 'Read some oranges',
action: () => {},
}, {
text: 'Update some melons',
action: () => {},
}, {
text: 'Delete some bananas',
danger: true,
action: () => {},
}], ev.currentTarget ?? ev.target);
};
</script>
<style lang="scss" module>
.preview {
padding: 16px;
&__content1 {
&__switch_button {
padding: 16px 0 8px 0;
}
&__input {
padding: 8px 0 8px 0;
div {
margin: 0 8px 8px 0;
}
}
&__button {
padding: 4px 0 8px 0;
button {
margin: 0 8px 8px 0;
}
}
}
&__content2 {
padding: 8px 0 8px 0;
}
&__content3 {
padding: 8px 0 8px 0;
button {
margin: 0 8px 8px 0;
}
}
}
</style>

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkCustomEmoji v-if="reaction[0] === ':'" ref="elRef" :name="reaction" :normal="true" :noStyle="noStyle" :url="emojiUrl"/>
<MkCustomEmoji v-if="reaction[0] === ':'" ref="elRef" :name="reaction" :normal="true" :noStyle="noStyle" :url="emojiUrl" :fallbackToImage="true"/>
<MkEmoji v-else ref="elRef" :emoji="reaction" :normal="true" :noStyle="noStyle"/>
</template>

View File

@@ -44,7 +44,7 @@ function getReactionName(reaction: string): string {
if (trimLocal.startsWith(':')) {
return trimLocal;
}
return getEmojiName(reaction) ?? reaction;
return getEmojiName(reaction);
}
</script>

View File

@@ -33,7 +33,8 @@ import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import * as sound from '@/scripts/sound.js';
import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js';
import { customEmojis } from '@/custom-emojis.js';
import { customEmojisMap } from '@/custom-emojis.js';
import { getUnicodeEmoji } from '@/scripts/emojilist.js';
const props = defineProps<{
reaction: string;
@@ -50,13 +51,11 @@ const emit = defineEmits<{
const buttonEl = shallowRef<HTMLElement>();
const isCustomEmoji = computed(() => props.reaction.includes(':'));
const emoji = computed(() => isCustomEmoji.value ? customEmojis.value.find(emoji => emoji.name === props.reaction.replace(/:/g, '').replace(/@\./, '')) : null);
const emojiName = computed(() => props.reaction.replace(/:/g, '').replace(/@\./, ''));
const emoji = computed(() => customEmojisMap.get(emojiName.value) ?? getUnicodeEmoji(props.reaction));
const canToggle = computed(() => {
return !props.reaction.match(/@\w/) && $i
&& (emoji.value && checkReactionPermissions($i, props.note, emoji.value))
|| !isCustomEmoji.value;
return !props.reaction.match(/@\w/) && $i && emoji.value && checkReactionPermissions($i, props.note, emoji.value);
});
const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':'));

View File

@@ -100,6 +100,9 @@ watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumbe
}
.root {
display: flex;
flex-wrap: wrap;
align-items: center;
margin: 4px -2px 0 -2px;
&:empty {

View File

@@ -31,15 +31,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="user && user.securityKeys" class="or-hr">
<p class="or-msg">{{ i18n.ts.or }}</p>
</div>
<div class="twofa-group totp-group">
<p style="margin-bottom:0;">{{ i18n.ts['2fa'] }}</p>
<div class="twofa-group totp-group _gaps">
<MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" autocomplete="current-password" :withPasswordToggle="true" required>
<template #label>{{ i18n.ts.password }}</template>
<template #prefix><i class="ti ti-lock"></i></template>
</MkInput>
<MkInput v-model="token" type="text" pattern="^([0-9]{6}|[A-Z0-9]{32})$" autocomplete="one-time-code" :spellcheck="false" required>
<template #label>{{ i18n.ts.token }}</template>
<template #prefix><i class="ti ti-123"></i></template>
<MkInput v-model="token" type="text" :pattern="isBackupCode ? '^[A-Z0-9]{32}$' :'^[0-9]{6}$'" autocomplete="one-time-code" required :spellcheck="false" :inputmode="isBackupCode ? undefined : 'numeric'">
<template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template>
<template #prefix><i v-if="isBackupCode" class="ti ti-key"></i><i v-else class="ti ti-123"></i></template>
<template #caption><button class="_textButton" type="button" @click="isBackupCode = !isBackupCode">{{ isBackupCode ? i18n.ts.useTotp : i18n.ts.useBackupCode }}</button></template>
</MkInput>
<MkButton type="submit" :disabled="signing" large primary rounded style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
</div>
@@ -70,6 +70,7 @@ const password = ref('');
const token = ref('');
const host = ref(toUnicode(configHost));
const totpLogin = ref(false);
const isBackupCode = ref(false);
const queryingKey = ref(false);
const credentialRequest = ref<CredentialRequestOptions | null>(null);

View File

@@ -51,13 +51,16 @@ export const Empty = {
expect(buttons.at(-1)).toBeEnabled();
},
args: {
// @ts-expect-error serverRules is for test
serverRules: [],
tosUrl: null,
},
decorators: [
(_, context) => ({
setup() {
// @ts-expect-error serverRules is for test
instance.serverRules = context.args.serverRules;
// @ts-expect-error tosUrl is for test
instance.tosUrl = context.args.tosUrl;
onBeforeUnmount(() => {
// FIXME: 呼び出されない
@@ -76,6 +79,7 @@ export const ServerRulesOnly = {
...Empty,
args: {
...Empty.args,
// @ts-expect-error serverRules is for test
serverRules: [
'ルール',
],
@@ -85,6 +89,7 @@ export const TOSOnly = {
...Empty,
args: {
...Empty.args,
// @ts-expect-error tosUrl is for test
tosUrl: 'https://example.com/tos',
},
} satisfies StoryObj<typeof MkSignupServerRules>;
@@ -92,6 +97,7 @@ export const ServerRulesAndTOS = {
...Empty,
args: {
...Empty.args,
// @ts-expect-error serverRules is for test
serverRules: ServerRulesOnly.args.serverRules,
tosUrl: TOSOnly.args.tosUrl,
},

View File

@@ -0,0 +1,112 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_panel _shadow" :class="$style.root">
<div :class="$style.icon">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-brand-open-source" width="40" height="40" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M12 3a9 9 0 0 1 3.618 17.243l-2.193 -5.602a3 3 0 1 0 -2.849 0l-2.193 5.603a9 9 0 0 1 3.617 -17.244z"/>
</svg>
</div>
<div :class="$style.main">
<div :class="$style.title">
<I18n :src="i18n.ts.aboutX" tag="span">
<template #x>
{{ instance.name ?? host }}
</template>
</I18n>
</div>
<div :class="$style.text">
<I18n :src="i18n.ts._aboutMisskey.thisIsModifiedVersion" tag="span">
<template #name>
{{ instance.name ?? host }}
</template>
</I18n>
<I18n :src="i18n.ts.correspondingSourceIsAvailable" tag="span">
<template #anchor>
<MkA to="/about-misskey" class="_link">{{ i18n.ts.aboutMisskey }}</MkA>
</template>
</I18n>
</div>
<div class="_buttons">
<MkButton @click="close">{{ i18n.ts.gotIt }}</MkButton>
</div>
</div>
<button class="_button" :class="$style.close" @click="close"><i class="ti ti-x"></i></button>
</div>
</template>
<script lang="ts" setup>
import MkButton from '@/components/MkButton.vue';
import { host } from '@/config.js';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import { miLocalStorage } from '@/local-storage.js';
import * as os from '@/os.js';
const emit = defineEmits<{
(ev: 'closed'): void;
}>();
const zIndex = os.claimZIndex('low');
function close() {
miLocalStorage.setItem('modifiedVersionMustProminentlyOfferInAgplV3Section13Read', 'true');
emit('closed');
}
</script>
<style lang="scss" module>
.root {
position: fixed;
z-index: v-bind(zIndex);
bottom: var(--margin);
left: 0;
right: 0;
margin: auto;
box-sizing: border-box;
width: calc(100% - (var(--margin) * 2));
max-width: 500px;
display: flex;
}
.icon {
text-align: center;
padding-top: 25px;
width: 100px;
color: var(--accent);
}
@media (max-width: 500px) {
.icon {
width: 80px;
}
}
@media (max-width: 450px) {
.icon {
width: 70px;
}
}
.main {
padding: 25px 25px 25px 0;
flex: 1;
}
.close {
position: absolute;
top: 8px;
right: 8px;
padding: 8px;
}
.title {
font-weight: bold;
}
.text {
margin: 0.7em 0 1em 0;
}
</style>

View File

@@ -41,13 +41,15 @@ const toggle = () => {
<style lang="scss" module>
.button {
--height: 21px;
position: relative;
display: inline-flex;
flex-shrink: 0;
margin: 0;
box-sizing: border-box;
width: 32px;
height: 23px;
width: calc(var(--height) * 1.6);
height: calc(var(--height) + 2px); // 枠線
outline: none;
background: var(--switchOffBg);
background-clip: content-box;
@@ -69,9 +71,10 @@ const toggle = () => {
.knob {
position: absolute;
box-sizing: border-box;
top: 3px;
width: 15px;
height: 15px;
width: calc(var(--height) - 6px);
height: calc(var(--height) - 6px);
border-radius: 999px;
transition: all 0.2s ease;
@@ -82,7 +85,7 @@ const toggle = () => {
}
.knobChecked {
left: 12px;
left: calc(calc(100% - var(--height)) + 3px);
background: var(--switchOnFg);
}
</style>

View File

@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@keydown.enter="toggle"
>
<XButton :checked="checked" :disabled="disabled" @toggle="toggle"/>
<span :class="$style.body">
<span v-if="!noBody" :class="$style.body">
<!-- TODO: 無名slotの方は廃止 -->
<span :class="$style.label">
<span @click="toggle">
@@ -34,16 +34,19 @@ const props = defineProps<{
modelValue: boolean | Ref<boolean>;
disabled?: boolean;
helpText?: string;
noBody?: boolean;
}>();
const emit = defineEmits<{
(ev: 'update:modelValue', v: boolean): void;
(ev: 'change', v: boolean): void;
}>();
const checked = toRefs(props).modelValue;
const toggle = () => {
if (props.disabled) return;
emit('update:modelValue', !checked.value);
emit('change', !checked.value);
};
</script>

View File

@@ -0,0 +1,45 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineAsyncComponent } from 'vue';
import * as os from '@/os.js';
export type SystemWebhookEventType = 'abuseReport' | 'abuseReportResolved';
export type MkSystemWebhookEditorProps = {
mode: 'create' | 'edit';
id?: string;
requiredEvents?: SystemWebhookEventType[];
};
export type MkSystemWebhookResult = {
id?: string;
isActive: boolean;
name: string;
on: SystemWebhookEventType[];
url: string;
secret: string;
};
export async function showSystemWebhookEditorDialog(props: MkSystemWebhookEditorProps): Promise<MkSystemWebhookResult | null> {
const { dispose, result } = await new Promise<{ dispose: () => void, result: MkSystemWebhookResult | null }>(async resolve => {
const res = await os.popup(
defineAsyncComponent(() => import('@/components/MkSystemWebhookEditor.vue')),
props,
{
submitted: (ev: MkSystemWebhookResult) => {
resolve({ dispose: res.dispose, result: ev });
},
closed: () => {
resolve({ dispose: res.dispose, result: null });
},
},
);
});
dispose();
return result;
}

View File

@@ -0,0 +1,217 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
:width="450"
:height="590"
:canClose="true"
:withOkButton="false"
:okButtonDisabled="false"
@click="onCancelClicked"
@close="onCancelClicked"
@closed="onCancelClicked"
>
<template #header>
{{ mode === 'create' ? i18n.ts._webhookSettings.createWebhook : i18n.ts._webhookSettings.modifyWebhook }}
</template>
<MkSpacer :marginMin="20" :marginMax="28">
<MkLoading v-if="loading !== 0"/>
<div v-else :class="$style.root" class="_gaps_m">
<MkInput v-model="title">
<template #label>{{ i18n.ts._webhookSettings.name }}</template>
</MkInput>
<MkInput v-model="url">
<template #label>URL</template>
</MkInput>
<MkInput v-model="secret">
<template #label>{{ i18n.ts._webhookSettings.secret }}</template>
</MkInput>
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts._webhookSettings.events }}</template>
<div class="_gaps_s">
<MkSwitch v-model="events.abuseReport" :disabled="disabledEvents.abuseReport">
<template #label>{{ i18n.ts._webhookSettings._systemEvents.abuseReport }}</template>
</MkSwitch>
<MkSwitch v-model="events.abuseReportResolved" :disabled="disabledEvents.abuseReportResolved">
<template #label>{{ i18n.ts._webhookSettings._systemEvents.abuseReportResolved }}</template>
</MkSwitch>
</div>
</MkFolder>
<MkSwitch v-model="isActive">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<div :class="$style.footer" class="_buttonsCenter">
<MkButton primary :disabled="disableSubmitButton" @click="onSubmitClicked">
<i class="ti ti-check"></i>
{{ i18n.ts.ok }}
</MkButton>
<MkButton @click="onCancelClicked"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton>
</div>
</div>
</MkSpacer>
</MkModalWindow>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, toRefs } from 'vue';
import FormSection from '@/components/form/section.vue';
import MkInput from '@/components/MkInput.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import {
MkSystemWebhookEditorProps,
MkSystemWebhookResult,
SystemWebhookEventType,
} from '@/components/MkSystemWebhookEditor.impl.js';
import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkFolder from '@/components/MkFolder.vue';
import * as os from '@/os.js';
type EventType = {
abuseReport: boolean;
abuseReportResolved: boolean;
}
const emit = defineEmits<{
(ev: 'submitted', result: MkSystemWebhookResult): void;
(ev: 'closed'): void;
}>();
const props = defineProps<MkSystemWebhookEditorProps>();
const { mode, id, requiredEvents } = toRefs(props);
const loading = ref<number>(0);
const title = ref<string>('');
const url = ref<string>('');
const secret = ref<string>('');
const events = ref<EventType>({
abuseReport: true,
abuseReportResolved: true,
});
const isActive = ref<boolean>(true);
const disabledEvents = ref<EventType>({
abuseReport: false,
abuseReportResolved: false,
});
const disableSubmitButton = computed(() => {
if (!title.value) {
return true;
}
if (!url.value) {
return true;
}
if (!secret.value) {
return true;
}
return false;
});
async function onSubmitClicked() {
await loadingScope(async () => {
const params = {
isActive: isActive.value,
name: title.value,
url: url.value,
secret: secret.value,
on: Object.keys(events.value).filter(ev => events.value[ev as keyof EventType]) as SystemWebhookEventType[],
};
try {
switch (mode.value) {
case 'create': {
const result = await misskeyApi('admin/system-webhook/create', params);
emit('submitted', result);
break;
}
case 'edit': {
// eslint-disable-next-line
const result = await misskeyApi('admin/system-webhook/update', { id: id.value!, ...params });
emit('submitted', result);
break;
}
}
// eslint-disable-next-line
} catch (ex: any) {
const msg = ex.message ?? i18n.ts.internalServerErrorDescription;
await os.alert({ type: 'error', title: i18n.ts.error, text: msg });
emit('closed');
}
});
}
function onCancelClicked() {
emit('closed');
}
async function loadingScope<T>(fn: () => Promise<T>): Promise<T> {
loading.value++;
try {
return await fn();
} finally {
loading.value--;
}
}
onMounted(async () => {
await loadingScope(async () => {
switch (mode.value) {
case 'edit': {
if (!id.value) {
throw new Error('id is required');
}
try {
const res = await misskeyApi('admin/system-webhook/show', { id: id.value });
title.value = res.name;
url.value = res.url;
secret.value = res.secret;
isActive.value = res.isActive;
for (const ev of Object.keys(events.value)) {
events.value[ev] = res.on.includes(ev as SystemWebhookEventType);
}
// eslint-disable-next-line
} catch (ex: any) {
const msg = ex.message ?? i18n.ts.internalServerErrorDescription;
await os.alert({ type: 'error', title: i18n.ts.error, text: msg });
emit('closed');
}
break;
}
}
for (const ev of requiredEvents.value ?? []) {
disabledEvents.value[ev] = true;
}
});
});
</script>
<style module lang="scss">
.root {
display: flex;
flex-direction: column;
justify-content: center;
align-items: stretch;
}
.footer {
display: flex;
justify-content: center;
align-items: flex-end;
margin-top: 20px;
}
</style>

View File

@@ -63,6 +63,7 @@ const exampleNote = reactive<Misskey.entities.Note>({
reactionAcceptance: null,
renoteCount: 0,
repliesCount: 1,
reactionCount: 0,
reactions: {},
reactionEmojis: {},
fileIds: [],

View File

@@ -68,6 +68,7 @@ const exampleCWNote = reactive<Misskey.entities.Note>({
reactionAcceptance: null,
renoteCount: 0,
repliesCount: 1,
reactionCount: 0,
reactions: {},
reactionEmojis: {},
fileIds: [],

View File

@@ -58,6 +58,7 @@ const exampleNote = reactive<Misskey.entities.Note>({
reactionAcceptance: null,
renoteCount: 0,
repliesCount: 1,
reactionCount: 0,
reactions: {},
reactionEmojis: {},
fileIds: ['0000000002'],

View File

@@ -172,7 +172,7 @@ const emit = defineEmits<{
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
// eslint-disable-next-line vue/no-setup-props-destructure
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
const page = ref(props.initialPage ?? 0);
watch(page, (to) => {

View File

@@ -152,15 +152,16 @@ requestUrl.hash = '';
window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`)
.then(res => {
if (!res.ok) {
fetching.value = false;
unknownUrl.value = true;
return;
if (_DEV_) {
console.warn(`[HTTP${res.status}] Failed to fetch url preview`);
}
return null;
}
return res.json();
})
.then((info: SummalyResult) => {
if (info.url == null) {
.then((info: SummalyResult | null) => {
if (!info || info.url == null) {
fetching.value = false;
unknownUrl.value = true;
return;

View File

@@ -33,8 +33,8 @@ const left = ref(0);
onMounted(() => {
const rect = props.source.getBoundingClientRect();
const x = Math.max((rect.left + (props.source.offsetWidth / 2)) - (300 / 2), 6) + window.pageXOffset;
const y = rect.top + props.source.offsetHeight + window.pageYOffset;
const x = Math.max((rect.left + (props.source.offsetWidth / 2)) - (300 / 2), 6) + window.scrollX;
const y = rect.top + props.source.offsetHeight + window.scrollY;
top.value = y;
left.value = x;

View File

@@ -106,8 +106,8 @@ onMounted(() => {
}
const rect = props.source.getBoundingClientRect();
const x = ((rect.left + (props.source.offsetWidth / 2)) - (300 / 2)) + window.pageXOffset;
const y = rect.top + props.source.offsetHeight + window.pageYOffset;
const x = ((rect.left + (props.source.offsetWidth / 2)) - (300 / 2)) + window.scrollX;
const y = rect.top + props.source.offsetHeight + window.scrollY;
top.value = y;
left.value = x;

View File

@@ -148,7 +148,7 @@ const emit = defineEmits<{
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
// eslint-disable-next-line vue/no-setup-props-destructure
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
const page = ref(defaultStore.state.accountSetupWizard);
watch(page, () => {

View File

@@ -9,21 +9,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="[$style.label, $style.item]">
{{ i18n.ts.visibility }}
</div>
<button key="public" :disabled="isSilenced" class="_button" :class="[$style.item, { [$style.active]: v === 'public' }]" data-index="1" @click="choose('public')">
<button key="public" :disabled="isSilenced || isReplyVisibilitySpecified" class="_button" :class="[$style.item, { [$style.active]: v === 'public' }]" data-index="1" @click="choose('public')">
<div :class="$style.icon"><i class="ti ti-world"></i></div>
<div :class="$style.body">
<span :class="$style.itemTitle">{{ i18n.ts._visibility.public }}</span>
<span :class="$style.itemDescription">{{ i18n.ts._visibility.publicDescription }}</span>
</div>
</button>
<button key="home" class="_button" :class="[$style.item, { [$style.active]: v === 'home' }]" data-index="2" @click="choose('home')">
<button key="home" :disabled="isReplyVisibilitySpecified" class="_button" :class="[$style.item, { [$style.active]: v === 'home' }]" data-index="2" @click="choose('home')">
<div :class="$style.icon"><i class="ti ti-home"></i></div>
<div :class="$style.body">
<span :class="$style.itemTitle">{{ i18n.ts._visibility.home }}</span>
<span :class="$style.itemDescription">{{ i18n.ts._visibility.homeDescription }}</span>
</div>
</button>
<button key="followers" class="_button" :class="[$style.item, { [$style.active]: v === 'followers' }]" data-index="3" @click="choose('followers')">
<button key="followers" :disabled="isReplyVisibilitySpecified" class="_button" :class="[$style.item, { [$style.active]: v === 'followers' }]" data-index="3" @click="choose('followers')">
<div :class="$style.icon"><i class="ti ti-lock"></i></div>
<div :class="$style.body">
<span :class="$style.itemTitle">{{ i18n.ts._visibility.followers }}</span>
@@ -54,6 +54,7 @@ const props = withDefaults(defineProps<{
isSilenced: boolean;
localOnly: boolean;
src?: HTMLElement;
isReplyVisibilitySpecified?: boolean;
}>(), {
});

View File

@@ -4,19 +4,19 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div v-if="meta" :class="$style.root">
<div v-if="instance" :class="$style.root">
<div :class="[$style.main, $style.panel]">
<img :src="instance.iconUrl || '/favicon.ico'" alt="" :class="$style.mainIcon"/>
<button class="_button _acrylic" :class="$style.mainMenu" @click="showMenu"><i class="ti ti-dots"></i></button>
<div :class="$style.mainFg">
<h1 :class="$style.mainTitle">
<!-- 背景色によってはロゴが見えなくなるのでとりあえず無効に -->
<!-- <img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> -->
<!-- <img class="logo" v-if="instance.logoImageUrl" :src="instance.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> -->
<span>{{ instanceName }}</span>
</h1>
<div :class="$style.mainAbout">
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="meta.description || i18n.ts.headlineMisskey"></div>
<div v-html="instance.description || i18n.ts.headlineMisskey"></div>
</div>
<div v-if="instance.disableRegistration" :class="$style.mainWarn">
<MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo>
@@ -65,14 +65,10 @@ import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import MkNumber from '@/components/MkNumber.vue';
import XActiveUsersChart from '@/components/MkVisitorDashboard.ActiveUsersChart.vue';
import { openInstanceMenu } from '@/ui/_common_/common';
const meta = ref<Misskey.entities.MetaResponse | null>(null);
const stats = ref<Misskey.entities.StatsResponse | null>(null);
misskeyApi('meta', { detail: true }).then(_meta => {
meta.value = _meta;
});
misskeyApi('stats', {}).then((res) => {
stats.value = res;
});
@@ -90,43 +86,7 @@ function signup() {
}
function showMenu(ev) {
os.popupMenu([{
text: i18n.ts.instanceInfo,
icon: 'ti ti-info-circle',
action: () => {
os.pageWindow('/about');
},
}, {
text: i18n.ts.aboutMisskey,
icon: 'ti ti-info-circle',
action: () => {
os.pageWindow('/about-misskey');
},
}, { type: 'divider' }, (instance.impressumUrl) ? {
text: i18n.ts.impressum,
icon: 'ti ti-file-invoice',
action: () => {
window.open(instance.impressumUrl!, '_blank', 'noopener');
},
} : undefined, (instance.tosUrl) ? {
text: i18n.ts.termsOfService,
icon: 'ti ti-notebook',
action: () => {
window.open(instance.tosUrl!, '_blank', 'noopener');
},
} : undefined, (instance.privacyPolicyUrl) ? {
text: i18n.ts.privacyPolicy,
icon: 'ti ti-shield-lock',
action: () => {
window.open(instance.privacyPolicyUrl!, '_blank', 'noopener');
},
} : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl) ? undefined : { type: 'divider' }, {
text: i18n.ts.help,
icon: 'ti ti-help-circle',
action: () => {
window.open('https://misskey-hub.net/docs/for-users/', '_blank', 'noopener');
},
}], ev.currentTarget ?? ev.target);
openInstanceMenu(ev);
}
function exploreOtherServers() {

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