Merge remote-tracking branch 'misskey-original/develop' into develop
# Conflicts: # packages/backend/src/models/Meta.ts # packages/backend/src/server/api/endpoints/admin/meta.ts # packages/backend/src/server/api/endpoints/admin/update-meta.ts # packages/frontend/src/components/MkButton.vue # packages/frontend/src/components/MkMenu.vue # packages/frontend/src/components/MkNote.vue # packages/frontend/src/components/MkNoteDetailed.vue # packages/frontend/src/components/MkSwitch.button.vue # packages/frontend/src/pages/settings/general.vue
This commit is contained in:
commit
04fae906c9
104 changed files with 3559 additions and 2248 deletions
|
|
@ -60,7 +60,7 @@
|
|||
"rollup": "4.12.0",
|
||||
"sanitize-html": "2.12.1",
|
||||
"sass": "1.71.1",
|
||||
"shiki": "1.1.7",
|
||||
"shiki": "1.2.0",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"textarea-caret": "3.1.0",
|
||||
"three": "0.162.0",
|
||||
|
|
|
|||
|
|
@ -145,8 +145,11 @@ export async function common(createVue: () => App<Element>) {
|
|||
// NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため)
|
||||
watch(defaultStore.reactiveState.darkMode, (darkMode) => {
|
||||
applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme'));
|
||||
document.documentElement.dataset.colorMode = darkMode ? 'dark' : 'light';
|
||||
}, { immediate: miLocalStorage.getItem('theme') == null });
|
||||
|
||||
document.documentElement.dataset.colorMode = defaultStore.state.darkMode ? 'dark' : 'light';
|
||||
|
||||
const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme'));
|
||||
const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme'));
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
}
|
||||
]"
|
||||
:to="to ?? '#'"
|
||||
:behavior="linkBehavior"
|
||||
@mousedown="onMousedown"
|
||||
>
|
||||
<div ref="ripples" :class="$style.ripples" :data-children-class="$style.ripple"></div>
|
||||
|
|
@ -76,7 +77,7 @@ const props = defineProps<{
|
|||
inline?: boolean;
|
||||
link?: boolean;
|
||||
to?: string;
|
||||
autofocus?: boolean;
|
||||
linkBehavior?: null | 'window' | 'browser';autofocus?: boolean;
|
||||
wait?: boolean;
|
||||
danger?: boolean;
|
||||
full?: boolean;
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { bundledLanguagesInfo } from 'shiki';
|
||||
import type { BuiltinLanguage } from 'shiki';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { bundledLanguagesInfo } from 'shiki/langs';
|
||||
import type { BundledLanguage } from 'shiki/langs';
|
||||
import { getHighlighter, getTheme } from '@/scripts/code-highlighter.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ const props = defineProps<{
|
|||
|
||||
const highlighter = await getHighlighter();
|
||||
const darkMode = defaultStore.reactiveState.darkMode;
|
||||
const codeLang = ref<BuiltinLanguage | 'aiscript'>('js');
|
||||
const codeLang = ref<BundledLanguage | 'aiscript'>('js');
|
||||
|
||||
const [lightThemeName, darkThemeName] = await Promise.all([
|
||||
getTheme('light', true),
|
||||
|
|
@ -42,7 +42,7 @@ const html = computed(() => highlighter.codeToHtml(props.code, {
|
|||
}));
|
||||
|
||||
async function fetchLanguage(to: string): Promise<void> {
|
||||
const language = to as BuiltinLanguage;
|
||||
const language = to as BundledLanguage;
|
||||
|
||||
// Check for the loaded languages, and load the language if it's not loaded yet.
|
||||
if (!highlighter.getLoadedLanguages().includes(language)) {
|
||||
|
|
|
|||
|
|
@ -80,11 +80,9 @@ function copy() {
|
|||
.codePlaceholderRoot {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
box-sizing: border-box;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:autocomplete="autocomplete"
|
||||
:autocapitalize="autocapitalize"
|
||||
:spellcheck="spellcheck"
|
||||
:inputmode="inputmode"
|
||||
:step="step"
|
||||
:list="id"
|
||||
:min="min"
|
||||
|
|
@ -63,6 +64,7 @@ const props = defineProps<{
|
|||
mfmAutocomplete?: boolean | SuggestionType[],
|
||||
autocapitalize?: string;
|
||||
spellcheck?: boolean;
|
||||
inputmode?: 'none' | 'text' | 'search' | 'email' | 'url' | 'numeric' | 'tel' | 'decimal';
|
||||
step?: any;
|
||||
datalist?: string[];
|
||||
min?: number;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { defineAsyncComponent, ref } from 'vue';
|
|||
import { url as local } from '@/config.js';
|
||||
import { useTooltip } from '@/scripts/use-tooltip.js';
|
||||
import * as os from '@/os.js';
|
||||
import { isEnabledUrlPreview } from '@/instance.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
url: string;
|
||||
|
|
@ -31,13 +32,15 @@ const target = self ? null : '_blank';
|
|||
|
||||
const el = ref<HTMLElement | { $el: HTMLElement }>();
|
||||
|
||||
useTooltip(el, (showing) => {
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
|
||||
showing,
|
||||
url: props.url,
|
||||
source: el.value instanceof HTMLElement ? el.value : el.value?.$el,
|
||||
}, {}, 'closed');
|
||||
});
|
||||
if (isEnabledUrlPreview.value) {
|
||||
useTooltip(el, (showing) => {
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
|
||||
showing,
|
||||
url: props.url,
|
||||
source: el.value instanceof HTMLElement ? el.value : el.value?.$el,
|
||||
}, {}, 'closed');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
|
|
|||
|
|
@ -5,11 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<div
|
||||
ref="playerEl"
|
||||
v-hotkey="keymap"
|
||||
tabindex="0"
|
||||
:class="[
|
||||
$style.audioContainer,
|
||||
(audio.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive,
|
||||
]"
|
||||
@contextmenu.stop
|
||||
@keydown.stop
|
||||
>
|
||||
<button v-if="hide" :class="$style.hidden" @click="hide = false">
|
||||
<div :class="$style.hiddenTextWrapper">
|
||||
|
|
@ -18,6 +22,19 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-else-if="defaultStore.reactiveState.useNativeUIForVideoAudioPlayer.value" :class="$style.nativeAudioContainer">
|
||||
<audio
|
||||
ref="audioEl"
|
||||
preload="metadata"
|
||||
controls
|
||||
:class="$style.nativeAudio"
|
||||
@keydown.prevent
|
||||
>
|
||||
<source :src="audio.url">
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<div v-else :class="$style.audioControls">
|
||||
<audio
|
||||
ref="audioEl"
|
||||
|
|
@ -72,6 +89,41 @@ const props = defineProps<{
|
|||
audio: Misskey.entities.DriveFile;
|
||||
}>();
|
||||
|
||||
const keymap = {
|
||||
'up': () => {
|
||||
if (hasFocus() && audioEl.value) {
|
||||
volume.value = Math.min(volume.value + 0.1, 1);
|
||||
}
|
||||
},
|
||||
'down': () => {
|
||||
if (hasFocus() && audioEl.value) {
|
||||
volume.value = Math.max(volume.value - 0.1, 0);
|
||||
}
|
||||
},
|
||||
'left': () => {
|
||||
if (hasFocus() && audioEl.value) {
|
||||
audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0);
|
||||
}
|
||||
},
|
||||
'right': () => {
|
||||
if (hasFocus() && audioEl.value) {
|
||||
audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, audioEl.value.duration);
|
||||
}
|
||||
},
|
||||
'space': () => {
|
||||
if (hasFocus()) {
|
||||
togglePlayPause();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// PlayerElもしくはその子要素にフォーカスがあるかどうか
|
||||
function hasFocus() {
|
||||
if (!playerEl.value) return false;
|
||||
return playerEl.value === document.activeElement || playerEl.value.contains(document.activeElement);
|
||||
}
|
||||
|
||||
const playerEl = shallowRef<HTMLDivElement>();
|
||||
const audioEl = shallowRef<HTMLAudioElement>();
|
||||
|
||||
// eslint-disable-next-line vue/no-setup-props-destructure
|
||||
|
|
@ -85,6 +137,30 @@ function showMenu(ev: MouseEvent) {
|
|||
|
||||
menu = [
|
||||
// TODO: 再生キューに追加
|
||||
{
|
||||
type: 'switch',
|
||||
text: i18n.ts._mediaControls.loop,
|
||||
icon: 'ti ti-repeat',
|
||||
ref: loop,
|
||||
},
|
||||
{
|
||||
type: 'radio',
|
||||
text: i18n.ts._mediaControls.playbackRate,
|
||||
icon: 'ti ti-clock-play',
|
||||
ref: speed,
|
||||
options: {
|
||||
'0.25x': 0.25,
|
||||
'0.5x': 0.5,
|
||||
'0.75x': 0.75,
|
||||
'1.0x': 1,
|
||||
'1.25x': 1.25,
|
||||
'1.5x': 1.5,
|
||||
'2.0x': 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
text: i18n.ts.hide,
|
||||
icon: 'ti ti-eye-off',
|
||||
|
|
@ -147,6 +223,8 @@ const rangePercent = computed({
|
|||
},
|
||||
});
|
||||
const volume = ref(.25);
|
||||
const speed = ref(1);
|
||||
const loop = ref(false); // TODO: ドライブファイルのフラグに置き換える
|
||||
const bufferedEnd = ref(0);
|
||||
const bufferedDataRatio = computed(() => {
|
||||
if (!audioEl.value) return 0;
|
||||
|
|
@ -176,6 +254,7 @@ function toggleMute() {
|
|||
}
|
||||
|
||||
let onceInit = false;
|
||||
let mediaTickFrameId: number | null = null;
|
||||
let stopAudioElWatch: () => void;
|
||||
|
||||
function init() {
|
||||
|
|
@ -195,8 +274,12 @@ function init() {
|
|||
}
|
||||
|
||||
elapsedTimeMs.value = audioEl.value.currentTime * 1000;
|
||||
|
||||
if (audioEl.value.loop !== loop.value) {
|
||||
loop.value = audioEl.value.loop;
|
||||
}
|
||||
}
|
||||
window.requestAnimationFrame(updateMediaTick);
|
||||
mediaTickFrameId = window.requestAnimationFrame(updateMediaTick);
|
||||
}
|
||||
|
||||
updateMediaTick();
|
||||
|
|
@ -234,6 +317,14 @@ watch(volume, (to) => {
|
|||
if (audioEl.value) audioEl.value.volume = to;
|
||||
});
|
||||
|
||||
watch(speed, (to) => {
|
||||
if (audioEl.value) audioEl.value.playbackRate = to;
|
||||
});
|
||||
|
||||
watch(loop, (to) => {
|
||||
if (audioEl.value) audioEl.value.loop = to;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
init();
|
||||
});
|
||||
|
|
@ -252,6 +343,10 @@ onDeactivated(() => {
|
|||
hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore');
|
||||
stopAudioElWatch();
|
||||
onceInit = false;
|
||||
if (mediaTickFrameId) {
|
||||
window.cancelAnimationFrame(mediaTickFrameId);
|
||||
mediaTickFrameId = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -262,6 +357,10 @@ onDeactivated(() => {
|
|||
border: .5px solid var(--divider);
|
||||
border-radius: var(--radius);
|
||||
overflow: clip;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sensitive {
|
||||
|
|
@ -367,4 +466,15 @@ onDeactivated(() => {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nativeAudioContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.nativeAudio {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<div
|
||||
ref="playerEl"
|
||||
v-hotkey="keymap"
|
||||
tabindex="0"
|
||||
:class="[
|
||||
$style.videoContainer,
|
||||
controlsShowing && $style.active,
|
||||
|
|
@ -14,15 +16,37 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
@mouseover="onMouseOver"
|
||||
@mouseleave="onMouseLeave"
|
||||
@contextmenu.stop
|
||||
@keydown.stop
|
||||
>
|
||||
<button v-if="hide" :class="$style.hidden" @click="hide = false">
|
||||
<div :class="$style.hiddenTextWrapper">
|
||||
<b v-if="video.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b>
|
||||
<b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b>
|
||||
<b v-else style="display: block;"><i class="ti ti-movie"></i> {{ defaultStore.state.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b>
|
||||
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
|
||||
</div>
|
||||
</button>
|
||||
<div v-else :class="$style.videoRoot" @click.self="togglePlayPause">
|
||||
|
||||
<div v-else-if="defaultStore.reactiveState.useNativeUIForVideoAudioPlayer.value" :class="$style.videoRoot">
|
||||
<video
|
||||
ref="videoEl"
|
||||
:class="$style.video"
|
||||
:poster="video.thumbnailUrl ?? undefined"
|
||||
:title="video.comment ?? undefined"
|
||||
:alt="video.comment"
|
||||
preload="metadata"
|
||||
controls
|
||||
@keydown.prevent
|
||||
>
|
||||
<source :src="video.url">
|
||||
</video>
|
||||
<i class="ti ti-eye-off" :class="$style.hide" @click="hide = true"></i>
|
||||
<div :class="$style.indicators">
|
||||
<div v-if="video.comment" :class="$style.indicator">ALT</div>
|
||||
<div v-if="video.isSensitive" :class="$style.indicator" style="color: var(--warn);" :title="i18n.ts.sensitive"><i class="ti ti-eye-exclamation"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else :class="$style.videoRoot">
|
||||
<video
|
||||
ref="videoEl"
|
||||
:class="$style.video"
|
||||
|
|
@ -31,6 +55,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:alt="video.comment"
|
||||
preload="metadata"
|
||||
playsinline
|
||||
@keydown.prevent
|
||||
@click.self="togglePlayPause"
|
||||
>
|
||||
<source :src="video.url">
|
||||
</video>
|
||||
|
|
@ -100,6 +126,40 @@ const props = defineProps<{
|
|||
video: Misskey.entities.DriveFile;
|
||||
}>();
|
||||
|
||||
const keymap = {
|
||||
'up': () => {
|
||||
if (hasFocus() && videoEl.value) {
|
||||
volume.value = Math.min(volume.value + 0.1, 1);
|
||||
}
|
||||
},
|
||||
'down': () => {
|
||||
if (hasFocus() && videoEl.value) {
|
||||
volume.value = Math.max(volume.value - 0.1, 0);
|
||||
}
|
||||
},
|
||||
'left': () => {
|
||||
if (hasFocus() && videoEl.value) {
|
||||
videoEl.value.currentTime = Math.max(videoEl.value.currentTime - 5, 0);
|
||||
}
|
||||
},
|
||||
'right': () => {
|
||||
if (hasFocus() && videoEl.value) {
|
||||
videoEl.value.currentTime = Math.min(videoEl.value.currentTime + 5, videoEl.value.duration);
|
||||
}
|
||||
},
|
||||
'space': () => {
|
||||
if (hasFocus()) {
|
||||
togglePlayPause();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// PlayerElもしくはその子要素にフォーカスがあるかどうか
|
||||
function hasFocus() {
|
||||
if (!playerEl.value) return false;
|
||||
return playerEl.value === document.activeElement || playerEl.value.contains(document.activeElement);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line vue/no-setup-props-destructure
|
||||
const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore'));
|
||||
|
||||
|
|
@ -111,6 +171,35 @@ function showMenu(ev: MouseEvent) {
|
|||
|
||||
menu = [
|
||||
// TODO: 再生キューに追加
|
||||
{
|
||||
type: 'switch',
|
||||
text: i18n.ts._mediaControls.loop,
|
||||
icon: 'ti ti-repeat',
|
||||
ref: loop,
|
||||
},
|
||||
{
|
||||
type: 'radio',
|
||||
text: i18n.ts._mediaControls.playbackRate,
|
||||
icon: 'ti ti-clock-play',
|
||||
ref: speed,
|
||||
options: {
|
||||
'0.25x': 0.25,
|
||||
'0.5x': 0.5,
|
||||
'0.75x': 0.75,
|
||||
'1.0x': 1,
|
||||
'1.25x': 1.25,
|
||||
'1.5x': 1.5,
|
||||
'2.0x': 2,
|
||||
},
|
||||
},
|
||||
...(document.pictureInPictureEnabled ? [{
|
||||
text: i18n.ts._mediaControls.pip,
|
||||
icon: 'ti ti-picture-in-picture',
|
||||
action: togglePictureInPicture,
|
||||
}] : []),
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
text: i18n.ts.hide,
|
||||
icon: 'ti ti-eye-off',
|
||||
|
|
@ -186,6 +275,8 @@ const rangePercent = computed({
|
|||
},
|
||||
});
|
||||
const volume = ref(.25);
|
||||
const speed = ref(1);
|
||||
const loop = ref(false); // TODO: ドライブファイルのフラグに置き換える
|
||||
const bufferedEnd = ref(0);
|
||||
const bufferedDataRatio = computed(() => {
|
||||
if (!videoEl.value) return 0;
|
||||
|
|
@ -243,6 +334,16 @@ function toggleFullscreen() {
|
|||
}
|
||||
}
|
||||
|
||||
function togglePictureInPicture() {
|
||||
if (videoEl.value) {
|
||||
if (document.pictureInPictureElement) {
|
||||
document.exitPictureInPicture();
|
||||
} else {
|
||||
videoEl.value.requestPictureInPicture();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
if (volume.value === 0) {
|
||||
volume.value = .25;
|
||||
|
|
@ -252,6 +353,7 @@ function toggleMute() {
|
|||
}
|
||||
|
||||
let onceInit = false;
|
||||
let mediaTickFrameId: number | null = null;
|
||||
let stopVideoElWatch: () => void;
|
||||
|
||||
function init() {
|
||||
|
|
@ -271,8 +373,12 @@ function init() {
|
|||
}
|
||||
|
||||
elapsedTimeMs.value = videoEl.value.currentTime * 1000;
|
||||
|
||||
if (videoEl.value.loop !== loop.value) {
|
||||
loop.value = videoEl.value.loop;
|
||||
}
|
||||
}
|
||||
window.requestAnimationFrame(updateMediaTick);
|
||||
mediaTickFrameId = window.requestAnimationFrame(updateMediaTick);
|
||||
}
|
||||
|
||||
updateMediaTick();
|
||||
|
|
@ -316,6 +422,14 @@ watch(volume, (to) => {
|
|||
if (videoEl.value) videoEl.value.volume = to;
|
||||
});
|
||||
|
||||
watch(speed, (to) => {
|
||||
if (videoEl.value) videoEl.value.playbackRate = to;
|
||||
});
|
||||
|
||||
watch(loop, (to) => {
|
||||
if (videoEl.value) videoEl.value.loop = to;
|
||||
});
|
||||
|
||||
watch(hide, (to) => {
|
||||
if (to && isFullscreen.value) {
|
||||
document.exitFullscreen();
|
||||
|
|
@ -341,6 +455,10 @@ onDeactivated(() => {
|
|||
hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore');
|
||||
stopVideoElWatch();
|
||||
onceInit = false;
|
||||
if (mediaTickFrameId) {
|
||||
window.cancelAnimationFrame(mediaTickFrameId);
|
||||
mediaTickFrameId = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -349,6 +467,10 @@ onDeactivated(() => {
|
|||
container-type: inline-size;
|
||||
position: relative;
|
||||
overflow: clip;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sensitive {
|
||||
|
|
@ -412,7 +534,7 @@ onDeactivated(() => {
|
|||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 120px 0;
|
||||
padding: 60px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -436,7 +558,6 @@ onDeactivated(() => {
|
|||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.videoOverlayPlayButton {
|
||||
|
|
|
|||
|
|
@ -42,9 +42,26 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</button>
|
||||
<button v-else-if="item.type === 'switch'" role="menuitemcheckbox" :tabindex="i" class="_button" :class="[$style.item, $style.switch, { [$style.switchDisabled]: item.disabled } , { [$style.gamingDark]: gamingType === 'dark',[$style.gamingLight]: gamingType === 'light' }]" @click="switchItem(item)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<MkSwitchButton :class="$style.switchButton" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)" model-value/>
|
||||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
||||
<MkSwitchButton v-else :class="$style.switchButton" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)" model-value/>
|
||||
<div :class="$style.item_content">
|
||||
<span :class="[$style.item_content_text, $style.switchText]">{{ item.text }}</span>
|
||||
<span :class="[$style.item_content_text, { [$style.switchText]: !item.icon }]">{{ item.text }}</span>
|
||||
<MkSwitchButton v-if="item.icon" :class="[$style.switchButton, $style.caret]" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/>
|
||||
</div>
|
||||
</button>
|
||||
<button v-else-if="item.type === 'radio'" class="_button" role="menuitem" :tabindex="i" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="preferClick ? null : showRadioOptions(item, $event)" @click="!preferClick ? null : showRadioOptions(item, $event)">
|
||||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i>
|
||||
<div :class="$style.item_content">
|
||||
<span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span>
|
||||
<span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span>
|
||||
</div>
|
||||
</button>
|
||||
<button v-else-if="item.type === 'radioOption'" :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.radioActive]: item.active }]" @click="clicked(item.action, $event, false)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<div :class="$style.icon">
|
||||
<span :class="[$style.radio, { [$style.radioChecked]: item.active }]"></span>
|
||||
</div>
|
||||
<div :class="$style.item_content">
|
||||
<span :class="$style.item_content_text">{{ item.text }}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button v-else-if="item.type === 'parent'" class="_button" role="menuitem" :tabindex="i" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item } , { [$style.gamingDark]: gamingType === 'dark',[$style.gamingLight]: gamingType === 'light' }]" @mouseenter="preferClick ? null : showChildren(item, $event)" @click="!preferClick ? null : showChildren(item, $event)">
|
||||
|
|
@ -76,7 +93,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts">
|
||||
import { ComputedRef, computed, defineAsyncComponent, isRef, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
|
||||
import { focusPrev, focusNext } from '@/scripts/focus.js';
|
||||
import { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuParent } from '@/types/menu.js';
|
||||
import { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio, MenuRadioOption, MenuParent } from '@/types/menu.js';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { isTouchUsing } from '@/scripts/touch.js';
|
||||
|
|
@ -170,6 +187,31 @@ function onItemMouseLeave(item) {
|
|||
if (childCloseTimer) window.clearTimeout(childCloseTimer);
|
||||
}
|
||||
|
||||
async function showRadioOptions(item: MenuRadio, ev: MouseEvent) {
|
||||
const children: MenuItem[] = Object.keys(item.options).map<MenuRadioOption>(key => {
|
||||
const value = item.options[key];
|
||||
return {
|
||||
type: 'radioOption',
|
||||
text: key,
|
||||
action: () => {
|
||||
item.ref = value;
|
||||
},
|
||||
active: computed(() => item.ref === value),
|
||||
};
|
||||
});
|
||||
|
||||
if (props.asDrawer) {
|
||||
os.popupMenu(children, ev.currentTarget ?? ev.target).finally(() => {
|
||||
emit('close');
|
||||
});
|
||||
emit('hide');
|
||||
} else {
|
||||
childTarget.value = (ev.currentTarget ?? ev.target) as HTMLElement;
|
||||
childMenu.value = children;
|
||||
childShowingItem.value = item;
|
||||
}
|
||||
}
|
||||
|
||||
async function showChildren(item: MenuParent, ev: MouseEvent) {
|
||||
const children: MenuItem[] = await (async () => {
|
||||
if (childrenCache.has(item)) {
|
||||
|
|
@ -198,8 +240,10 @@ async function showChildren(item: MenuParent, ev: MouseEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
function clicked(fn: MenuAction, ev: MouseEvent) {
|
||||
function clicked(fn: MenuAction, ev: MouseEvent, doClose = true) {
|
||||
fn(ev);
|
||||
|
||||
if (!doClose) return;
|
||||
close(true);
|
||||
}
|
||||
|
||||
|
|
@ -394,6 +438,15 @@ onBeforeUnmount(() => {
|
|||
}
|
||||
}
|
||||
|
||||
&.radioActive {
|
||||
color: var(--accent) !important;
|
||||
opacity: 1;
|
||||
|
||||
&:before {
|
||||
background-color: var(--accentedBg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:active):focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--focus) inset;
|
||||
}
|
||||
|
|
@ -484,11 +537,11 @@ onBeforeUnmount(() => {
|
|||
|
||||
.switchButton {
|
||||
margin-left: -2px;
|
||||
--height: 1.35em;
|
||||
}
|
||||
|
||||
.switchText {
|
||||
margin-left: 8px;
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
|
@ -529,6 +582,34 @@ onBeforeUnmount(() => {
|
|||
margin: 8px 0;
|
||||
border-top: solid 0.5px var(--divider);
|
||||
}
|
||||
|
||||
.radio {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
vertical-align: -.125em;
|
||||
border-radius: 50%;
|
||||
border: solid 2px var(--divider);
|
||||
background-color: var(--panel);
|
||||
|
||||
&.radioChecked {
|
||||
border-color: var(--accent);
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
border-radius: 50%;
|
||||
background-color: var(--accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
@-webkit-keyframes AnimationLight {
|
||||
0% {
|
||||
background-position: 0% 50%
|
||||
|
|
|
|||
|
|
@ -90,7 +90,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkMediaList :mediaList="appearNote.files"/>
|
||||
</div>
|
||||
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/>
|
||||
<div v-if="isEnabledUrlPreview">
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/>
|
||||
</div>
|
||||
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
|
||||
<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false">
|
||||
<span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span>
|
||||
|
|
@ -103,7 +105,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" :note="appearNote" :maxNumber="16" @mockUpdateMyReaction="emitUpdReaction">
|
||||
<template #more>
|
||||
<div :class="$style.reactionOmitted">{{ i18n.ts.more }}</div>
|
||||
<MkA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</MkA>
|
||||
</template>
|
||||
</MkReactionsViewer>
|
||||
<footer :class="$style.footer">
|
||||
|
|
@ -173,6 +175,7 @@ import MkNoteSub from '@/components/MkNoteSub.vue';
|
|||
import MkNoteHeader from '@/components/MkNoteHeader.vue';
|
||||
import MkNoteSimple from '@/components/MkNoteSimple.vue';
|
||||
import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
|
||||
import MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue';
|
||||
import MkMediaList from '@/components/MkMediaList.vue';
|
||||
import MkCwButton from '@/components/MkCwButton.vue';
|
||||
import MkPoll from '@/components/MkPoll.vue';
|
||||
|
|
@ -186,7 +189,7 @@ import {userPage} from '@/filters/user.js';
|
|||
import number from '@/filters/number.js';
|
||||
import * as os from '@/os.js';
|
||||
import * as sound from '@/scripts/sound.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
|
||||
import {defaultStore, noteViewInterruptors} from '@/store.js';
|
||||
import {reactionPicker} from '@/scripts/reaction-picker.js';
|
||||
import {extractUrlFromMfm} from '@/scripts/extract-url-from-mfm.js';
|
||||
|
|
@ -202,6 +205,7 @@ import {MenuItem} from '@/types/menu.js';
|
|||
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
||||
import {showMovedDialog} from '@/scripts/show-moved-dialog.js';
|
||||
import {shouldCollapsed} from '@/scripts/collapsed.js';
|
||||
import { isEnabledUrlPreview } from '@/instance.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
|
|
@ -275,7 +279,7 @@ const renoteCollapsed = ref(
|
|||
defaultStore.state.collapseRenotes && isRenote && (
|
||||
($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131
|
||||
(appearNote.value.myReaction != null)
|
||||
)
|
||||
),
|
||||
);
|
||||
|
||||
/* Overload FunctionにLintが対応していないのでコメントアウト
|
||||
|
|
@ -344,6 +348,28 @@ if (!props.mock) {
|
|||
targetElement: renoteButton.value,
|
||||
}, {}, 'closed');
|
||||
});
|
||||
|
||||
if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
||||
useTooltip(reactButton, async (showing) => {
|
||||
const reactions = await misskeyApiGet('notes/reactions', {
|
||||
noteId: appearNote.value.id,
|
||||
limit: 10,
|
||||
_cacheKey_: appearNote.value.reactionCount,
|
||||
});
|
||||
|
||||
const users = reactions.map(x => x.user);
|
||||
|
||||
if (users.length < 1) return;
|
||||
|
||||
os.popup(MkReactionsViewerDetails, {
|
||||
showing,
|
||||
reaction: '❤️',
|
||||
users,
|
||||
count: appearNote.value.reactionCount,
|
||||
targetElement: reactButton.value!,
|
||||
}, {}, 'closed');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renote(viaKeyboard = false) {
|
||||
|
|
@ -1033,10 +1059,9 @@ function emitUpdReaction(emoji: string, delta: number) {
|
|||
|
||||
.reactionOmitted {
|
||||
display: inline-block;
|
||||
height: 32px;
|
||||
margin: 2px;
|
||||
padding: 0 6px;
|
||||
margin-left: 8px;
|
||||
opacity: .8;
|
||||
font-size: 95%;
|
||||
}
|
||||
|
||||
.root:has(.ti-home) {
|
||||
|
|
|
|||
|
|
@ -94,7 +94,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkMediaList :mediaList="appearNote.files"/>
|
||||
</div>
|
||||
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
|
||||
<div v-if="isEnabledUrlPreview">
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
|
||||
</div>
|
||||
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
|
||||
</div>
|
||||
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
|
||||
|
|
@ -223,6 +225,7 @@ import MkNoteSub from '@/components/MkNoteSub.vue';
|
|||
import MkNoteSimple from '@/components/MkNoteSimple.vue';
|
||||
import MkNotePreview from '@/components/MkNotePreview.vue';
|
||||
import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
|
||||
import MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue';
|
||||
import MkMediaList from '@/components/MkMediaList.vue';
|
||||
import MkCwButton from '@/components/MkCwButton.vue';
|
||||
import MkPoll from '@/components/MkPoll.vue';
|
||||
|
|
@ -235,7 +238,7 @@ import { userPage } from '@/filters/user.js';
|
|||
import { notePage } from '@/filters/note.js';
|
||||
import number from '@/filters/number.js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
|
||||
import * as sound from '@/scripts/sound.js';
|
||||
import { defaultStore, noteViewInterruptors } from '@/store.js';
|
||||
import { reactionPicker } from '@/scripts/reaction-picker.js';
|
||||
|
|
@ -260,10 +263,14 @@ import { deviceKind } from '@/scripts/device-kind.js';
|
|||
|
||||
const MOBILE_THRESHOLD = 500;
|
||||
const isMobile = ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD);
|
||||
import { isEnabledUrlPreview } from '@/instance.js';
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
}>();
|
||||
initialTab: string;
|
||||
}>(), {
|
||||
initialTab: 'replies',
|
||||
});
|
||||
|
||||
const inChannel = inject('inChannel', null);
|
||||
|
||||
|
|
@ -362,7 +369,7 @@ provide('react', (reaction: string) => {
|
|||
});
|
||||
});
|
||||
|
||||
const tab = ref('replies');
|
||||
const tab = ref(props.initialTab);
|
||||
const reactionTabType = ref<string | null>(null);
|
||||
|
||||
const renotesPagination = computed<Paging>(() => ({
|
||||
|
|
@ -407,6 +414,28 @@ useTooltip(renoteButton, async (showing) => {
|
|||
}, {}, 'closed');
|
||||
});
|
||||
|
||||
if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
||||
useTooltip(reactButton, async (showing) => {
|
||||
const reactions = await misskeyApiGet('notes/reactions', {
|
||||
noteId: appearNote.value.id,
|
||||
limit: 10,
|
||||
_cacheKey_: appearNote.value.reactionCount,
|
||||
});
|
||||
|
||||
const users = reactions.map(x => x.user);
|
||||
|
||||
if (users.length < 1) return;
|
||||
|
||||
os.popup(MkReactionsViewerDetails, {
|
||||
showing,
|
||||
reaction: '❤️',
|
||||
users,
|
||||
count: appearNote.value.reactionCount,
|
||||
targetElement: reactButton.value!,
|
||||
}, {}, 'closed');
|
||||
});
|
||||
}
|
||||
|
||||
function renote(viaKeyboard = false) {
|
||||
pleaseLogin();
|
||||
showMovedDialog();
|
||||
|
|
|
|||
|
|
@ -19,18 +19,21 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div style="margin-top: 16px;">{{ i18n.ts.authenticationRequiredToContinue }}</div>
|
||||
</div>
|
||||
|
||||
<div class="_gaps">
|
||||
<MkInput ref="passwordInput" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" :withPasswordToggle="true">
|
||||
<template #prefix><i class="ti ti-password"></i></template>
|
||||
</MkInput>
|
||||
<form @submit.prevent="done">
|
||||
<div class="_gaps">
|
||||
<MkInput ref="passwordInput" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" required :withPasswordToggle="true">
|
||||
<template #prefix><i class="ti ti-password"></i></template>
|
||||
</MkInput>
|
||||
|
||||
<MkInput v-if="$i.twoFactorEnabled" v-model="token" type="text" pattern="^([0-9]{6}|[A-Z0-9]{32})$" autocomplete="one-time-code" :spellcheck="false">
|
||||
<template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template>
|
||||
<template #prefix><i class="ti ti-123"></i></template>
|
||||
</MkInput>
|
||||
<MkInput v-if="$i.twoFactorEnabled" v-model="token" type="text" :pattern="isBackupCode ? '^[A-Z0-9]{32}$' :'^[0-9]{6}$'" autocomplete="one-time-code" required :spellcheck="false" :inputmode="isBackupCode ? undefined : 'numeric'">
|
||||
<template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template>
|
||||
<template #prefix><i v-if="isBackupCode" class="ti ti-key"></i><i v-else class="ti ti-123"></i></template>
|
||||
<template #caption><button class="_textButton" type="button" @click="isBackupCode = !isBackupCode">{{ isBackupCode ? i18n.ts.useTotp : i18n.ts.useBackupCode }}</button></template>
|
||||
</MkInput>
|
||||
|
||||
<MkButton :disabled="(password ?? '') == '' || ($i.twoFactorEnabled && (token ?? '') == '')" primary rounded style="margin: 0 auto;" @click="done"><i class="ti ti-lock-open"></i> {{ i18n.ts.continue }}</MkButton>
|
||||
</div>
|
||||
<MkButton :disabled="(password ?? '') == '' || ($i.twoFactorEnabled && (token ?? '') == '')" type="submit" primary rounded style="margin: 0 auto;"><i class="ti ti-lock-open"></i> {{ i18n.ts.continue }}</MkButton>
|
||||
</div>
|
||||
</form>
|
||||
</MkSpacer>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
|
@ -54,6 +57,7 @@ const emit = defineEmits<{
|
|||
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
|
||||
const passwordInput = shallowRef<InstanceType<typeof MkInput>>();
|
||||
const password = ref('');
|
||||
const isBackupCode = ref(false);
|
||||
const token = ref<string | null>(null);
|
||||
|
||||
function onClose() {
|
||||
|
|
@ -61,7 +65,7 @@ function onClose() {
|
|||
if (dialog.value) dialog.value.close();
|
||||
}
|
||||
|
||||
function done(res) {
|
||||
function done() {
|
||||
emit('done', { password: password.value, token: token.value });
|
||||
if (dialog.value) dialog.value.close();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,9 @@ watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumbe
|
|||
}
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin: 4px -2px 0 -2px;
|
||||
|
||||
&:empty {
|
||||
|
|
|
|||
|
|
@ -31,15 +31,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div v-if="user && user.securityKeys" class="or-hr">
|
||||
<p class="or-msg">{{ i18n.ts.or }}</p>
|
||||
</div>
|
||||
<div class="twofa-group totp-group">
|
||||
<p style="margin-bottom:0;">{{ i18n.ts['2fa'] }}</p>
|
||||
<div class="twofa-group totp-group _gaps">
|
||||
<MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" autocomplete="current-password" :withPasswordToggle="true" required>
|
||||
<template #label>{{ i18n.ts.password }}</template>
|
||||
<template #prefix><i class="ti ti-lock"></i></template>
|
||||
</MkInput>
|
||||
<MkInput v-model="token" type="text" pattern="^([0-9]{6}|[A-Z0-9]{32})$" autocomplete="one-time-code" :spellcheck="false" required>
|
||||
<template #label>{{ i18n.ts.token }}</template>
|
||||
<template #prefix><i class="ti ti-123"></i></template>
|
||||
<MkInput v-model="token" type="text" :pattern="isBackupCode ? '^[A-Z0-9]{32}$' :'^[0-9]{6}$'" autocomplete="one-time-code" required :spellcheck="false" :inputmode="isBackupCode ? undefined : 'numeric'">
|
||||
<template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template>
|
||||
<template #prefix><i v-if="isBackupCode" class="ti ti-key"></i><i v-else class="ti ti-123"></i></template>
|
||||
<template #caption><button class="_textButton" type="button" @click="isBackupCode = !isBackupCode">{{ isBackupCode ? i18n.ts.useTotp : i18n.ts.useBackupCode }}</button></template>
|
||||
</MkInput>
|
||||
<MkButton type="submit" :disabled="signing" large primary rounded style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
|
||||
</div>
|
||||
|
|
@ -70,6 +70,7 @@ const password = ref('');
|
|||
const token = ref('');
|
||||
const host = ref(toUnicode(configHost));
|
||||
const totpLogin = ref(false);
|
||||
const isBackupCode = ref(false);
|
||||
const queryingKey = ref(false);
|
||||
const credentialRequest = ref<CredentialRequestOptions | null>(null);
|
||||
|
||||
|
|
|
|||
|
|
@ -48,13 +48,13 @@ const toggle = () => {
|
|||
|
||||
<style lang="scss" module>
|
||||
.button {
|
||||
position: relative;
|
||||
--height: 21px;position: relative;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
width: 32px;
|
||||
height: 23px;
|
||||
width: calc(var(--height) * 1.6);
|
||||
height: calc(var(--height) + 2px); // 枠線
|
||||
outline: none;
|
||||
background: var(--switchOffBg);
|
||||
background-clip: content-box;
|
||||
|
|
@ -102,9 +102,9 @@ const toggle = () => {
|
|||
|
||||
.knob {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
box-sizing: border-box;top: 3px;
|
||||
width: calc(var(--height) - 6px);
|
||||
height: calc(var(--height) - 6px);
|
||||
border-radius: 999px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
|
|
@ -116,7 +116,7 @@ const toggle = () => {
|
|||
}
|
||||
|
||||
.knobChecked {
|
||||
left: 12px;
|
||||
left: calc(calc(100% - var(--height)) + 3px);
|
||||
background: var(--switchOnFg);
|
||||
|
||||
&.gamingDark {
|
||||
|
|
|
|||
|
|
@ -152,15 +152,16 @@ requestUrl.hash = '';
|
|||
window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`)
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
fetching.value = false;
|
||||
unknownUrl.value = true;
|
||||
return;
|
||||
if (_DEV_) {
|
||||
console.warn(`[HTTP${res.status}] Failed to fetch url preview`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return res.json();
|
||||
})
|
||||
.then((info: SummalyResult) => {
|
||||
if (info.url == null) {
|
||||
.then((info: SummalyResult | null) => {
|
||||
if (!info || info.url == null) {
|
||||
fetching.value = false;
|
||||
unknownUrl.value = true;
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { url as local } from '@/config.js';
|
|||
import * as os from '@/os.js';
|
||||
import { useTooltip } from '@/scripts/use-tooltip.js';
|
||||
import { safeURIDecode } from '@/scripts/safe-uri-decode.js';
|
||||
import { isEnabledUrlPreview } from '@/instance.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
url: string;
|
||||
|
|
@ -44,7 +45,7 @@ const url = new URL(props.url);
|
|||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('invalid url');
|
||||
const el = ref();
|
||||
|
||||
if (props.showUrlPreview) {
|
||||
if (props.showUrlPreview && isEnabledUrlPreview.value) {
|
||||
useTooltip(el, (showing) => {
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
|
||||
showing,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import XText from './page.text.vue';
|
|||
import XSection from './page.section.vue';
|
||||
import XImage from './page.image.vue';
|
||||
import XNote from './page.note.vue';
|
||||
import XDynamic from './page.dynamic.vue';
|
||||
|
||||
function getComponent(type: string) {
|
||||
switch (type) {
|
||||
|
|
@ -21,6 +22,20 @@ function getComponent(type: string) {
|
|||
case 'section': return XSection;
|
||||
case 'image': return XImage;
|
||||
case 'note': return XNote;
|
||||
|
||||
// 動的ページの代替用ブロック
|
||||
case 'button':
|
||||
case 'if':
|
||||
case 'textarea':
|
||||
case 'post':
|
||||
case 'canvas':
|
||||
case 'numberInput':
|
||||
case 'textInput':
|
||||
case 'switch':
|
||||
case 'radioButton':
|
||||
case 'counter':
|
||||
return XDynamic;
|
||||
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
43
packages/frontend/src/components/page/page.dynamic.vue
Normal file
43
packages/frontend/src/components/page/page.dynamic.vue
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<!-- 動的ページのブロックの代替。利用できないということを表示する -->
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.heading"><i class="ti ti-dice-5"></i> {{ i18n.ts._pages.blocks.dynamic }}</div>
|
||||
<I18n :src="i18n.ts._pages.blocks.dynamicDescription" tag="div" :class="$style.text">
|
||||
<template #play>
|
||||
<MkA to="/play" class="_link">Play</MkA>
|
||||
</template>
|
||||
</I18n>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const props = defineProps<{
|
||||
block: Misskey.entities.PageBlock,
|
||||
page: Misskey.entities.Page,
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--margin);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 90%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -6,7 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<div class="_gaps" :class="$style.textRoot">
|
||||
<Mfm :text="block.text ?? ''" :isNote="false"/>
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url"/>
|
||||
<div v-if="isEnabledUrlPreview" class="_gaps_s">
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -15,6 +17,7 @@ import { defineAsyncComponent } from 'vue';
|
|||
import * as mfm from 'mfm-js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
|
||||
import { isEnabledUrlPreview } from '@/instance.js';
|
||||
|
||||
const MkUrlPreview = defineAsyncComponent(() => import('@/components/MkUrlPreview.vue'));
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self' https://newassets.hcaptcha.com/ https://challenges.cloudflare.com/ http://localhost:7493/;
|
||||
worker-src 'self';
|
||||
script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com;
|
||||
script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com https://esm.sh;
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data: blob: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
|
||||
media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ export const infoImageUrl = computed(() => instance.infoImageUrl ?? DEFAULT_INFO
|
|||
|
||||
export const notFoundImageUrl = computed(() => instance.notFoundImageUrl ?? DEFAULT_NOT_FOUND_IMAGE_URL);
|
||||
|
||||
export const isEnabledUrlPreview = computed(() => instance.enableUrlPreview ?? true);
|
||||
|
||||
export async function fetchInstance(force = false): Promise<void> {
|
||||
if (!force) {
|
||||
const cachedAt = miLocalStorage.getItem('instanceCachedAt') ? parseInt(miLocalStorage.getItem('instanceCachedAt')!) : 0;
|
||||
|
|
|
|||
|
|
@ -226,6 +226,15 @@ const patronsWithIcon = [{
|
|||
}, {
|
||||
name: '有栖かずみ',
|
||||
icon: 'https://assets.misskey-hub.net/patrons/9240e8e0ba294a8884143e99ac7ed6a0.jpg',
|
||||
}, {
|
||||
name: 'イカロ(コアラ)',
|
||||
icon: 'https://assets.misskey-hub.net/patrons/50b9bdc03735412c80807dbdf32cecb6.jpg',
|
||||
}, {
|
||||
name: 'ハチノス3号',
|
||||
icon: 'https://assets.misskey-hub.net/patrons/030347a6f8ce4e82bc5184b5aad09a18.jpg',
|
||||
}, {
|
||||
name: 'Takeno',
|
||||
icon: 'https://assets.misskey-hub.net/patrons/6fba81536aea48fe94a30909c502dfa1.jpg',
|
||||
}];
|
||||
|
||||
const patrons = [
|
||||
|
|
@ -329,6 +338,7 @@ const patrons = [
|
|||
'たっくん',
|
||||
'SHO SEKIGUCHI',
|
||||
'塩キャベツ',
|
||||
'はとぽぷさん',
|
||||
];
|
||||
|
||||
const thereIsTreasure = ref($i && !claimedAchievements.includes('foundTreasure'));
|
||||
|
|
|
|||
|
|
@ -134,19 +134,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkSwitch>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder>
|
||||
<template #label>Summaly Proxy</template>
|
||||
|
||||
<div class="_gaps_m">
|
||||
<MkInput v-model="summalyProxy">
|
||||
<template #prefix><i class="ti ti-link"></i></template>
|
||||
<template #label>Summaly Proxy URL</template>
|
||||
</MkInput>
|
||||
|
||||
<MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</div>
|
||||
</FormSuspense>
|
||||
</MkSpacer>
|
||||
|
|
@ -171,7 +158,6 @@ import { fetchInstance } from '@/instance.js';
|
|||
import { i18n } from '@/i18n.js';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
|
||||
const summalyProxy = ref<string>('');
|
||||
const enableHcaptcha = ref<boolean>(false);
|
||||
const enableMcaptcha = ref<boolean>(false);
|
||||
const enableRecaptcha = ref<boolean>(false);
|
||||
|
|
@ -193,7 +179,6 @@ const proxyCheckioApiKey = ref<string | null>(null);
|
|||
|
||||
async function init() {
|
||||
const meta = await misskeyApi('admin/meta');
|
||||
summalyProxy.value = meta.summalyProxy;
|
||||
enableHcaptcha.value = meta.enableHcaptcha;
|
||||
enableMcaptcha.value = meta.enableMcaptcha;
|
||||
enableRecaptcha.value = meta.enableRecaptcha;
|
||||
|
|
@ -221,7 +206,6 @@ async function init() {
|
|||
|
||||
function save() {
|
||||
os.apiWithDialog('admin/update-meta', {
|
||||
summalyProxy: summalyProxy.value,
|
||||
sensitiveMediaDetection: sensitiveMediaDetection.value,
|
||||
sensitiveMediaDetectionSensitivity:
|
||||
sensitiveMediaDetectionSensitivity.value === 0 ? 'veryLow' :
|
||||
|
|
|
|||
|
|
@ -143,6 +143,53 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<FormSection>
|
||||
<template #label>{{ i18n.ts._urlPreviewSetting.title }}</template>
|
||||
|
||||
<div class="_gaps_m">
|
||||
<MkSwitch v-model="urlPreviewEnabled">
|
||||
<template #label>{{ i18n.ts._urlPreviewSetting.enable }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkSwitch v-model="urlPreviewRequireContentLength">
|
||||
<template #label>{{ i18n.ts._urlPreviewSetting.requireContentLength }}</template>
|
||||
<template #caption>{{ i18n.ts._urlPreviewSetting.requireContentLengthDescription }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkInput v-model="urlPreviewMaximumContentLength" type="number">
|
||||
<template #label>{{ i18n.ts._urlPreviewSetting.maximumContentLength }}</template>
|
||||
<template #caption>{{ i18n.ts._urlPreviewSetting.maximumContentLengthDescription }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkInput v-model="urlPreviewTimeout" type="number">
|
||||
<template #label>{{ i18n.ts._urlPreviewSetting.timeout }}</template>
|
||||
<template #caption>{{ i18n.ts._urlPreviewSetting.timeoutDescription }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkInput v-model="urlPreviewUserAgent" type="text">
|
||||
<template #label>{{ i18n.ts._urlPreviewSetting.userAgent }}</template>
|
||||
<template #caption>{{ i18n.ts._urlPreviewSetting.userAgentDescription }}</template>
|
||||
</MkInput>
|
||||
|
||||
<div>
|
||||
<MkInput v-model="urlPreviewSummaryProxyUrl" type="text">
|
||||
<template #label>{{ i18n.ts._urlPreviewSetting.summaryProxy }}</template>
|
||||
<template #caption>[{{ i18n.ts.notUsePleaseLeaveBlank }}] {{ i18n.ts._urlPreviewSetting.summaryProxyDescription }}</template>
|
||||
</MkInput>
|
||||
|
||||
<div :class="$style.subCaption">
|
||||
{{ i18n.ts._urlPreviewSetting.summaryProxyDescription2 }}
|
||||
<ul style="padding-left: 20px; margin: 4px 0">
|
||||
<li>{{ i18n.ts._urlPreviewSetting.timeout }} / key:timeout</li>
|
||||
<li>{{ i18n.ts._urlPreviewSetting.maximumContentLength }} / key:contentLengthLimit</li>
|
||||
<li>{{ i18n.ts._urlPreviewSetting.requireContentLength }} / key:contentLengthRequired</li>
|
||||
<li>{{ i18n.ts._urlPreviewSetting.userAgent }} / key:userAgent</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
</div>
|
||||
</FormSuspense>
|
||||
</MkSpacer>
|
||||
|
|
@ -173,6 +220,8 @@ import { fetchInstance, instance } from '@/instance.js';
|
|||
import { i18n } from '@/i18n.js';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
|
||||
const name = ref<string | null>(null);
|
||||
const shortName = ref<string | null>(null);
|
||||
|
|
@ -194,6 +243,12 @@ const perRemoteUserUserTimelineCacheMax = ref<number>(0);
|
|||
const perUserHomeTimelineCacheMax = ref<number>(0);
|
||||
const perUserListTimelineCacheMax = ref<number>(0);
|
||||
const notesPerOneAd = ref<number>(0);
|
||||
const urlPreviewEnabled = ref<boolean>(true);
|
||||
const urlPreviewTimeout = ref<number>(10000);
|
||||
const urlPreviewMaximumContentLength = ref<number>(1024 * 1024 * 10);
|
||||
const urlPreviewRequireContentLength = ref<boolean>(true);
|
||||
const urlPreviewUserAgent = ref<string | null>(null);
|
||||
const urlPreviewSummaryProxyUrl = ref<string | null>(null);
|
||||
|
||||
async function init(): Promise<void> {
|
||||
const meta = await misskeyApi('admin/meta');
|
||||
|
|
@ -217,9 +272,15 @@ async function init(): Promise<void> {
|
|||
perUserHomeTimelineCacheMax.value = meta.perUserHomeTimelineCacheMax;
|
||||
perUserListTimelineCacheMax.value = meta.perUserListTimelineCacheMax;
|
||||
notesPerOneAd.value = meta.notesPerOneAd;
|
||||
urlPreviewEnabled.value = meta.urlPreviewEnabled;
|
||||
urlPreviewTimeout.value = meta.urlPreviewTimeout;
|
||||
urlPreviewMaximumContentLength.value = meta.urlPreviewMaximumContentLength;
|
||||
urlPreviewRequireContentLength.value = meta.urlPreviewRequireContentLength;
|
||||
urlPreviewUserAgent.value = meta.urlPreviewUserAgent;
|
||||
urlPreviewSummaryProxyUrl.value = meta.urlPreviewSummaryProxyUrl;
|
||||
}
|
||||
|
||||
async function save(): void {
|
||||
async function save() {
|
||||
await os.apiWithDialog('admin/update-meta', {
|
||||
name: name.value,
|
||||
shortName: shortName.value === '' ? null : shortName.value,
|
||||
|
|
@ -241,6 +302,12 @@ async function save(): void {
|
|||
perUserHomeTimelineCacheMax: perUserHomeTimelineCacheMax.value,
|
||||
perUserListTimelineCacheMax: perUserListTimelineCacheMax.value,
|
||||
notesPerOneAd: notesPerOneAd.value,
|
||||
urlPreviewEnabled: urlPreviewEnabled.value,
|
||||
urlPreviewTimeout: urlPreviewTimeout.value,
|
||||
urlPreviewMaximumContentLength: urlPreviewMaximumContentLength.value,
|
||||
urlPreviewRequireContentLength: urlPreviewRequireContentLength.value,
|
||||
urlPreviewUserAgent: urlPreviewUserAgent.value,
|
||||
urlPreviewSummaryProxyUrl: urlPreviewSummaryProxyUrl.value,
|
||||
});
|
||||
|
||||
fetchInstance(true);
|
||||
|
|
@ -259,4 +326,9 @@ definePageMetadata(() => ({
|
|||
-webkit-backdrop-filter: var(--blur, blur(15px));
|
||||
backdrop-filter: var(--blur, blur(15px));
|
||||
}
|
||||
|
||||
.subCaption {
|
||||
font-size: 0.85em;
|
||||
color: var(--fgTransparentWeak);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const draft = ref({
|
|||
users: [],
|
||||
keywords: [],
|
||||
excludeKeywords: [],
|
||||
excludeBots: false,
|
||||
withReplies: false,
|
||||
caseSensitive: false,
|
||||
localOnly: false,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #label>{{ i18n.ts.users }}</template>
|
||||
<template #caption>{{ i18n.ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ i18n.ts.addUser }}</button></template>
|
||||
</MkTextarea>
|
||||
<MkSwitch v-model="excludeBots">{{ i18n.ts.antennaExcludeBots }}</MkSwitch>
|
||||
<MkSwitch v-model="withReplies">{{ i18n.ts.withReplies }}</MkSwitch>
|
||||
<MkTextarea v-model="keywords">
|
||||
<template #label>{{ i18n.ts.antennaKeywords }}</template>
|
||||
|
|
@ -78,6 +79,7 @@ const keywords = ref<string>(props.antenna.keywords.map(x => x.join(' ')).join('
|
|||
const excludeKeywords = ref<string>(props.antenna.excludeKeywords.map(x => x.join(' ')).join('\n'));
|
||||
const caseSensitive = ref<boolean>(props.antenna.caseSensitive);
|
||||
const localOnly = ref<boolean>(props.antenna.localOnly);
|
||||
const excludeBots = ref<boolean>(props.antenna.excludeBots);
|
||||
const withReplies = ref<boolean>(props.antenna.withReplies);
|
||||
const withFile = ref<boolean>(props.antenna.withFile);
|
||||
const notify = ref<boolean>(props.antenna.notify);
|
||||
|
|
@ -94,6 +96,7 @@ async function saveAntenna() {
|
|||
name: name.value,
|
||||
src: src.value,
|
||||
userListId: userListId.value,
|
||||
excludeBots: excludeBots.value,
|
||||
withReplies: withReplies.value,
|
||||
withFile: withFile.value,
|
||||
notify: notify.value,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<div class="_margin _gaps_s">
|
||||
<MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/>
|
||||
<MkNoteDetailed :key="note.id" v-model:note="note" :class="$style.note"/>
|
||||
<MkNoteDetailed :key="note.id" v-model:note="note" :initialTab="initialTab" :class="$style.note"/>
|
||||
</div>
|
||||
<div v-if="clips && clips.length > 0" class="_margin">
|
||||
<div style="font-weight: bold; padding: 12px;">{{ i18n.ts.clip }}</div>
|
||||
|
|
@ -66,6 +66,7 @@ import { defaultStore } from '@/store.js';
|
|||
|
||||
const props = defineProps<{
|
||||
noteId: string;
|
||||
initialTab?: string;
|
||||
}>();
|
||||
|
||||
const note = ref<null | Misskey.entities.Note>();
|
||||
|
|
|
|||
|
|
@ -33,8 +33,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" class="_link">Google Authenticator</a>
|
||||
</template>
|
||||
</I18n>
|
||||
<div>{{ i18n.ts._2fa.step2 }}<br>{{ i18n.ts._2fa.step2Click }}</div>
|
||||
<a :href="twoFactorData.url"><img :class="$style.qr" :src="twoFactorData.qr"></a>
|
||||
<div>{{ i18n.ts._2fa.step2 }}</div>
|
||||
<div>
|
||||
<a :class="$style.qrRoot" :href="twoFactorData.url"><img :class="$style.qr" :src="twoFactorData.qr"></a>
|
||||
<!-- QRコード側にマージンが入っているので直下でOK -->
|
||||
<div><MkButton inline rounded link :to="twoFactorData.url" :linkBehavior="'browser'">{{ i18n.ts.launchApp }}</MkButton></div>
|
||||
</div>
|
||||
<MkKeyValue :copy="twoFactorData.url">
|
||||
<template #key>{{ i18n.ts._2fa.step2Uri }}</template>
|
||||
<template #value>{{ twoFactorData.url }}</template>
|
||||
|
|
@ -52,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkSpacer :marginMin="20" :marginMax="28">
|
||||
<div class="_gaps">
|
||||
<div>{{ i18n.ts._2fa.step3Title }}</div>
|
||||
<MkInput v-model="token" autocomplete="one-time-code"></MkInput>
|
||||
<MkInput v-model="token" autocomplete="one-time-code" inputmode="numeric"></MkInput>
|
||||
<div>{{ i18n.ts._2fa.step3 }}</div>
|
||||
</div>
|
||||
<div class="_buttonsCenter" style="margin-top: 16px;">
|
||||
|
|
@ -177,8 +181,14 @@ function allDone() {
|
|||
transform: translateX(-50px);
|
||||
}
|
||||
|
||||
.qr {
|
||||
.qrRoot {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
width: 200px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.qr {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ import MkSwitch from '@/components/MkSwitch.vue';
|
|||
import FormSection from '@/components/form/section.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { signinRequired } from '@/account.js';
|
||||
import { signinRequired, updateAccount } from '@/account.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const $i = signinRequired();
|
||||
|
|
@ -116,6 +116,10 @@ async function unregisterTOTP(): Promise<void> {
|
|||
os.apiWithDialog('i/2fa/unregister', {
|
||||
password: auth.result.password,
|
||||
token: auth.result.token,
|
||||
}).then(res => {
|
||||
updateAccount({
|
||||
twoFactorEnabled: false,
|
||||
});
|
||||
}).catch(error => {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
|
|
|
|||
|
|
@ -177,6 +177,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkSwitch v-model="enableonlyAndWithSave">{{ i18n.ts.onlyAndWithSave }}<template #caption>{{ i18n.ts.onlyAndWithSaveInfo }} </template></MkSwitch>
|
||||
<MkSwitch v-model="enablehanntenn">{{ i18n.ts.hanntenn }}<template #caption>{{ i18n.ts.hanntennInfo }} </template></MkSwitch>
|
||||
<MkSwitch v-model="enableSeasonalScreenEffect">{{ i18n.ts.seasonalScreenEffect }}</MkSwitch>
|
||||
<MkSwitch v-model="useNativeUIForVideoAudioPlayer">{{ i18n.ts.useNativeUIForVideoAudioPlayer }}</MkSwitch>
|
||||
</div>
|
||||
<div>
|
||||
<MkRadios v-model="emojiStyle">
|
||||
|
|
@ -475,6 +476,7 @@ const remoteLocalTimelineName2 = ref(defaultStore.state['remoteLocalTimelineName
|
|||
const remoteLocalTimelineName3 = ref(defaultStore.state['remoteLocalTimelineName3']);
|
||||
const remoteLocalTimelineName4 = ref(defaultStore.state['remoteLocalTimelineName4']);
|
||||
const remoteLocalTimelineName5 = ref(defaultStore.state['remoteLocalTimelineName5']);
|
||||
const useNativeUIForVideoAudioPlayer = computed(defaultStore.makeGetterSetter('useNativeUIForVideoAudioPlayer'));
|
||||
|
||||
const remoteLocalTimelineEnable1 = computed(defaultStore.makeGetterSetter('remoteLocalTimelineEnable1'));
|
||||
const remoteLocalTimelineEnable2 = computed(defaultStore.makeGetterSetter('remoteLocalTimelineEnable2'));
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ const routes: RouteDef[] = [{
|
|||
component: page(() => import('@/pages/user/index.vue')),
|
||||
}, {
|
||||
name: 'note',
|
||||
path: '/notes/:noteId',
|
||||
path: '/notes/:noteId/:initialTab?',
|
||||
component: page(() => import('@/pages/note.vue')),
|
||||
}, {
|
||||
name: 'list',
|
||||
|
|
|
|||
|
|
@ -3,18 +3,19 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { bundledThemesInfo } from 'shiki';
|
||||
import { getHighlighterCore, loadWasm } from 'shiki/core';
|
||||
import darkPlus from 'shiki/themes/dark-plus.mjs';
|
||||
import { bundledThemesInfo } from 'shiki/themes';
|
||||
import { bundledLanguagesInfo } from 'shiki/langs';
|
||||
import { unique } from './array.js';
|
||||
import { deepClone } from './clone.js';
|
||||
import { deepMerge } from './merge.js';
|
||||
import type { Highlighter, LanguageRegistration, ThemeRegistration, ThemeRegistrationRaw } from 'shiki';
|
||||
import type { HighlighterCore, LanguageRegistration, ThemeRegistration, ThemeRegistrationRaw } from 'shiki/core';
|
||||
import { ColdDeviceStorage } from '@/store.js';
|
||||
import lightTheme from '@/themes/_light.json5';
|
||||
import darkTheme from '@/themes/_dark.json5';
|
||||
|
||||
let _highlighter: Highlighter | null = null;
|
||||
let _highlighter: HighlighterCore | null = null;
|
||||
|
||||
export async function getTheme(mode: 'light' | 'dark', getName: true): Promise<string>;
|
||||
export async function getTheme(mode: 'light' | 'dark', getName?: false): Promise<ThemeRegistration | ThemeRegistrationRaw>;
|
||||
|
|
@ -51,16 +52,14 @@ export async function getTheme(mode: 'light' | 'dark', getName = false): Promise
|
|||
return darkPlus;
|
||||
}
|
||||
|
||||
export async function getHighlighter(): Promise<Highlighter> {
|
||||
export async function getHighlighter(): Promise<HighlighterCore> {
|
||||
if (!_highlighter) {
|
||||
return await initHighlighter();
|
||||
}
|
||||
return _highlighter;
|
||||
}
|
||||
|
||||
export async function initHighlighter() {
|
||||
const aiScriptGrammar = await import('aiscript-vscode/aiscript/syntaxes/aiscript.tmLanguage.json');
|
||||
|
||||
async function initHighlighter() {
|
||||
await loadWasm(import('shiki/onig.wasm?init'));
|
||||
|
||||
// テーマの重複を消す
|
||||
|
|
@ -69,11 +68,12 @@ export async function initHighlighter() {
|
|||
...(await Promise.all([getTheme('light'), getTheme('dark')])),
|
||||
]);
|
||||
|
||||
const jsLangInfo = bundledLanguagesInfo.find(t => t.id === 'javascript');
|
||||
const highlighter = await getHighlighterCore({
|
||||
themes,
|
||||
langs: [
|
||||
import('shiki/langs/javascript.mjs'),
|
||||
aiScriptGrammar.default as unknown as LanguageRegistration,
|
||||
...(jsLangInfo ? [async () => await jsLangInfo.import()] : []),
|
||||
async () => (await import('aiscript-vscode/aiscript/syntaxes/aiscript.tmLanguage.json')).default as unknown as LanguageRegistration,
|
||||
],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export default (input: string): string[] => {
|
|||
export const aliases = {
|
||||
'esc': 'Escape',
|
||||
'enter': ['Enter', 'NumpadEnter'],
|
||||
'space': [' ', 'Spacebar'],
|
||||
'up': 'ArrowUp',
|
||||
'down': 'ArrowDown',
|
||||
'left': 'ArrowLeft',
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import { ref } from 'vue';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { deepClone } from './clone.js';
|
||||
import type { BuiltinTheme } from 'shiki';
|
||||
import type { BundledTheme } from 'shiki/themes';
|
||||
import { globalEvents } from '@/events.js';
|
||||
import lightTheme from '@/themes/_light.json5';
|
||||
import darkTheme from '@/themes/_dark.json5';
|
||||
|
|
@ -20,7 +20,7 @@ export type Theme = {
|
|||
base?: 'dark' | 'light';
|
||||
props: Record<string, string>;
|
||||
codeHighlighter?: {
|
||||
base: BuiltinTheme;
|
||||
base: BundledTheme;
|
||||
overrides?: Record<string, any>;
|
||||
} | {
|
||||
base: '_none_';
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { markRaw, ref } from 'vue';
|
|||
import * as Misskey from 'misskey-js';
|
||||
import { miLocalStorage } from './local-storage.js';
|
||||
import type { SoundType } from '@/scripts/sound.js';
|
||||
import type { BuiltinTheme as ShikiBuiltinTheme } from 'shiki';
|
||||
import { Storage } from '@/pizzax.js';
|
||||
import { hemisphere } from '@/scripts/intl-const.js';
|
||||
|
||||
|
|
@ -709,6 +708,10 @@ export const defaultStore = markRaw(new Storage('base', {
|
|||
where: 'device',
|
||||
default: true,
|
||||
},
|
||||
useNativeUIForVideoAudioPlayer: {
|
||||
where: 'device',
|
||||
default: false,
|
||||
},
|
||||
|
||||
sound_masterVolume: {
|
||||
where: 'device',
|
||||
|
|
|
|||
|
|
@ -439,12 +439,13 @@ rt {
|
|||
border-radius: 10px;
|
||||
|
||||
--bg: #F1E8DC;
|
||||
--panel: #fff;
|
||||
--fg: #693410;
|
||||
--switchOffBg: rgba(0, 0, 0, 0.1);
|
||||
--switchOffFg: rgb(255, 255, 255);
|
||||
--switchOnBg: var(--accent);
|
||||
--switchOnFg: rgb(255, 255, 255);
|
||||
}
|
||||
|
||||
html[data-color-mode=dark] ._woodenFrame {
|
||||
--bg: #1d0c02;
|
||||
--fg: #F1E8DC;
|
||||
--panel: #192320;
|
||||
}
|
||||
|
||||
._woodenFrameH {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
import * as Misskey from 'misskey-js';
|
||||
import { ComputedRef, Ref } from 'vue';
|
||||
|
||||
interface MenuRadioOptionsDef extends Record<string, any> { }
|
||||
|
||||
export type MenuAction = (ev: MouseEvent) => void;
|
||||
|
||||
export type MenuDivider = { type: 'divider' };
|
||||
|
|
@ -14,13 +16,15 @@ export type MenuLabel = { type: 'label', text: string };
|
|||
export type MenuLink = { type: 'link', to: string, text: string, icon?: string, indicate?: boolean, avatar?: Misskey.entities.User };
|
||||
export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, icon?: string, indicate?: boolean };
|
||||
export type MenuUser = { type: 'user', user: Misskey.entities.User, active?: boolean, indicate?: boolean, action: MenuAction };
|
||||
export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, disabled?: boolean | Ref<boolean> };
|
||||
export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, icon?: string, disabled?: boolean | Ref<boolean> };
|
||||
export type MenuButton = { type?: 'button', text: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean | ComputedRef<boolean>, avatar?: Misskey.entities.User; action: MenuAction };
|
||||
export type MenuRadio = { type: 'radio', text: string, icon?: string, ref: Ref<MenuRadioOptionsDef[keyof MenuRadioOptionsDef]>, options: MenuRadioOptionsDef, disabled?: boolean | Ref<boolean> };
|
||||
export type MenuRadioOption = { type: 'radioOption', text: string, action: MenuAction; active?: boolean | ComputedRef<boolean> };
|
||||
export type MenuParent = { type: 'parent', text: string, icon?: string, children: MenuItem[] | (() => Promise<MenuItem[]> | MenuItem[]) };
|
||||
|
||||
export type MenuPending = { type: 'pending' };
|
||||
|
||||
type OuterMenuItem = MenuDivider | MenuNull | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent;
|
||||
type OuterMenuItem = MenuDivider | MenuNull | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuRadio | MenuRadioOption | MenuParent;
|
||||
type OuterPromiseMenuItem = Promise<MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent>;
|
||||
export type MenuItem = OuterMenuItem | OuterPromiseMenuItem;
|
||||
export type InnerMenuItem = MenuDivider | MenuPending | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent;
|
||||
export type InnerMenuItem = MenuDivider | MenuPending | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuRadio | MenuRadioOption | MenuParent;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkContainer :showHeader="widgetProps.showHeader" class="mkw-bdayfollowings">
|
||||
<template #icon><i class="ti ti-cake"></i></template>
|
||||
<template #header>{{ i18n.ts._widgets.birthdayFollowings }}</template>
|
||||
<template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="actualFetch()"><i class="ti ti-refresh"></i></button></template>
|
||||
|
||||
<div :class="$style.bdayFRoot">
|
||||
<MkLoading v-if="fetching"/>
|
||||
|
|
@ -53,7 +54,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name,
|
|||
emit,
|
||||
);
|
||||
|
||||
const users = ref<Misskey.entities.FollowingFolloweePopulated[]>([]);
|
||||
const users = ref<Misskey.Endpoints['users/following']['res']>([]);
|
||||
const fetching = ref(true);
|
||||
let lastFetchedAt = '1970-01-01';
|
||||
|
||||
|
|
@ -70,19 +71,35 @@ const fetch = () => {
|
|||
now.setHours(0, 0, 0, 0);
|
||||
|
||||
if (now > lfAtD) {
|
||||
misskeyApi('users/following', {
|
||||
limit: 18,
|
||||
birthday: now.toISOString(),
|
||||
userId: $i.id,
|
||||
}).then(res => {
|
||||
users.value = res;
|
||||
fetching.value = false;
|
||||
});
|
||||
actualFetch();
|
||||
|
||||
lastFetchedAt = now.toISOString();
|
||||
}
|
||||
};
|
||||
|
||||
function actualFetch() {
|
||||
if ($i == null) {
|
||||
users.value = [];
|
||||
fetching.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
fetching.value = true;
|
||||
misskeyApi('users/following', {
|
||||
limit: 18,
|
||||
birthday: `${now.getFullYear().toString().padStart(4, '0')}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`,
|
||||
userId: $i.id,
|
||||
}).then(res => {
|
||||
users.value = res;
|
||||
window.setTimeout(() => {
|
||||
// 早すぎるとチカチカする
|
||||
fetching.value = false;
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
useInterval(fetch, 1000 * 60, {
|
||||
immediate: true,
|
||||
afterMounted: true,
|
||||
|
|
|
|||
|
|
@ -5,11 +5,30 @@ import {type UserConfig, defineConfig} from 'vite';
|
|||
|
||||
import locales from '../../locales/index.js';
|
||||
import meta from '../../package.json';
|
||||
import packageInfo from './package.json' assert { type: 'json' };
|
||||
import pluginUnwindCssModuleClassName from './lib/rollup-plugin-unwind-css-module-class-name.js';
|
||||
import pluginJson5 from './vite.json5.js';
|
||||
|
||||
const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue'];
|
||||
|
||||
/**
|
||||
* Misskeyのフロントエンドにバンドルせず、CDNなどから別途読み込むリソースを記述する。
|
||||
* CDNを使わずにバンドルしたい場合、以下の配列から該当要素を削除orコメントアウトすればOK
|
||||
*/
|
||||
const externalPackages = [
|
||||
// shiki(コードブロックのシンタックスハイライトで使用中)はテーマ・言語の定義の容量が大きいため、それらはCDNから読み込む
|
||||
{
|
||||
name: 'shiki',
|
||||
match: /^shiki\/(?<subPkg>(langs|themes))$/,
|
||||
path(id: string, pattern: RegExp): string {
|
||||
const match = pattern.exec(id)?.groups;
|
||||
return match
|
||||
? `https://esm.sh/shiki@${packageInfo.dependencies.shiki}/${match['subPkg']}`
|
||||
: id;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const hash = (str: string, seed = 0): number => {
|
||||
let h1 = 0xdeadbeef ^ seed,
|
||||
h2 = 0x41c6ce57 ^ seed;
|
||||
|
|
@ -112,6 +131,7 @@ export function getConfig(): UserConfig {
|
|||
input: {
|
||||
app: './src/_boot_.ts',
|
||||
},
|
||||
external: externalPackages.map(p => p.match),
|
||||
output: {
|
||||
manualChunks: {
|
||||
vue: ['vue'],
|
||||
|
|
@ -119,6 +139,15 @@ export function getConfig(): UserConfig {
|
|||
},
|
||||
chunkFileNames: process.env.NODE_ENV === 'production' ? '[hash:8].js' : '[name]-[hash:8].js',
|
||||
assetFileNames: process.env.NODE_ENV === 'production' ? '[hash:8][extname]' : '[name]-[hash:8][extname]',
|
||||
paths(id) {
|
||||
for (const p of externalPackages) {
|
||||
if (p.match.test(id)) {
|
||||
return p.path(id, p.match);
|
||||
}
|
||||
}
|
||||
|
||||
return id;
|
||||
},
|
||||
},
|
||||
},
|
||||
cssCodeSplit: true,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue