merge: upstream

This commit is contained in:
Mar0xy 2023-10-31 19:33:24 +01:00
commit 4dd23a3793
No known key found for this signature in database
GPG key ID: 56569BBE47D2C828
217 changed files with 6773 additions and 2275 deletions

View file

@ -74,6 +74,7 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi
onlineStatus: 'unknown',
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: [],
bannerBlurhash: 'eQA^IW^-MH8w9tE8I=S^o{$*R4RikXtSxutRozjEnNR.RQadoyozog',
bannerColor: '#000000',

View file

@ -26,10 +26,11 @@
"@phosphor-icons/web": "^2.0.3",
"@vitejs/plugin-vue": "4.4.0",
"@vue-macros/reactivity-transform": "0.3.23",
"@vue/compiler-sfc": "3.3.4",
"@vue/compiler-sfc": "3.3.7",
"astring": "1.8.6",
"autosize": "6.0.1",
"broadcast-channel": "5.4.0",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.0.5",
"broadcast-channel": "5.5.1",
"browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3",
"buraha": "0.0.1",
"canvas-confetti": "1.6.1",
@ -38,7 +39,7 @@
"chartjs-chart-matrix": "2.0.1",
"chartjs-plugin-gradient": "0.6.1",
"chartjs-plugin-zoom": "2.0.1",
"chromatic": "7.4.0",
"chromatic": "7.5.4",
"compare-versions": "6.1.0",
"cropperjs": "2.0.0-beta.4",
"date-fns": "2.30.0",
@ -54,15 +55,15 @@
"mfm-js": "0.23.3",
"misskey-js": "workspace:*",
"photoswipe": "5.4.2",
"prismjs": "1.29.0",
"punycode": "2.3.0",
"querystring": "0.2.1",
"rollup": "4.1.4",
"sanitize-html": "2.11.0",
"sass": "1.69.3",
"shiki": "^0.14.5",
"sass": "1.69.5",
"strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0",
"three": "0.157.0",
"three": "0.158.0",
"throttle-debounce": "5.0.0",
"tinycolor2": "1.6.0",
"tsc-alias": "1.8.8",
@ -72,70 +73,69 @@
"uuid": "9.0.1",
"v-code-diff": "1.7.1",
"vanilla-tilt": "1.8.1",
"vite": "4.4.11",
"vue": "3.3.4",
"vue-prism-editor": "2.0.0-alpha.2",
"vite": "4.5.0",
"vue": "3.3.7",
"vuedraggable": "next"
},
"devDependencies": {
"@storybook/addon-actions": "7.5.0",
"@storybook/addon-essentials": "7.5.0",
"@storybook/addon-interactions": "7.5.0",
"@storybook/addon-links": "7.5.0",
"@storybook/addon-storysource": "7.5.0",
"@storybook/addons": "7.5.0",
"@storybook/blocks": "7.5.0",
"@storybook/core-events": "7.5.0",
"@storybook/addon-actions": "7.5.1",
"@storybook/addon-essentials": "7.5.1",
"@storybook/addon-interactions": "7.5.1",
"@storybook/addon-links": "7.5.1",
"@storybook/addon-storysource": "7.5.1",
"@storybook/addons": "7.5.1",
"@storybook/blocks": "7.5.1",
"@storybook/core-events": "7.5.1",
"@storybook/jest": "0.2.3",
"@storybook/manager-api": "7.5.0",
"@storybook/preview-api": "7.5.0",
"@storybook/react": "7.5.0",
"@storybook/react-vite": "7.5.0",
"@storybook/manager-api": "7.5.1",
"@storybook/preview-api": "7.5.1",
"@storybook/react": "7.5.1",
"@storybook/react-vite": "7.5.1",
"@storybook/testing-library": "0.2.2",
"@storybook/theming": "7.5.0",
"@storybook/types": "7.5.0",
"@storybook/vue3": "7.5.0",
"@storybook/vue3-vite": "7.5.0",
"@storybook/theming": "7.5.1",
"@storybook/types": "7.5.1",
"@storybook/vue3": "7.5.1",
"@storybook/vue3-vite": "7.5.1",
"@testing-library/vue": "7.0.0",
"@types/escape-regexp": "0.0.1",
"@types/estree": "1.0.2",
"@types/matter-js": "0.19.1",
"@types/micromatch": "4.0.3",
"@types/node": "20.8.6",
"@types/punycode": "2.1.0",
"@types/sanitize-html": "2.9.2",
"@types/throttle-debounce": "5.0.0",
"@types/tinycolor2": "1.4.4",
"@types/uuid": "9.0.5",
"@types/websocket": "1.0.7",
"@types/ws": "8.5.7",
"@typescript-eslint/eslint-plugin": "6.8.0",
"@typescript-eslint/parser": "6.8.0",
"@types/escape-regexp": "0.0.2",
"@types/estree": "1.0.3",
"@types/matter-js": "0.19.2",
"@types/micromatch": "4.0.4",
"@types/node": "20.8.9",
"@types/punycode": "2.1.1",
"@types/sanitize-html": "2.9.3",
"@types/throttle-debounce": "5.0.1",
"@types/tinycolor2": "1.4.5",
"@types/uuid": "9.0.6",
"@types/websocket": "1.0.8",
"@types/ws": "8.5.8",
"@typescript-eslint/eslint-plugin": "6.9.0",
"@typescript-eslint/parser": "6.9.0",
"@vitest/coverage-v8": "0.34.6",
"@vue/runtime-core": "3.3.4",
"acorn": "8.10.0",
"@vue/runtime-core": "3.3.7",
"acorn": "8.11.2",
"cross-env": "7.0.3",
"cypress": "13.3.1",
"eslint": "8.51.0",
"eslint-plugin-import": "2.28.1",
"eslint-plugin-vue": "9.17.0",
"cypress": "13.3.3",
"eslint": "8.52.0",
"eslint-plugin-import": "2.29.0",
"eslint-plugin-vue": "9.18.1",
"fast-glob": "3.3.1",
"happy-dom": "10.0.3",
"micromatch": "4.0.5",
"msw": "1.3.2",
"msw-storybook-addon": "1.9.0",
"msw-storybook-addon": "1.10.0",
"nodemon": "3.0.1",
"prettier": "3.0.3",
"react": "18.2.0",
"react-dom": "18.2.0",
"start-server-and-test": "2.0.1",
"storybook": "7.5.0",
"storybook": "7.5.1",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"summaly": "github:misskey-dev/summaly",
"vite-plugin-turbosnap": "1.0.3",
"vitest": "0.34.6",
"vitest-fetch-mock": "0.2.2",
"vue-eslint-parser": "9.3.2",
"vue-tsc": "1.8.19"
"vue-tsc": "1.8.22"
}
}

View file

