merge: upstream
This commit is contained in:
commit
4dd23a3793
217 changed files with 6773 additions and 2275 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
166
packages/frontend/src/components/MkCodeEditor.vue
Normal file
166
packages/frontend/src/components/MkCodeEditor.vue
Normal 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>
|
||||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -160,6 +160,7 @@ async function ok() {
|
|||
function cancel() {
|
||||
done(true);
|
||||
}
|
||||
|
||||
/*
|
||||
function onBgClick() {
|
||||
if (props.cancelableByBgClick) cancel();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -283,6 +283,12 @@ useTooltip(reactionRef, (showing) => {
|
|||
|
||||
.quote:first-child {
|
||||
margin-right: 4px;
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
position: absolute;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.quote:last-child {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -166,6 +166,8 @@ defineExpose({
|
|||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
overscroll-behavior: none;
|
||||
|
||||
min-height: 100%;
|
||||
background: var(--bg);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
240
packages/frontend/src/components/MkPullToRefresh.vue
Normal file
240
packages/frontend/src/components/MkPullToRefresh.vue
Normal 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);
|
||||
// ポインターの場合、ポップアップ系の動作をするとdownだけ発火されてupが発火されないため
|
||||
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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
32
packages/frontend/src/components/global/MkFooterSpacer.vue
Normal file
32
packages/frontend/src/components/global/MkFooterSpacer.vue
Normal 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>
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue