diff options
| author | Mar0xy <marie@kaifa.ch> | 2023-10-31 19:33:24 +0100 |
|---|---|---|
| committer | Mar0xy <marie@kaifa.ch> | 2023-10-31 19:33:24 +0100 |
| commit | 4dd23a37931e6e2dc5935b2aa47a1fe51f1a9fc4 (patch) | |
| tree | 6df74a71fb0cdd479edc1ad1e510a1729e402c0b /packages/frontend/src/components | |
| parent | merge: fix file sorting on user notes (#122) (diff) | |
| parent | Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop (diff) | |
| download | sharkey-4dd23a37931e6e2dc5935b2aa47a1fe51f1a9fc4.tar.gz sharkey-4dd23a37931e6e2dc5935b2aa47a1fe51f1a9fc4.tar.bz2 sharkey-4dd23a37931e6e2dc5935b2aa47a1fe51f1a9fc4.zip | |
merge: upstream
Diffstat (limited to 'packages/frontend/src/components')
34 files changed, 805 insertions, 116 deletions
diff --git a/packages/frontend/src/components/MkCode.core.vue b/packages/frontend/src/components/MkCode.core.vue index a1300be1f6..4ec3540419 100644 --- a/packages/frontend/src/components/MkCode.core.vue +++ b/packages/frontend/src/components/MkCode.core.vue @@ -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> diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue index 8972b1863b..b39e6ff23c 100644 --- a/packages/frontend/src/components/MkCode.vue +++ b/packages/frontend/src/components/MkCode.vue @@ -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> diff --git a/packages/frontend/src/components/MkCodeEditor.vue b/packages/frontend/src/components/MkCodeEditor.vue new file mode 100644 index 0000000000..2d56a61963 --- /dev/null +++ b/packages/frontend/src/components/MkCodeEditor.vue @@ -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> diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index e9236b38a1..96687a3368 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -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 = ''; diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 118852d717..b62f4324eb 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -160,6 +160,7 @@ async function ok() { function cancel() { done(true); } + /* function onBgClick() { if (props.cancelableByBgClick) cancel(); diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index 3fbbd536e5..5281541927 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -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); diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index d0f1d63e61..2af7a76a37 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -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"> diff --git a/packages/frontend/src/components/MkFoldableSection.vue b/packages/frontend/src/components/MkFoldableSection.vue index e889dd3cff..65afc48f06 100644 --- a/packages/frontend/src/components/MkFoldableSection.vue +++ b/packages/frontend/src/components/MkFoldableSection.vue @@ -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); diff --git a/packages/frontend/src/components/MkGoogle.vue b/packages/frontend/src/components/MkGoogle.vue index be282bbfd6..b245b1b7f4 100644 --- a/packages/frontend/src/components/MkGoogle.vue +++ b/packages/frontend/src/components/MkGoogle.vue @@ -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> diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue index 9918a3ff60..6979250c65 100644 --- a/packages/frontend/src/components/MkInput.vue +++ b/packages/frontend/src/components/MkInput.vue @@ -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[]; diff --git a/packages/frontend/src/components/MkMention.vue b/packages/frontend/src/components/MkMention.vue index e9cc9d5f4f..2e8f9e26d3 100644 --- a/packages/frontend/src/components/MkMention.vue +++ b/packages/frontend/src/components/MkMention.vue @@ -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> diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index 626698aa42..1588f924c4 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -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); } diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 10ec7fb44a..9e1c5cb9e3 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -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, }); diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 5afc7d7b4e..42e62a505f 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -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', { diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue index 1b899933cc..ed15b43d0a 100644 --- a/packages/frontend/src/components/MkNoteHeader.vue +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -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> diff --git a/packages/frontend/src/components/MkNotePreview.vue b/packages/frontend/src/components/MkNotePreview.vue index fc6ea89085..79ce60baff 100644 --- a/packages/frontend/src/components/MkNotePreview.vue +++ b/packages/frontend/src/components/MkNotePreview.vue @@ -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> diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue index 5bcbd62f1f..b22e9e016a 100644 --- a/packages/frontend/src/components/MkNoteSimple.vue +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -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"> diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue index fcc4dff98b..3c8ba00121 100644 --- a/packages/frontend/src/components/MkNoteSub.vue +++ b/packages/frontend/src/components/MkNoteSub.vue @@ -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"> diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index c33f7cba7f..379dfe806d 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -283,6 +283,12 @@ useTooltip(reactionRef, (showing) => { .quote:first-child { margin-right: 4px; + position: relative; + + &:before { + position: absolute; + transform: rotate(180deg); + } } .quote:last-child { diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index 6ba2e513c5..263e0aa1c2 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -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, })), diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index 70fae436f4..8bb8084bf7 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -166,6 +166,8 @@ defineExpose({ <style lang="scss" module> .root { + overscroll-behavior: none; + min-height: 100%; background: var(--bg); diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index 5a87273386..5643de7683 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -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(); diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index ac78d3c906..e3fa05d374 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -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"> diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue index 4fa0928223..bc622d9350 100644 --- a/packages/frontend/src/components/MkPostFormAttaches.vue +++ b/packages/frontend/src/components/MkPostFormAttaches.vue @@ -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, diff --git a/packages/frontend/src/components/MkPullToRefresh.vue b/packages/frontend/src/components/MkPullToRefresh.vue new file mode 100644 index 0000000000..467c02832a --- /dev/null +++ b/packages/frontend/src/components/MkPullToRefresh.vue @@ -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> diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue index 03d8da4393..922c3a3a0e 100644 --- a/packages/frontend/src/components/MkRange.vue +++ b/packages/frontend/src/components/MkRange.vue @@ -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; diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index fa04384194..22424a64a2 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -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 diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index 3b07ac110a..8ca0991c0b 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -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> diff --git a/packages/frontend/src/components/global/MkFooterSpacer.vue b/packages/frontend/src/components/global/MkFooterSpacer.vue new file mode 100644 index 0000000000..07df76b256 --- /dev/null +++ b/packages/frontend/src/components/global/MkFooterSpacer.vue @@ -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> diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts index 2ae3fc89c8..fa1c09d84e 100644 --- a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts +++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts @@ -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)); } diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue index 57e41ed06a..b98750da44 100644 --- a/packages/frontend/src/components/global/MkPageHeader.tabs.vue +++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue @@ -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 = ''; } diff --git a/packages/frontend/src/components/global/MkTime.vue b/packages/frontend/src/components/global/MkTime.vue index d06aa036e7..5ba13ca3f3 100644 --- a/packages/frontend/src/components/global/MkTime.vue +++ b/packages/frontend/src/components/global/MkTime.vue @@ -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> diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue index 8ec8eefed6..d29c720278 100644 --- a/packages/frontend/src/components/global/MkUrl.vue +++ b/packages/frontend/src/components/global/MkUrl.vue @@ -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); diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts index 48af4754d7..c740d181f9 100644 --- a/packages/frontend/src/components/index.ts +++ b/packages/frontend/src/components/index.ts @@ -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; } } |