@ -175,7 +175,7 @@ export async function common(createVue: () => App<Element>) {
defaultStore.set('darkMode', isDeviceDarkmode());
}
window.matchMedia('(prefers-color-scheme: dark)').addListener(mql => {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (mql) => {
if (ColdDeviceStorage.get('syncDeviceDarkMode')) {
defaultStore.set('darkMode', mql.matches);
}

View file

@ -8,7 +8,7 @@ import { common } from './common.js';
import { version, ui, lang, updateLocale } from '@/config.js';
import { i18n, updateI18n } from '@/i18n.js';
import { confirm, alert, post, popup, toast } from '@/os.js';
import { useStream } from '@/stream.js';
import { useStream, isReloading } from '@/stream.js';
import * as sound from '@/scripts/sound.js';
import { $i, refreshAccount, login, updateAccount, signout } from '@/account.js';
import { defaultStore, ColdDeviceStorage } from '@/store.js';
@ -39,6 +39,7 @@ export async function mainBoot() {
let reloadDialogShowing = false;
stream.on('_disconnected_', async () => {
if (isReloading) return;
if (defaultStore.state.serverDisconnectedBehavior === 'reload') {
location.reload();
} else if (defaultStore.state.serverDisconnectedBehavior === 'dialog') {

View file

@ -5,21 +5,90 @@ SPDX-License-Identifier: AGPL-3.0-only
<!-- eslint-disable vue/no-v-html -->
<template>
<code v-if="inline" :class="`language-${prismLang}`" style="overflow-wrap: anywhere;" v-html="html"></code>
<pre v-else :class="`language-${prismLang}`"><code :class="`language-${prismLang}`" v-html="html"></code></pre>
<div :class="['codeBlockRoot', { 'codeEditor': codeEditor }]" v-html="html"></div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import Prism from 'prismjs';
import 'prismjs/themes/prism-okaidia.css';
import { ref, computed, watch } from 'vue';
import { BUNDLED_LANGUAGES } from 'shiki';
import type { Lang as ShikiLang } from 'shiki';
import { getHighlighter } from '@/scripts/code-highlighter.js';
const props = defineProps<{
code: string;
lang?: string;
inline?: boolean;
codeEditor?: boolean;
}>();
const prismLang = computed(() => Prism.languages[props.lang] ? props.lang : 'js');
const html = computed(() => Prism.highlight(props.code, Prism.languages[prismLang.value], prismLang.value));
const highlighter = await getHighlighter();
const codeLang = ref<ShikiLang | 'aiscript'>('js');
const html = computed(() => highlighter.codeToHtml(props.code, {
lang: codeLang.value,
theme: 'dark-plus',
}));
async function fetchLanguage(to: string): Promise<void> {
const language = to as ShikiLang;
// Check for the loaded languages, and load the language if it's not loaded yet.
if (!highlighter.getLoadedLanguages().includes(language)) {
// Check if the language is supported by Shiki
const bundles = BUNDLED_LANGUAGES.filter((bundle) => {
// Languages are specified by their id, they can also have aliases (i. e. "js" and "javascript")
return bundle.id === language || bundle.aliases?.includes(language);
});
if (bundles.length > 0) {
await highlighter.loadLanguage(language);
codeLang.value = language;
} else {
codeLang.value = 'js';
}
} else {
codeLang.value = language;
}
}
watch(() => props.lang, (to) => {
if (codeLang.value === to || !to) return;
return new Promise((resolve) => {
fetchLanguage(to).then(() => resolve);
});
}, { immediate: true, });
</script>
<style scoped lang="scss">
.codeBlockRoot :deep(.shiki) {
padding: 1em;
margin: .5em 0;
overflow: auto;
border-radius: .3em;
& pre,
& code {
font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
}
}
.codeBlockRoot.codeEditor {
min-width: 100%;
height: 100%;
& :deep(.shiki) {
padding: 12px;
margin: 0;
border-radius: 6px;
min-height: 130px;
pointer-events: none;
min-width: calc(100% - 24px);
height: 100%;
display: inline-block;
line-height: 1.5em;
font-size: 1em;
overflow: visible;
text-rendering: inherit;
text-transform: inherit;
white-space: pre;
}
}
</style>

View file

@ -4,11 +4,18 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<XCode :code="code" :lang="lang" :inline="inline"/>
<Suspense>
<template #fallback>
<MkLoading v-if="!inline ?? true" />
</template>
<code v-if="inline" :class="$style.codeInlineRoot">{{ code }}</code>
<XCode v-else :code="code" :lang="lang"/>
</Suspense>
</template>
<script lang="ts" setup>
import { defineAsyncComponent } from 'vue';
import MkLoading from '@/components/global/MkLoading.vue';
defineProps<{
code: string;
@ -18,3 +25,15 @@ defineProps<{
const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue'));
</script>
<style module lang="scss">
.codeInlineRoot {
display: inline-block;
font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
overflow-wrap: anywhere;
color: #D4D4D4;
background: #1E1E1E;
padding: .1em;
border-radius: .3em;
}
</style>

View file

@ -0,0 +1,166 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="[$style.codeEditorRoot, { [$style.disabled]: disabled, [$style.focused]: focused }]">
<div :class="$style.codeEditorScroller">
<textarea
ref="inputEl"
v-model="vModel"
:class="[$style.textarea]"
:disabled="disabled"
:required="required"
:readonly="readonly"
autocomplete="off"
wrap="off"
spellcheck="false"
@focus="focused = true"
@blur="focused = false"
@keydown="onKeydown($event)"
@input="onInput"
></textarea>
<XCode :class="$style.codeEditorHighlighter" :codeEditor="true" :code="v" :lang="lang"/>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, watch, toRefs, shallowRef, nextTick } from 'vue';
import XCode from '@/components/MkCode.core.vue';
const props = withDefaults(defineProps<{
modelValue: string | null;
lang: string;
required?: boolean;
readonly?: boolean;
disabled?: boolean;
}>(), {
lang: 'js',
});
const emit = defineEmits<{
(ev: 'change', _ev: KeyboardEvent): void;
(ev: 'keydown', _ev: KeyboardEvent): void;
(ev: 'enter'): void;
(ev: 'update:modelValue', value: string): void;
}>();
const { modelValue } = toRefs(props);
const vModel = ref<string>(modelValue.value ?? '');
const v = ref<string>(modelValue.value ?? '');
const focused = ref(false);
const changed = ref(false);
const inputEl = shallowRef<HTMLTextAreaElement>();
const onInput = (ev) => {
v.value = ev.target?.value ?? v.value;
changed.value = true;
emit('change', ev);
};
const onKeydown = (ev: KeyboardEvent) => {
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return;
emit('keydown', ev);
if (ev.code === 'Enter') {
const pos = inputEl.value?.selectionStart ?? 0;
const posEnd = inputEl.value?.selectionEnd ?? vModel.value.length;
if (pos === posEnd) {
const lines = vModel.value.slice(0, pos).split('\n');
const currentLine = lines[lines.length - 1];
const currentLineSpaces = currentLine.match(/^\s+/);
const posDelta = currentLineSpaces ? currentLineSpaces[0].length : 0;
ev.preventDefault();
vModel.value = vModel.value.slice(0, pos) + '\n' + (currentLineSpaces ? currentLineSpaces[0] : '') + vModel.value.slice(pos);
v.value = vModel.value;
nextTick(() => {
inputEl.value?.setSelectionRange(pos + 1 + posDelta, pos + 1 + posDelta);
});
}
emit('enter');
}
if (ev.key === 'Tab') {
const pos = inputEl.value?.selectionStart ?? 0;
const posEnd = inputEl.value?.selectionEnd ?? vModel.value.length;
vModel.value = vModel.value.slice(0, pos) + '\t' + vModel.value.slice(posEnd);
v.value = vModel.value;
nextTick(() => {
inputEl.value?.setSelectionRange(pos + 1, pos + 1);
});
ev.preventDefault();
}
};
const updated = () => {
changed.value = false;
emit('update:modelValue', v.value);
};
watch(modelValue, newValue => {
v.value = newValue ?? '';
});
watch(v, () => {
updated();
});
</script>
<style lang="scss" module>
.codeEditorRoot {
min-width: 100%;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
box-sizing: border-box;
margin: 0;
padding: 0;
color: var(--fg);
border: solid 1px var(--panel);
transition: border-color 0.1s ease-out;
font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
&:hover {
border-color: var(--inputBorderHover) !important;
}
}
.focused.codeEditorRoot {
border-color: var(--accent) !important;
border-radius: 6px;
}
.codeEditorScroller {
position: relative;
display: inline-block;
min-width: 100%;
height: 100%;
}
.textarea {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: inline-block;
appearance: none;
resize: none;
text-align: left;
color: transparent;
caret-color: rgb(225, 228, 232);
background-color: transparent;
border: 0;
outline: 0;
padding: 12px;
line-height: 1.5em;
font-size: 1em;
font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
}
.textarea::selection {
color: #fff;
}
</style>

View file

@ -42,6 +42,7 @@ export default defineComponent({
setup(props, { slots, expose }) {
const $style = useCssModule(); // 使
function getDateText(time: string) {
const date = new Date(time).getDate();
const month = new Date(time).getMonth() + 1;
@ -121,6 +122,7 @@ export default defineComponent({
el.style.top = `${el.offsetTop}px`;
el.style.left = `${el.offsetLeft}px`;
}
function onLeaveCanceled(el: HTMLElement) {
el.style.top = '';
el.style.left = '';

View file

@ -160,6 +160,7 @@ async function ok() {
function cancel() {
done(true);
}
/*
function onBgClick() {
if (props.cancelableByBgClick) cancel();

View file

@ -505,6 +505,7 @@ function appendFile(file: Misskey.entities.DriveFile) {
function appendFolder(folderToAppend: Misskey.entities.DriveFolder) {
addFolder(folderToAppend);
}
/*
function prependFile(file: Misskey.entities.DriveFile) {
addFile(file, true);

View file

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div class="omfetrab" :class="['s' + size, 'w' + width, 'h' + height, { asDrawer, asWindow }]" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }">
<input ref="searchEl" :value="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.ts.search" type="search" @input="input()" @paste.stop="paste" @keydown.stop.prevent.enter="onEnter">
<input ref="searchEl" :value="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.ts.search" type="search" autocapitalize="off" @input="input()" @paste.stop="paste" @keydown.stop.prevent.enter="onEnter">
<!-- FirefoxのTabフォーカスが想定外の挙動となるためtabindex="-1"を追加 https://github.com/misskey-dev/misskey/issues/10744 -->
<div ref="emojisEl" class="emojis" tabindex="-1">
<section class="result">

View file

@ -84,6 +84,7 @@ onMounted(() => {
return getParentBg(el.parentElement);
}
}
const rawBg = getParentBg(el.value);
const _bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
_bg.setAlpha(0.85);

View file

@ -21,7 +21,9 @@ const props = defineProps<{
const query = ref(props.q);
const search = () => {
window.open(`https://www.google.com/search?q=${query.value}`, '_blank');
const sp = new URLSearchParams();
sp.append('q', query.value);
window.open(`https://www.google.com/search?${sp.toString()}`, '_blank');
};
</script>

View file

@ -20,6 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:placeholder="placeholder"
:pattern="pattern"
:autocomplete="autocomplete"
:autocapitalize="autocapitalize"
:spellcheck="spellcheck"
:step="step"
:list="id"
@ -58,6 +59,7 @@ const props = defineProps<{
placeholder?: string;
autofocus?: boolean;
autocomplete?: string;
autocapitalize?: string;
spellcheck?: boolean;
step?: any;
datalist?: string[];

View file

@ -5,7 +5,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 }">
<img :class="$style.icon" :src="`/avatar/@${username}@${host}`" alt="">
<img :class="$style.icon" :src="avatarUrl" alt="">
<span>
<span>@{{ username }}</span>
<span v-if="(host != localHost) || defaultStore.state.showFullAcct" :class="$style.host">@{{ toUnicode(host) }}</span>
@ -15,11 +15,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { toUnicode } from 'punycode';
import { } from 'vue';
import { computed } from 'vue';
import tinycolor from 'tinycolor2';
import { host as localHost } from '@/config.js';
import { $i } from '@/account.js';
import { defaultStore } from '@/store.js';
import { getStaticImageUrl } from '@/scripts/media-proxy.js';
const props = defineProps<{
username: string;
@ -37,6 +38,11 @@ const isMe = $i && (
const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue(isMe ? '--mentionMe' : '--mention'));
bg.setAlpha(0.1);
const bgCss = bg.toRgbString();
const avatarUrl = computed(() => defaultStore.state.disableShowingAnimatedImages
? getStaticImageUrl(`/avatar/@${props.username}@${props.host}`)
: `/avatar/@${props.username}@${props.host}`,
);
</script>
<style lang="scss" module>

View file

@ -145,11 +145,13 @@ const onGlobalMousedown = (event: MouseEvent) => {
};
let childCloseTimer: null | number = null;
function onItemMouseEnter(item) {
childCloseTimer = window.setTimeout(() => {
closeChild();
}, 300);
}
function onItemMouseLeave(item) {
if (childCloseTimer) window.clearTimeout(childCloseTimer);
}

View file

@ -44,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div v-if="renoteCollapsed" :class="$style.collapsedRenoteTarget">
<MkAvatar :class="$style.collapsedRenoteTargetAvatar" :user="appearNote.user" link preview/>
<Mfm :text="getNoteSummary(appearNote)" :plain="true" :nowrap="true" :author="appearNote.user" :class="$style.collapsedRenoteTargetText" @click="renoteCollapsed = false"/>
<Mfm :text="getNoteSummary(appearNote)" :plain="true" :nowrap="true" :author="appearNote.user" :nyaize="'account'" :class="$style.collapsedRenoteTargetText" @click="renoteCollapsed = false"/>
</div>
<article v-else :class="$style.article" @contextmenu.stop="onContextmenu">
<div v-if="appearNote.channel" :class="$style.colorBar" :style="{ background: appearNote.channel.color }"></div>
@ -54,19 +54,19 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/>
<div style="container-type: inline-size;">
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :i="$i"/>
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'account'" :i="$i"/>
<MkCwButton v-model="showContent" :note="appearNote" style="margin: 4px 0;" v-on:click.stop/>
</p>
<div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]" >
<div :class="$style.text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<MkA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold pg-lg"></i></MkA>
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :emojiUrls="appearNote.emojis"/>
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :nyaize="'account'" :i="$i" :emojiUrls="appearNote.emojis"/>
<div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
<div v-else>
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :author="appearNote.user" :i="$i" :emojiUrls="appearNote.emojis"/>
<Mfm :text="translation.text" :author="appearNote.user" :nyaize="'account'" :i="$i" :emojiUrls="appearNote.emojis"/>
</div>
</div>
</div>
@ -208,9 +208,11 @@ function noteclick(id: string) {
// plugin
if (noteViewInterruptors.length > 0) {
onMounted(async () => {
let result = deepClone(note);
let result:Misskey.entities.Note | null = deepClone(note);
for (const interruptor of noteViewInterruptors) {
result = await interruptor.handler(result);
if (result === null) return isDeleted.value = true;
}
note = result;
});
@ -265,6 +267,7 @@ const keymap = {
useNoteCapture({
rootEl: el,
note: $$(appearNote),
pureNote: $$(note),
isDeletedRef: isDeleted,
});

View file

@ -68,19 +68,19 @@ SPDX-License-Identifier: AGPL-3.0-only
</header>
<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" :i="$i"/>
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'account'" :i="$i"/>
<MkCwButton v-model="showContent" :note="appearNote"/>
</p>
<div v-show="appearNote.cw == null || showContent">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold pg-lg"></i></MkA>
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :emojiUrls="appearNote.emojis"/>
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :nyaize="'account'" :i="$i" :emojiUrls="appearNote.emojis"/>
<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
<div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
<div v-else>
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :author="appearNote.user" :i="$i" :emojiUrls="appearNote.emojis"/>
<Mfm :text="translation.text" :author="appearNote.user" :nyaize="'account'" :i="$i" :emojiUrls="appearNote.emojis"/>
</div>
</div>
<div v-if="appearNote.files.length > 0">
@ -98,7 +98,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.edited }}: <MkTime :time="appearNote.updatedAt" mode="detail"/>
</div>
<MkA :to="notePage(appearNote)">
<MkTime :time="appearNote.createdAt" mode="detail"/>
<MkTime :time="appearNote.createdAt" mode="detail" colored/>
</MkA>
</div>
<MkReactionsViewer ref="reactionsViewer" :note="appearNote"/>
@ -257,9 +257,11 @@ let note = $ref(deepClone(props.note));
// plugin
if (noteViewInterruptors.length > 0) {
onMounted(async () => {
let result = deepClone(note);
let result:Misskey.entities.Note | null = deepClone(note);
for (const interruptor of noteViewInterruptors) {
result = await interruptor.handler(result);
if (result === null) return isDeleted.value = true;
}
note = result;
});
@ -355,6 +357,7 @@ const reactionsPagination = $computed(() => ({
useNoteCapture({
rootEl: el,
note: $$(appearNote),
pureNote: $$(note),
isDeletedRef: isDeleted,
});
@ -652,6 +655,7 @@ function blur() {
}
const repliesLoaded = ref(false);
function loadReplies() {
repliesLoaded.value = true;
os.api('notes/children', {
@ -678,6 +682,7 @@ function loadQuotes() {
loadQuotes();
const conversationLoaded = ref(false);
function loadConversation() {
conversationLoaded.value = true;
os.api('notes/conversation', {

View file

@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div :class="$style.info">
<MkA :to="notePage(note)">
<MkTime :time="note.createdAt"/>
<MkTime :time="note.createdAt" colored/>
</MkA>
<span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]">
<i v-if="note.visibility === 'home'" class="ph-house ph-bold ph-lg"></i>

View file

@ -5,14 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="$style.root">
<MkAvatar :class="$style.avatar" :user="$i" link preview/>
<MkAvatar :class="$style.avatar" :user="user" link preview/>
<div :class="$style.main">
<div :class="$style.header">
<MkUserName :user="$i" :nowrap="true"/>
<MkUserName :user="user" :nowrap="true"/>
</div>
<div>
<div>
<Mfm :text="text.trim()" :author="$i" :i="$i"/>
<Mfm :text="text.trim()" :author="user" :nyaize="'account'" :i="user"/>
</div>
</div>
</div>
@ -21,10 +21,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { } from 'vue';
import { $i } from '@/account.js';
import * as Misskey from 'misskey-js';
const props = defineProps<{
text: string;
user: Misskey.entities.User;
}>();
</script>

View file

@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div>
<p v-if="note.cw != null" :class="$style.cw">
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :i="$i" :emojiUrls="note.emojis"/>
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'account'" :i="$i" :emojiUrls="note.emojis"/>
<MkCwButton v-model="showContent" :note="note"/>
</p>
<div v-show="note.cw == null || showContent">

View file

@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div :class="$style.content">
<p v-if="note.cw != null" :class="$style.cw">
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :i="$i"/>
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'account'" :i="$i"/>
<MkCwButton v-model="showContent" :note="note"/>
</p>
<div v-show="note.cw == null || showContent">

View file

@ -283,6 +283,12 @@ useTooltip(reactionRef, (showing) => {
.quote:first-child {
margin-right: 4px;
position: relative;
&:before {
position: absolute;
transform: rotate(180deg);
}
}
.quote:last-child {

View file

@ -41,7 +41,7 @@ const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
const pagination: Paging = {
endpoint: 'i/notifications' as const,
limit: 10,
limit: 20,
params: computed(() => ({
excludeTypes: props.excludeTypes ?? undefined,
})),

View file

@ -166,6 +166,8 @@ defineExpose({
<style lang="scss" module>
.root {
overscroll-behavior: none;
min-height: 100%;
background: var(--bg);

View file

@ -87,6 +87,7 @@ function arrayToEntries(entities: MisskeyEntity[]): [string, MisskeyEntity][] {
function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): MisskeyEntityMap {
return new Map([...map, ...arrayToEntries(entities)]);
}
</script>
<script lang="ts" setup>
import { infoImageUrl } from '@/instance.js';
@ -101,6 +102,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
(ev: 'queue', count: number): void;
(ev: 'status', error: boolean): void;
}>();
let rootEl = $shallowRef<HTMLElement>();
@ -192,6 +194,11 @@ watch(queue, (a, b) => {
emit('queue', queue.value.size);
}, { deep: true });
watch(error, (n, o) => {
if (n === o) return;
emit('status', n);
});
async function init(): Promise<void> {
items.value = new Map();
queue.value = new Map();

View file

@ -73,7 +73,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/>
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text"/>
<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :user="postAccount ?? $i"/>
<div v-if="showingOptions" style="padding: 8px 16px;">
</div>
<footer :class="$style.footer">

View file

@ -59,6 +59,7 @@ function toggleSensitive(file) {
emit('changeSensitive', file, !file.isSensitive);
});
}
async function rename(file) {
const { canceled, result } = await os.inputText({
title: i18n.ts.enterFileName,

View file

@ -0,0 +1,240 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div ref="rootEl">
<div v-if="isPullStart" :class="$style.frame" :style="`--frame-min-height: ${pullDistance / (PULL_BRAKE_BASE + (pullDistance / PULL_BRAKE_FACTOR))}px;`">
<div :class="$style.frameContent">
<MkLoading v-if="isRefreshing" :class="$style.loader" :em="true"/>
<i v-else class="ph-arrow-line-down ph-bold pg-lg" :class="[$style.icon, { [$style.refresh]: isPullEnd }]"></i>
<div :class="$style.text">
<template v-if="isPullEnd">{{ i18n.ts.releaseToRefresh }}</template>
<template v-else-if="isRefreshing">{{ i18n.ts.refreshing }}</template>
<template v-else>{{ i18n.ts.pullDownToRefresh }}</template>
</div>
</div>
</div>
<div :class="{ [$style.slotClip]: isPullStart }">
<slot/>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted } from 'vue';
import { deviceKind } from '@/scripts/device-kind.js';
import { i18n } from '@/i18n.js';
const SCROLL_STOP = 10;
const MAX_PULL_DISTANCE = Infinity;
const FIRE_THRESHOLD = 230;
const RELEASE_TRANSITION_DURATION = 200;
const PULL_BRAKE_BASE = 2;
const PULL_BRAKE_FACTOR = 200;
let isPullStart = $ref(false);
let isPullEnd = $ref(false);
let isRefreshing = $ref(false);
let pullDistance = $ref(0);
let supportPointerDesktop = false;
let startScreenY: number | null = null;
const rootEl = $shallowRef<HTMLDivElement>();
let scrollEl: HTMLElement | null = null;
let disabled = false;
const emits = defineEmits<{
(ev: 'refresh'): void;
}>();
function getScrollableParentElement(node) {
if (node == null) {
return null;
}
if (node.scrollHeight > node.clientHeight) {
return node;
} else {
return getScrollableParentElement(node.parentNode);
}
}
function getScreenY(event) {
if (supportPointerDesktop) {
return event.screenY;
}
return event.touches[0].screenY;
}
function moveStart(event) {
if (!isPullStart && !isRefreshing && !disabled) {
isPullStart = true;
startScreenY = getScreenY(event);
pullDistance = 0;
}
}
function moveBySystem(to: number): Promise<void> {
return new Promise(r => {
const startHeight = pullDistance;
const overHeight = pullDistance - to;
if (overHeight < 1) {
r();
return;
}
const startTime = Date.now();
let intervalId = setInterval(() => {
const time = Date.now() - startTime;
if (time > RELEASE_TRANSITION_DURATION) {
pullDistance = to;
clearInterval(intervalId);
r();
return;
}
const nextHeight = startHeight - (overHeight / RELEASE_TRANSITION_DURATION) * time;
if (pullDistance < nextHeight) return;
pullDistance = nextHeight;
}, 1);
});
}
async function fixOverContent() {
if (pullDistance > FIRE_THRESHOLD) {
await moveBySystem(FIRE_THRESHOLD);
}
}
async function closeContent() {
if (pullDistance > 0) {
await moveBySystem(0);
}
}
function moveEnd() {
if (isPullStart && !isRefreshing) {
startScreenY = null;
if (isPullEnd) {
isPullEnd = false;
isRefreshing = true;
fixOverContent().then(() => emits('refresh'));
} else {
closeContent().then(() => isPullStart = false);
}
}
}
function moving(event) {
if (!isPullStart || isRefreshing || disabled) return;
if (!scrollEl) {
scrollEl = getScrollableParentElement(rootEl);
}
if ((scrollEl?.scrollTop ?? 0) > (supportPointerDesktop ? SCROLL_STOP : SCROLL_STOP + pullDistance)) {
pullDistance = 0;
isPullEnd = false;
moveEnd();
return;
}
if (startScreenY === null) {
startScreenY = getScreenY(event);
}
const moveScreenY = getScreenY(event);
const moveHeight = moveScreenY - startScreenY!;
pullDistance = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE);
isPullEnd = pullDistance >= FIRE_THRESHOLD;
}
/**
* emit(refresh)が完了したことを知らせる関数
*
* タイムアウトがないのでこれを最終的に実行しないと出たままになる
*/
function refreshFinished() {
closeContent().then(() => {
isPullStart = false;
isRefreshing = false;
});
}
function setDisabled(value) {
disabled = value;
}
onMounted(() => {
// pull to refresh便
//supportPointerDesktop = !!window.PointerEvent && deviceKind === 'desktop';
if (supportPointerDesktop) {
rootEl.addEventListener('pointerdown', moveStart);
// downup
window.addEventListener('pointerup', moveEnd);
rootEl.addEventListener('pointermove', moving, { passive: true });
} else {
rootEl.addEventListener('touchstart', moveStart);
rootEl.addEventListener('touchend', moveEnd);
rootEl.addEventListener('touchmove', moving, { passive: true });
}
});
onUnmounted(() => {
if (supportPointerDesktop) window.removeEventListener('pointerup', moveEnd);
});
defineExpose({
refreshFinished,
setDisabled,
});
</script>
<style lang="scss" module>
.frame {
position: relative;
overflow: clip;
width: 100%;
min-height: var(--frame-min-height, 0px);
mask-image: linear-gradient(90deg, #000 0%, #000 80%, transparent);
-webkit-mask-image: -webkit-linear-gradient(90deg, #000 0%, #000 80%, transparent);
pointer-events: none;
}
.frameContent {
position: absolute;
bottom: 0;
width: 100%;
margin: 5px 0;
display: flex;
flex-direction: column;
align-items: center;
font-size: 14px;
> .icon, > .loader {
margin: 6px 0;
}
> .icon {
transition: transform .25s;
&.refresh {
transform: rotate(180deg);
}
}
> .text {
margin: 5px 0;
}
}
.slotClip {
overflow-y: clip;
}
</style>

View file

@ -34,6 +34,7 @@ const props = withDefaults(defineProps<{
textConverter?: (value: number) => string,
showTicks?: boolean;
easing?: boolean;
continuousUpdate?: boolean;
}>(), {
step: 1,
textConverter: (v) => v.toString(),
@ -123,6 +124,10 @@ const onMousedown = (ev: MouseEvent | TouchEvent) => {
const pointerX = ev.touches && ev.touches.length > 0 ? ev.touches[0].clientX : ev.clientX;
const pointerPositionOnContainer = pointerX - (containerRect.left + (thumbWidth / 2));
rawValue.value = Math.min(1, Math.max(0, pointerPositionOnContainer / (containerEl.value!.offsetWidth - thumbWidth)));
if (props.continuousUpdate) {
emit('update:modelValue', finalValue.value);
}
};
let beforeValue = finalValue.value;

View file

@ -4,13 +4,16 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkNotes ref="tlComponent" :noGap="!defaultStore.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/>
<MkPullToRefresh ref="prComponent" @refresh="() => reloadTimeline(true)">
<MkNotes ref="tlComponent" :noGap="!defaultStore.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)" @status="prComponent.setDisabled($event)"/>
</MkPullToRefresh>
</template>
<script lang="ts" setup>
import { computed, provide, onUnmounted } from 'vue';
import MkNotes from '@/components/MkNotes.vue';
import { useStream } from '@/stream.js';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import { useStream, reloadStream } from '@/stream.js';
import * as sound from '@/scripts/sound.js';
import { $i } from '@/account.js';
import { instance } from '@/instance.js';
@ -41,6 +44,7 @@ const emit = defineEmits<{
provide('inChannel', computed(() => props.src === 'channel'));
const prComponent: InstanceType<typeof MkPullToRefresh> = $ref();
const tlComponent: InstanceType<typeof MkNotes> = $ref();
let tlNotesCount = 0;
@ -67,29 +71,77 @@ let connection;
let connection2;
const stream = useStream();
const connectChannel = () => {
if (props.src === 'antenna') {
connection = stream.useChannel('antenna', {
antennaId: props.antenna,
});
} else if (props.src === 'home') {
connection = stream.useChannel('homeTimeline', {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
});
connection2 = stream.useChannel('main');
} else if (props.src === 'local') {
connection = stream.useChannel('localTimeline', {
withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined,
withBots: props.withBots,
});
} else if (props.src === 'social') {
connection = stream.useChannel('hybridTimeline', {
withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined,
withBots: props.withBots,
});
} else if (props.src === 'global') {
connection = stream.useChannel('globalTimeline', {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
withBots: props.withBots,
});
} else if (props.src === 'mentions') {
connection = stream.useChannel('main');
connection.on('mention', prepend);
} else if (props.src === 'directs') {
const onNote = note => {
if (note.visibility === 'specified') {
prepend(note);
}
};
connection = stream.useChannel('main');
connection.on('mention', onNote);
} else if (props.src === 'list') {
connection = stream.useChannel('userList', {
withFiles: props.onlyFiles ? true : undefined,
listId: props.list,
});
} else if (props.src === 'channel') {
connection = stream.useChannel('channel', {
channelId: props.channel,
});
} else if (props.src === 'role') {
connection = stream.useChannel('roleTimeline', {
roleId: props.role,
});
}
if (props.src !== 'directs' || props.src !== 'mentions') connection.on('note', prepend);
};
if (props.src === 'antenna') {
endpoint = 'antennas/notes';
query = {
antennaId: props.antenna,
};
connection = stream.useChannel('antenna', {
antennaId: props.antenna,
});
connection.on('note', prepend);
} else if (props.src === 'home') {
endpoint = 'notes/timeline';
query = {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
withBots: props.withBots,
};
connection = stream.useChannel('homeTimeline', {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
});
connection.on('note', prepend);
connection2 = stream.useChannel('main');
} else if (props.src === 'local') {
endpoint = 'notes/local-timeline';
query = {
@ -98,13 +150,6 @@ if (props.src === 'antenna') {
withBots: props.withBots,
withFiles: props.onlyFiles ? true : undefined,
};
connection = stream.useChannel('localTimeline', {
withRenotes: props.withRenotes,
withReplies: props.withReplies,
withBots: props.withBots,
withFiles: props.onlyFiles ? true : undefined,
});
connection.on('note', prepend);
} else if (props.src === 'social') {
endpoint = 'notes/hybrid-timeline';
query = {
@ -113,13 +158,6 @@ if (props.src === 'antenna') {
withBots: props.withBots,
withFiles: props.onlyFiles ? true : undefined,
};
connection = stream.useChannel('hybridTimeline', {
withRenotes: props.withRenotes,
withReplies: props.withReplies,
withBots: props.withBots,
withFiles: props.onlyFiles ? true : undefined,
});
connection.on('note', prepend);
} else if (props.src === 'global') {
endpoint = 'notes/global-timeline';
query = {
@ -127,57 +165,38 @@ if (props.src === 'antenna') {
withBots: props.withBots,
withFiles: props.onlyFiles ? true : undefined,
};
connection = stream.useChannel('globalTimeline', {
withRenotes: props.withRenotes,
withBots: props.withBots,
withFiles: props.onlyFiles ? true : undefined,
});
connection.on('note', prepend);
} else if (props.src === 'mentions') {
endpoint = 'notes/mentions';
connection = stream.useChannel('main');
connection.on('mention', prepend);
} else if (props.src === 'directs') {
endpoint = 'notes/mentions';
query = {
visibility: 'specified',
};
const onNote = note => {
if (note.visibility === 'specified') {
prepend(note);
}
};
connection = stream.useChannel('main');
connection.on('mention', onNote);
} else if (props.src === 'list') {
endpoint = 'notes/user-list-timeline';
query = {
withFiles: props.onlyFiles ? true : undefined,
listId: props.list,
};
connection = stream.useChannel('userList', {
withFiles: props.onlyFiles ? true : undefined,
listId: props.list,
});
connection.on('note', prepend);
} else if (props.src === 'channel') {
endpoint = 'channels/timeline';
query = {
channelId: props.channel,
};
connection = stream.useChannel('channel', {
channelId: props.channel,
});
connection.on('note', prepend);
} else if (props.src === 'role') {
endpoint = 'roles/notes';
query = {
roleId: props.role,
};
connection = stream.useChannel('roleTimeline', {
roleId: props.role,
}
if (!defaultStore.state.disableStreamingTimeline) {
connectChannel();
onUnmounted(() => {
connection.dispose();
if (connection2) connection2.dispose();
});
connection.on('note', prepend);
}
const pagination = {
@ -186,9 +205,19 @@ const pagination = {
params: query,
};
onUnmounted(() => {
connection.dispose();
if (connection2) connection2.dispose();
const reloadTimeline = (fromPR = false) => {
tlNotesCount = 0;
tlComponent.pagingComponent?.reload().then(() => {
reloadStream();
if (fromPR) prComponent.refreshFinished();
});
};
//const pullRefresh = () => reloadTimeline(true);
defineExpose({
reloadTimeline,
});
/* TODO

View file

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" class="_noSelect" :class="[$style.root, { [$style.animation]: animation, [$style.cat]: user.isCat, [$style.square]: squareAvatars }]" :style="{ color }" :title="acct(user)" @click="onClick">
<MkImgWithBlurhash :class="$style.inner" :src="url" :hash="user?.avatarBlurhash" :cover="true" :onlyAvgColor="true"/>
<MkImgWithBlurhash :class="$style.inner" :src="url" :hash="user.avatarBlurhash" :cover="true" :onlyAvgColor="true"/>
<MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/>
<div v-if="user.isCat" :class="[$style.ears]">
<div :class="$style.earLeft">
@ -23,6 +23,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</div>
<img
v-if="showDecoration && (decoration || user.avatarDecorations.length > 0)"
:class="[$style.decoration]"
:src="decoration?.url ?? user.avatarDecorations[0].url"
:style="{
rotate: getDecorationAngle(),
scale: getDecorationScale(),
}"
alt=""
>
</component>
</template>
@ -47,22 +57,33 @@ const props = withDefaults(defineProps<{
link?: boolean;
preview?: boolean;
indicator?: boolean;
decoration?: {
url: string;
angle?: number;
flipH?: boolean;
flipV?: boolean;
};
forceShowDecoration?: boolean;
}>(), {
target: null,
link: false,
preview: false,
indicator: false,
decoration: undefined,
forceShowDecoration: false,
});
const emit = defineEmits<{
(ev: 'click', v: MouseEvent): void;
}>();
const showDecoration = props.forceShowDecoration || defaultStore.state.showAvatarDecorations;
const bound = $computed(() => props.link
? { to: userPage(props.user), target: props.target }
: {});
const url = $computed(() => defaultStore.state.disableShowingAnimatedImages
const url = $computed(() => (defaultStore.state.disableShowingAnimatedImages || defaultStore.state.enableDataSaverMode)
? getStaticImageUrl(props.user.avatarUrl)
: props.user.avatarUrl);
@ -71,6 +92,30 @@ function onClick(ev: MouseEvent): void {
emit('click', ev);
}
function getDecorationAngle() {
let angle;
if (props.decoration) {
angle = props.decoration.angle ?? 0;
} else if (props.user.avatarDecorations.length > 0) {
angle = props.user.avatarDecorations[0].angle ?? 0;
} else {
angle = 0;
}
return angle === 0 ? undefined : `${angle * 360}deg`;
}
function getDecorationScale() {
let scaleX;
if (props.decoration) {
scaleX = props.decoration.flipH ? -1 : 1;
} else if (props.user.avatarDecorations.length > 0) {
scaleX = props.user.avatarDecorations[0].flipH ? -1 : 1;
} else {
scaleX = 1;
}
return scaleX === 1 ? undefined : `${scaleX} 1`;
}
let color = $ref<string | undefined>();
watch(() => props.user.avatarBlurhash, () => {
@ -134,7 +179,7 @@ watch(() => props.user.avatarBlurhash, () => {
.indicator {
position: absolute;
z-index: 1;
z-index: 2;
bottom: 0;
left: 0;
width: 20%;
@ -278,4 +323,13 @@ watch(() => props.user.avatarBlurhash, () => {
}
}
}
.decoration {
position: absolute;
z-index: 1;
top: -50%;
left: -50%;
width: 200%;
pointer-events: none;
}
</style>

View file

@ -0,0 +1,32 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="[$style.spacer, defaultStore.reactiveState.darkMode ? $style.dark : $style.light]"></div>
</template>
<script lang="ts" setup>
import { defaultStore } from '@/store.js';
</script>
<style lang="scss" module>
.spacer {
box-sizing: border-box;
padding: 32px;
margin: 0 auto;
height: 300px;
background-clip: content-box;
background-size: auto auto;
background-color: rgba(255, 255, 255, 0);
&.light {
background-image: repeating-linear-gradient(135deg, transparent, transparent 16px, #00000026 16px, #00000026 20px );
}
&.dark {
background-image: repeating-linear-gradient(135deg, transparent, transparent 16px, #FFFFFF16 16px, #FFFFFF16 20px );
}
}
</style>

View file

@ -17,6 +17,7 @@ import MkSparkle from '@/components/MkSparkle.vue';
import MkA from '@/components/global/MkA.vue';
import { host } from '@/config.js';
import { defaultStore } from '@/store.js';
import { nyaize as doNyaize } from '@/scripts/nyaize.js';
const QUOTE_STYLE = `
display: block;
@ -27,21 +28,27 @@ border-left: solid 3px var(--fg);
opacity: 0.7;
`.split('\n').join(' ');
export default function(props: {
type MfmProps = {
text: string;
plain?: boolean;
nowrap?: boolean;
author?: Misskey.entities.UserLite;
i?: Misskey.entities.UserLite;
i?: Misskey.entities.UserLite | null;
isNote?: boolean;
emojiUrls?: string[];
rootScale?: number;
}) {
const isNote = props.isNote !== undefined ? props.isNote : true;
nyaize: boolean | 'account';
};
// eslint-disable-next-line import/no-default-export
export default function(props: MfmProps) {
const isNote = props.isNote ?? true;
const shouldNyaize = props.nyaize ? props.nyaize === 'account' ? props.author?.isCat ? props.author?.speakAsCat : false : false : false;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (props.text == null || props.text === '') return;
const ast = (props.plain ? mfm.parseSimple : mfm.parse)(props.text);
const rootAst = (props.plain ? mfm.parseSimple : mfm.parse)(props.text);
const validTime = (t: string | null | undefined) => {
if (t == null) return null;
@ -54,11 +61,15 @@ export default function(props: {
* Gen Vue Elements from MFM AST
* @param ast MFM AST
* @param scale How times large the text is
* @param disableNyaize Whether nyaize is disabled or not
*/
const genEl = (ast: mfm.MfmNode[], scale: number) => ast.map((token): VNode | string | (VNode | string)[] => {
const genEl = (ast: mfm.MfmNode[], scale: number, disableNyaize = false) => ast.map((token): VNode | string | (VNode | string)[] => {
switch (token.type) {
case 'text': {
const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
let text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
if (!disableNyaize && shouldNyaize) {
text = doNyaize(text);
}
if (!props.plain) {
const res: (VNode | string)[] = [];
@ -260,7 +271,7 @@ export default function(props: {
key: Math.random(),
url: token.props.url,
rel: 'nofollow noopener',
}, genEl(token.children, scale))];
}, genEl(token.children, scale, true))];
}
case 'mention': {
@ -299,11 +310,11 @@ export default function(props: {
if (!props.nowrap) {
return [h('div', {
style: QUOTE_STYLE,
}, genEl(token.children, scale))];
}, genEl(token.children, scale, true))];
} else {
return [h('span', {
style: QUOTE_STYLE,
}, genEl(token.children, scale))];
}, genEl(token.children, scale, true))];
}
}
@ -358,7 +369,7 @@ export default function(props: {
}
case 'plain': {
return [h('span', genEl(token.children, scale))];
return [h('span', genEl(token.children, scale, true))];
}
default: {
@ -373,5 +384,5 @@ export default function(props: {
return h('span', {
// https://codeday.me/jp/qa/20190424/690106.html
style: props.nowrap ? 'white-space: pre; word-wrap: normal; overflow: hidden; text-overflow: ellipsis;' : 'white-space: pre-wrap;',
}, genEl(ast, props.rootScale ?? 1));
}, genEl(rootAst, props.rootScale ?? 1));
}

View file

@ -134,9 +134,11 @@ async function enter(el: HTMLElement) {
setTimeout(renderTab, 170);
}
function afterEnter(el: HTMLElement) {
//el.style.width = '';
}
async function leave(el: HTMLElement) {
const elementWidth = el.getBoundingClientRect().width;
el.style.width = elementWidth + 'px';
@ -145,6 +147,7 @@ async function leave(el: HTMLElement) {
el.style.width = '0';
el.style.paddingLeft = '0';
}
function afterLeave(el: HTMLElement) {
el.style.width = '';
}

View file

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<time :title="absolute">
<time :title="absolute" :class="{ [$style.old1]: colored && (ago > 60 * 60 * 24 * 90), [$style.old2]: colored && (ago > 60 * 60 * 24 * 180) }">
<template v-if="invalid">{{ i18n.ts._ago.invalid }}</template>
<template v-else-if="mode === 'relative'">{{ relative }}</template>
<template v-else-if="mode === 'absolute'">{{ absolute }}</template>
@ -22,6 +22,7 @@ const props = withDefaults(defineProps<{
time: Date | string | number | null;
origin?: Date | null;
mode?: 'relative' | 'absolute' | 'detail';
colored?: boolean;
}>(), {
origin: isChromatic() ? new Date('2023-04-01T00:00:00Z') : null,
mode: 'relative',
@ -75,3 +76,13 @@ if (!invalid && props.origin === null && (props.mode === 'relative' || props.mod
});
}
</script>
<style lang="scss" module>
.old1 {
color: var(--warn);
}
.old1.old2 {
color: var(--error);
}
</style>

View file

@ -31,23 +31,28 @@ import * as os from '@/os.js';
import { useTooltip } from '@/scripts/use-tooltip.js';
import { safeURIDecode } from '@/scripts/safe-uri-decode.js';
const props = defineProps<{
const props = withDefaults(defineProps<{
url: string;
rel?: string;
}>();
showUrlPreview?: boolean;
}>(), {
showUrlPreview: true,
});
const self = props.url.startsWith(local);
const url = new URL(props.url);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('invalid url');
const el = ref();
useTooltip(el, (showing) => {
os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
showing,
url: props.url,
source: el.value,
}, {}, 'closed');
});
if (props.showUrlPreview) {
useTooltip(el, (showing) => {
os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
showing,
url: props.url,
source: el.value,
}, {}, 'closed');
});
}
const schema = url.protocol;
const hostname = decodePunycode(url.hostname);

View file

@ -5,7 +5,7 @@
import { App } from 'vue';
import Mfm from './global/MkMisskeyFlavoredMarkdown.ts';
import Mfm from './global/MkMisskeyFlavoredMarkdown.js';
import MkA from './global/MkA.vue';
import MkAcct from './global/MkAcct.vue';
import MkAvatar from './global/MkAvatar.vue';
@ -16,13 +16,14 @@ import MkUserName from './global/MkUserName.vue';
import MkEllipsis from './global/MkEllipsis.vue';
import MkTime from './global/MkTime.vue';
import MkUrl from './global/MkUrl.vue';
import I18n from './global/i18n';
import I18n from './global/i18n.js';
import RouterView from './global/RouterView.vue';
import MkLoading from './global/MkLoading.vue';
import MkError from './global/MkError.vue';
import MkAd from './global/MkAd.vue';
import MkPageHeader from './global/MkPageHeader.vue';
import MkSpacer from './global/MkSpacer.vue';
import MkFooterSpacer from './global/MkFooterSpacer.vue';
import MkStickyContainer from './global/MkStickyContainer.vue';
export default function(app: App) {
@ -50,6 +51,7 @@ export const components = {
MkAd: MkAd,
MkPageHeader: MkPageHeader,
MkSpacer: MkSpacer,
MkFooterSpacer: MkFooterSpacer,
MkStickyContainer: MkStickyContainer,
};
@ -73,6 +75,7 @@ declare module '@vue/runtime-core' {
MkAd: typeof MkAd;
MkPageHeader: typeof MkPageHeader;
MkSpacer: typeof MkSpacer;
MkFooterSpacer: typeof MkFooterSpacer;
MkStickyContainer: typeof MkStickyContainer;
}
}

View file

@ -66,6 +66,7 @@ export const ROLE_POLICIES = [
'inviteLimitCycle',
'inviteExpirationTime',
'canManageCustomEmojis',
'canManageAvatarDecorations',
'canSearchNotes',
'canUseTranslator',
'canHideAds',

View file

@ -1061,7 +1061,7 @@
["💰", "moneybag", 6],
["🪙", "coin", 6],
["💳", "credit_card", 6],
["🪫", "identification_card", 6],
["🪪", "identification_card", 6],
["💎", "gem", 6],
["⚖", "balance_scale", 6],
["🧰", "toolbox", 6],

View file

@ -6,7 +6,7 @@
import { computed, reactive } from 'vue';
import { $i } from '@/account.js';
import { miLocalStorage } from '@/local-storage.js';
import { openInstanceMenu } from '@/ui/_common_/common.js';
import { openInstanceMenu, openToolsMenu } from '@/ui/_common_/common.js';
import { lookup } from '@/scripts/lookup.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
@ -143,6 +143,13 @@ export const navbarItemDef = reactive({
openInstanceMenu(ev);
},
},
tools: {
title: i18n.ts.tools,
icon: 'ph-toolbox ph-bold ph-lg',
action: (ev) => {
openToolsMenu(ev);
},
},
reload: {
title: i18n.ts.reload,
icon: 'ph-arrows-clockwise ph-bold ph-lg',

View file

@ -42,7 +42,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</FormSection>
<FormSection>
<template #label>{{ i18n.ts._aboutMisskey.contributors }}</template>
<template #label>{{ i18n.ts._aboutMisskey.projectMembers }}</template>
<div :class="$style.contributors">
<a href="https://github.com/Mar0xy" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/8841466?v=4" :class="$style.contributorAvatar">
@ -70,22 +70,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<img src="https://avatars.githubusercontent.com/u/20679825?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@acid-chicken</span>
</a>
<a href="https://github.com/rinsuki" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/6533808?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@rinsuki</span>
<a href="https://github.com/kakkokari-gtyih" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/67428053?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@kakkokari-gtyih</span>
</a>
<a href="https://github.com/mei23" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/30769358?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@mei23</span>
</a>
<a href="https://github.com/robflop" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/8159402?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@robflop</span>
<a href="https://github.com/taichanNE30" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/40626578?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@taichanNE30</span>
</a>
</div>
</FormSection>
<FormSection>
<template #label>Our lovely GitHub Sponsors</template>
<template #label>Our lovely Sponsors</template>
<div :class="$style.contributors">
<span
v-for="sponsor in sponsors[0]"

View file

@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton v-if="$i && ($i.isModerator || $i.policies.canManageCustomEmojis)" primary link to="/custom-emojis-manager">{{ i18n.ts.manageCustomEmojis }}</MkButton>
<div class="query">
<MkInput v-model="q" class="" :placeholder="i18n.ts.search">
<MkInput v-model="q" class="" :placeholder="i18n.ts.search" autocapitalize="off">
<template #prefix><i class="ph-magnifying-glass ph-bold ph-lg"></i></template>
</MkInput>

View file

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkStickyContainer>
<template #header>
<XHeader :actions="headerActions" :tabs="headerTabs" />
<XHeader :actions="headerActions" :tabs="headerTabs"/>
</template>
<MkSpacer :contentMax="900">
<MkSwitch :modelValue="publishing" @update:modelValue="onChangePublishing">
@ -14,11 +14,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
<div>
<div v-for="ad in ads" class="_panel _gaps_m" :class="$style.ad">
<MkAd v-if="ad.url" :specify="ad" />
<MkAd v-if="ad.url" :specify="ad"/>
<MkInput v-model="ad.url" type="url">
<template #label>URL</template>
</MkInput>
<MkInput v-model="ad.imageUrl">
<MkInput v-model="ad.imageUrl" type="url">
<template #label>{{ i18n.ts.imageUrl }}</template>
</MkInput>
<MkRadios v-model="ad.place">
@ -51,8 +51,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<span>
{{ i18n.ts._ad.timezoneinfo }}
<div v-for="(day, index) in daysOfWeek" :key="index">
<input :id="`ad${ad.id}-${index}`" type="checkbox" :checked="(ad.dayOfWeek & (1 << index)) !== 0"
@change="toggleDayOfWeek(ad, index)">
<input
:id="`ad${ad.id}-${index}`" type="checkbox" :checked="(ad.dayOfWeek & (1 << index)) !== 0"
@change="toggleDayOfWeek(ad, index)"
>
<label :for="`ad${ad.id}-${index}`">{{ day }}</label>
</div>
</span>
@ -61,8 +63,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.memo }}</template>
</MkTextarea>
<div class="buttons">
<MkButton class="button" inline primary style="margin-right: 12px;" @click="save(ad)"><i class="ph-floppy-disk ph-bold pg-lg"></i> {{ i18n.ts.save }}</MkButton>
<MkButton class="button" inline danger @click="remove(ad)"><i class="ph-trash ph-bold ph-lg"></i> {{ i18n.ts.remove }}</MkButton>
<MkButton class="button" inline primary style="margin-right: 12px;" @click="save(ad)">
<i
class="ph-floppy-disk ph-bold pg-lg"
></i> {{ i18n.ts.save }}
</MkButton>
<MkButton class="button" inline danger @click="remove(ad)">
<i class="ph-trash ph-bold ph-lg"></i> {{ i18n.ts.remove }}
</MkButton>
</div>
</div>
<MkButton class="button" @click="more()">
@ -113,6 +121,7 @@ const onChangePublishing = (v) => {
publishing = v;
refresh();
};
// (index)
function toggleDayOfWeek(ad, index) {
ad.dayOfWeek ^= 1 << index;
@ -185,6 +194,7 @@ function save(ad) {
});
}
}
function more() {
os.api('admin/ad/list', { untilId: ads.reduce((acc, ad) => ad.id != null ? ad : acc).id, publishing: publishing }).then(adsResponse => {
ads = ads.concat(adsResponse.map(r => {

View file

@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkTextarea v-model="announcement.text">
<template #label>{{ i18n.ts.text }}</template>
</MkTextarea>
<MkInput v-model="announcement.imageUrl">
<MkInput v-model="announcement.imageUrl" type="url">
<template #label>{{ i18n.ts.imageUrl }}</template>
</MkInput>
<MkRadios v-model="announcement.icon">

View file

@ -10,12 +10,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init">
<div class="_gaps_m">
<MkInput v-model="iconUrl">
<MkInput v-model="iconUrl" type="url">
<template #prefix><i class="ph-link ph-bold ph-lg"></i></template>
<template #label>{{ i18n.ts._serverSettings.iconUrl }}</template>
</MkInput>
<MkInput v-model="app192IconUrl">
<MkInput v-model="app192IconUrl" type="url">
<template #prefix><i class="ph-link ph-bold ph-lg"></i></template>
<template #label>{{ i18n.ts._serverSettings.iconUrl }} (App/192px)</template>
<template #caption>
@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
</MkInput>
<MkInput v-model="app512IconUrl">
<MkInput v-model="app512IconUrl" type="url">
<template #prefix><i class="ph-link ph-bold ph-lg"></i></template>
<template #label>{{ i18n.ts._serverSettings.iconUrl }} (App/512px)</template>
<template #caption>
@ -37,27 +37,27 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
</MkInput>
<MkInput v-model="bannerUrl">
<MkInput v-model="bannerUrl" type="url">
<template #prefix><i class="ph-link ph-bold ph-lg"></i></template>
<template #label>{{ i18n.ts.bannerUrl }}</template>
</MkInput>
<MkInput v-model="backgroundImageUrl">
<MkInput v-model="backgroundImageUrl" type="url">
<template #prefix><i class="ph-link ph-bold ph-lg"></i></template>
<template #label>{{ i18n.ts.backgroundImageUrl }}</template>
</MkInput>
<MkInput v-model="notFoundImageUrl">
<MkInput v-model="notFoundImageUrl" type="url">
<template #prefix><i class="ph-link ph-bold ph-lg"></i></template>
<template #label>{{ i18n.ts.notFoundDescription }}</template>
</MkInput>
<MkInput v-model="infoImageUrl">
<MkInput v-model="infoImageUrl" type="url">
<template #prefix><i class="ph-link ph-bold ph-lg"></i></template>
<template #label>{{ i18n.ts.nothing }}</template>
</MkInput>
<MkInput v-model="serverErrorImageUrl">
<MkInput v-model="serverErrorImageUrl" type="url">
<template #prefix><i class="ph-link ph-bold ph-lg"></i></template>
<template #label>{{ i18n.ts.somethingHappened }}</template>
</MkInput>

View file

@ -129,6 +129,11 @@ const menuDef = $computed(() => [{
text: i18n.ts.customEmojis,
to: '/admin/emojis',
active: currentPage?.route.name === 'emojis',
}, {
icon: 'ph-sparkle ph-bold ph-lg',
text: i18n.ts.avatarDecorations,
to: '/avatar-decorations',
active: currentPage?.route.name === 'avatarDecorations',
}, {
icon: 'ph-globe-hemisphere-west ph-bold ph-lg',
text: i18n.ts.federation,

View file

@ -24,13 +24,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<FormLink to="/admin/server-rules">{{ i18n.ts.serverRules }}</FormLink>
<MkInput v-model="tosUrl">
<MkInput v-model="tosUrl" type="url">
<template #prefix><i class="ph-link ph-bold ph-lg"></i></template>
<template #label>{{ i18n.ts.tosUrl }}</template>
</MkInput>
<MkInput v-model="privacyPolicyUrl">
<template #prefix><i class="ti ti-link"></i></template>
<MkInput v-model="privacyPolicyUrl" type="url">
<template #prefix><i class="ph-link ph-bold ph-lg"></i></template>
<template #label>{{ i18n.ts.privacyPolicyUrl }}</template>
</MkInput>

View file

@ -8,9 +8,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>
<b
:class="{
[$style.logGreen]: ['createRole', 'addCustomEmoji', 'createGlobalAnnouncement', 'createUserAnnouncement', 'createAd', 'createInvitation'].includes(log.type),
[$style.logGreen]: ['createRole', 'addCustomEmoji', 'createGlobalAnnouncement', 'createUserAnnouncement', 'createAd', 'createInvitation', 'createAvatarDecoration'].includes(log.type),
[$style.logYellow]: ['markSensitiveDriveFile', 'resetPassword'].includes(log.type),
[$style.logRed]: ['suspend', 'approve', 'deleteRole', 'suspendRemoteInstance', 'deleteGlobalAnnouncement', 'deleteUserAnnouncement', 'deleteCustomEmoji', 'deleteNote', 'deleteDriveFile', 'deleteAd'].includes(log.type)
[$style.logRed]: ['suspend', 'approve', 'deleteRole', 'suspendRemoteInstance', 'deleteGlobalAnnouncement', 'deleteUserAnnouncement', 'deleteCustomEmoji', 'deleteNote', 'deleteDriveFile', 'deleteAd', 'deleteAvatarDecoration'].includes(log.type)
}"
>{{ i18n.ts._moderationLogTypes[log.type] }}</b>
<span v-if="log.type === 'updateUserNote'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="log.type === 'approve'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'unsuspend'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'resetPassword'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'assignRole'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} <i class="ti ti-arrow-right"></i> {{ log.info.roleName }}</span>
<span v-else-if="log.type === 'unassignRole'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} <i class="ti ti-equal-not"></i> {{ log.info.roleName }}</span>
<span v-else-if="log.type === 'assignRole'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} <i class="ph-arrow-right ph-bold ph-lg"></i> {{ log.info.roleName }}</span>
<span v-else-if="log.type === 'unassignRole'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} <i class="ph-prohibit ph-bold ph-lg"></i> {{ log.info.roleName }}</span>
<span v-else-if="log.type === 'createRole'">: {{ log.info.role.name }}</span>
<span v-else-if="log.type === 'updateRole'">: {{ log.info.before.name }}</span>
<span v-else-if="log.type === 'deleteRole'">: {{ log.info.role.name }}</span>
@ -38,6 +38,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="log.type === 'deleteUserAnnouncement'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'deleteNote'">: @{{ log.info.noteUserUsername }}{{ log.info.noteUserHost ? '@' + log.info.noteUserHost : '' }}</span>
<span v-else-if="log.type === 'deleteDriveFile'">: @{{ log.info.fileUserUsername }}{{ log.info.fileUserHost ? '@' + log.info.fileUserHost : '' }}</span>
<span v-else-if="log.type === 'createAvatarDecoration'">: {{ log.info.avatarDecoration.name }}</span>
<span v-else-if="log.type === 'updateAvatarDecoration'">: {{ log.info.before.name }}</span>
<span v-else-if="log.type === 'deleteAvatarDecoration'">: {{ log.info.avatarDecoration.name }}</span>
</template>
<template #icon>
<MkAvatar :user="log.user" :class="$style.avatar"/>
@ -106,6 +109,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<CodeDiff :context="5" :hideHeader="true" :oldString="JSON5.stringify(log.info.before, null, '\t')" :newString="JSON5.stringify(log.info.after, null, '\t')" language="javascript" maxHeight="300px"/>
</div>
</template>
<template v-else-if="log.type === 'updateAvatarDecoration'">
<div :class="$style.diff">
<CodeDiff :context="5" :hideHeader="true" :oldString="JSON5.stringify(log.info.before, null, '\t')" :newString="JSON5.stringify(log.info.after, null, '\t')" language="javascript" maxHeight="300px"/>
</div>
</template>
<details>
<summary>raw</summary>

View file

@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="useObjectStorage">{{ i18n.ts.useObjectStorage }}</MkSwitch>
<template v-if="useObjectStorage">
<MkInput v-model="objectStorageBaseUrl" :placeholder="'https://example.com'">
<MkInput v-model="objectStorageBaseUrl" :placeholder="'https://example.com'" type="url">
<template #label>{{ i18n.ts.objectStorageBaseUrl }}</template>
<template #caption>{{ i18n.ts.objectStorageBaseUrlDesc }}</template>
</MkInput>

View file

@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.color }}</template>
</MkColorInput>
<MkInput v-model="role.iconUrl">
<MkInput v-model="role.iconUrl" type="url">
<template #label>{{ i18n.ts._role.iconUrl }}</template>
</MkInput>
@ -259,6 +259,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canManageAvatarDecorations, 'canManageAvatarDecorations'])">
<template #label>{{ i18n.ts._role._options.canManageAvatarDecorations }}</template>
<template #suffix>
<span v-if="role.policies.canManageAvatarDecorations.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canManageAvatarDecorations.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canManageAvatarDecorations)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canManageAvatarDecorations.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canManageAvatarDecorations.value" :disabled="role.policies.canManageAvatarDecorations.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canManageAvatarDecorations.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canSearchNotes, 'canSearchNotes'])">
<template #label>{{ i18n.ts._role._options.canSearchNotes }}</template>
<template #suffix>

View file

@ -79,6 +79,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canManageAvatarDecorations, 'canManageAvatarDecorations'])">
<template #label>{{ i18n.ts._role._options.canManageAvatarDecorations }}</template>
<template #suffix>{{ policies.canManageAvatarDecorations ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canManageAvatarDecorations">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canManageCustomEmojis, 'canManageCustomEmojis'])">
<template #label>{{ i18n.ts._role._options.canManageCustomEmojis }}</template>
<template #suffix>{{ policies.canManageCustomEmojis ? i18n.ts.yes : i18n.ts.no }}</template>

View file

@ -34,9 +34,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput>
</FormSplit>
<MkInput v-model="impressumUrl">
<MkInput v-model="impressumUrl" type="url">
<template #label>{{ i18n.ts.impressumUrl }}</template>
<template #prefix><i class="ti ti-link"></i></template>
<template #prefix><i class="ph-link ph-bold ph-lg"></i></template>
<template #caption>{{ i18n.ts.impressumDescription }}</template>
</MkInput>
@ -87,9 +87,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</FormSection>
<FormSection>
<template #label>Timeline caching</template>
<template #label>Misskey® Fan-out Timeline Technology (FTT)</template>
<div class="_gaps_m">
<MkSwitch v-model="enableFanoutTimeline">
<template #label>{{ i18n.ts.enable }}</template>
<template #caption>{{ i18n.ts._serverSettings.fanoutTimelineDescription }}</template>
</MkSwitch>
<MkInput v-model="perLocalUserUserTimelineCacheMax" type="number">
<template #label>perLocalUserUserTimelineCacheMax</template>
</MkInput>
@ -165,6 +170,7 @@ let cacheRemoteSensitiveFiles: boolean = $ref(false);
let enableServiceWorker: boolean = $ref(false);
let swPublicKey: any = $ref(null);
let swPrivateKey: any = $ref(null);
let enableFanoutTimeline: boolean = $ref(false);
let perLocalUserUserTimelineCacheMax: number = $ref(0);
let perRemoteUserUserTimelineCacheMax: number = $ref(0);
let perUserHomeTimelineCacheMax: number = $ref(0);
@ -185,6 +191,7 @@ async function init(): Promise<void> {
enableServiceWorker = meta.enableServiceWorker;
swPublicKey = meta.swPublickey;
swPrivateKey = meta.swPrivateKey;
enableFanoutTimeline = meta.enableFanoutTimeline;
perLocalUserUserTimelineCacheMax = meta.perLocalUserUserTimelineCacheMax;
perRemoteUserUserTimelineCacheMax = meta.perRemoteUserUserTimelineCacheMax;
perUserHomeTimelineCacheMax = meta.perUserHomeTimelineCacheMax;
@ -206,6 +213,7 @@ async function save(): void {
enableServiceWorker,
swPublicKey,
swPrivateKey,
enableFanoutTimeline,
perLocalUserUserTimelineCacheMax,
perRemoteUserUserTimelineCacheMax,
perUserHomeTimelineCacheMax,

View file

@ -0,0 +1,102 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="900">
<div class="_gaps">
<MkFolder v-for="avatarDecoration in avatarDecorations" :key="avatarDecoration.id ?? avatarDecoration._id" :defaultOpen="avatarDecoration.id == null">
<template #label>{{ avatarDecoration.name }}</template>
<template #caption>{{ avatarDecoration.description }}</template>
<div class="_gaps_m">
<MkInput v-model="avatarDecoration.name">
<template #label>{{ i18n.ts.name }}</template>
</MkInput>
<MkTextarea v-model="avatarDecoration.description">
<template #label>{{ i18n.ts.description }}</template>
</MkTextarea>
<MkInput v-model="avatarDecoration.url">
<template #label>{{ i18n.ts.imageUrl }}</template>
</MkInput>
<div class="buttons _buttons">
<MkButton class="button" inline primary @click="save(avatarDecoration)"><i class="ph-floppy ph-bold ph-lg"></i> {{ i18n.ts.save }}</MkButton>
<MkButton v-if="avatarDecoration.id != null" class="button" inline danger @click="del(avatarDecoration)"><i class="ph-trash ph-bold ph-lg"></i> {{ i18n.ts.delete }}</MkButton>
</div>
</div>
</MkFolder>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { } from 'vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkRadios from '@/components/MkRadios.vue';
import MkInfo from '@/components/MkInfo.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkFolder from '@/components/MkFolder.vue';
let avatarDecorations: any[] = $ref([]);
function add() {
avatarDecorations.unshift({
_id: Math.random().toString(36),
id: null,
name: '',
description: '',
url: '',
});
}
function del(avatarDecoration) {
os.confirm({
type: 'warning',
text: i18n.t('deleteAreYouSure', { x: avatarDecoration.name }),
}).then(({ canceled }) => {
if (canceled) return;
avatarDecorations = avatarDecorations.filter(x => x !== avatarDecoration);
os.api('admin/avatar-decorations/delete', avatarDecoration);
});
}
async function save(avatarDecoration) {
if (avatarDecoration.id == null) {
await os.apiWithDialog('admin/avatar-decorations/create', avatarDecoration);
load();
} else {
os.apiWithDialog('admin/avatar-decorations/update', avatarDecoration);
}
}
function load() {
os.api('admin/avatar-decorations/list').then(_avatarDecorations => {
avatarDecorations = _avatarDecorations;
});
}
load();
const headerActions = $computed(() => [{
asFullButton: true,
icon: 'ph-plus ph-bold ph-lg',
text: i18n.ts.add,
handler: add,
}]);
const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.avatarDecorations,
icon: 'ph-sparkle ph-bold ph-lg',
});
</script>

View file

@ -154,12 +154,9 @@ function save() {
if (props.channelId) {
params.channelId = props.channelId;
os.api('channels/update', params).then(() => {
os.success();
});
os.apiWithDialog('channels/update', params);
} else {
os.api('channels/create', params).then(created => {
os.success();
os.apiWithDialog('channels/create', params).then(created => {
router.push(`/channels/${created.id}`);
});
}

View file

@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :contentMax="900">
<div class="ogwlenmc">
<div v-if="tab === 'local'" class="local">
<MkInput v-model="query" :debounce="true" type="search">
<MkInput v-model="query" :debounce="true" type="search" autocapitalize="off">
<template #prefix><i class="ph-magnifying-glass ph-bold ph-lg"></i></template>
<template #label>{{ i18n.ts.search }}</template>
</MkInput>
@ -44,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-else-if="tab === 'remote'" class="remote">
<FormSplit>
<MkInput v-model="queryRemote" :debounce="true" type="search">
<MkInput v-model="queryRemote" :debounce="true" type="search" autocapitalize="off">
<template #prefix><i class="ph-magnifying-glass ph-bold ph-lg"></i></template>
<template #label>{{ i18n.ts.search }}</template>
</MkInput>

View file

@ -41,7 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<button class="_button" :class="$style.fileAltEditBtn" @click="describe()">
<MkKeyValue>
<template #key>{{ i18n.ts.description }}</template>
<template #value>{{ file.comment ? file.comment : `(${i18n.ts.none})` }}<i class="ti ti-pencil" :class="$style.fileAltEditIcon"></i></template>
<template #value>{{ file.comment ? file.comment : `(${i18n.ts.none})` }}<i class="ph-pencil ph-bold ph-lg" :class="$style.fileAltEditIcon"></i></template>
</MkKeyValue>
</button>
<MkKeyValue :class="$style.fileMetaDataChildren">

View file

@ -31,13 +31,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<MkButton rounded style="margin: 0 auto;" @click="changeImage">{{ i18n.ts.selectFile }}</MkButton>
<MkInput v-model="name" pattern="[a-z0-9_]">
<MkInput v-model="name" pattern="[a-z0-9_]" autocapitalize="off">
<template #label>{{ i18n.ts.name }}</template>
</MkInput>
<MkInput v-model="category" :datalist="customEmojiCategories">
<template #label>{{ i18n.ts.category }}</template>
</MkInput>
<MkInput v-model="aliases">
<MkInput v-model="aliases" autocapitalize="off">
<template #label>{{ i18n.ts.tags }}</template>
<template #caption>{{ i18n.ts.setMultipleBySeparatingWithSpace }}</template>
</MkInput>

View file

@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #icon><i class="ph-code ph-bold pg-lg"></i></template>
<template #label>{{ i18n.ts._play.viewSource }}</template>
<MkCode :code="flash.script" :inline="false" class="_monospace"/>
<MkCode :code="flash.script" lang="is" :inline="false" class="_monospace"/>
</MkFolder>
<div :class="$style.footer">
<Mfm :text="`By @${flash.user.username}`"/>

View file

@ -0,0 +1,360 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="500">
<MkLoading v-if="uiPhase === 'fetching'"/>
<div v-else-if="uiPhase === 'confirm' && data" class="_gaps_m" :class="$style.extInstallerRoot">
<div :class="$style.extInstallerIconWrapper">
<i v-if="data.type === 'plugin'" class="ph-plug ph-bold ph-lg"></i>
<i v-else-if="data.type === 'theme'" class="ph-palette ph-bold ph-lg"></i>
<i v-else class="ph-download ph-bold ph-lg"></i>
</div>
<h2 :class="$style.extInstallerTitle">{{ i18n.ts._externalResourceInstaller[`_${data.type}`].title }}</h2>
<div :class="$style.extInstallerNormDesc">{{ i18n.ts._externalResourceInstaller.checkVendorBeforeInstall }}</div>
<MkInfo v-if="data.type === 'plugin'" :warn="true">{{ i18n.ts._plugin.installWarn }}</MkInfo>
<FormSection>
<template #label>{{ i18n.ts._externalResourceInstaller[`_${data.type}`].metaTitle }}</template>
<div class="_gaps_s">
<FormSplit>
<MkKeyValue>
<template #key>{{ i18n.ts.name }}</template>
<template #value>{{ data.meta?.name }}</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.author }}</template>
<template #value>{{ data.meta?.author }}</template>
</MkKeyValue>
</FormSplit>
<MkKeyValue v-if="data.type === 'plugin'">
<template #key>{{ i18n.ts.description }}</template>
<template #value>{{ data.meta?.description }}</template>
</MkKeyValue>
<MkKeyValue v-if="data.type === 'plugin'">
<template #key>{{ i18n.ts.version }}</template>
<template #value>{{ data.meta?.version }}</template>
</MkKeyValue>
<MkKeyValue v-if="data.type === 'plugin'">
<template #key>{{ i18n.ts.permission }}</template>
<template #value>
<ul :class="$style.extInstallerKVList">
<li v-for="permission in data.meta?.permissions" :key="permission">{{ i18n.ts._permissions[permission] }}</li>
</ul>
</template>
</MkKeyValue>
<MkKeyValue v-if="data.type === 'theme' && data.meta?.base">
<template #key>{{ i18n.ts._externalResourceInstaller._meta.base }}</template>
<template #value>{{ i18n.ts[data.meta.base] }}</template>
</MkKeyValue>
<MkFolder>
<template #icon><i class="ph-code ph-bold ph-lg"></i></template>
<template #label>{{ i18n.ts._plugin.viewSource }}</template>
<MkCode :code="data.raw ?? ''"/>
</MkFolder>
</div>
</FormSection>
<FormSection>
<template #label>{{ i18n.ts._externalResourceInstaller._vendorInfo.title }}</template>
<div class="_gaps_s">
<MkKeyValue>
<template #key>{{ i18n.ts._externalResourceInstaller._vendorInfo.endpoint }}</template>
<template #value><MkUrl :url="url ?? ''" :showUrlPreview="false"></MkUrl></template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts._externalResourceInstaller._vendorInfo.hashVerify }}</template>
<template #value>
<!--この画面が出ている時点でハッシュの検証には成功している-->
<i class="ph-check ph-bold ph-lg" style="color: var(--accent)"></i>
</template>
</MkKeyValue>
</div>
</FormSection>
<div class="_buttonsCenter">
<MkButton primary @click="install()"><i class="ph-check ph-bold ph-lg"></i> {{ i18n.ts.install }}</MkButton>
</div>
</div>
<div v-else-if="uiPhase === 'error'" class="_gaps_m" :class="[$style.extInstallerRoot, $style.error]">
<div :class="$style.extInstallerIconWrapper">
<i class="ph-x-circle ph-bold ph-lg"></i>
</div>
<h2 :class="$style.extInstallerTitle">{{ errorKV?.title }}</h2>
<div :class="$style.extInstallerNormDesc">{{ errorKV?.description }}</div>
<div class="_buttonsCenter">
<MkButton @click="goBack()">{{ i18n.ts.goBack }}</MkButton>
<MkButton @click="goToMisskey()">{{ i18n.ts.goToMisskey }}</MkButton>
</div>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { ref, computed, onActivated, onDeactivated, nextTick } from 'vue';
import MkLoading from '@/components/global/MkLoading.vue';
import MkButton from '@/components/MkButton.vue';
import FormSection from '@/components/form/section.vue';
import FormSplit from '@/components/form/split.vue';
import MkCode from '@/components/MkCode.vue';
import MkUrl from '@/components/global/MkUrl.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import * as os from '@/os.js';
import { AiScriptPluginMeta, parsePluginMeta, installPlugin } from '@/scripts/install-plugin.js';
import { parseThemeCode, installTheme } from '@/scripts/install-theme.js';
import { unisonReload } from '@/scripts/unison-reload.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
const uiPhase = ref<'fetching' | 'confirm' | 'error'>('fetching');
const errorKV = ref<{
title?: string;
description?: string;
}>({
title: '',
description: '',
});
const url = ref<string | null>(null);
const hash = ref<string | null>(null);
const data = ref<{
type: 'plugin' | 'theme';
raw: string;
meta?: {
// Plugin & Theme Common
name: string;
author: string;
// Plugin
description?: string;
version?: string;
permissions?: string[];
config?: Record<string, any>;
// Theme
base?: 'light' | 'dark';
};
} | null>(null);
function goBack(): void {
history.back();
}
function goToMisskey(): void {
location.href = '/';
}
async function fetch() {
if (!url.value || !hash.value) {
errorKV.value = {
title: i18n.ts._externalResourceInstaller._errors._invalidParams.title,
description: i18n.ts._externalResourceInstaller._errors._invalidParams.description,
};
uiPhase.value = 'error';
return;
}
const res = await os.api('fetch-external-resources', {
url: url.value,
hash: hash.value,
}).catch((err) => {
switch (err.id) {
case 'bb774091-7a15-4a70-9dc5-6ac8cf125856':
errorKV.value = {
title: i18n.ts._externalResourceInstaller._errors._failedToFetch.title,
description: i18n.ts._externalResourceInstaller._errors._failedToFetch.parseErrorDescription,
};
uiPhase.value = 'error';
break;
case '693ba8ba-b486-40df-a174-72f8279b56a4':
errorKV.value = {
title: i18n.ts._externalResourceInstaller._errors._hashUnmatched.title,
description: i18n.ts._externalResourceInstaller._errors._hashUnmatched.description,
};
uiPhase.value = 'error';
break;
default:
errorKV.value = {
title: i18n.ts._externalResourceInstaller._errors._failedToFetch.title,
description: i18n.ts._externalResourceInstaller._errors._failedToFetch.fetchErrorDescription,
};
uiPhase.value = 'error';
break;
}
throw new Error(err.code);
});
if (!res) {
errorKV.value = {
title: i18n.ts._externalResourceInstaller._errors._failedToFetch.title,
description: i18n.ts._externalResourceInstaller._errors._failedToFetch.fetchErrorDescription,
};
uiPhase.value = 'error';
return;
}
switch (res.type) {
case 'plugin':
try {
const meta = await parsePluginMeta(res.data);
data.value = {
type: 'plugin',
meta,
raw: res.data,
};
} catch (err) {
errorKV.value = {
title: i18n.ts._externalResourceInstaller._errors._pluginParseFailed.title,
description: i18n.ts._externalResourceInstaller._errors._pluginParseFailed.description,
};
console.error(err);
uiPhase.value = 'error';
return;
}
break;
case 'theme':
try {
const metaRaw = parseThemeCode(res.data);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { id, props, desc: description, ...meta } = metaRaw;
data.value = {
type: 'theme',
meta: {
description,
...meta,
},
raw: res.data,
};
} catch (err) {
switch (err.message.toLowerCase()) {
case 'this theme is already installed':
errorKV.value = {
title: i18n.ts._externalResourceInstaller._errors._themeParseFailed.title,
description: i18n.ts._theme.alreadyInstalled,
};
break;
default:
errorKV.value = {
title: i18n.ts._externalResourceInstaller._errors._themeParseFailed.title,
description: i18n.ts._externalResourceInstaller._errors._themeParseFailed.description,
};
break;
}
console.error(err);
uiPhase.value = 'error';
return;
}
break;
default:
errorKV.value = {
title: i18n.ts._externalResourceInstaller._errors._resourceTypeNotSupported.title,
description: i18n.ts._externalResourceInstaller._errors._resourceTypeNotSupported.description,
};
uiPhase.value = 'error';
return;
}
uiPhase.value = 'confirm';
}
async function install() {
if (!data.value) return;
switch (data.value.type) {
case 'plugin':
if (!data.value.meta) return;
try {
await installPlugin(data.value.raw, data.value.meta as AiScriptPluginMeta);
os.success();
nextTick(() => {
unisonReload('/');
});
} catch (err) {
errorKV.value = {
title: i18n.ts._externalResourceInstaller._errors._pluginInstallFailed.title,
description: i18n.ts._externalResourceInstaller._errors._pluginInstallFailed.description,
};
console.error(err);
uiPhase.value = 'error';
}
break;
case 'theme':
if (!data.value.meta) return;
await installTheme(data.value.raw);
os.success();
nextTick(() => {
location.href = '/settings/theme';
});
}
}
onActivated(() => {
const urlParams = new URLSearchParams(window.location.search);
url.value = urlParams.get('url');
hash.value = urlParams.get('hash');
fetch();
});
onDeactivated(() => {
uiPhase.value = 'fetching';
});
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata({
title: i18n.ts._externalResourceInstaller.title,
icon: 'ph-download ph-bold ph-lg',
});
</script>
<style lang="scss" module>
.extInstallerRoot {
border-radius: var(--radius);
background: var(--panel);
padding: 1.5rem;
}
.extInstallerIconWrapper {
width: 48px;
height: 48px;
font-size: 24px;
line-height: 48px;
text-align: center;
border-radius: 50%;
margin-left: auto;
margin-right: auto;
background-color: var(--accentedBg);
color: var(--accent);
}
.error .extInstallerIconWrapper {
background-color: rgba(255, 42, 42, .15);
color: #ff2a2a;
}
.extInstallerTitle {
font-size: 1.2rem;
text-align: center;
margin: 0;
}
.extInstallerNormDesc {
text-align: center;
}
.extInstallerKVList {
margin-top: 0;
margin-bottom: 0;
}
</style>

View file

@ -36,8 +36,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_s">
<MkSwitch v-model="suspended" :disabled="!instance" @update:modelValue="toggleSuspend">{{ i18n.ts.stopActivityDelivery }}</MkSwitch>
<MkSwitch v-model="isBlocked" :disabled="!meta || !instance" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch>
<MkSwitch v-model="isSilenced" :disabled="!meta || !instance" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch>
<MkButton @click="refreshMetadata"><i class="ph-arrows-counter-clockwise ph-bold pg-lg"></i> Refresh metadata</MkButton>
<MkSwitch v-model="isSilenced" :disabled="!meta || !instance" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch>
<MkButton @click="refreshMetadata"><i class="ph-arrows-counter-clockwise ph-bold pg-lg"></i> Refresh metadata</MkButton>
</div>
</FormSection>
@ -171,8 +171,8 @@ async function fetch(): Promise<void> {
});
suspended = instance.isSuspended;
isBlocked = instance.isBlocked;
isSilenced = instance.isSilenced;
faviconUrl = getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.iconUrl, 'preview');
isSilenced = instance.isSilenced;
faviconUrl = getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.iconUrl, 'preview');
}
async function toggleBlock(): Promise<void> {
@ -183,14 +183,16 @@ async function toggleBlock(): Promise<void> {
blockedHosts: isBlocked ? meta.blockedHosts.concat([host]) : meta.blockedHosts.filter(x => x !== host),
});
}
async function toggleSilenced(): Promise<void> {
if (!meta) throw new Error('No meta?');
if (!instance) throw new Error('No instance?');
const { host } = instance;
await os.api('admin/update-meta', {
silencedHosts: isSilenced ? meta.silencedHosts.concat([host]) : meta.silencedHosts.filter(x => x !== host),
});
if (!meta) throw new Error('No meta?');
if (!instance) throw new Error('No instance?');
const { host } = instance;
await os.api('admin/update-meta', {
silencedHosts: isSilenced ? meta.silencedHosts.concat([host]) : meta.silencedHosts.filter(x => x !== host),
});
}
async function toggleSuspend(): Promise<void> {
if (!instance) throw new Error('No instance?');
await os.api('admin/federation/update-instance', {

View file

@ -4,46 +4,46 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkSpacer :contentMax="800">
<div :class="$style.root">
<div :class="$style.editor" class="_panel">
<PrismEditor v-model="code" class="_monospace" :class="$style.code" :highlight="highlighter" :lineNumbers="false"/>
<MkButton style="position: absolute; top: 8px; right: 8px;" primary @click="run()"><i class="ph-play ph-bold pg-lg"></i></MkButton>
</div>
<MkStickyContainer>
<template #header><MkPageHeader/></template>
<MkContainer v-if="root && components.length > 1" :key="uiKey" :foldable="true">
<template #header>UI</template>
<div :class="$style.ui">
<MkAsUi :component="root" :components="components" size="small"/>
<MkSpacer :contentMax="800">
<div :class="$style.root">
<div class="_gaps_s">
<div :class="$style.editor" class="_panel">
<MkCodeEditor v-model="code" lang="aiscript"/>
</div>
<MkButton primary @click="run()"><i class="ph-play ph-bold ph-lg"></i></MkButton>
</div>
</MkContainer>
<MkContainer :foldable="true" class="">
<template #header>{{ i18n.ts.output }}</template>
<div :class="$style.logs">
<div v-for="log in logs" :key="log.id" class="log" :class="{ print: log.print }">{{ log.text }}</div>
<MkContainer v-if="root && components.length > 1" :key="uiKey" :foldable="true">
<template #header>UI</template>
<div :class="$style.ui">
<MkAsUi :component="root" :components="components" size="small"/>
</div>
</MkContainer>
<MkContainer :foldable="true" class="">
<template #header>{{ i18n.ts.output }}</template>
<div :class="$style.logs">
<div v-for="log in logs" :key="log.id" class="log" :class="{ print: log.print }">{{ log.text }}</div>
</div>
</MkContainer>
<div class="">
{{ i18n.ts.scratchpadDescription }}
</div>
</MkContainer>
<div class="">
{{ i18n.ts.scratchpadDescription }}
</div>
</div>
</MkSpacer>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { onDeactivated, onUnmounted, Ref, ref, watch } from 'vue';
import 'prismjs';
import { highlight, languages } from 'prismjs/components/prism-core';
import 'prismjs/components/prism-clike';
import 'prismjs/components/prism-javascript';
import 'prismjs/themes/prism-okaidia.css';
import { PrismEditor } from 'vue-prism-editor';
import 'vue-prism-editor/dist/prismeditor.min.css';
import { Interpreter, Parser, utils } from '@syuilo/aiscript';
import MkContainer from '@/components/MkContainer.vue';
import MkButton from '@/components/MkButton.vue';
import MkCodeEditor from '@/components/MkCodeEditor.vue';
import { createAiScriptEnv } from '@/scripts/aiscript/api.js';
import * as os from '@/os.js';
import { $i } from '@/account.js';
@ -152,10 +152,6 @@ async function run() {
}
}
function highlighter(code) {
return highlight(code, languages.js, 'javascript');
}
onDeactivated(() => {
if (aiscript) aiscript.abort();
});

View file

@ -119,6 +119,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="disableShowingAnimatedImages">{{ i18n.ts.disableShowingAnimatedImages }}</MkSwitch>
<MkSwitch v-model="highlightSensitiveMedia">{{ i18n.ts.highlightSensitiveMedia }}</MkSwitch>
<MkSwitch v-model="squareAvatars">{{ i18n.ts.squareAvatars }}</MkSwitch>
<MkSwitch v-model="showAvatarDecorations">{{ i18n.ts.showAvatarDecorations }}</MkSwitch>
<MkSwitch v-model="useSystemFont">{{ i18n.ts.useSystemFont }}</MkSwitch>
<MkSwitch v-model="disableDrawer">{{ i18n.ts.disableDrawer }}</MkSwitch>
<MkSwitch v-model="forceShowAds">{{ i18n.ts.forceShowAds }}</MkSwitch>
@ -154,6 +155,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="keepScreenOn">{{ i18n.ts.keepScreenOn }}</MkSwitch>
<MkSwitch v-model="clickToOpen">{{ i18n.ts.clickToOpen }}</MkSwitch>
<MkSwitch v-model="showBots">{{ i18n.ts.showBots }}</MkSwitch>
<MkSwitch v-model="disableStreamingTimeline">{{ i18n.ts.disableStreamingTimeline }}</MkSwitch>
</div>
<MkSelect v-model="serverDisconnectedBehavior">
<template #label>{{ i18n.ts.whenServerDisconnected }}</template>
@ -205,7 +207,7 @@ import { unisonReload } from '@/scripts/unison-reload.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { miLocalStorage } from '@/local-storage.js';
import { globalEvents } from '@/events';
import { globalEvents } from '@/events.js';
import { claimAchievement } from '@/scripts/achievements.js';
const lang = ref(miLocalStorage.getItem('lang'));
@ -254,11 +256,13 @@ const instanceTicker = computed(defaultStore.makeGetterSetter('instanceTicker'))
const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll'));
const useReactionPickerForContextMenu = computed(defaultStore.makeGetterSetter('useReactionPickerForContextMenu'));
const squareAvatars = computed(defaultStore.makeGetterSetter('squareAvatars'));
const showAvatarDecorations = computed(defaultStore.makeGetterSetter('showAvatarDecorations'));
const mediaListWithOneImageAppearance = computed(defaultStore.makeGetterSetter('mediaListWithOneImageAppearance'));
const notificationPosition = computed(defaultStore.makeGetterSetter('notificationPosition'));
const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificationStackAxis'));
const keepScreenOn = computed(defaultStore.makeGetterSetter('keepScreenOn'));
const defaultWithReplies = computed(defaultStore.makeGetterSetter('defaultWithReplies'));
const disableStreamingTimeline = computed(defaultStore.makeGetterSetter('disableStreamingTimeline'));
watch(lang, () => {
miLocalStorage.setItem('lang', lang.value as string);
@ -295,6 +299,7 @@ watch([
reactionsDisplaySize,
highlightSensitiveMedia,
keepScreenOn,
disableStreamingTimeline,
], async () => {
await reloadAsk();
});
@ -304,12 +309,14 @@ const emojiIndexLangs = ['en-US'];
function downloadEmojiIndex(lang: string) {
async function main() {
const currentIndexes = defaultStore.state.additionalUnicodeEmojiIndexes;
function download() {
switch (lang) {
case 'en-US': return import('../../unicode-emoji-indexes/en-US.json').then(x => x.default);
default: throw new Error('unrecognized lang: ' + lang);
}
}
currentIndexes[lang] = await download();
await defaultStore.set('additionalUnicodeEmojiIndexes', currentIndexes);
}
@ -346,6 +353,7 @@ function removePinnedList() {
let smashCount = 0;
let smashTimer: number | null = null;
function testNotification(): void {
const notification: Misskey.entities.Notification = {
id: Math.random().toString(),

View file

@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</MkSpacer>
<MkFooterSpacer/>
</mkstickycontainer>
</template>

View file

@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSelect>
<div class="_buttons">
<MkButton inline primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
<MkButton inline primary @click="save"><i class="ph-check ph-bold ph-lg"></i> {{ i18n.ts.save }}</MkButton>
</div>
</div>
</template>

View file

@ -73,6 +73,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<FormSection>
<FormLink to="/registry"><template #icon><i class="ph-faders ph-bold ph-lg"></i></template>{{ i18n.ts.registry }}</FormLink>
</FormSection>
<FormSection>
<div class="_gaps_s">
<MkButton danger @click="updateRepliesAll(true)"><i class="ph-chats ph-bold ph-lg"></i> {{ i18n.ts.showRepliesToOthersInTimelineAll }}</MkButton>
<MkButton danger @click="updateRepliesAll(false)"><i class="ph-chat ph-bold ph-lg"></i> {{ i18n.ts.hideRepliesToOthersInTimelineAll }}</MkButton>
</div>
</FormSection>
</div>
</template>
@ -138,6 +145,15 @@ async function reloadAsk() {
unisonReload();
}
async function updateRepliesAll(withReplies: boolean) {
const { canceled } = os.confirm({
type: 'warning',
text: withReplies ? i18n.ts.confirmShowRepliesAll : i18n.ts.confirmHideRepliesAll,
});
if (canceled) return;
await os.api('following/update-all', { withReplies });
}
watch([
enableCondensedLineForAcct,
], async () => {

View file

@ -18,130 +18,35 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { defineAsyncComponent, nextTick, ref } from 'vue';
import { compareVersions } from 'compare-versions';
import { Interpreter, Parser, utils } from '@syuilo/aiscript';
import { v4 as uuid } from 'uuid';
import { nextTick, ref } from 'vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkButton from '@/components/MkButton.vue';
import FormInfo from '@/components/MkInfo.vue';
import * as os from '@/os.js';
import { ColdDeviceStorage } from '@/store.js';
import { installPlugin } from '@/scripts/install-plugin.js';
import { unisonReload } from '@/scripts/unison-reload.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
const parser = new Parser();
const code = ref(null);
function installPlugin({ id, meta, src, token }) {
ColdDeviceStorage.set('plugins', ColdDeviceStorage.get('plugins').concat({
...meta,
id,
active: true,
configData: {},
token: token,
src: src,
}));
}
function isSupportedAiScriptVersion(version: string): boolean {
try {
return (compareVersions(version, '0.12.0') >= 0);
} catch (err) {
return false;
}
}
const code = ref<string | null>(null);
async function install() {
if (code.value == null) return;
if (!code.value) return;
const lv = utils.getLangVersion(code.value);
if (lv == null) {
os.alert({
type: 'error',
text: 'No language version annotation found :(',
});
return;
} else if (!isSupportedAiScriptVersion(lv)) {
os.alert({
type: 'error',
text: `aiscript version '${lv}' is not supported :(`,
});
return;
}
let ast;
try {
ast = parser.parse(code.value);
await installPlugin(code.value);
os.success();
nextTick(() => {
unisonReload();
});
} catch (err) {
os.alert({
type: 'error',
text: 'Syntax error :(',
title: 'Install failed',
text: err.toString() ?? null,
});
return;
}
const meta = Interpreter.collectMetadata(ast);
if (meta == null) {
os.alert({
type: 'error',
text: 'No metadata found :(',
});
return;
}
const metadata = meta.get(null);
if (metadata == null) {
os.alert({
type: 'error',
text: 'No metadata found :(',
});
return;
}
const { name, version, author, description, permissions, config } = metadata;
if (name == null || version == null || author == null) {
os.alert({
type: 'error',
text: 'Required property not found :(',
});
return;
}
const token = permissions == null || permissions.length === 0 ? null : await new Promise((res, rej) => {
os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {
title: i18n.ts.tokenRequested,
information: i18n.ts.pluginTokenRequestedDescription,
initialName: name,
initialPermissions: permissions,
}, {
done: async result => {
const { name, permissions } = result;
const { token } = await os.api('miauth/gen-token', {
session: null,
name: name,
permission: permissions,
});
res(token);
},
}, 'closed');
});
installPlugin({
id: uuid(),
meta: {
name, version, author, description, permissions, config,
},
token,
src: code.value,
});
os.success();
nextTick(() => {
unisonReload();
});
}
const headerActions = $computed(() => []);

View file

@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton inline @click="copy(plugin)"><i class="ph-copy ph-bold ph-lg"></i> {{ i18n.ts.copy }}</MkButton>
</div>
<MkCode :code="plugin.src ?? ''"/>
<MkCode :code="plugin.src ?? ''" lang="is"/>
</div>
</MkFolder>
</div>
@ -77,9 +77,11 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
const plugins = ref(ColdDeviceStorage.get('plugins'));
function uninstall(plugin) {
async function uninstall(plugin) {
ColdDeviceStorage.set('plugins', plugins.value.filter(x => x.id !== plugin.id));
os.success();
await os.apiWithDialog('i/revoke-token', {
token: plugin.token,
});
nextTick(() => {
unisonReload();
});

View file

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

View file

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_m">
<div :class="$style.avatarAndBanner" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : null }">
<div :class="$style.avatarContainer">
<MkAvatar :class="$style.avatar" :user="$i" @click="changeAvatar"/>
<MkAvatar :class="$style.avatar" :user="$i" forceShowDecoration @click="changeAvatar"/>
<MkButton primary rounded @click="changeAvatar">{{ i18n.ts._profile.changeAvatar }}</MkButton>
</div>
<MkButton primary rounded :class="$style.backgroundEdit" @click="changeBackground">{{ i18n.ts._profile.changeBackground }}</MkButton>
@ -89,6 +89,24 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>{{ i18n.ts._profile.metadataDescription }}</template>
</FormSlot>
<MkFolder>
<template #icon><i class="ph-sparkle ph-bold pg-lg"></i></template>
<template #label>{{ i18n.ts.avatarDecorations }}</template>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); grid-gap: 12px;">
<div
v-for="avatarDecoration in avatarDecorations"
:key="avatarDecoration.id"
:class="[$style.avatarDecoration, { [$style.avatarDecorationActive]: $i.avatarDecorations.some(x => x.id === avatarDecoration.id) }]"
@click="openDecoration(avatarDecoration)"
>
<div :class="$style.avatarDecorationName"><MkCondensedLine :minScale="2 / 3">{{ avatarDecoration.name }}</MkCondensedLine></div>
<MkAvatar style="width: 60px; height: 60px;" :user="$i" :decoration="{ url: avatarDecoration.url }" forceShowDecoration/>
<i v-if="avatarDecoration.roleIdsThatCanBeUsedThisDecoration.length > 0 && !$i.roles.some(r => avatarDecoration.roleIdsThatCanBeUsedThisDecoration.includes(r.id))" :class="$style.avatarDecorationLock" class="ph-lock ph-bold ph-lg"></i>
</div>
</div>
</MkFolder>
<MkFolder>
<template #label>{{ i18n.ts.advancedSettings }}</template>
@ -133,6 +151,7 @@ import MkInfo from '@/components/MkInfo.vue';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
const reactionAcceptance = computed(defaultStore.makeGetterSetter('reactionAcceptance'));
let avatarDecorations: any[] = $ref([]);
const now = new Date();
@ -163,6 +182,10 @@ watch(() => profile, () => {
const fields = ref($i?.fields.map(field => ({ id: Math.random().toString(), name: field.name, value: field.value })) ?? []);
const fieldEditMode = ref(false);
os.api('get-avatar-decorations').then(_avatarDecorations => {
avatarDecorations = _avatarDecorations;
});
function addField() {
fields.value.push({
id: Math.random().toString(),
@ -295,6 +318,12 @@ function changeBackground(ev) {
});
}
function openDecoration(avatarDecoration) {
os.popup(defineAsyncComponent(() => import('./profile.avatar-decoration-dialog.vue')), {
decoration: avatarDecoration,
}, {}, 'closed');
}
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);
@ -394,4 +423,33 @@ definePageMetadata({
.dragItemForm {
flex-grow: 1;
}
.avatarDecoration {
cursor: pointer;
padding: 16px 16px 28px 16px;
border: solid 2px var(--divider);
border-radius: 8px;
text-align: center;
font-size: 90%;
overflow: clip;
contain: content;
}
.avatarDecorationActive {
background-color: var(--accentedBg);
border-color: var(--accent);
}
.avatarDecorationName {
position: relative;
z-index: 10;
font-weight: bold;
margin-bottom: 20px;
}
.avatarDecorationLock {
position: absolute;
bottom: 12px;
right: 12px;
}
</style>

View file

@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkTextarea>
<div class="_buttons">
<MkButton :disabled="installThemeCode == null" inline @click="() => preview(installThemeCode)"><i class="ph-eye ph-bold ph-lg"></i> {{ i18n.ts.preview }}</MkButton>
<MkButton :disabled="installThemeCode == null" inline @click="() => previewTheme(installThemeCode)"><i class="ph-eye ph-bold ph-lg"></i> {{ i18n.ts.preview }}</MkButton>
<MkButton :disabled="installThemeCode == null" primary inline @click="() => install(installThemeCode)"><i class="ph-check ph-bold ph-lg"></i> {{ i18n.ts.install }}</MkButton>
</div>
</div>
@ -18,60 +18,41 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { } from 'vue';
import JSON5 from 'json5';
import MkTextarea from '@/components/MkTextarea.vue';
import MkButton from '@/components/MkButton.vue';
import { applyTheme, validateTheme } from '@/scripts/theme.js';
import { parseThemeCode, previewTheme, installTheme } from '@/scripts/install-theme.js';
import * as os from '@/os.js';
import { addTheme, getThemes } from '@/theme-store';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
let installThemeCode = $ref(null);
function parseThemeCode(code: string) {
let theme;
try {
theme = JSON5.parse(code);
} catch (err) {
os.alert({
type: 'error',
text: i18n.ts._theme.invalid,
});
return false;
}
if (!validateTheme(theme)) {
os.alert({
type: 'error',
text: i18n.ts._theme.invalid,
});
return false;
}
if (getThemes().some(t => t.id === theme.id)) {
os.alert({
type: 'info',
text: i18n.ts._theme.alreadyInstalled,
});
return false;
}
return theme;
}
function preview(code: string): void {
const theme = parseThemeCode(code);
if (theme) applyTheme(theme, false);
}
async function install(code: string): Promise<void> {
const theme = parseThemeCode(code);
if (!theme) return;
await addTheme(theme);
os.alert({
type: 'success',
text: i18n.t('_theme.installed', { name: theme.name }),
});
try {
const theme = parseThemeCode(code);
await installTheme(code);
os.alert({
type: 'success',
text: i18n.t('_theme.installed', { name: theme.name }),
});
} catch (err) {
switch (err.message.toLowerCase()) {
case 'this theme is already installed':
os.alert({
type: 'info',
text: i18n.ts._theme.alreadyInstalled,
});
break;
default:
os.alert({
type: 'error',
text: i18n.ts._theme.invalid,
});
break;
}
console.error(err);
}
}
const headerActions = $computed(() => []);

View file

@ -108,6 +108,7 @@ async function del(): Promise<void> {
router.push('/settings/webhook');
}
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);

View file

@ -45,6 +45,7 @@ import { $i } from '@/account.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { miLocalStorage } from '@/local-storage.js';
import { antennasCache, userListsCache } from '@/cache.js';
import { deviceKind } from '@/scripts/device-kind.js';
provide('shouldOmitHeaderTitle', true);
@ -141,27 +142,42 @@ function focus(): void {
tlComponent.focus();
}
const headerActions = $computed(() => [{
icon: 'ph-dots-three ph-bold ph-lg',
text: i18n.ts.options,
handler: (ev) => {
os.popupMenu([{
type: 'switch',
text: i18n.ts.showRenotes,
icon: 'ph-rocket-launch ph-bold ph-lg',
ref: $$(withRenotes),
}, src === 'local' || src === 'social' ? {
type: 'switch',
text: i18n.ts.showRepliesToOthersInTimeline,
ref: $$(withReplies),
} : undefined, {
type: 'switch',
text: i18n.ts.fileAttachedOnly,
icon: 'ph-image ph-bold pg-lg',
ref: $$(onlyFiles),
}], ev.currentTarget ?? ev.target);
},
}]);
const headerActions = $computed(() => {
const tmp = [
{
icon: 'ph-dots-three ph-bold ph-lg',
text: i18n.ts.options,
handler: (ev) => {
os.popupMenu([{
type: 'switch',
text: i18n.ts.showRenotes,
icon: 'ph-rocket-launch ph-bold ph-lg',
ref: $$(withRenotes),
}, src === 'local' || src === 'social' ? {
type: 'switch',
text: i18n.ts.showRepliesToOthersInTimeline,
ref: $$(withReplies),
} : undefined, {
type: 'switch',
text: i18n.ts.fileAttachedOnly,
icon: 'ph-image ph-bold pg-lg',
ref: $$(onlyFiles),
}], ev.currentTarget ?? ev.target);
},
},
];
if (deviceKind === 'desktop') {
tmp.unshift({
icon: 'ph-arrows-counter-clockwise ph-bold pg-lg',
text: i18n.ts.reload,
handler: (ev: Event) => {
console.log('called');
tlComponent.reloadTimeline();
},
});
}
return tmp;
});
const headerTabs = $computed(() => [...(defaultStore.reactiveState.pinnedUserLists.value.map(l => ({
key: 'list:' + l.id,

View file

@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-for="file in files" :key="file.note.id + file.file.id">
<div v-if="file.file.isSensitive && !showingFiles.includes(file.file.id)" :class="$style.sensitive" @click="showingFiles.push(file.file.id)">
<div>
<div><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}</div>
<div><i class="ph-eye-slash ph-bold ph-lg"></i> {{ i18n.ts.sensitive }}</div>
<div>{{ i18n.ts.clickToShow }}</div>
</div>
</div>

View file

@ -37,7 +37,7 @@ const pagination = {
params: computed(() => ({
userId: props.user.id,
withRenotes: include.value === 'all',
withReplies: include.value === 'all' || include.value === 'files',
withReplies: include.value === 'all',
withChannelNotes: include.value === 'all',
withFiles: include.value === 'files',
})),

View file

@ -313,6 +313,10 @@ export const routes = [{
}, {
path: '/custom-emojis-manager',
component: page(() => import('./pages/custom-emojis-manager.vue')),
}, {
path: '/avatar-decorations',
name: 'avatarDecorations',
component: page(() => import('./pages/avatar-decorations.vue')),
}, {
path: '/registry/keys/system/:path(*)?',
component: page(() => import('./pages/registry.keys.vue')),
@ -322,6 +326,10 @@ export const routes = [{
}, {
path: '/registry',
component: page(() => import('./pages/registry.vue')),
}, {
path: '/install-extentions',
component: page(() => import('./pages/install-extentions.vue')),
loginRequired: true,
}, {
path: '/admin/user/:userId',
component: iAmModerator ? page(() => import('./pages/admin-user.vue')) : page(() => import('./pages/not-found.vue')),
@ -343,6 +351,10 @@ export const routes = [{
path: '/emojis',
name: 'emojis',
component: page(() => import('./pages/custom-emojis-manager.vue')),
}, {
path: '/avatar-decorations',
name: 'avatarDecorations',
component: page(() => import('./pages/avatar-decorations.vue')),
}, {
path: '/queue',
name: 'queue',

View file

@ -9,6 +9,7 @@ import { $i } from '@/account.js';
import { miLocalStorage } from '@/local-storage.js';
import { customEmojis } from '@/custom-emojis.js';
import { url, lang } from '@/config.js';
import { nyaize } from '@/scripts/nyaize.js';
export function createAiScriptEnv(opts) {
return {
@ -71,5 +72,9 @@ export function createAiScriptEnv(opts) {
'Mk:url': values.FN_NATIVE(() => {
return values.STR(window.location.href);
}),
'Mk:nyaize': values.FN_NATIVE(([text]) => {
utils.assertString(text);
return values.STR(nyaize(text.value));
}),
};
}

View file

@ -0,0 +1,31 @@
import { setWasm, setCDN, Highlighter, getHighlighter as _getHighlighter } from 'shiki';
setWasm('/assets/shiki/dist/onig.wasm');
setCDN('/assets/shiki/');
let _highlighter: Highlighter | null = null;
export async function getHighlighter(): Promise<Highlighter> {
if (!_highlighter) {
return await initHighlighter();
}
return _highlighter;
}
export async function initHighlighter() {
const highlighter = await _getHighlighter({
theme: 'dark-plus',
langs: ['js'],
});
await highlighter.loadLanguage({
path: 'languages/aiscript.tmLanguage.json',
id: 'aiscript',
scopeName: 'source.aiscript',
aliases: ['is', 'ais'],
});
_highlighter = highlighter;
return highlighter;
}

View file

@ -56,6 +56,7 @@ function copyUrl(file: Misskey.entities.DriveFile) {
copyToClipboard(file.url);
os.success();
}
/*
function addApp() {
alert('not implemented yet');

View file

@ -114,6 +114,12 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
return !confirm.canceled;
}
async function userInfoUpdate() {
os.apiWithDialog('federation/update-remote-user', {
userId: user.id,
});
}
async function invalidateFollow() {
if (!await getConfirmed(i18n.ts.breakFollowConfirm)) return;
@ -330,6 +336,14 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
}]);
}
if (user.host !== null) {
menu = menu.concat([null, {
icon: 'ph-arrows-counter-clockwise ph-bold ph-lg',
text: i18n.ts.updateRemoteUser,
action: userInfoUpdate,
}]);
}
if (defaultStore.state.devMode) {
menu = menu.concat([null, {
icon: 'ph-identification-card ph-bold ph-lg',

View file

@ -0,0 +1,129 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineAsyncComponent } from 'vue';
import { compareVersions } from 'compare-versions';
import { v4 as uuid } from 'uuid';
import { Interpreter, Parser, utils } from '@syuilo/aiscript';
import type { Plugin } from '@/store.js';
import { ColdDeviceStorage } from '@/store.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
export type AiScriptPluginMeta = {
name: string;
version: string;
author: string;
description?: string;
permissions?: string[];
config?: Record<string, any>;
};
const parser = new Parser();
export function savePlugin({ id, meta, src, token }: {
id: string;
meta: AiScriptPluginMeta;
src: string;
token: string;
}) {
ColdDeviceStorage.set('plugins', ColdDeviceStorage.get('plugins').concat({
...meta,
id,
active: true,
configData: {},
token: token,
src: src,
} as Plugin));
}
export function isSupportedAiScriptVersion(version: string): boolean {
try {
return (compareVersions(version, '0.12.0') >= 0);
} catch (err) {
return false;
}
}
export async function parsePluginMeta(code: string): Promise<AiScriptPluginMeta> {
if (!code) {
throw new Error('code is required');
}
const lv = utils.getLangVersion(code);
if (lv == null) {
throw new Error('No language version annotation found');
} else if (!isSupportedAiScriptVersion(lv)) {
throw new Error(`Aiscript version '${lv}' is not supported`);
}
let ast;
try {
ast = parser.parse(code);
} catch (err) {
throw new Error('Aiscript syntax error');
}
const meta = Interpreter.collectMetadata(ast);
if (meta == null) {
throw new Error('Meta block not found');
}
const metadata = meta.get(null);
if (metadata == null) {
throw new Error('Metadata not found');
}
const { name, version, author, description, permissions, config } = metadata;
if (name == null || version == null || author == null) {
throw new Error('Required property not found');
}
return {
name,
version,
author,
description,
permissions,
config,
};
}
export async function installPlugin(code: string, meta?: AiScriptPluginMeta) {
if (!code) return;
let realMeta: AiScriptPluginMeta;
if (!meta) {
realMeta = await parsePluginMeta(code);
} else {
realMeta = meta;
}
const token = realMeta.permissions == null || realMeta.permissions.length === 0 ? null : await new Promise((res, rej) => {
os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {
title: i18n.ts.tokenRequested,
information: i18n.ts.pluginTokenRequestedDescription,
initialName: realMeta.name,
initialPermissions: realMeta.permissions,
}, {
done: async result => {
const { name, permissions } = result;
const { token } = await os.api('miauth/gen-token', {
session: null,
name: name,
permission: permissions,
});
res(token);
},
}, 'closed');
});
savePlugin({
id: uuid(),
meta: realMeta,
token,
src: code,
});
}

View file

@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import JSON5 from 'json5';
import { addTheme, getThemes } from '@/theme-store.js';
import { Theme, applyTheme, validateTheme } from '@/scripts/theme.js';
export function parseThemeCode(code: string): Theme {
let theme;
try {
theme = JSON5.parse(code);
} catch (err) {
throw new Error('Failed to parse theme json');
}
if (!validateTheme(theme)) {
throw new Error('This theme is invaild');
}
if (getThemes().some(t => t.id === theme.id)) {
throw new Error('This theme is already installed');
}
return theme;
}
export function previewTheme(code: string): void {
const theme = parseThemeCode(code);
if (theme) applyTheme(theme, false);
}
export async function installTheme(code: string): Promise<void> {
const theme = parseThemeCode(code);
if (!theme) return;
await addTheme(theme);
}

View file

@ -6,12 +6,41 @@
import { lang } from '@/config.js';
export const versatileLang = (lang ?? 'ja-JP').replace('ja-KS', 'ja-JP');
export const dateTimeFormat = new Intl.DateTimeFormat(versatileLang, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
});
export const numberFormat = new Intl.NumberFormat(versatileLang);
let _dateTimeFormat: Intl.DateTimeFormat;
try {
_dateTimeFormat = new Intl.DateTimeFormat(versatileLang, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
});
} catch (err) {
console.warn(err);
if (_DEV_) console.log('[Intl] Fallback to en-US');
// Fallback to en-US
_dateTimeFormat = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
});
}
export const dateTimeFormat = _dateTimeFormat;
let _numberFormat: Intl.NumberFormat;
try {
_numberFormat = new Intl.NumberFormat(versatileLang);
} catch (err) {
console.warn(err);
if (_DEV_) console.log('[Intl] Fallback to en-US');
// Fallback to en-US
_numberFormat = new Intl.NumberFormat('en-US');
}
export const numberFormat = _numberFormat;

View file

@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function nyaize(text: string): string {
return text
// ja-JP
.replaceAll('な', 'にゃ').replaceAll('ナ', 'ニャ').replaceAll('ナ', 'ニャ')
// en-US
.replace(/(?<=n)a/gi, x => x === 'A' ? 'YA' : 'ya')
.replace(/(?<=morn)ing/gi, x => x === 'ING' ? 'YAN' : 'yan')
.replace(/(?<=every)one/gi, x => x === 'ONE' ? 'NYAN' : 'nyan')
// ko-KR
.replace(/[나-낳]/g, match => String.fromCharCode(
match.charCodeAt(0)! + '냐'.charCodeAt(0) - '나'.charCodeAt(0),
))
.replace(/(다$)|(다(?=\.))|(다(?= ))|(다(?=!))|(다(?=\?))/gm, '다냥')
.replace(/(야(?=\?))|(야$)|(야(?= ))/gm, '냥');
}

View file

@ -46,6 +46,7 @@ export function onScrollTop(el: HTMLElement, cb: () => unknown, tolerance = 1, o
};
function removeListener() { container.removeEventListener('scroll', onScroll); }
container.addEventListener('scroll', onScroll, { passive: true });
return removeListener;
}
@ -71,6 +72,7 @@ export function onScrollBottom(el: HTMLElement, cb: () => unknown, tolerance = 1
function removeListener() {
containerOrWindow.removeEventListener('scroll', onScroll);
}
containerOrWindow.addEventListener('scroll', onScroll, { passive: true });
return removeListener;
}

View file

@ -12,15 +12,17 @@ import * as os from '@/os.js';
export function useNoteCapture(props: {
rootEl: Ref<HTMLElement>;
note: Ref<Misskey.entities.Note>;
pureNote: Ref<Misskey.entities.Note>;
isDeletedRef: Ref<boolean>;
}) {
const note = props.note;
const pureNote = props.pureNote !== undefined ? props.pureNote : props.note;
const connection = $i ? useStream() : null;
async function onStreamNoteUpdated(noteData): void {
const { type, id, body } = noteData;
if (id !== note.value.id) return;
if ((id !== note.value.id) && (id !== pureNote.value.id)) return;
switch (type) {
case 'reacted': {
@ -98,6 +100,7 @@ export function useNoteCapture(props: {
if (connection) {
// TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
connection.send(document.body.contains(props.rootEl.value) ? 'sr' : 's', { id: note.value.id });
if (pureNote.value.id !== note.value.id) connection.send('s', { id: pureNote.value.id });
if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated);
}
}
@ -107,6 +110,11 @@ export function useNoteCapture(props: {
connection.send('un', {
id: note.value.id,
});
if (pureNote.value.id !== note.value.id) {
connection.send('un', {
id: pureNote.value.id,
});
}
if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated);
}
}

View file

@ -71,9 +71,11 @@ export class WorkerMultiDispatch<POST = any, RETURN = any> {
public isTerminated() {
return this.terminated;
}
public getWorkers() {
return this.workers;
}
public getSymbol() {
return this.symbol;
}

View file

@ -301,6 +301,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: true,
},
showAvatarDecorations: {
where: 'device',
default: true,
},
postFormWithHashtags: {
where: 'device',
default: false,
@ -381,6 +385,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'account',
default: false,
},
disableStreamingTimeline: {
where: 'device',
default: false,
},
}));
// TODO: 他のタブと永続化されたstateを同期

View file

@ -9,6 +9,9 @@ import { $i } from '@/account.js';
import { url } from '@/config.js';
let stream: Misskey.Stream | null = null;
let timeoutHeartBeat: number | null = null;
export let isReloading: boolean = false;
export function useStream(): Misskey.Stream {
if (stream) return stream;
@ -17,7 +20,20 @@ export function useStream(): Misskey.Stream {
token: $i.token,
} : null));
window.setTimeout(heartbeat, 1000 * 60);
timeoutHeartBeat = window.setTimeout(heartbeat, 1000 * 60);
return stream;
}
export function reloadStream() {
if (!stream) return useStream();
if (timeoutHeartBeat) window.clearTimeout(timeoutHeartBeat);
isReloading = true;
stream.close();
stream.once('_connected_', () => isReloading = false);
stream.stream.reconnect();
timeoutHeartBeat = window.setTimeout(heartbeat, 1000 * 60);
return stream;
}
@ -26,5 +42,5 @@ function heartbeat(): void {
if (stream != null && document.visibilityState === 'visible') {
stream.heartbeat();
}
window.setTimeout(heartbeat, 1000 * 60);
timeoutHeartBeat = window.setTimeout(heartbeat, 1000 * 60);
}

View file

@ -408,10 +408,6 @@ hr {
font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace !important;
}
.prism-editor__textarea:focus {
outline: none;
}
._zoom {
transition-duration: 0.5s, 0.5s;
transition-property: opacity, transform;

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Theme } from '@/scripts/theme.js';
import { Theme, getBuiltinThemes } from '@/scripts/theme.js';
import { miLocalStorage } from '@/local-storage.js';
import { api } from '@/os.js';
import { $i } from '@/account.js';
@ -29,6 +29,10 @@ export async function fetchThemes(): Promise<void> {
export async function addTheme(theme: Theme): Promise<void> {
if ($i == null) return;
const builtinThemes = await getBuiltinThemes();
if (builtinThemes.some(t => t.id === theme.id)) {
throw new Error('builtin theme');
}
await fetchThemes();
const themes = getThemes().concat(theme);
await api('i/registry/set', { scope: ['client'], key: 'themes', value: themes });

View file

@ -3,12 +3,42 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { MenuItem } from '@/types/menu.js';
import * as os from '@/os.js';
import { instance } from '@/instance.js';
import { host } from '@/config.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/account.js';
function toolsMenuItems(): MenuItem[] {
return [{
type: 'link',
to: '/scratchpad',
text: i18n.ts.scratchpad,
icon: 'ph-terminal-window ph-bold ph-lg-2',
}, {
type: 'link',
to: '/api-console',
text: 'API Console',
icon: 'ph-terminal-window ph-bold ph-lg-2',
}, {
type: 'link',
to: '/clicker',
text: '🍪👈',
icon: 'ph-cookie ph-bold pg-lg',
}, ($i && ($i.isAdmin || $i.policies.canManageCustomEmojis)) ? {
type: 'link',
to: '/custom-emojis-manager',
text: i18n.ts.manageCustomEmojis,
icon: 'ph-smiley ph-bold pg-lg',
} : undefined, ($i && ($i.isAdmin || $i.policies.canManageAvatarDecorations)) ? {
type: 'link',
to: '/avatar-decorations',
text: i18n.ts.manageAvatarDecorations,
icon: 'ph-sparkle ph-bold pg-lg',
} : undefined];
}
export function openInstanceMenu(ev: MouseEvent) {
os.popupMenu([{
text: instance.name ?? host,
@ -46,28 +76,8 @@ export function openInstanceMenu(ev: MouseEvent) {
} : undefined, {
type: 'parent',
text: i18n.ts.tools,
icon: 'ph-wrench ph-bold ph-lg',
children: [{
type: 'link',
to: '/scratchpad',
text: i18n.ts.scratchpad,
icon: 'ph-terminal-window ph-bold ph-lg-2',
}, {
type: 'link',
to: '/api-console',
text: 'API Console',
icon: 'ph-terminal-window ph-bold ph-lg-2',
}, {
type: 'link',
to: '/clicker',
text: '🍪👈',
icon: 'ph-cookie ph-bold pg-lg',
}, ($i && ($i.isAdmin || $i.policies.canManageCustomEmojis)) ? {
type: 'link',
to: '/custom-emojis-manager',
text: i18n.ts.manageCustomEmojis,
icon: 'ph-smiley ph-bold pg-lg',
} : undefined],
icon: 'ph-toolbox ph-bold ph-lg',
children: toolsMenuItems(),
}, null, (instance.impressumUrl) ? {
text: i18n.ts.impressum,
icon: 'ph-newspaper-clipping ph-bold pg-lg',
@ -100,3 +110,9 @@ export function openInstanceMenu(ev: MouseEvent) {
align: 'left',
});
}
export function openToolsMenu(ev: MouseEvent) {
os.popupMenu(toolsMenuItems(), ev.currentTarget ?? ev.target, {
align: 'left',
});
}

View file

@ -58,9 +58,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, defineAsyncComponent, ref, watch } from 'vue';
import { openInstanceMenu } from './common';
import { openInstanceMenu } from './common.js';
import * as os from '@/os.js';
import { navbarItemDef } from '@/navbar';
import { navbarItemDef } from '@/navbar.js';
import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
@ -230,6 +230,7 @@ function more(ev: MouseEvent) {
text-align: left;
box-sizing: border-box;
margin-top: 16px;
overflow: clip;
}
.avatar {
@ -401,6 +402,7 @@ function more(ev: MouseEvent) {
display: block;
text-align: center;
width: 100%;
overflow: clip;
}
.avatar {

View file

@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { onUnmounted } from 'vue';
import { useStream } from '@/stream.js';
import { useStream, isReloading } from '@/stream.js';
import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
@ -26,6 +26,7 @@ const zIndex = os.claimZIndex('high');
let hasDisconnected = $ref(false);
function onDisconnected() {
if (isReloading) return;
hasDisconnected = true;
}

View file

@ -319,7 +319,7 @@ $widgets-hide-threshold: 1090px;
min-width: 0;
overflow: auto;
overflow-y: scroll;
overscroll-behavior: contain;
overscroll-behavior: none;
background: var(--bg);
}

View file

@ -7,8 +7,8 @@ import { describe, test, assert, afterEach } from 'vitest';
import { render, cleanup, type RenderResult } from '@testing-library/vue';
import './init';
import type * as Misskey from 'misskey-js';
import { directives } from '@/directives';
import { components } from '@/components/index';
import { directives } from '@/directives/index.js';
import { components } from '@/components/index.js';
import XHome from '@/pages/user/home.vue';
describe('XHome', () => {
@ -34,6 +34,8 @@ describe('XHome', () => {
createdAt: '1970-01-01T00:00:00.000Z',
fields: [],
pinnedNotes: [],
avatarUrl: 'https://example.com',
avatarDecorations: [],
});
const anchor = home.container.querySelector<HTMLAnchorElement>('a[href^="https://example.com/"]');
@ -54,6 +56,8 @@ describe('XHome', () => {
createdAt: '1970-01-01T00:00:00.000Z',
fields: [],
pinnedNotes: [],
avatarUrl: 'https://example.com',
avatarDecorations: [],
});
const anchor = home.container.querySelector<HTMLAnchorElement>('a[href^="https://example.com/"]');

View file

@ -7,8 +7,8 @@ import { describe, test, assert, afterEach } from 'vitest';
import { render, cleanup, type RenderResult } from '@testing-library/vue';
import './init';
import type * as Misskey from 'misskey-js';
import { components } from '@/components';
import { directives } from '@/directives';
import { components } from '@/components/index.js';
import { directives } from '@/directives/index.js';
import MkMediaImage from '@/components/MkMediaImage.vue';
describe('MkMediaImage', () => {

View file

@ -7,8 +7,8 @@ import { describe, test, assert, afterEach } from 'vitest';
import { render, cleanup, type RenderResult } from '@testing-library/vue';
import './init';
import type { summaly } from 'summaly';
import { components } from '@/components';
import { directives } from '@/directives';
import { components } from '@/components/index.js';
import { directives } from '@/directives/index.js';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
type SummalyResult = Awaited<ReturnType<typeof summaly>>;