Merge branch 'develop' into pr/15512
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
"lint": "pnpm typecheck && pnpm eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@analytics/google-analytics": "1.1.0",
|
||||
"@discordapp/twemoji": "15.1.0",
|
||||
"@github/webauthn-json": "2.1.1",
|
||||
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
|
||||
@@ -29,16 +30,17 @@
|
||||
"@vitejs/plugin-vue": "5.2.1",
|
||||
"@vue/compiler-sfc": "3.5.13",
|
||||
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15",
|
||||
"analytics": "0.8.16",
|
||||
"astring": "1.9.0",
|
||||
"broadcast-channel": "7.0.0",
|
||||
"buraha": "0.0.1",
|
||||
"canvas-confetti": "1.9.3",
|
||||
"chart.js": "4.4.7",
|
||||
"chart.js": "4.4.8",
|
||||
"chartjs-adapter-date-fns": "3.0.0",
|
||||
"chartjs-chart-matrix": "2.0.1",
|
||||
"chartjs-plugin-gradient": "0.6.1",
|
||||
"chartjs-plugin-zoom": "2.2.0",
|
||||
"chromatic": "11.25.2",
|
||||
"chromatic": "11.26.1",
|
||||
"compare-versions": "6.1.1",
|
||||
"cropperjs": "2.0.0-rc.2",
|
||||
"date-fns": "4.1.0",
|
||||
@@ -57,84 +59,84 @@
|
||||
"misskey-reversi": "workspace:*",
|
||||
"photoswipe": "5.4.4",
|
||||
"punycode.js": "2.3.1",
|
||||
"rollup": "4.34.7",
|
||||
"rollup": "4.34.8",
|
||||
"sanitize-html": "2.14.0",
|
||||
"sass": "1.85.0",
|
||||
"shiki": "2.4.1",
|
||||
"sass": "1.85.1",
|
||||
"shiki": "3.1.0",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"textarea-caret": "3.1.0",
|
||||
"three": "0.173.0",
|
||||
"three": "0.174.0",
|
||||
"throttle-debounce": "5.0.2",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tsc-alias": "1.8.10",
|
||||
"tsc-alias": "1.8.11",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "5.7.3",
|
||||
"uuid": "11.0.5",
|
||||
"typescript": "5.8.2",
|
||||
"uuid": "11.1.0",
|
||||
"v-code-diff": "1.13.1",
|
||||
"vite": "6.1.0",
|
||||
"vite": "6.2.0",
|
||||
"vue": "3.5.13",
|
||||
"vuedraggable": "next"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/summaly": "5.2.0",
|
||||
"@storybook/addon-actions": "8.5.6",
|
||||
"@storybook/addon-essentials": "8.5.6",
|
||||
"@storybook/addon-interactions": "8.5.6",
|
||||
"@storybook/addon-links": "8.5.6",
|
||||
"@storybook/addon-mdx-gfm": "8.5.6",
|
||||
"@storybook/addon-storysource": "8.5.6",
|
||||
"@storybook/blocks": "8.5.6",
|
||||
"@storybook/components": "8.5.6",
|
||||
"@storybook/core-events": "8.5.6",
|
||||
"@storybook/manager-api": "8.5.6",
|
||||
"@storybook/preview-api": "8.5.6",
|
||||
"@storybook/react": "8.5.6",
|
||||
"@storybook/react-vite": "8.5.6",
|
||||
"@storybook/test": "8.5.6",
|
||||
"@storybook/theming": "8.5.6",
|
||||
"@storybook/types": "8.5.6",
|
||||
"@storybook/vue3": "8.5.6",
|
||||
"@storybook/vue3-vite": "8.5.6",
|
||||
"@storybook/addon-actions": "8.6.2",
|
||||
"@storybook/addon-essentials": "8.6.2",
|
||||
"@storybook/addon-interactions": "8.6.2",
|
||||
"@storybook/addon-links": "8.6.2",
|
||||
"@storybook/addon-mdx-gfm": "8.6.2",
|
||||
"@storybook/addon-storysource": "8.6.2",
|
||||
"@storybook/blocks": "8.6.2",
|
||||
"@storybook/components": "8.6.2",
|
||||
"@storybook/core-events": "8.6.2",
|
||||
"@storybook/manager-api": "8.6.2",
|
||||
"@storybook/preview-api": "8.6.2",
|
||||
"@storybook/react": "8.6.2",
|
||||
"@storybook/react-vite": "8.6.2",
|
||||
"@storybook/test": "8.6.2",
|
||||
"@storybook/theming": "8.6.2",
|
||||
"@storybook/types": "8.6.2",
|
||||
"@storybook/vue3": "8.6.2",
|
||||
"@storybook/vue3-vite": "8.6.2",
|
||||
"@testing-library/vue": "8.1.0",
|
||||
"@types/canvas-confetti": "1.9.0",
|
||||
"@types/estree": "1.0.6",
|
||||
"@types/matter-js": "0.19.8",
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "22.13.4",
|
||||
"@types/node": "22.13.7",
|
||||
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
||||
"@types/sanitize-html": "2.13.0",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@types/throttle-debounce": "5.0.2",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@types/ws": "8.5.14",
|
||||
"@typescript-eslint/eslint-plugin": "8.24.0",
|
||||
"@typescript-eslint/parser": "8.24.0",
|
||||
"@vitest/coverage-v8": "3.0.5",
|
||||
"@typescript-eslint/eslint-plugin": "8.25.0",
|
||||
"@typescript-eslint/parser": "8.25.0",
|
||||
"@vitest/coverage-v8": "3.0.7",
|
||||
"@vue/runtime-core": "3.5.13",
|
||||
"acorn": "8.14.0",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "14.0.3",
|
||||
"cypress": "14.1.0",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-vue": "9.32.0",
|
||||
"fast-glob": "3.3.3",
|
||||
"happy-dom": "17.1.0",
|
||||
"happy-dom": "17.1.8",
|
||||
"intersection-observer": "0.12.2",
|
||||
"micromatch": "4.0.8",
|
||||
"msw": "2.7.0",
|
||||
"msw": "2.7.3",
|
||||
"msw-storybook-addon": "2.0.4",
|
||||
"nodemon": "3.1.9",
|
||||
"prettier": "3.5.1",
|
||||
"prettier": "3.5.2",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"seedrandom": "3.0.5",
|
||||
"start-server-and-test": "2.0.10",
|
||||
"storybook": "8.5.6",
|
||||
"storybook": "8.6.2",
|
||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vitest": "3.0.5",
|
||||
"vitest-fetch-mock": "0.4.3",
|
||||
"vue-component-type-helpers": "2.2.2",
|
||||
"vitest": "3.0.7",
|
||||
"vitest-fetch-mock": "0.4.4",
|
||||
"vue-component-type-helpers": "2.2.4",
|
||||
"vue-eslint-parser": "9.4.3",
|
||||
"vue-tsc": "2.2.2"
|
||||
"vue-tsc": "2.2.4"
|
||||
}
|
||||
}
|
||||
|
@@ -7,6 +7,7 @@ import { defineAsyncComponent, reactive, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { apiUrl } from '@@/js/config.js';
|
||||
import type { MenuItem, MenuButton } from '@/types/menu.js';
|
||||
import { defaultMemoryStorage } from '@/memory-storage';
|
||||
import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
@@ -40,6 +41,8 @@ export function incNotesCount() {
|
||||
export async function signout() {
|
||||
if (!$i) return;
|
||||
|
||||
defaultMemoryStorage.clear();
|
||||
|
||||
waiting();
|
||||
document.cookie.split(';').forEach((cookie) => {
|
||||
const cookieName = cookie.split('=')[0].trim();
|
||||
@@ -107,7 +110,7 @@ export async function removeAccount(idOrToken: Account['id']) {
|
||||
}
|
||||
|
||||
function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise<Account> {
|
||||
document.cookie = "token=; path=/; max-age=0";
|
||||
document.cookie = 'token=; path=/; max-age=0';
|
||||
document.cookie = `token=${token}; path=/queue; max-age=86400; SameSite=Strict; Secure`; // bull dashboardの認証とかで使う
|
||||
|
||||
return new Promise((done, fail) => {
|
||||
|
107
packages/frontend/src/analytics.ts
Normal file
107
packages/frontend/src/analytics.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as Misskey from 'misskey-js';
|
||||
import type { AnalyticsInstance, AnalyticsPlugin } from 'analytics';
|
||||
|
||||
/**
|
||||
* analytics moduleを読み込まなくても動作するようにするためのラッパー
|
||||
*/
|
||||
class AnalyticsProxy implements AnalyticsInstance {
|
||||
private analytics?: AnalyticsInstance;
|
||||
|
||||
constructor(analytics?: AnalyticsInstance) {
|
||||
if (analytics) {
|
||||
this.analytics = analytics;
|
||||
}
|
||||
}
|
||||
|
||||
public setAnalytics(analytics: AnalyticsInstance) {
|
||||
if (this.analytics) {
|
||||
throw new Error('Analytics instance already exists.');
|
||||
}
|
||||
this.analytics = analytics;
|
||||
}
|
||||
|
||||
public identify(...args: Parameters<AnalyticsInstance['identify']>) {
|
||||
return this.analytics?.identify(...args) ?? Promise.resolve();
|
||||
}
|
||||
|
||||
public track(...args: Parameters<AnalyticsInstance['track']>) {
|
||||
return this.analytics?.track(...args) ?? Promise.resolve();
|
||||
}
|
||||
|
||||
public page(...args: Parameters<AnalyticsInstance['page']>) {
|
||||
return this.analytics?.page(...args) ?? Promise.resolve();
|
||||
}
|
||||
|
||||
public user(...args: Parameters<AnalyticsInstance['user']>) {
|
||||
return this.analytics?.user(...args) ?? Promise.resolve();
|
||||
}
|
||||
|
||||
public reset(...args: Parameters<AnalyticsInstance['reset']>) {
|
||||
return this.analytics?.reset(...args) ?? Promise.resolve();
|
||||
}
|
||||
|
||||
public ready(...args: Parameters<AnalyticsInstance['ready']>) {
|
||||
return this.analytics?.ready(...args) ?? function () { void 0; };
|
||||
}
|
||||
|
||||
public on(...args: Parameters<AnalyticsInstance['on']>) {
|
||||
return this.analytics?.on(...args) ?? function () { void 0; };
|
||||
}
|
||||
|
||||
public once(...args: Parameters<AnalyticsInstance['once']>) {
|
||||
return this.analytics?.once(...args) ?? function () { void 0; };
|
||||
}
|
||||
|
||||
public getState(...args: Parameters<AnalyticsInstance['getState']>) {
|
||||
return this.analytics?.getState(...args) ?? Promise.resolve();
|
||||
}
|
||||
|
||||
public get storage() {
|
||||
return this.analytics?.storage ?? {
|
||||
getItem: () => null,
|
||||
setItem: () => void 0,
|
||||
removeItem: () => void 0,
|
||||
};
|
||||
}
|
||||
|
||||
public get plugins() {
|
||||
return this.analytics?.plugins ?? {
|
||||
enable: (p, c) => Promise.resolve(c ? c() : void 0),
|
||||
disable: (p, c) => Promise.resolve(c ? c() : void 0),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const analytics = new AnalyticsProxy();
|
||||
|
||||
export async function initAnalytics(instance: Misskey.entities.MetaDetailed) {
|
||||
// アナリティクスプロバイダに関する設定がひとつもない場合は、アナリティクスモジュールを読み込まない
|
||||
if (!instance.googleAnalyticsMeasurementId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { default: Analytics } = await import('analytics');
|
||||
const plugins: AnalyticsPlugin[] = [];
|
||||
|
||||
// Google Analytics
|
||||
if (instance.googleAnalyticsMeasurementId) {
|
||||
const { default: googleAnalytics } = await import('@analytics/google-analytics');
|
||||
|
||||
plugins.push(googleAnalytics({
|
||||
measurementIds: [instance.googleAnalyticsMeasurementId],
|
||||
debug: _DEV_,
|
||||
}));
|
||||
}
|
||||
|
||||
analytics.setAnalytics(Analytics({
|
||||
app: 'misskey',
|
||||
version: _VERSION_,
|
||||
debug: _DEV_,
|
||||
plugins,
|
||||
}));
|
||||
}
|
@@ -4,9 +4,9 @@
|
||||
*/
|
||||
|
||||
import { computed, watch, version as vueVersion } from 'vue';
|
||||
import type { App } from 'vue';
|
||||
import { compareVersions } from 'compare-versions';
|
||||
import { version, lang, updateLocale, locale } from '@@/js/config.js';
|
||||
import type { App } from 'vue';
|
||||
import widgets from '@/widgets/index.js';
|
||||
import directives from '@/directives/index.js';
|
||||
import components from '@/components/index.js';
|
||||
@@ -21,6 +21,7 @@ import { reloadChannel } from '@/scripts/unison-reload.js';
|
||||
import { getUrlWithoutLoginId } from '@/scripts/login-id.js';
|
||||
import { getAccountFromId } from '@/scripts/get-account-from-id.js';
|
||||
import { deckStore } from '@/ui/deck/deck-store.js';
|
||||
import { analytics, initAnalytics } from '@/analytics.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { fetchCustomEmojis } from '@/custom-emojis.js';
|
||||
import { setupRouter } from '@/router/main.js';
|
||||
@@ -241,6 +242,19 @@ export async function common(createVue: () => App<Element>) {
|
||||
await fetchCustomEmojis();
|
||||
} catch (err) { /* empty */ }
|
||||
|
||||
// analytics
|
||||
fetchInstanceMetaPromise.then(async () => {
|
||||
await initAnalytics(instance);
|
||||
|
||||
if ($i) {
|
||||
analytics.identify($i.id);
|
||||
}
|
||||
|
||||
analytics.page({
|
||||
path: window.location.pathname,
|
||||
});
|
||||
});
|
||||
|
||||
const app = createVue();
|
||||
|
||||
setupRouter(app, createMainRouter);
|
||||
|
@@ -49,6 +49,7 @@ import sanitizeHtml from 'sanitize-html';
|
||||
import { emojilist, getEmojiName } from '@@/js/emojilist.js';
|
||||
import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@@/js/emoji-base.js';
|
||||
import { MFM_TAGS, MFM_PARAMS } from '@@/js/const.js';
|
||||
import type { EmojiDef } from '@/scripts/search-emoji.js';
|
||||
import contains from '@/scripts/contains.js';
|
||||
import { acct } from '@/filters/user.js';
|
||||
import * as os from '@/os.js';
|
||||
@@ -58,7 +59,6 @@ import { i18n } from '@/i18n.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { customEmojis } from '@/custom-emojis.js';
|
||||
import { searchEmoji } from '@/scripts/search-emoji.js';
|
||||
import type { EmojiDef } from '@/scripts/search-emoji.js';
|
||||
|
||||
const lib = emojilist.filter(x => x.category !== 'flags');
|
||||
|
||||
@@ -198,8 +198,10 @@ function exec() {
|
||||
users.value = JSON.parse(cache);
|
||||
fetching.value = false;
|
||||
} else {
|
||||
const [username, host] = props.q.toString().split('@');
|
||||
misskeyApi('users/search-by-username-and-host', {
|
||||
username: props.q,
|
||||
username: username,
|
||||
host: host,
|
||||
limit: 10,
|
||||
detail: false,
|
||||
}).then(searchedUsers => {
|
||||
|
@@ -257,7 +257,7 @@ import type { Keymap } from '@/scripts/hotkey.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
initialTab: string;
|
||||
initialTab?: string;
|
||||
}>(), {
|
||||
initialTab: 'replies',
|
||||
});
|
||||
|
@@ -32,6 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
import { computed, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue';
|
||||
import { url } from '@@/js/config.js';
|
||||
import { getScrollContainer } from '@@/js/scroll.js';
|
||||
import type { PageMetadata } from '@/scripts/page-metadata.js';
|
||||
import RouterView from '@/components/global/RouterView.vue';
|
||||
import MkWindow from '@/components/MkWindow.vue';
|
||||
import { popout as _popout } from '@/scripts/popout.js';
|
||||
@@ -39,11 +40,11 @@ import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
|
||||
import { useScrollPositionManager } from '@/nirax.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
|
||||
import type { PageMetadata } from '@/scripts/page-metadata.js';
|
||||
import { openingWindowsCount } from '@/os.js';
|
||||
import { claimAchievement } from '@/scripts/achievements.js';
|
||||
import { useRouterFactory } from '@/router/supplier.js';
|
||||
import { mainRouter } from '@/router/main.js';
|
||||
import { analytics } from '@/analytics.js';
|
||||
|
||||
const props = defineProps<{
|
||||
initialPath: string;
|
||||
@@ -99,6 +100,14 @@ windowRouter.addListener('replace', ctx => {
|
||||
history.value.push({ path: ctx.path, key: ctx.key });
|
||||
});
|
||||
|
||||
windowRouter.addListener('change', ctx => {
|
||||
console.log('windowRouter: change', ctx.path);
|
||||
analytics.page({
|
||||
path: ctx.path,
|
||||
title: ctx.path,
|
||||
});
|
||||
});
|
||||
|
||||
windowRouter.init();
|
||||
|
||||
provide('router', windowRouter);
|
||||
@@ -160,6 +169,11 @@ function popout() {
|
||||
useScrollPositionManager(() => getScrollContainer(contents.value), windowRouter);
|
||||
|
||||
onMounted(() => {
|
||||
analytics.page({
|
||||
path: props.initialPath,
|
||||
title: props.initialPath,
|
||||
});
|
||||
|
||||
openingWindowsCount.value++;
|
||||
if (openingWindowsCount.value >= 3) {
|
||||
claimAchievement('open3windows');
|
||||
|
@@ -4,14 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i>{{ i18n.ts.remoteUserCaution }}<a :class="$style.link" :href="href" rel="nofollow noopener" target="_blank">{{ i18n.ts.showOnRemote }}</a></div>
|
||||
<div :class="$style.root"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i>{{ i18n.ts.remoteUserCaution }}<a v-if="href" :class="$style.link" :href="href" rel="nofollow noopener" target="_blank">{{ i18n.ts.showOnRemote }}</a></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
defineProps<{
|
||||
href: string;
|
||||
href?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
|
@@ -41,11 +41,28 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, nextTick, ref, watch, computed, toRefs, useSlots } from 'vue';
|
||||
import type { VNode, VNodeChild } from 'vue';
|
||||
import { useInterval } from '@@/js/use-interval.js';
|
||||
import type { VNode, VNodeChild } from 'vue';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import * as os from '@/os.js';
|
||||
|
||||
type ItemOption = {
|
||||
type?: 'option';
|
||||
value: string | number | null;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type ItemGroup = {
|
||||
type: 'group';
|
||||
label: string;
|
||||
items: ItemOption[];
|
||||
};
|
||||
|
||||
export type MkSelectItem = ItemOption | ItemGroup;
|
||||
|
||||
// TODO: itemsをslot内のoptionで指定する用法は廃止する(props.itemsを必須化する)
|
||||
// see: https://github.com/misskey-dev/misskey/issues/15558
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | number | null;
|
||||
required?: boolean;
|
||||
@@ -56,6 +73,7 @@ const props = defineProps<{
|
||||
inline?: boolean;
|
||||
small?: boolean;
|
||||
large?: boolean;
|
||||
items?: MkSelectItem[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -107,7 +125,30 @@ onMounted(() => {
|
||||
});
|
||||
});
|
||||
|
||||
watch(modelValue, () => {
|
||||
watch([modelValue, () => props.items], () => {
|
||||
if (props.items) {
|
||||
let found: ItemOption | null = null;
|
||||
for (const item of props.items) {
|
||||
if (item.type === 'group') {
|
||||
for (const option of item.items) {
|
||||
if (option.value === modelValue.value) {
|
||||
found = option;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (item.value === modelValue.value) {
|
||||
found = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (found) {
|
||||
currentValueText.value = found.label;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const scanOptions = (options: VNodeChild[]) => {
|
||||
for (const vnode of options) {
|
||||
if (typeof vnode !== 'object' || vnode === null || Array.isArray(vnode)) continue;
|
||||
@@ -130,7 +171,7 @@ watch(modelValue, () => {
|
||||
};
|
||||
|
||||
scanOptions(slots.default!());
|
||||
}, { immediate: true });
|
||||
}, { immediate: true, deep: true });
|
||||
|
||||
function show() {
|
||||
if (opening.value) return;
|
||||
@@ -139,41 +180,70 @@ function show() {
|
||||
opening.value = true;
|
||||
|
||||
const menu: MenuItem[] = [];
|
||||
let options = slots.default!();
|
||||
|
||||
const pushOption = (option: VNode) => {
|
||||
menu.push({
|
||||
text: option.children as string,
|
||||
active: computed(() => modelValue.value === option.props?.value),
|
||||
action: () => {
|
||||
emit('update:modelValue', option.props?.value);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const scanOptions = (options: VNodeChild[]) => {
|
||||
for (const vnode of options) {
|
||||
if (typeof vnode !== 'object' || vnode === null || Array.isArray(vnode)) continue;
|
||||
if (vnode.type === 'optgroup') {
|
||||
const optgroup = vnode;
|
||||
if (props.items) {
|
||||
for (const item of props.items) {
|
||||
if (item.type === 'group') {
|
||||
menu.push({
|
||||
type: 'label',
|
||||
text: optgroup.props?.label,
|
||||
text: item.label,
|
||||
});
|
||||
if (Array.isArray(optgroup.children)) scanOptions(optgroup.children);
|
||||
} else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある
|
||||
const fragment = vnode;
|
||||
if (Array.isArray(fragment.children)) scanOptions(fragment.children);
|
||||
} else if (vnode.props == null) { // v-if で条件が false のときにこうなる
|
||||
// nop?
|
||||
for (const option of item.items) {
|
||||
menu.push({
|
||||
text: option.label,
|
||||
active: computed(() => modelValue.value === option.value),
|
||||
action: () => {
|
||||
emit('update:modelValue', option.value);
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const option = vnode;
|
||||
pushOption(option);
|
||||
menu.push({
|
||||
text: item.label,
|
||||
active: computed(() => modelValue.value === item.value),
|
||||
action: () => {
|
||||
emit('update:modelValue', item.value);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
} else {
|
||||
let options = slots.default!();
|
||||
|
||||
scanOptions(options);
|
||||
const pushOption = (option: VNode) => {
|
||||
menu.push({
|
||||
text: option.children as string,
|
||||
active: computed(() => modelValue.value === option.props?.value),
|
||||
action: () => {
|
||||
emit('update:modelValue', option.props?.value);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const scanOptions = (options: VNodeChild[]) => {
|
||||
for (const vnode of options) {
|
||||
if (typeof vnode !== 'object' || vnode === null || Array.isArray(vnode)) continue;
|
||||
if (vnode.type === 'optgroup') {
|
||||
const optgroup = vnode;
|
||||
menu.push({
|
||||
type: 'label',
|
||||
text: optgroup.props?.label,
|
||||
});
|
||||
if (Array.isArray(optgroup.children)) scanOptions(optgroup.children);
|
||||
} else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある
|
||||
const fragment = vnode;
|
||||
if (Array.isArray(fragment.children)) scanOptions(fragment.children);
|
||||
} else if (vnode.props == null) { // v-if で条件が false のときにこうなる
|
||||
// nop?
|
||||
} else {
|
||||
const option = vnode;
|
||||
pushOption(option);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
scanOptions(options);
|
||||
}
|
||||
|
||||
os.popupMenu(menu, container.value, {
|
||||
width: container.value?.offsetWidth,
|
||||
|
57
packages/frontend/src/memory-storage.ts
Normal file
57
packages/frontend/src/memory-storage.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export type MemoryStorage = {
|
||||
has: (key: string) => boolean;
|
||||
getItem: <T>(key: string) => T | null;
|
||||
setItem: (key: string, value: unknown) => void;
|
||||
removeItem: (key: string) => void;
|
||||
clear: () => void;
|
||||
size: number;
|
||||
};
|
||||
|
||||
class MemoryStorageImpl implements MemoryStorage {
|
||||
private readonly storage: Map<string, unknown>;
|
||||
|
||||
constructor() {
|
||||
this.storage = new Map();
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return this.storage.has(key);
|
||||
}
|
||||
|
||||
getItem<T>(key: string): T | null {
|
||||
return this.storage.has(key) ? this.storage.get(key) as T : null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: unknown): void {
|
||||
this.storage.set(key, value);
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.storage.delete(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.storage.clear();
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.storage.size;
|
||||
}
|
||||
}
|
||||
|
||||
export function createMemoryStorage(): MemoryStorage {
|
||||
return new MemoryStorageImpl();
|
||||
}
|
||||
|
||||
/**
|
||||
* SessionStorageよりも更に短い期間でクリアされるストレージです
|
||||
* - ブラウザの再読み込みやタブの閉じると内容が揮発します
|
||||
* - このストレージは他のタブと共有されません
|
||||
* - アカウント切り替えやログアウトを行うと内容が揮発します
|
||||
*/
|
||||
export const defaultMemoryStorage: MemoryStorage = createMemoryStorage();
|
@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MkInfo v-if="['instance.actor', 'relay.actor'].includes(user.username)">{{ i18n.ts.isSystemAccount }}</MkInfo>
|
||||
<MkInfo v-if="isSystem">{{ i18n.ts.isSystemAccount }}</MkInfo>
|
||||
|
||||
<FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ i18n.ts.instanceInfo }}</FormLink>
|
||||
|
||||
@@ -37,21 +37,23 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template #value><span class="_monospace">{{ ips[0].ip }}</span></template>
|
||||
</MkKeyValue>
|
||||
-->
|
||||
<MkKeyValue oneline>
|
||||
<template #key>{{ i18n.ts.createdAt }}</template>
|
||||
<template #value><span class="_monospace"><MkTime :time="user.createdAt" :mode="'detail'"/></span></template>
|
||||
</MkKeyValue>
|
||||
<MkKeyValue v-if="info" oneline>
|
||||
<template #key>{{ i18n.ts.lastActiveDate }}</template>
|
||||
<template #value><span class="_monospace"><MkTime :time="info.lastActiveDate" :mode="'detail'"/></span></template>
|
||||
</MkKeyValue>
|
||||
<MkKeyValue v-if="info" oneline>
|
||||
<template #key>{{ i18n.ts.email }}</template>
|
||||
<template #value><span class="_monospace">{{ info.email }}</span></template>
|
||||
</MkKeyValue>
|
||||
<template v-if="!isSystem">
|
||||
<MkKeyValue oneline>
|
||||
<template #key>{{ i18n.ts.createdAt }}</template>
|
||||
<template #value><span class="_monospace"><MkTime :time="user.createdAt" :mode="'detail'"/></span></template>
|
||||
</MkKeyValue>
|
||||
<MkKeyValue v-if="info" oneline>
|
||||
<template #key>{{ i18n.ts.lastActiveDate }}</template>
|
||||
<template #value><span class="_monospace"><MkTime :time="info.lastActiveDate" :mode="'detail'"/></span></template>
|
||||
</MkKeyValue>
|
||||
<MkKeyValue v-if="info" oneline>
|
||||
<template #key>{{ i18n.ts.email }}</template>
|
||||
<template #value><span class="_monospace">{{ info.email }}</span></template>
|
||||
</MkKeyValue>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<MkTextarea v-model="moderationNote" manualSave>
|
||||
<MkTextarea v-if="!isSystem" v-model="moderationNote" manualSave>
|
||||
<template #label>{{ i18n.ts.moderationNote }}</template>
|
||||
<template #caption>{{ i18n.ts.moderationNoteDescription }}</template>
|
||||
</MkTextarea>
|
||||
@@ -92,7 +94,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</FormSection>
|
||||
-->
|
||||
|
||||
<FormSection>
|
||||
<FormSection v-if="!isSystem">
|
||||
<div class="_gaps">
|
||||
<MkSwitch v-model="suspended" @update:modelValue="toggleSuspend">{{ i18n.ts.suspend }}</MkSwitch>
|
||||
|
||||
@@ -252,6 +254,7 @@ const ap = ref<any>(null);
|
||||
const moderator = ref(false);
|
||||
const silenced = ref(false);
|
||||
const suspended = ref(false);
|
||||
const isSystem = ref(false);
|
||||
const moderationNote = ref('');
|
||||
const filesPagination = {
|
||||
endpoint: 'admin/drive/files' as const,
|
||||
@@ -288,6 +291,7 @@ function createFetcher() {
|
||||
silenced.value = info.value.isSilenced;
|
||||
suspended.value = info.value.isSuspended;
|
||||
moderationNote.value = info.value.moderationNote;
|
||||
isSystem.value = user.value.host == null && user.value.username.includes('.');
|
||||
|
||||
watch(moderationNote, async () => {
|
||||
await misskeyApi('admin/update-user-note', { userId: user.value.id, text: moderationNote.value });
|
||||
@@ -507,7 +511,15 @@ watch(user, () => {
|
||||
|
||||
const headerActions = computed(() => []);
|
||||
|
||||
const headerTabs = computed(() => [{
|
||||
const headerTabs = computed(() => isSystem.value ? [{
|
||||
key: 'overview',
|
||||
title: i18n.ts.overview,
|
||||
icon: 'ti ti-info-circle',
|
||||
}, {
|
||||
key: 'raw',
|
||||
title: 'Raw',
|
||||
icon: 'ti ti-code',
|
||||
}] : [{
|
||||
key: 'overview',
|
||||
title: i18n.ts.overview,
|
||||
icon: 'ti ti-info-circle',
|
||||
|
@@ -8,20 +8,34 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
|
||||
<FormSuspense :p="init">
|
||||
<MkFolder>
|
||||
<template #label>DeepL Translation</template>
|
||||
<div class="_gaps_m">
|
||||
<MkFolder>
|
||||
<template #label>Google Analytics<span class="_beta">{{ i18n.ts.beta }}</span></template>
|
||||
|
||||
<div class="_gaps_m">
|
||||
<MkInput v-model="deeplAuthKey">
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>DeepL Auth Key</template>
|
||||
</MkInput>
|
||||
<MkSwitch v-model="deeplIsPro">
|
||||
<template #label>Pro account</template>
|
||||
</MkSwitch>
|
||||
<MkButton primary @click="save_deepl">Save</MkButton>
|
||||
</div>
|
||||
</MkFolder>
|
||||
<div class="_gaps_m">
|
||||
<MkInput v-model="googleAnalyticsMeasurementId">
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>Measurement ID</template>
|
||||
</MkInput>
|
||||
<MkButton primary @click="save_googleAnalytics">Save</MkButton>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder>
|
||||
<template #label>DeepL Translation</template>
|
||||
|
||||
<div class="_gaps_m">
|
||||
<MkInput v-model="deeplAuthKey">
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>DeepL Auth Key</template>
|
||||
</MkInput>
|
||||
<MkSwitch v-model="deeplIsPro">
|
||||
<template #label>Pro account</template>
|
||||
</MkSwitch>
|
||||
<MkButton primary @click="save_deepl">Save</MkButton>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</div>
|
||||
</FormSuspense>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
@@ -44,10 +58,13 @@ import MkFolder from '@/components/MkFolder.vue';
|
||||
const deeplAuthKey = ref<string>('');
|
||||
const deeplIsPro = ref<boolean>(false);
|
||||
|
||||
const googleAnalyticsMeasurementId = ref<string>('');
|
||||
|
||||
async function init() {
|
||||
const meta = await misskeyApi('admin/meta');
|
||||
deeplAuthKey.value = meta.deeplAuthKey;
|
||||
deeplAuthKey.value = meta.deeplAuthKey ?? '';
|
||||
deeplIsPro.value = meta.deeplIsPro;
|
||||
googleAnalyticsMeasurementId.value = meta.googleAnalyticsMeasurementId ?? '';
|
||||
}
|
||||
|
||||
function save_deepl() {
|
||||
@@ -59,6 +76,14 @@ function save_deepl() {
|
||||
});
|
||||
}
|
||||
|
||||
function save_googleAnalytics() {
|
||||
os.apiWithDialog('admin/update-meta', {
|
||||
googleAnalyticsMeasurementId: googleAnalyticsMeasurementId.value,
|
||||
}).then(() => {
|
||||
fetchInstance(true);
|
||||
});
|
||||
}
|
||||
|
||||
const headerActions = computed(() => []);
|
||||
|
||||
const headerTabs = computed(() => []);
|
||||
|
@@ -170,6 +170,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<CodeDiff :context="5" :hideHeader="true" :oldString="log.info.before ?? ''" :newString="log.info.after ?? ''" maxHeight="300px"/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="log.type === 'updateProxyAccountDescription'">
|
||||
<div :class="$style.diff">
|
||||
<CodeDiff :context="5" :hideHeader="true" :oldString="log.info.before ?? ''" :newString="log.info.after ?? ''" maxHeight="300px"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<details>
|
||||
<summary>raw</summary>
|
||||
|
@@ -238,15 +238,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<MkFolder>
|
||||
<template #icon><i class="ti ti-ghost"></i></template>
|
||||
<template #label>{{ i18n.ts.proxyAccount }}</template>
|
||||
<template v-if="proxyAccountForm.modified.value" #footer>
|
||||
<MkFormFooter :form="proxyAccountForm"/>
|
||||
</template>
|
||||
|
||||
<div class="_gaps">
|
||||
<MkInfo>{{ i18n.ts.proxyAccountDescription }}</MkInfo>
|
||||
<MkKeyValue>
|
||||
<template #key>{{ i18n.ts.proxyAccount }}</template>
|
||||
<template #value>{{ proxyAccount ? `@${proxyAccount.username}` : i18n.ts.none }}</template>
|
||||
</MkKeyValue>
|
||||
|
||||
<MkButton primary @click="chooseProxyAccount">{{ i18n.ts.selectAccount }}</MkButton>
|
||||
<MkTextarea v-model="proxyAccountForm.state.description" :max="500" tall mfmAutocomplete :mfmPreview="true">
|
||||
<template #label>{{ i18n.ts._profile.description }}</template>
|
||||
<template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template>
|
||||
</MkTextarea>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</div>
|
||||
@@ -256,7 +258,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, reactive } from 'vue';
|
||||
import XHeader from './_header_.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
@@ -277,7 +279,7 @@ import MkRadios from '@/components/MkRadios.vue';
|
||||
|
||||
const meta = await misskeyApi('admin/meta');
|
||||
|
||||
const proxyAccount = ref(meta.proxyAccountId ? await misskeyApi('users/show', { userId: meta.proxyAccountId }) : null);
|
||||
const proxyAccount = await misskeyApi('users/show', { userId: meta.proxyAccountId });
|
||||
|
||||
const infoForm = useForm({
|
||||
name: meta.name ?? '',
|
||||
@@ -378,16 +380,14 @@ const federationForm = useForm({
|
||||
fetchInstance(true);
|
||||
});
|
||||
|
||||
function chooseProxyAccount() {
|
||||
os.selectUser({ localOnly: true }).then(user => {
|
||||
proxyAccount.value = user;
|
||||
os.apiWithDialog('admin/update-meta', {
|
||||
proxyAccountId: user.id,
|
||||
}).then(() => {
|
||||
fetchInstance(true);
|
||||
});
|
||||
const proxyAccountForm = useForm({
|
||||
description: proxyAccount.description,
|
||||
}, async (state) => {
|
||||
await os.apiWithDialog('admin/update-proxy-account', {
|
||||
description: state.description,
|
||||
});
|
||||
}
|
||||
fetchInstance(true);
|
||||
});
|
||||
|
||||
const headerTabs = computed(() => []);
|
||||
|
||||
|
@@ -9,6 +9,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :contentMax="900">
|
||||
<div class="_gaps">
|
||||
<div :class="$style.inputs">
|
||||
<MkButton style="margin-left: auto" @click="resetQuery">{{ i18n.ts.reset }}</MkButton>
|
||||
</div>
|
||||
<div :class="$style.inputs">
|
||||
<MkSelect v-model="sort" style="flex: 1;">
|
||||
<template #label>{{ i18n.ts.sort }}</template>
|
||||
@@ -57,8 +60,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, shallowRef, ref } from 'vue';
|
||||
import { computed, shallowRef, ref, watchEffect } from 'vue';
|
||||
import XHeader from './_header_.vue';
|
||||
import { defaultMemoryStorage } from '@/memory-storage';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
@@ -69,13 +74,22 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
||||
import { dateString } from '@/filters/date.js';
|
||||
|
||||
const paginationComponent = shallowRef<InstanceType<typeof MkPagination>>();
|
||||
type SearchQuery = {
|
||||
sort?: string;
|
||||
state?: string;
|
||||
origin?: string;
|
||||
username?: string;
|
||||
hostname?: string;
|
||||
};
|
||||
|
||||
const sort = ref('+createdAt');
|
||||
const state = ref('all');
|
||||
const origin = ref('local');
|
||||
const searchUsername = ref('');
|
||||
const searchHost = ref('');
|
||||
const paginationComponent = shallowRef<InstanceType<typeof MkPagination>>();
|
||||
const storedQuery = JSON.parse(defaultMemoryStorage.getItem('admin-users-query') ?? '{}') as SearchQuery;
|
||||
|
||||
const sort = ref(storedQuery.sort ?? '+createdAt');
|
||||
const state = ref(storedQuery.state ?? 'all');
|
||||
const origin = ref(storedQuery.origin ?? 'local');
|
||||
const searchUsername = ref(storedQuery.username ?? '');
|
||||
const searchHost = ref(storedQuery.hostname ?? '');
|
||||
const pagination = {
|
||||
endpoint: 'admin/show-users' as const,
|
||||
limit: 10,
|
||||
@@ -119,6 +133,14 @@ function show(user) {
|
||||
os.pageWindow(`/admin/user/${user.id}`);
|
||||
}
|
||||
|
||||
function resetQuery() {
|
||||
sort.value = '+createdAt';
|
||||
state.value = 'all';
|
||||
origin.value = 'local';
|
||||
searchUsername.value = '';
|
||||
searchHost.value = '';
|
||||
}
|
||||
|
||||
const headerActions = computed(() => [{
|
||||
icon: 'ti ti-search',
|
||||
text: i18n.ts.search,
|
||||
@@ -137,6 +159,16 @@ const headerActions = computed(() => [{
|
||||
|
||||
const headerTabs = computed(() => []);
|
||||
|
||||
watchEffect(() => {
|
||||
defaultMemoryStorage.setItem('admin-users-query', JSON.stringify({
|
||||
sort: sort.value,
|
||||
state: state.value,
|
||||
origin: origin.value,
|
||||
username: searchUsername.value,
|
||||
hostname: searchHost.value,
|
||||
}));
|
||||
});
|
||||
|
||||
definePageMetadata(() => ({
|
||||
title: i18n.ts.users,
|
||||
icon: 'ti ti-users',
|
||||
|
@@ -63,6 +63,7 @@ import { dateString } from '@/filters/date.js';
|
||||
import MkClipPreview from '@/components/MkClipPreview.vue';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { pleaseLogin } from '@/scripts/please-login.js';
|
||||
import { getAppearNote } from '@/scripts/get-appear-note.js';
|
||||
import { serverContext, assertServerContext } from '@/server-context.js';
|
||||
import { $i } from '@/account.js';
|
||||
|
||||
@@ -132,10 +133,11 @@ function fetchNote() {
|
||||
noteId: props.noteId,
|
||||
}).then(res => {
|
||||
note.value = res;
|
||||
const appearNote = getAppearNote(res);
|
||||
// 古いノートは被クリップ数をカウントしていないので、2023-10-01以前のものは強制的にnotes/clipsを叩く
|
||||
if (note.value.clippedCount > 0 || new Date(note.value.createdAt).getTime() < new Date('2023-10-01').getTime()) {
|
||||
if ((appearNote.clippedCount ?? 0) > 0 || new Date(appearNote.createdAt).getTime() < new Date('2023-10-01').getTime()) {
|
||||
misskeyApi('notes/clips', {
|
||||
noteId: note.value.id,
|
||||
noteId: appearNote.id,
|
||||
}).then((_clips) => {
|
||||
clips.value = _clips;
|
||||
});
|
||||
@@ -170,7 +172,7 @@ definePageMetadata(() => ({
|
||||
avatar: note.value.user,
|
||||
path: `/notes/${note.value.id}`,
|
||||
share: {
|
||||
title: i18n.tsx.noteOf({ user: note.value.user.name }),
|
||||
title: i18n.tsx.noteOf({ user: note.value.user.name ?? note.value.user.username }),
|
||||
text: note.value.text,
|
||||
},
|
||||
} : {},
|
||||
|
@@ -6,69 +6,127 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template>
|
||||
<div class="_gaps">
|
||||
<div class="_gaps">
|
||||
<MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter.prevent="search">
|
||||
<MkInput
|
||||
v-model="searchQuery"
|
||||
large
|
||||
autofocus
|
||||
type="search"
|
||||
@enter.prevent="search"
|
||||
>
|
||||
<template #prefix><i class="ti ti-search"></i></template>
|
||||
</MkInput>
|
||||
<MkFoldableSection :expanded="true">
|
||||
<MkFoldableSection expanded>
|
||||
<template #header>{{ i18n.ts.options }}</template>
|
||||
|
||||
<div class="_gaps_m">
|
||||
<template v-if="instance.federation !== 'none'">
|
||||
<MkRadios v-model="hostSelect">
|
||||
<template #label>{{ i18n.ts.host }}</template>
|
||||
<option value="all" default>{{ i18n.ts.all }}</option>
|
||||
<option value="local">{{ i18n.ts.local }}</option>
|
||||
<option v-if="noteSearchableScope === 'global'" value="specified">{{ i18n.ts.specifyHost }}</option>
|
||||
</MkRadios>
|
||||
<MkInput v-if="noteSearchableScope === 'global'" v-model="hostInput" :disabled="hostSelect !== 'specified'" :large="true" type="search">
|
||||
<MkRadios v-model="searchScope">
|
||||
<option v-if="instance.federation !== 'none' && noteSearchableScope === 'global'" value="all">{{ i18n.ts._search.searchScopeAll }}</option>
|
||||
<option value="local">{{ instance.federation === 'none' ? i18n.ts._search.searchScopeAll : i18n.ts._search.searchScopeLocal }}</option>
|
||||
<option v-if="instance.federation !== 'none' && noteSearchableScope === 'global'" value="server">{{ i18n.ts._search.searchScopeServer }}</option>
|
||||
<option value="user">{{ i18n.ts._search.searchScopeUser }}</option>
|
||||
</MkRadios>
|
||||
|
||||
<div v-if="instance.federation !== 'none' && searchScope === 'server'" :class="$style.subOptionRoot">
|
||||
<MkInput
|
||||
v-model="hostInput"
|
||||
:placeholder="i18n.ts._search.serverHostPlaceholder"
|
||||
@enter.prevent="search"
|
||||
>
|
||||
<template #label>{{ i18n.ts._search.pleaseEnterServerHost }}</template>
|
||||
<template #prefix><i class="ti ti-server"></i></template>
|
||||
</MkInput>
|
||||
</template>
|
||||
|
||||
<MkFolder :defaultOpen="true">
|
||||
<template #label>{{ i18n.ts.specifyUser }}</template>
|
||||
<template v-if="user" #suffix>@{{ user.username }}{{ user.host ? `@${user.host}` : "" }}</template>
|
||||
</div>
|
||||
|
||||
<div v-if="searchScope === 'user'" :class="$style.subOptionRoot">
|
||||
<div :class="$style.userSelectLabel">{{ i18n.ts._search.pleaseSelectUser }}</div>
|
||||
<div class="_gaps">
|
||||
<div :class="$style.userItem">
|
||||
<MkUserCardMini v-if="user" :class="$style.userCard" :user="user" :withChart="false"/>
|
||||
<MkButton v-if="user == null && $i != null" transparent :class="$style.addMeButton" @click="selectSelf"><div :class="$style.addUserButtonInner"><span><i class="ti ti-plus"></i><i class="ti ti-user"></i></span><span>{{ i18n.ts.selectSelf }}</span></div></MkButton>
|
||||
<MkButton v-if="user == null" transparent :class="$style.addUserButton" @click="selectUser"><div :class="$style.addUserButtonInner"><i class="ti ti-plus"></i><span>{{ i18n.ts.selectUser }}</span></div></MkButton>
|
||||
<button class="_button" :class="$style.remove" :disabled="user == null" @click="removeUser"><i class="ti ti-x"></i></button>
|
||||
<div v-if="user == null" :class="$style.userSelectButtons">
|
||||
<div v-if="$i != null">
|
||||
<MkButton
|
||||
transparent
|
||||
:class="$style.userSelectButton"
|
||||
@click="selectSelf"
|
||||
>
|
||||
<div :class="$style.userSelectButtonInner">
|
||||
<span><i class="ti ti-plus"></i><i class="ti ti-user"></i></span>
|
||||
<span>{{ i18n.ts.selectSelf }}</span>
|
||||
</div>
|
||||
</MkButton>
|
||||
</div>
|
||||
<div :style="$i == null ? 'grid-column: span 2;' : undefined">
|
||||
<MkButton
|
||||
transparent
|
||||
:class="$style.userSelectButton"
|
||||
@click="selectUser"
|
||||
>
|
||||
<div :class="$style.userSelectButtonInner">
|
||||
<span><i class="ti ti-plus"></i></span>
|
||||
<span>{{ i18n.ts.selectUser }}</span>
|
||||
</div>
|
||||
</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else :class="$style.userSelectedButtons">
|
||||
<div style="overflow: hidden;">
|
||||
<MkUserCardMini
|
||||
:user="user"
|
||||
:withChart="false"
|
||||
:class="$style.userSelectedCard"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="_button"
|
||||
:class="$style.userSelectedRemoveButton"
|
||||
@click="removeUser"
|
||||
>
|
||||
<i class="ti ti-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</div>
|
||||
</div>
|
||||
</MkFoldableSection>
|
||||
<div>
|
||||
<MkButton large primary gradate rounded style="margin: 0 auto;" @click="search">{{ i18n.ts.search }}</MkButton>
|
||||
<MkButton
|
||||
large
|
||||
primary
|
||||
gradate
|
||||
rounded
|
||||
:disabled="searchParams == null"
|
||||
style="margin: 0 auto;"
|
||||
@click="search"
|
||||
>
|
||||
{{ i18n.ts.search }}
|
||||
</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MkFoldableSection v-if="notePagination">
|
||||
<template #header>{{ i18n.ts.searchResult }}</template>
|
||||
<MkNotes :key="key" :pagination="notePagination"/>
|
||||
<MkNotes :key="`searchNotes:${key}`" :pagination="notePagination"/>
|
||||
</MkFoldableSection>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, toRef, watch } from 'vue';
|
||||
import type { UserDetailed } from 'misskey-js/entities.js';
|
||||
import { computed, ref, shallowRef, toRef } from 'vue';
|
||||
import type * as Misskey from 'misskey-js';
|
||||
import type { Paging } from '@/components/MkPagination.vue';
|
||||
import MkNotes from '@/components/MkNotes.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { $i } from '@/account.js';
|
||||
import { host as localHost } from '@@/js/config.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import MkFoldableSection from '@/components/MkFoldableSection.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import { useRouter } from '@/router/supplier.js';
|
||||
import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkFoldableSection from '@/components/MkFoldableSection.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkNotes from '@/components/MkNotes.vue';
|
||||
import MkRadios from '@/components/MkRadios.vue';
|
||||
import { $i } from '@/account.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
query?: string;
|
||||
@@ -83,76 +141,127 @@ const props = withDefaults(defineProps<{
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const key = ref(0);
|
||||
const notePagination = ref<Paging<'notes/search'>>();
|
||||
|
||||
const searchQuery = ref(toRef(props, 'query').value);
|
||||
const notePagination = ref<Paging>();
|
||||
const user = ref<UserDetailed | null>(null);
|
||||
const hostInput = ref(toRef(props, 'host').value);
|
||||
|
||||
const user = shallowRef<Misskey.entities.UserDetailed | null>(null);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
const noteSearchableScope = instance.noteSearchableScope ?? 'local';
|
||||
|
||||
const hostSelect = ref<'all' | 'local' | 'specified'>('all');
|
||||
//#region set user
|
||||
let fetchedUser: Misskey.entities.UserDetailed | null = null;
|
||||
|
||||
const setHostSelectWithInput = (after: string | undefined | null, before: string | undefined | null) => {
|
||||
if (before === after) return;
|
||||
if (after === '') hostSelect.value = 'all';
|
||||
else hostSelect.value = 'specified';
|
||||
if (props.userId) {
|
||||
fetchedUser = await misskeyApi('users/show', {
|
||||
userId: props.userId,
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
if (props.username && fetchedUser == null) {
|
||||
fetchedUser = await misskeyApi('users/show', {
|
||||
username: props.username,
|
||||
...(props.host ? { host: props.host } : {}),
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
if (fetchedUser != null) {
|
||||
if (!(noteSearchableScope === 'local' && fetchedUser.host != null)) {
|
||||
user.value = fetchedUser;
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
const searchScope = ref<'all' | 'local' | 'server' | 'user'>((() => {
|
||||
if (user.value != null) return 'user';
|
||||
if (noteSearchableScope === 'local') return 'local';
|
||||
if (hostInput.value) return 'server';
|
||||
return 'all';
|
||||
})());
|
||||
|
||||
type SearchParams = {
|
||||
readonly query: string;
|
||||
readonly host?: string;
|
||||
readonly userId?: string;
|
||||
};
|
||||
|
||||
setHostSelectWithInput(hostInput.value, undefined);
|
||||
const fixHostIfLocal = (target: string | null | undefined) => {
|
||||
if (!target || target === localHost) return '.';
|
||||
return target;
|
||||
};
|
||||
|
||||
watch(hostInput, setHostSelectWithInput);
|
||||
const searchParams = computed<SearchParams | null>(() => {
|
||||
const trimmedQuery = searchQuery.value.trim();
|
||||
if (!trimmedQuery) return null;
|
||||
|
||||
const searchHost = computed(() => {
|
||||
if (hostSelect.value === 'local' || instance.federation === 'none') return '.';
|
||||
if (hostSelect.value === 'specified') return hostInput.value;
|
||||
return null;
|
||||
if (searchScope.value === 'user') {
|
||||
if (user.value == null) return null;
|
||||
return {
|
||||
query: trimmedQuery,
|
||||
host: fixHostIfLocal(user.value.host),
|
||||
userId: user.value.id,
|
||||
};
|
||||
}
|
||||
|
||||
if (instance.federation !== 'none' && searchScope.value === 'server') {
|
||||
let trimmedHost = hostInput.value?.trim();
|
||||
if (!trimmedHost) return null;
|
||||
if (trimmedHost.startsWith('https://') || trimmedHost.startsWith('http://')) {
|
||||
try {
|
||||
trimmedHost = new URL(trimmedHost).host;
|
||||
} catch (err) { /* empty */ }
|
||||
}
|
||||
return {
|
||||
query: trimmedQuery,
|
||||
host: fixHostIfLocal(trimmedHost),
|
||||
};
|
||||
}
|
||||
|
||||
if (instance.federation === 'none' || searchScope.value === 'local') {
|
||||
return {
|
||||
query: trimmedQuery,
|
||||
host: '.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
query: trimmedQuery,
|
||||
};
|
||||
});
|
||||
|
||||
if (props.userId != null) {
|
||||
misskeyApi('users/show', { userId: props.userId }).then(_user => {
|
||||
user.value = _user;
|
||||
});
|
||||
} else if (props.username != null) {
|
||||
misskeyApi('users/show', {
|
||||
username: props.username,
|
||||
...(props.host != null && props.host !== '') ? { host: props.host } : {},
|
||||
function selectUser() {
|
||||
os.selectUser({
|
||||
includeSelf: true,
|
||||
localOnly: instance.noteSearchableScope === 'local',
|
||||
}).then(_user => {
|
||||
user.value = _user;
|
||||
});
|
||||
}
|
||||
|
||||
function selectUser() {
|
||||
os.selectUser({ includeSelf: true, localOnly: instance.noteSearchableScope === 'local' }).then(_user => {
|
||||
user.value = _user;
|
||||
hostInput.value = _user.host ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
function selectSelf() {
|
||||
user.value = $i as UserDetailed | null;
|
||||
hostInput.value = null;
|
||||
user.value = $i;
|
||||
}
|
||||
|
||||
function removeUser() {
|
||||
user.value = null;
|
||||
hostInput.value = '';
|
||||
}
|
||||
|
||||
async function search() {
|
||||
const query = searchQuery.value.toString().trim();
|
||||
|
||||
if (query == null || query === '') return;
|
||||
if (searchParams.value == null) return;
|
||||
|
||||
//#region AP lookup
|
||||
if (query.startsWith('https://') && !query.includes(' ')) {
|
||||
if (searchParams.value.query.startsWith('https://') && !searchParams.value.query.includes(' ')) {
|
||||
const confirm = await os.confirm({
|
||||
type: 'info',
|
||||
text: i18n.ts.lookupConfirm,
|
||||
});
|
||||
if (!confirm.canceled) {
|
||||
const promise = misskeyApi('ap/show', {
|
||||
uri: query,
|
||||
uri: searchParams.value.query,
|
||||
});
|
||||
|
||||
os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
|
||||
@@ -161,6 +270,7 @@ async function search() {
|
||||
|
||||
if (res.type === 'User') {
|
||||
router.push(`/@${res.object.username}@${res.object.host}`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
} else if (res.type === 'Note') {
|
||||
router.push(`/notes/${res.object.id}`);
|
||||
}
|
||||
@@ -170,25 +280,25 @@ async function search() {
|
||||
}
|
||||
//#endregion
|
||||
|
||||
if (query.length > 1 && !query.includes(' ')) {
|
||||
if (query.startsWith('@')) {
|
||||
if (searchParams.value.query.length > 1 && !searchParams.value.query.includes(' ')) {
|
||||
if (searchParams.value.query.startsWith('@')) {
|
||||
const confirm = await os.confirm({
|
||||
type: 'info',
|
||||
text: i18n.ts.lookupConfirm,
|
||||
});
|
||||
if (!confirm.canceled) {
|
||||
router.push(`/${query}`);
|
||||
router.push(`/${searchParams.value.query}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (query.startsWith('#')) {
|
||||
if (searchParams.value.query.startsWith('#')) {
|
||||
const confirm = await os.confirm({
|
||||
type: 'info',
|
||||
text: i18n.ts.openTagPageConfirm,
|
||||
});
|
||||
if (!confirm.canceled) {
|
||||
router.push(`/tags/${encodeURIComponent(query.substring(1))}`);
|
||||
router.push(`/tags/${encodeURIComponent(searchParams.value.query.substring(1))}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -198,9 +308,7 @@ async function search() {
|
||||
endpoint: 'notes/search',
|
||||
limit: 10,
|
||||
params: {
|
||||
query: searchQuery.value,
|
||||
userId: user.value ? user.value.id : null,
|
||||
...(searchHost.value ? { host: searchHost.value } : {}),
|
||||
...searchParams.value,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -208,41 +316,48 @@ async function search() {
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" module>
|
||||
.userItem {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
.subOptionRoot {
|
||||
background: var(--MI_THEME-panel);
|
||||
border-radius: var(--MI-radius);
|
||||
padding: var(--MI-margin);
|
||||
}
|
||||
.addMeButton {
|
||||
border: 2px dashed var(--MI_THEME-fgTransparent);
|
||||
|
||||
.userSelectLabel {
|
||||
font-size: 0.85em;
|
||||
padding: 0 0 8px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.userSelectButtons {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.userSelectButton {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
margin-right: 16px;
|
||||
border: 2px dashed var(--MI_THEME-fgTransparent);
|
||||
}
|
||||
.addUserButton {
|
||||
border: 2px dashed var(--MI_THEME-fgTransparent);
|
||||
padding: 12px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
.addUserButtonInner {
|
||||
|
||||
.userSelectButtonInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 38px;
|
||||
}
|
||||
.userCard {
|
||||
flex-grow: 1;
|
||||
|
||||
.userSelectedButtons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
}
|
||||
.remove {
|
||||
|
||||
.userSelectedRemoveButton {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
align-self: center;
|
||||
|
||||
& > i:before {
|
||||
color: #ff2a2a;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0;
|
||||
}
|
||||
color: #ff2a2a;
|
||||
}
|
||||
</style>
|
||||
|
@@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<MkFoldableSection v-if="userPagination">
|
||||
<template #header>{{ i18n.ts.searchResult }}</template>
|
||||
<MkUserList :key="key" :pagination="userPagination"/>
|
||||
<MkUserList :key="`searchUsers:${key}`" :pagination="userPagination"/>
|
||||
</MkFoldableSection>
|
||||
</div>
|
||||
</template>
|
||||
@@ -49,14 +49,16 @@ const props = withDefaults(defineProps<{
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const key = ref('');
|
||||
const key = ref(0);
|
||||
const userPagination = ref<Paging<'users/search'>>();
|
||||
|
||||
const searchQuery = ref(toRef(props, 'query').value);
|
||||
const searchOrigin = ref(toRef(props, 'origin').value);
|
||||
const userPagination = ref<Paging>();
|
||||
|
||||
async function search() {
|
||||
const query = searchQuery.value.toString().trim();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (query == null || query === '') return;
|
||||
|
||||
//#region AP lookup
|
||||
@@ -76,6 +78,7 @@ async function search() {
|
||||
|
||||
if (res.type === 'User') {
|
||||
router.push(`/@${res.object.username}@${res.object.host}`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
} else if (res.type === 'Note') {
|
||||
router.push(`/notes/${res.object.id}`);
|
||||
}
|
||||
@@ -118,6 +121,6 @@ async function search() {
|
||||
},
|
||||
};
|
||||
|
||||
key.value = query;
|
||||
key.value++;
|
||||
}
|
||||
</script>
|
||||
|
@@ -32,27 +32,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
|
||||
<div class="selects">
|
||||
<MkSelect v-model="lightThemeId" large class="select">
|
||||
<MkSelect v-model="lightThemeId" large class="select" :items="lightThemeSelectorItems">
|
||||
<template #label>{{ i18n.ts.themeForLightMode }}</template>
|
||||
<template #prefix><i class="ti ti-sun"></i></template>
|
||||
<option v-if="instanceLightTheme" :key="'instance:' + instanceLightTheme.id" :value="instanceLightTheme.id">{{ instanceLightTheme.name }}</option>
|
||||
<optgroup v-if="installedLightThemes.length > 0" :label="i18n.ts._theme.installedThemes">
|
||||
<option v-for="x in installedLightThemes" :key="'installed:' + x.id" :value="x.id">{{ x.name }}</option>
|
||||
</optgroup>
|
||||
<optgroup :label="i18n.ts._theme.builtinThemes">
|
||||
<option v-for="x in builtinLightThemes" :key="'builtin:' + x.id" :value="x.id">{{ x.name }}</option>
|
||||
</optgroup>
|
||||
</MkSelect>
|
||||
<MkSelect v-model="darkThemeId" large class="select">
|
||||
<MkSelect v-model="darkThemeId" large class="select" :items="darkThemeSelectorItems">
|
||||
<template #label>{{ i18n.ts.themeForDarkMode }}</template>
|
||||
<template #prefix><i class="ti ti-moon"></i></template>
|
||||
<option v-if="instanceDarkTheme" :key="'instance:' + instanceDarkTheme.id" :value="instanceDarkTheme.id">{{ instanceDarkTheme.name }}</option>
|
||||
<optgroup v-if="installedDarkThemes.length > 0" :label="i18n.ts._theme.installedThemes">
|
||||
<option v-for="x in installedDarkThemes" :key="'installed:' + x.id" :value="x.id">{{ x.name }}</option>
|
||||
</optgroup>
|
||||
<optgroup :label="i18n.ts._theme.builtinThemes">
|
||||
<option v-for="x in builtinDarkThemes" :key="'builtin:' + x.id" :value="x.id">{{ x.name }}</option>
|
||||
</optgroup>
|
||||
</MkSelect>
|
||||
</div>
|
||||
|
||||
@@ -73,6 +59,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<script lang="ts" setup>
|
||||
import { computed, onActivated, ref, watch } from 'vue';
|
||||
import JSON5 from 'json5';
|
||||
import type { MkSelectItem } from '@/components/MkSelect.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import FormSection from '@/components/form/section.vue';
|
||||
@@ -102,6 +89,70 @@ const installedLightThemes = computed(() => installedThemes.value.filter(t => t.
|
||||
const builtinLightThemes = computed(() => builtinThemes.value.filter(t => t.base === 'light' || t.kind === 'light'));
|
||||
const themes = computed(() => uniqueBy([instanceDarkTheme.value, instanceLightTheme.value, ...builtinThemes.value, ...installedThemes.value].filter(x => x != null), theme => theme.id));
|
||||
|
||||
const lightThemeSelectorItems = computed(() => {
|
||||
const items = [] as MkSelectItem[];
|
||||
if (instanceLightTheme.value) {
|
||||
items.push({
|
||||
type: 'option',
|
||||
value: instanceLightTheme.value.id,
|
||||
label: instanceLightTheme.value.name,
|
||||
});
|
||||
}
|
||||
if (installedLightThemes.value.length > 0) {
|
||||
items.push({
|
||||
type: 'group',
|
||||
label: i18n.ts._theme.installedThemes,
|
||||
items: installedLightThemes.value.map(x => ({
|
||||
type: 'option',
|
||||
value: x.id,
|
||||
label: x.name,
|
||||
})),
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
type: 'group',
|
||||
label: i18n.ts._theme.builtinThemes,
|
||||
items: builtinLightThemes.value.map(x => ({
|
||||
type: 'option',
|
||||
value: x.id,
|
||||
label: x.name,
|
||||
})),
|
||||
});
|
||||
return items;
|
||||
});
|
||||
|
||||
const darkThemeSelectorItems = computed(() => {
|
||||
const items = [] as MkSelectItem[];
|
||||
if (instanceDarkTheme.value) {
|
||||
items.push({
|
||||
type: 'option',
|
||||
value: instanceDarkTheme.value.id,
|
||||
label: instanceDarkTheme.value.name,
|
||||
});
|
||||
}
|
||||
if (installedDarkThemes.value.length > 0) {
|
||||
items.push({
|
||||
type: 'group',
|
||||
label: i18n.ts._theme.installedThemes,
|
||||
items: installedDarkThemes.value.map(x => ({
|
||||
type: 'option',
|
||||
value: x.id,
|
||||
label: x.name,
|
||||
})),
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
type: 'group',
|
||||
label: i18n.ts._theme.builtinThemes,
|
||||
items: builtinDarkThemes.value.map(x => ({
|
||||
type: 'option',
|
||||
value: x.id,
|
||||
label: x.name,
|
||||
})),
|
||||
});
|
||||
return items;
|
||||
});
|
||||
|
||||
const darkTheme = ColdDeviceStorage.ref('darkTheme');
|
||||
const darkThemeId = computed({
|
||||
get() {
|
||||
|
@@ -13,7 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<div class="profile _gaps">
|
||||
<MkAccountMoved v-if="user.movedTo" :movedTo="user.movedTo"/>
|
||||
<MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!" class="warn"/>
|
||||
<MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!"/>
|
||||
<MkInfo v-if="user.host == null && user.username.includes('.')">{{ i18n.ts.isSystemAccount }}</MkInfo>
|
||||
|
||||
<div :key="user.id" class="main _panel">
|
||||
<div class="banner-container" :style="style">
|
||||
|
@@ -7,6 +7,7 @@ import { EventEmitter } from 'eventemitter3';
|
||||
import type { IRouter, Resolved, RouteDef, RouterEvent, RouterFlag } from '@/nirax.js';
|
||||
|
||||
import type { App, ShallowRef } from 'vue';
|
||||
import { analytics } from '@/analytics.js';
|
||||
|
||||
/**
|
||||
* {@link Router}による画面遷移を可能とするために{@link mainRouter}をセットアップする。
|
||||
@@ -29,6 +30,14 @@ export function setupRouter(app: App, routerFactory: ((path: string) => IRouter)
|
||||
window.history.replaceState({ key: ctx.key }, '', ctx.path);
|
||||
});
|
||||
|
||||
mainRouter.addListener('change', ctx => {
|
||||
console.log('mainRouter: change', ctx.path);
|
||||
analytics.page({
|
||||
path: ctx.path,
|
||||
title: ctx.path,
|
||||
});
|
||||
});
|
||||
|
||||
mainRouter.init();
|
||||
|
||||
setMainRouter(mainRouter);
|
||||
|
@@ -4,9 +4,9 @@
|
||||
*/
|
||||
|
||||
import { nextTick, ref, defineAsyncComponent } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import getCaretCoordinates from 'textarea-caret';
|
||||
import { toASCII } from 'punycode.js';
|
||||
import type { Ref } from 'vue';
|
||||
import { popup } from '@/os.js';
|
||||
|
||||
export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag' | 'mfmParam';
|
||||
@@ -98,15 +98,21 @@ export class Autocomplete {
|
||||
|
||||
const isMention = mentionIndex !== -1;
|
||||
const isHashtag = hashtagIndex !== -1;
|
||||
const isMfmParam = mfmParamIndex !== -1 && afterLastMfmParam?.includes('.') && !afterLastMfmParam?.includes(' ');
|
||||
const isMfmParam = mfmParamIndex !== -1 && afterLastMfmParam?.includes('.') && !afterLastMfmParam.includes(' ');
|
||||
const isMfmTag = mfmTagIndex !== -1 && !isMfmParam;
|
||||
const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':');
|
||||
|
||||
let opened = false;
|
||||
|
||||
if (isMention && this.onlyType.includes('user')) {
|
||||
const username = text.substring(mentionIndex + 1);
|
||||
if (username !== '' && username.match(/^[a-zA-Z0-9_]+$/)) {
|
||||
// ユーザのサジェスト中に@を入力すると、その位置から新たにユーザ名を取りなおそうとしてしまう
|
||||
// この動きはリモートユーザのサジェストを阻害するので、@を検知したらその位置よりも前の@を探し、
|
||||
// ホスト名を含むリモートのユーザ名を全て拾えるようにする
|
||||
const mentionIndexAlt = text.lastIndexOf('@', mentionIndex - 1);
|
||||
const username = mentionIndexAlt === -1
|
||||
? text.substring(mentionIndex + 1)
|
||||
: text.substring(mentionIndexAlt + 1);
|
||||
if (username !== '' && username.match(/^[a-zA-Z0-9_@.]+$/)) {
|
||||
this.open('user', username);
|
||||
opened = true;
|
||||
} else if (username === '') {
|
||||
|
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { host as localHost } from '@@/js/config.js';
|
||||
|
||||
export async function genSearchQuery(v: any, q: string) {
|
||||
let host: string;
|
||||
let userId: string;
|
||||
if (q.split(' ').some(x => x.startsWith('@'))) {
|
||||
for (const at of q.split(' ').filter(x => x.startsWith('@')).map(x => x.substring(1))) {
|
||||
if (at.includes('.')) {
|
||||
if (at === localHost || at === '.') {
|
||||
host = null;
|
||||
} else {
|
||||
host = at;
|
||||
}
|
||||
} else {
|
||||
const user = await v.api('users/show', Misskey.acct.parse(at)).catch(x => null);
|
||||
if (user) {
|
||||
userId = user.id;
|
||||
} else {
|
||||
// todo: show error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
query: q.split(' ').filter(x => !x.startsWith('/') && !x.startsWith('@')).join(' '),
|
||||
host: host,
|
||||
userId: userId,
|
||||
};
|
||||
}
|
@@ -54,10 +54,6 @@ export async function lookup(router?: Router) {
|
||||
title = i18n.ts._remoteLookupErrors._responseInvalid.title;
|
||||
text = i18n.ts._remoteLookupErrors._responseInvalid.description;
|
||||
break;
|
||||
case 'a2c9c61a-cb72-43ab-a964-3ca5fddb410a':
|
||||
title = i18n.ts._remoteLookupErrors._responseInvalid.title;
|
||||
text = i18n.ts._remoteLookupErrors._responseInvalidIdHostNotMatch.description;
|
||||
break;
|
||||
case 'dc94d745-1262-4e63-a17d-fecaa57efc82':
|
||||
title = i18n.ts._remoteLookupErrors._noSuchObject.title;
|
||||
text = i18n.ts._remoteLookupErrors._noSuchObject.description;
|
||||
|
@@ -42,8 +42,6 @@ describe('XHome', () => {
|
||||
const anchor = home.container.querySelector<HTMLAnchorElement>('a[href^="https://example.com/"]');
|
||||
assert.exists(anchor, 'anchor to the remote exists');
|
||||
assert.strictEqual(anchor?.href, 'https://example.com/@user/profile');
|
||||
|
||||
assert.ok(anchor?.parentElement?.classList.contains('warn'), 'the parent is a warning');
|
||||
});
|
||||
|
||||
test('The remote caution should fall back to uri if url is null', async () => {
|
||||
|
Reference in New Issue
Block a user