diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2021-04-18 00:07:33 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2021-04-18 00:07:33 +0900 |
| commit | 938fcb3e5ecc6862e40c9cb85b8010af63c69181 (patch) | |
| tree | 512fa77d5b4dca9efd38af00a805c187876029fc /src | |
| parent | Merge branch 'develop' (diff) | |
| parent | 12.77.0 (diff) | |
| download | misskey-938fcb3e5ecc6862e40c9cb85b8010af63c69181.tar.gz misskey-938fcb3e5ecc6862e40c9cb85b8010af63c69181.tar.bz2 misskey-938fcb3e5ecc6862e40c9cb85b8010af63c69181.zip | |
Merge branch 'develop'
Diffstat (limited to 'src')
65 files changed, 1488 insertions, 235 deletions
diff --git a/src/argv.ts b/src/argv.ts index 9c69a450db..ae6396129c 100644 --- a/src/argv.ts +++ b/src/argv.ts @@ -1,6 +1,8 @@ -import * as program from 'commander'; +import { Command } from 'commander'; import config from '@/config'; +const program = new Command(); + program .version(config.version) .option('--no-daemons', 'Disable daemon processes (for debbuging)') diff --git a/src/client/components/avatars.vue b/src/client/components/avatars.vue index cac95e6d40..da862967dd 100644 --- a/src/client/components/avatars.vue +++ b/src/client/components/avatars.vue @@ -1,7 +1,7 @@ <template> <div> <div v-for="user in us" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;"> - <MkAvatar :user="user" style="width:32px;height:32px;"/> + <MkAvatar :user="user" style="width:32px;height:32px;" :show-indicator="true"/> </div> </div> </template> diff --git a/src/client/components/date-separated-list.vue b/src/client/components/date-separated-list.vue index 833cdfc898..012ed42385 100644 --- a/src/client/components/date-separated-list.vue +++ b/src/client/components/date-separated-list.vue @@ -18,7 +18,12 @@ export default defineComponent({ type: Boolean, required: false, default: false - } + }, + noGap: { + type: Boolean, + required: false, + default: false + }, }, methods: { @@ -37,18 +42,16 @@ export default defineComponent({ }, render() { - const noGap = [...document.querySelectorAll('._noGap_')].some(el => el.contains(this.$parent.$el)); - if (this.items.length === 0) return; return h(this.$store.state.animation ? TransitionGroup : 'div', this.$store.state.animation ? { - class: 'sqadhkmv' + (noGap ? ' _block' : ''), + class: 'sqadhkmv' + (this.noGap ? ' noGap _block' : ''), name: 'list', tag: 'div', 'data-direction': this.direction, 'data-reversed': this.reversed ? 'true' : 'false', } : { - class: 'sqadhkmv', + class: 'sqadhkmv' + (this.noGap ? ' noGap _block' : ''), }, this.items.map((item, i) => { const el = this.$slots.default({ item: item @@ -154,17 +157,17 @@ export default defineComponent({ } } } -} -._noGap_ .sqadhkmv { - > * { - margin: 0 !important; - border: none; - border-radius: 0; - box-shadow: none; + &.noGap { + > * { + margin: 0 !important; + border: none; + border-radius: 0; + box-shadow: none; - &:not(:last-child) { - border-bottom: solid 0.5px var(--divider); + &:not(:last-child) { + border-bottom: solid 0.5px var(--divider); + } } } } diff --git a/src/client/components/drive-file-thumbnail.vue b/src/client/components/drive-file-thumbnail.vue index 91f57d1f49..aadf22ed77 100644 --- a/src/client/components/drive-file-thumbnail.vue +++ b/src/client/components/drive-file-thumbnail.vue @@ -26,7 +26,7 @@ import { faFileArchive, faFilm } from '@fortawesome/free-solid-svg-icons'; -import ImgWithBlurhash from './img-with-blurhash.vue'; +import ImgWithBlurhash from '@client/components/img-with-blurhash.vue'; import { ColdDeviceStorage } from '@client/store'; export default defineComponent({ diff --git a/src/client/components/form/base.vue b/src/client/components/form/base.vue index 84438a5b32..34deb39465 100644 --- a/src/client/components/form/base.vue +++ b/src/client/components/form/base.vue @@ -24,9 +24,12 @@ export default defineComponent({ --formXPadding: 32px; --formYPadding: 32px; + font-size: 95%; line-height: 1.3em; background: var(--bg); padding: var(--formYPadding) var(--formXPadding); + max-width: 750px; + margin: 0 auto; &:not(.wide).max-width_400px { --formXPadding: 0px; @@ -40,16 +43,16 @@ export default defineComponent({ } ._form_group { - > * { - &:not(:first-child) { + > *:not(._formNoConcat) { + &:not(:last-child):not(._formNoConcatPrev) { &._formPanel, ._formPanel { - border-top: none; + border-bottom: solid 0.5px var(--divider); } } - &:not(:last-child) { + &:not(:first-child):not(._formNoConcatNext) { &._formPanel, ._formPanel { - border-bottom: solid 0.5px var(--divider); + border-top: none; } } } diff --git a/src/client/components/form/group.vue b/src/client/components/form/group.vue index d07852155a..34ccaeff07 100644 --- a/src/client/components/form/group.vue +++ b/src/client/components/form/group.vue @@ -1,7 +1,7 @@ <template> -<div class="vrtktovg _formItem" v-size="{ max: [500] }"> +<div class="vrtktovg _formItem _formNoConcat" v-size="{ max: [500] }" v-sticky-container> <div class="_formLabel"><slot name="label"></slot></div> - <div class="main _form_group"> + <div class="main _form_group" ref="child"> <slot></slot> </div> <div class="_formCaption"><slot name="caption"></slot></div> @@ -9,33 +9,69 @@ </template> <script lang="ts"> -import { defineComponent } from 'vue'; +import { defineComponent, onMounted, ref } from 'vue'; export default defineComponent({ + setup(props, context) { + const child = ref<HTMLElement | null>(null); + + const scanChild = () => { + if (child.value == null) return; + const els = Array.from(child.value.children); + for (let i = 0; i < els.length; i++) { + const el = els[i]; + if (el.classList.contains('_formNoConcat')) { + if (els[i - 1]) els[i - 1].classList.add('_formNoConcatPrev'); + if (els[i + 1]) els[i + 1].classList.add('_formNoConcatNext'); + } + } + }; + + onMounted(() => { + scanChild(); + + const observer = new MutationObserver(records => { + scanChild(); + }); + + observer.observe(child.value, { + childList: true, + subtree: false, + attributes: false, + characterData: false, + }); + }); + + return { + child + }; + } }); </script> <style lang="scss" scoped> .vrtktovg { > .main { - > ::v-deep(*) { - margin: 0; - - &:not(:first-child) { - &._formPanel, ._formPanel { - border-top: none; - border-top-left-radius: 0; - border-top-right-radius: 0; - } + > ::v-deep(*):not(._formNoConcat) { + &:not(._formNoConcatNext) { + margin: 0; } - &:not(:last-child) { + &:not(:last-child):not(._formNoConcatPrev) { &._formPanel, ._formPanel { border-bottom: solid 0.5px var(--divider); border-bottom-left-radius: 0; border-bottom-right-radius: 0; } } + + &:not(:first-child):not(._formNoConcatNext) { + &._formPanel, ._formPanel { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; + } + } } } } diff --git a/src/client/components/form/key-value-view.vue b/src/client/components/form/key-value-view.vue index eadc675f89..85f4febef9 100644 --- a/src/client/components/form/key-value-view.vue +++ b/src/client/components/form/key-value-view.vue @@ -22,9 +22,17 @@ export default defineComponent({ align-items: center; padding: 14px 16px; + > .key { + margin-right: 12px; + white-space: nowrap; + } + > .value { margin-left: auto; opacity: 0.7; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; } } </style> diff --git a/src/client/components/form/object-view.vue b/src/client/components/form/object-view.vue new file mode 100644 index 0000000000..cbd4186e56 --- /dev/null +++ b/src/client/components/form/object-view.vue @@ -0,0 +1,102 @@ +<template> +<FormGroup class="_formItem"> + <template #label><slot></slot></template> + <div class="drooglns _formItem" :class="{ tall }"> + <div class="input _formPanel"> + <textarea class="_monospace" + v-model="v" + readonly + :spellcheck="false" + ></textarea> + </div> + </div> + <template #caption><slot name="desc"></slot></template> +</FormGroup> +</template> + +<script lang="ts"> +import { defineComponent, ref, toRefs, watch } from 'vue'; +import * as JSON5 from 'json5'; +import './form.scss'; +import FormGroup from './group.vue'; + +export default defineComponent({ + components: { + FormGroup, + }, + props: { + value: { + required: false + }, + tall: { + type: Boolean, + required: false, + default: false + }, + pre: { + type: Boolean, + required: false, + default: false + }, + manualSave: { + type: Boolean, + required: false, + default: false + }, + }, + setup(props, context) { + const { value } = toRefs(props); + const v = ref(''); + + watch(() => value, newValue => { + v.value = JSON5.stringify(newValue.value, null, '\t'); + }, { + immediate: true + }); + + return { + v, + }; + } +}); +</script> + +<style lang="scss" scoped> +.drooglns { + position: relative; + + > .input { + position: relative; + + > textarea { + display: block; + width: 100%; + min-width: 100%; + max-width: 100%; + min-height: 130px; + margin: 0; + padding: 16px; + box-sizing: border-box; + font: inherit; + font-weight: normal; + font-size: 1em; + background: transparent; + border: none; + border-radius: 0; + outline: none; + box-shadow: none; + color: var(--fg); + tab-size: 2; + white-space: pre; + } + } + + &.tall { + > .input { + > textarea { + min-height: 200px; + } + } + } +} +</style> diff --git a/src/client/components/form/suspense.vue b/src/client/components/form/suspense.vue new file mode 100644 index 0000000000..6a8282733f --- /dev/null +++ b/src/client/components/form/suspense.vue @@ -0,0 +1,92 @@ +<template> +<transition name="fade" mode="out-in"> + <div class="_formItem" v-if="pending"> + <div class="_formPanel"> + <MkLoading/> + </div> + </div> + <FormGroup v-else-if="resolved" class="_formItem"> + <slot :result="result"></slot> + </FormGroup> + <div class="_formItem" v-else> + <div class="_formPanel"> + error! + <button @click="retry">retry</button> + </div> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent, PropType, ref, watch } from 'vue'; +import './form.scss'; +import FormGroup from './group.vue'; + +export default defineComponent({ + components: { + FormGroup, + }, + + props: { + p: { + type: Function as PropType<() => Promise<any>>, + required: true, + } + }, + + setup(props, context) { + const pending = ref(true); + const resolved = ref(false); + const rejected = ref(false); + const result = ref(null); + + const process = () => { + if (props.p == null) { + return; + } + const promise = props.p(); + pending.value = true; + resolved.value = false; + rejected.value = false; + promise.then((_result) => { + pending.value = false; + resolved.value = true; + result.value = _result; + }); + promise.catch(() => { + pending.value = false; + rejected.value = true; + }); + }; + + watch(() => props.p, () => { + process(); + }, { + immediate: true + }); + + const retry = () => { + process(); + }; + + return { + pending, + resolved, + rejected, + result, + retry, + }; + } +}); +</script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} +</style> diff --git a/src/client/components/global/avatar.vue b/src/client/components/global/avatar.vue index 83675a155c..64fb2116b6 100644 --- a/src/client/components/global/avatar.vue +++ b/src/client/components/global/avatar.vue @@ -1,9 +1,11 @@ <template> <span class="eiwwqkts _noSelect" :class="{ cat }" :title="acct(user)" v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" @click="onClick"> <img class="inner" :src="url" decoding="async"/> + <MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/> </span> <MkA class="eiwwqkts _noSelect" :class="{ cat }" :to="userPage(user)" :title="acct(user)" :target="target" v-else v-user-preview="disablePreview ? undefined : user.id"> <img class="inner" :src="url" decoding="async"/> + <MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/> </MkA> </template> @@ -12,8 +14,12 @@ import { defineComponent } from 'vue'; import { getStaticImageUrl } from '@client/scripts/get-static-image-url'; import { extractAvgColorFromBlurhash } from '@client/scripts/extract-avg-color-from-blurhash'; import { acct, userPage } from '@client/filters/user'; +import MkUserOnlineIndicator from '@client/components/user-online-indicator.vue'; export default defineComponent({ + components: { + MkUserOnlineIndicator + }, props: { user: { type: Object, @@ -30,6 +36,10 @@ export default defineComponent({ disablePreview: { required: false, default: false + }, + showIndicator: { + required: false, + default: false } }, emits: ['click'], @@ -93,7 +103,7 @@ export default defineComponent({ } } - .inner { + > .inner { position: absolute; bottom: 0; left: 0; @@ -106,5 +116,14 @@ export default defineComponent({ width: 100%; height: 100%; } + + > .indicator { + position: absolute; + z-index: 1; + bottom: 0; + left: 0; + width: 20%; + height: 20%; + } } </style> diff --git a/src/client/components/global/loading.vue b/src/client/components/global/loading.vue index 5d0c10c086..9b810f0a16 100644 --- a/src/client/components/global/loading.vue +++ b/src/client/components/global/loading.vue @@ -1,12 +1,11 @@ <template> -<div class="yxspomdl" :class="{ inline }"> +<div class="yxspomdl" :class="{ inline, colored }"> <div class="ring"></div> </div> </template> <script lang="ts"> import { defineComponent } from 'vue'; -import * as os from '@client/os'; export default defineComponent({ props: { @@ -14,6 +13,11 @@ export default defineComponent({ type: Boolean, required: false, default: false + }, + colored: { + type: Boolean, + required: false, + default: true } } }); @@ -32,6 +36,11 @@ export default defineComponent({ .yxspomdl { padding: 32px; text-align: center; + cursor: wait; + + &.colored { + color: var(--accent); + } &.inline { display: inline; @@ -41,24 +50,43 @@ export default defineComponent({ width: 32px; height: 32px; } + + > .ring { + &:before, + &:after { + width: 32px; + height: 32px; + } + } } > .ring { + position: relative; display: inline-block; - opacity: 0.7; vertical-align: middle; - } - > .ring:after { - content: " "; - display: block; - box-sizing: border-box; - width: 48px; - height: 48px; - border-radius: 50%; - border: solid 4px; - border-color: currentColor transparent transparent transparent; - animation: ring 0.5s linear infinite; + &:before, + &:after { + content: " "; + display: block; + box-sizing: border-box; + width: 48px; + height: 48px; + border-radius: 50%; + border: solid 4px; + } + + &:before { + border-color: currentColor; + opacity: 0.3; + } + + &:after { + position: absolute; + top: 0; + border-color: currentColor transparent transparent transparent; + animation: ring 0.5s linear infinite; + } } } </style> diff --git a/src/client/components/img-with-blurhash.vue b/src/client/components/img-with-blurhash.vue index 7606708e9b..7e80b00208 100644 --- a/src/client/components/img-with-blurhash.vue +++ b/src/client/components/img-with-blurhash.vue @@ -71,6 +71,7 @@ export default defineComponent({ <style lang="scss" scoped> .xubzgfgb { + position: relative; width: 100%; height: 100%; @@ -82,6 +83,7 @@ export default defineComponent({ } > canvas { + position: absolute; object-fit: cover; } diff --git a/src/client/components/media-image.vue b/src/client/components/media-image.vue index 4de5daa84f..5760466138 100644 --- a/src/client/components/media-image.vue +++ b/src/client/components/media-image.vue @@ -27,7 +27,7 @@ import { faExclamationTriangle, faEyeSlash } from '@fortawesome/free-solid-svg-i import { getStaticImageUrl } from '@client/scripts/get-static-image-url'; import { extractAvgColorFromBlurhash } from '@client/scripts/extract-avg-color-from-blurhash'; import ImageViewer from './image-viewer.vue'; -import ImgWithBlurhash from './img-with-blurhash.vue'; +import ImgWithBlurhash from '@client/components/img-with-blurhash.vue'; import * as os from '@client/os'; export default defineComponent({ diff --git a/src/client/components/note-detailed.vue b/src/client/components/note-detailed.vue index b25c97543b..50e76e5299 100644 --- a/src/client/components/note-detailed.vue +++ b/src/client/components/note-detailed.vue @@ -35,7 +35,7 @@ </div> <article class="article" @contextmenu.stop="onContextmenu"> <header class="header"> - <MkAvatar class="avatar" :user="appearNote.user"/> + <MkAvatar class="avatar" :user="appearNote.user" :show-indicator="true"/> <div class="body"> <div class="top"> <MkA class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.user.id"> diff --git a/src/client/components/notes.vue b/src/client/components/notes.vue index aedf11bc40..675748d540 100644 --- a/src/client/components/notes.vue +++ b/src/client/components/notes.vue @@ -1,30 +1,34 @@ <template> -<div> - <div class="_fullinfo" v-if="empty"> +<transition name="fade" mode="out-in"> + <MkLoading v-if="fetching"/> + + <MkError v-else-if="error" @retry="init()"/> + + <div class="_fullinfo" v-else-if="empty"> <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> <div>{{ $ts.noNotes }}</div> </div> - <MkError v-if="error" @retry="init()"/> + <div v-else> + <div v-show="more && reversed" style="margin-bottom: var(--margin);"> + <MkButton style="margin: 0 auto;" @click="fetchMoreFeature" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <template v-if="!moreFetching">{{ $ts.loadMore }}</template> + <template v-if="moreFetching"><MkLoading inline/></template> + </MkButton> + </div> - <div v-show="more && reversed" style="margin-bottom: var(--margin);"> - <MkButton style="margin: 0 auto;" @click="fetchMoreFeature" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> - <template v-if="!moreFetching">{{ $ts.loadMore }}</template> - <template v-if="moreFetching"><MkLoading inline/></template> - </MkButton> - </div> + <XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed" :no-gap="noGap"> + <XNote :note="note" class="_block" @update:note="updated(note, $event)" :key="note._featuredId_ || note._prId_ || note.id"/> + </XList> - <XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed"> - <XNote :note="note" class="_block" @update:note="updated(note, $event)" :key="note._featuredId_ || note._prId_ || note.id"/> - </XList> - - <div v-show="more && !reversed" style="margin-top: var(--margin);"> - <MkButton style="margin: 0 auto;" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> - <template v-if="!moreFetching">{{ $ts.loadMore }}</template> - <template v-if="moreFetching"><MkLoading inline/></template> - </MkButton> + <div v-show="more && !reversed" style="margin-top: var(--margin);"> + <MkButton style="margin: 0 auto;" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <template v-if="!moreFetching">{{ $ts.loadMore }}</template> + <template v-if="moreFetching"><MkLoading inline/></template> + </MkButton> + </div> </div> -</div> +</transition> </template> <script lang="ts"> @@ -55,11 +59,15 @@ export default defineComponent({ pagination: { required: true }, - prop: { type: String, required: false - } + }, + noGap: { + type: Boolean, + required: false, + default: false + }, }, emits: ['before', 'after'], @@ -90,3 +98,14 @@ export default defineComponent({ } }); </script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} +</style> diff --git a/src/client/components/notifications.vue b/src/client/components/notifications.vue index 0891108d8b..1b789093ef 100644 --- a/src/client/components/notifications.vue +++ b/src/client/components/notifications.vue @@ -1,19 +1,23 @@ <template> -<div class="mfcuwfyp _noGap_ _magnetParent"> - <XList class="notifications _magnetChild" :items="items" v-slot="{ item: notification }"> - <XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :note="notification.note" @update:note="noteUpdated(notification.note, $event)" :key="notification.id"/> - <XNotification v-else :notification="notification" :with-time="true" :full="true" class="_panel notification" :key="notification.id"/> - </XList> +<transition name="fade" mode="out-in"> + <MkLoading v-if="fetching"/> - <button class="_buttonPrimary" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" v-show="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> - <template v-if="!moreFetching">{{ $ts.loadMore }}</template> - <template v-if="moreFetching"><MkLoading inline/></template> - </button> + <MkError v-else-if="error" @retry="init()"/> - <p class="empty" v-if="empty">{{ $ts.noNotifications }}</p> + <p class="mfcuwfyp" v-else-if="empty">{{ $ts.noNotifications }}</p> - <MkError v-if="error" @retry="init()"/> -</div> + <div v-else class="_magnetParent"> + <XList class="notifications _magnetChild" :items="items" v-slot="{ item: notification }" :no-gap="true"> + <XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :note="notification.note" @update:note="noteUpdated(notification.note, $event)" :key="notification.id"/> + <XNotification v-else :notification="notification" :with-time="true" :full="true" class="_panel notification" :key="notification.id"/> + </XList> + + <button class="_buttonPrimary" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" v-show="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <template v-if="!moreFetching">{{ $ts.loadMore }}</template> + <template v-if="moreFetching"><MkLoading inline/></template> + </button> + </div> +</transition> </template> <script lang="ts"> @@ -120,17 +124,19 @@ export default defineComponent({ </script> <style lang="scss" scoped> -.mfcuwfyp { - > .empty { - margin: 0; - padding: 16px; - text-align: center; - color: var(--fg); - } +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} - > .placeholder { - padding: 32px; - opacity: 0.3; - } +.mfcuwfyp { + margin: 0; + padding: 16px; + text-align: center; + color: var(--fg); } </style> diff --git a/src/client/components/timeline.vue b/src/client/components/timeline.vue index faa3984638..753eba2ba1 100644 --- a/src/client/components/timeline.vue +++ b/src/client/components/timeline.vue @@ -1,5 +1,5 @@ <template> -<XNotes :class="{ _noGap_: !$store.state.showGapBetweenNotesInTimeline }" ref="tl" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)" @queue="$emit('queue', $event)"/> +<XNotes :no-gap="!$store.state.showGapBetweenNotesInTimeline" ref="tl" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)" @queue="$emit('queue', $event)"/> </template> <script lang="ts"> diff --git a/src/client/components/ui/container.vue b/src/client/components/ui/container.vue index 427421af7d..ecd48876e8 100644 --- a/src/client/components/ui/container.vue +++ b/src/client/components/ui/container.vue @@ -4,7 +4,7 @@ <div class="title"><slot name="header"></slot></div> <div class="sub"> <slot name="func"></slot> - <button class="_button" v-if="bodyTogglable" @click="() => showBody = !showBody"> + <button class="_button" v-if="foldable" @click="() => showBody = !showBody"> <template v-if="showBody"><Fa :icon="faAngleUp"/></template> <template v-else><Fa :icon="faAngleDown"/></template> </button> @@ -16,8 +16,11 @@ @leave="leave" @after-leave="afterLeave" > - <div v-show="showBody"> + <div v-show="showBody" class="content" :class="{ omitted }" ref="content"> <slot></slot> + <button v-if="omitted" class="fade _button" @click="() => { ignoreOmit = true; omitted = false; }"> + <span>{{ $ts.showMore }}</span> + </button> </div> </transition> </div> @@ -39,7 +42,7 @@ export default defineComponent({ required: false, default: false }, - bodyTogglable: { + foldable: { type: Boolean, required: false, default: false @@ -54,10 +57,17 @@ export default defineComponent({ required: false, default: false }, + maxHeight: { + type: Number, + required: false, + default: null + }, }, data() { return { showBody: this.expanded, + omitted: null, + ignoreOmit: false, faAngleUp, faAngleDown }; }, @@ -73,10 +83,23 @@ export default defineComponent({ }, { immediate: true }); + + this.$el.style.setProperty('--maxHeight', this.maxHeight + 'px'); + + const calcOmit = () => { + if (this.omitted || this.ignoreOmit || this.maxHeight == null) return; + const height = this.$refs.content.offsetHeight; + this.omitted = height > this.maxHeight; + }; + + calcOmit(); + new ResizeObserver((entries, observer) => { + calcOmit(); + }).observe(this.$refs.content); }, methods: { toggleContent(show: boolean) { - if (!this.bodyTogglable) return; + if (!this.foldable) return; this.showBody = show; }, @@ -127,7 +150,7 @@ export default defineComponent({ display: flex; flex-direction: column; - > div { + > .content { overflow: auto; } } @@ -169,12 +192,35 @@ export default defineComponent({ } } - > div { - > ::v-deep(._content) { - padding: 24px; + > .content { + &.omitted { + position: relative; + max-height: var(--maxHeight); + overflow: hidden; + + > .fade { + display: block; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 64px; + background: linear-gradient(0deg, var(--panel), var(--X15)); - & + ._content { - border-top: solid 0.5px var(--divider); + > span { + display: inline-block; + background: var(--panel); + padding: 6px 10px; + font-size: 0.8em; + border-radius: 999px; + box-shadow: 0 2px 6px rgb(0 0 0 / 20%); + } + + &:hover { + > span { + background: var(--panelHighlight); + } + } } } } @@ -187,10 +233,7 @@ export default defineComponent({ } } - > div { - > ::v-deep(._content) { - padding: 16px; - } + > .content { } } } diff --git a/src/client/components/ui/tooltip.vue b/src/client/components/ui/tooltip.vue index b220fe5d8c..de8c02ad4a 100644 --- a/src/client/components/ui/tooltip.vue +++ b/src/client/components/ui/tooltip.vue @@ -39,7 +39,7 @@ export default defineComponent({ const contentHeight = this.$refs.content.offsetHeight; let left = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); - let top = rect.top + window.pageYOffset + this.source.offsetHeight; + let top = rect.top + window.pageYOffset - contentHeight; left -= (this.$el.offsetWidth / 2); @@ -47,9 +47,9 @@ export default defineComponent({ left = window.innerWidth - contentWidth + window.pageXOffset - 1; } - if (top + contentHeight - window.pageYOffset > window.innerHeight) { - top = rect.top + window.pageYOffset - contentHeight; - this.$refs.content.style.transformOrigin = 'center bottom'; + if (top - window.pageYOffset < 0) { + top = rect.top + window.pageYOffset + this.source.offsetHeight; + this.$refs.content.style.transformOrigin = 'center top'; } this.$el.style.left = left + 'px'; @@ -81,6 +81,6 @@ export default defineComponent({ text-align: center; border-radius: 4px; pointer-events: none; - transform-origin: center top; + transform-origin: center bottom; } </style> diff --git a/src/client/components/user-info.vue b/src/client/components/user-info.vue index ac2f9a75a6..289e0f3c3f 100644 --- a/src/client/components/user-info.vue +++ b/src/client/components/user-info.vue @@ -1,7 +1,7 @@ <template> <div class="_panel vjnjpkug"> <div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div> - <MkAvatar class="avatar" :user="user" :disable-preview="true"/> + <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/> <div class="title"> <MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA> <p class="username"><MkAcct :user="user"/></p> diff --git a/src/client/components/user-online-indicator.vue b/src/client/components/user-online-indicator.vue new file mode 100644 index 0000000000..bb98978bba --- /dev/null +++ b/src/client/components/user-online-indicator.vue @@ -0,0 +1,50 @@ +<template> +<div class="fzgwjkgc" :class="user.onlineStatus" v-tooltip="text"></div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + user: { + type: Object, + required: true + }, + }, + + computed: { + text(): string { + switch (this.user.onlineStatus) { + case 'online': return this.$ts.online; + case 'active': return this.$ts.active; + case 'offline': return this.$ts.offline; + case 'unknown': return this.$ts.unknown; + } + } + } +}); +</script> + +<style lang="scss" scoped> +.fzgwjkgc { + box-shadow: 0 0 0 3px var(--panel); + border-radius: 100%; + + &.online { + background: #58d4c9; + } + + &.active { + background: #e4bc48; + } + + &.offline { + background: #ea5353; + } + + &.unknown { + background: #888; + } +} +</style> diff --git a/src/client/components/user-preview.vue b/src/client/components/user-preview.vue index b2b3358374..2ec81a5220 100644 --- a/src/client/components/user-preview.vue +++ b/src/client/components/user-preview.vue @@ -3,7 +3,7 @@ <div v-if="showing" class="fxxzrfni _popup _shadow" :style="{ top: top + 'px', left: left + 'px' }" @mouseover="() => { $emit('mouseover'); }" @mouseleave="() => { $emit('mouseleave'); }"> <div v-if="fetched" class="info"> <div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div> - <MkAvatar class="avatar" :user="user" :disable-preview="true"/> + <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/> <div class="title"> <MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA> <p class="username"><MkAcct :user="user"/></p> diff --git a/src/client/components/user-select-dialog.vue b/src/client/components/user-select-dialog.vue index 05a43402a8..a243e182e8 100644 --- a/src/client/components/user-select-dialog.vue +++ b/src/client/components/user-select-dialog.vue @@ -17,7 +17,7 @@ <div class="tbhwbxda _section result" v-if="username != '' || host != ''" :class="{ hit: users.length > 0 }"> <div class="users" v-if="users.length > 0"> <div class="user" v-for="user in users" :key="user.id" :class="{ selected: selected && selected.id === user.id }" @click="selected = user" @dblclick="ok()"> - <MkAvatar :user="user" class="avatar"/> + <MkAvatar :user="user" class="avatar" :show-indicator="true"/> <div class="body"> <MkUserName :user="user" class="name"/> <MkAcct :user="user" class="acct"/> @@ -31,7 +31,7 @@ <div class="tbhwbxda _section recent" v-if="username == '' && host == ''"> <div class="users"> <div class="user" v-for="user in recentUsers" :key="user.id" :class="{ selected: selected && selected.id === user.id }" @click="selected = user" @dblclick="ok()"> - <MkAvatar :user="user" class="avatar"/> + <MkAvatar :user="user" class="avatar" :show-indicator="true"/> <div class="body"> <MkUserName :user="user" class="name"/> <MkAcct :user="user" class="acct"/> diff --git a/src/client/components/users-dialog.vue b/src/client/components/users-dialog.vue index 381aa60911..ebf867f702 100644 --- a/src/client/components/users-dialog.vue +++ b/src/client/components/users-dialog.vue @@ -7,7 +7,7 @@ <div class="users"> <MkA v-for="item in items" class="user" :key="item.id" :to="userPage(extract ? extract(item) : item)"> - <MkAvatar :user="extract ? extract(item) : item" class="avatar" :disable-link="true"/> + <MkAvatar :user="extract ? extract(item) : item" class="avatar" :disable-link="true" :show-indicator="true"/> <div class="body"> <MkUserName :user="extract ? extract(item) : item" class="name"/> <MkAcct :user="extract ? extract(item) : item" class="acct"/> diff --git a/src/client/pages/channel.vue b/src/client/pages/channel.vue index 0d0184a517..f98bb41a38 100644 --- a/src/client/pages/channel.vue +++ b/src/client/pages/channel.vue @@ -22,7 +22,7 @@ <XPostForm :channel="channel" class="post-form _content _panel _gap" fixed v-if="$i"/> - <XTimeline class="_content _gap _noGap_" src="channel" :key="channelId" :channel="channelId" @before="before" @after="after"/> + <XTimeline class="_content _gap" src="channel" :key="channelId" :channel="channelId" @before="before" @after="after"/> </div> </template> diff --git a/src/client/pages/clip.vue b/src/client/pages/clip.vue index 493a34e7f0..ca3e051d51 100644 --- a/src/client/pages/clip.vue +++ b/src/client/pages/clip.vue @@ -5,7 +5,7 @@ <Mfm :text="clip.description" :is-note="false" :i="$i"/> </div> <div class="user"> - <MkAvatar :user="clip.user" class="avatar"/> <MkUserName :user="clip.user" :nowrap="false"/> + <MkAvatar :user="clip.user" class="avatar" :show-indicator="true"/> <MkUserName :user="clip.user" :nowrap="false"/> </div> </div> diff --git a/src/client/pages/explore.vue b/src/client/pages/explore.vue index 7e0acaddf7..dc0803237b 100644 --- a/src/client/pages/explore.vue +++ b/src/client/pages/explore.vue @@ -36,7 +36,7 @@ <header><span>{{ $ts.exploreFediverse }}</span></header> </div> - <MkFolder :body-togglable="true" :expanded="false" ref="tags" class="_gap"> + <MkFolder :foldable="true" :expanded="false" ref="tags" class="_gap"> <template #header><Fa :icon="faHashtag" fixed-width style="margin-right: 0.5em;"/>{{ $ts.popularTags }}</template> <div class="vxjfqztj"> diff --git a/src/client/pages/follow-requests.vue b/src/client/pages/follow-requests.vue index 309c5b4fdf..31c00d63cd 100644 --- a/src/client/pages/follow-requests.vue +++ b/src/client/pages/follow-requests.vue @@ -9,7 +9,7 @@ </template> <template #default="{items}"> <div class="user _panel" v-for="req in items" :key="req.id"> - <MkAvatar class="avatar" :user="req.follower"/> + <MkAvatar class="avatar" :user="req.follower" :show-indicator="true"/> <div class="body"> <div class="name"> <MkA class="name" :to="userPage(req.follower)" v-user-preview="req.follower.id"><MkUserName :user="req.follower"/></MkA> diff --git a/src/client/pages/instance-info.vue b/src/client/pages/instance-info.vue new file mode 100644 index 0000000000..a3cd402993 --- /dev/null +++ b/src/client/pages/instance-info.vue @@ -0,0 +1,464 @@ +<template> +<FormBase> + <FormGroup v-if="instance"> + <template #label>{{ instance.host }}</template> + <FormGroup> + <div class="_formItem"> + <div class="_formPanel fnfelxur"> + <img :src="instance.iconUrl || instance.faviconUrl" alt="" class="icon"/> + </div> + </div> + <FormKeyValueView> + <template #key>Name</template> + <template #value><span class="_monospace">{{ instance.name || `(${$ts.unknown})` }}</span></template> + </FormKeyValueView> + </FormGroup> + + <FormTextarea readonly :value="instance.description"> + <span>{{ $ts.description }}</span> + </FormTextarea> + + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts.software }}</template> + <template #value><span class="_monospace">{{ instance.softwareName || `(${$ts.unknown})` }}</span></template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.version }}</template> + <template #value><span class="_monospace">{{ instance.softwareVersion || `(${$ts.unknown})` }}</span></template> + </FormKeyValueView> + </FormGroup> + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts.administrator }}</template> + <template #value><span class="_monospace">{{ instance.maintainerName || `(${$ts.unknown})` }}</span></template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.contact }}</template> + <template #value><span class="_monospace">{{ instance.maintainerEmail || `(${$ts.unknown})` }}</span></template> + </FormKeyValueView> + </FormGroup> + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts.latestRequestSentAt }}</template> + <template #value><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.latestStatus }}</template> + <template #value>{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.latestRequestReceivedAt }}</template> + <template #value><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></template> + </FormKeyValueView> + </FormGroup> + <FormGroup> + <FormKeyValueView> + <template #key>Open Registrations</template> + <template #value>{{ instance.openRegistrations ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + </FormGroup> + <div class="_formItem"> + <div class="_formLabel">{{ $ts.statistics }}</div> + <div class="_formPanel cmhjzshl"> + <div class="selects"> + <MkSelect v-model:value="chartSrc" style="margin: 0; flex: 1;"> + <option value="requests">{{ $ts._instanceCharts.requests }}</option> + <option value="users">{{ $ts._instanceCharts.users }}</option> + <option value="users-total">{{ $ts._instanceCharts.usersTotal }}</option> + <option value="notes">{{ $ts._instanceCharts.notes }}</option> + <option value="notes-total">{{ $ts._instanceCharts.notesTotal }}</option> + <option value="ff">{{ $ts._instanceCharts.ff }}</option> + <option value="ff-total">{{ $ts._instanceCharts.ffTotal }}</option> + <option value="drive-usage">{{ $ts._instanceCharts.cacheSize }}</option> + <option value="drive-usage-total">{{ $ts._instanceCharts.cacheSizeTotal }}</option> + <option value="drive-files">{{ $ts._instanceCharts.files }}</option> + <option value="drive-files-total">{{ $ts._instanceCharts.filesTotal }}</option> + </MkSelect> + <MkSelect v-model:value="chartSpan" style="margin: 0;"> + <option value="hour">{{ $ts.perHour }}</option> + <option value="day">{{ $ts.perDay }}</option> + </MkSelect> + </div> + <div class="chart"> + <canvas :ref="setChart"></canvas> + </div> + </div> + </div> + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts.registeredAt }}</template> + <template #value><MkTime mode="detail" :time="instance.caughtAt"/></template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.updatedAt }}</template> + <template #value><MkTime mode="detail" :time="instance.infoUpdatedAt"/></template> + </FormKeyValueView> + </FormGroup> + <FormObjectView tall :value="instance"> + <span>Raw</span> + </FormObjectView> + <FormGroup> + <FormLink :to="`https://${host}/.well-known/host-meta`" external>host-meta</FormLink> + <FormLink :to="`https://${host}/.well-known/host-meta.json`" external>host-meta.json</FormLink> + <FormLink :to="`https://${host}/.well-known/nodeinfo`" external>nodeinfo</FormLink> + </FormGroup> + <FormSuspense :p="dnsPromiseFactory" v-slot="{ result: dns }"> + <FormGroup> + <template #label>DNS</template> + <FormKeyValueView v-for="record in dns.a" :key="record"> + <template #key>A</template> + <template #value><span class="_monospace">{{ record }}</span></template> + </FormKeyValueView> + <FormKeyValueView v-for="record in dns.aaaa" :key="record"> + <template #key>AAAA</template> + <template #value><span class="_monospace">{{ record }}</span></template> + </FormKeyValueView> + <FormKeyValueView v-for="record in dns.cname" :key="record"> + <template #key>CNAME</template> + <template #value><span class="_monospace">{{ record }}</span></template> + </FormKeyValueView> + <FormKeyValueView v-for="record in dns.txt"> + <template #key>TXT</template> + <template #value><span class="_monospace">{{ record[0] }}</span></template> + </FormKeyValueView> + </FormGroup> + </FormSuspense> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import { faExternalLinkAlt, faInfoCircle } from '@fortawesome/free-solid-svg-icons'; +import Chart from 'chart.js'; +import FormObjectView from '@client/components/form/object-view.vue'; +import FormTextarea from '@client/components/form/textarea.vue'; +import FormLink from '@client/components/form/link.vue'; +import FormBase from '@client/components/form/base.vue'; +import FormGroup from '@client/components/form/group.vue'; +import FormButton from '@client/components/form/button.vue'; +import FormKeyValueView from '@client/components/form/key-value-view.vue'; +import FormSuspense from '@client/components/form/suspense.vue'; +import MkSelect from '@client/components/ui/select.vue'; +import * as os from '@client/os'; +import number from '@client/filters/number'; +import bytes from '@client/filters/bytes'; +import * as symbols from '@client/symbols'; +import { url } from '@client/config'; + +const chartLimit = 90; +const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); +const negate = arr => arr.map(x => -x); +const alpha = hex => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + return `rgba(${r}, ${g}, ${b}, 0.1)`; +}; + +export default defineComponent({ + components: { + FormBase, + FormTextarea, + FormObjectView, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + FormSuspense, + MkSelect, + }, + + props: { + host: { + type: String, + required: true + } + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.instanceInfo, + icon: faInfoCircle, + actions: [{ + text: `https://${this.host}`, + icon: faExternalLinkAlt, + handler: () => { + window.open(`https://${this.host}`, '_blank'); + } + }], + }, + instance: null, + dnsPromiseFactory: () => os.api('federation/dns', { + host: this.host + }), + now: null, + canvas: null, + chart: null, + chartInstance: null, + chartSrc: 'requests', + chartSpan: 'hour', + } + }, + + computed: { + data(): any { + if (this.chart == null) return null; + switch (this.chartSrc) { + case 'requests': return this.requestsChart(); + case 'users': return this.usersChart(false); + case 'users-total': return this.usersChart(true); + case 'notes': return this.notesChart(false); + case 'notes-total': return this.notesChart(true); + case 'ff': return this.ffChart(false); + case 'ff-total': return this.ffChart(true); + case 'drive-usage': return this.driveUsageChart(false); + case 'drive-usage-total': return this.driveUsageChart(true); + case 'drive-files': return this.driveFilesChart(false); + case 'drive-files-total': return this.driveFilesChart(true); + } + }, + + stats(): any[] { + const stats = + this.chartSpan == 'day' ? this.chart.perDay : + this.chartSpan == 'hour' ? this.chart.perHour : + null; + + return stats; + }, + }, + + watch: { + chartSrc() { + this.renderChart(); + }, + + chartSpan() { + this.renderChart(); + } + }, + + mounted() { + this.fetch(); + }, + + methods: { + number, + bytes, + + async fetch() { + this.instance = await os.api('federation/show-instance', { + host: this.host + }); + + this.now = new Date(); + + const [perHour, perDay] = await Promise.all([ + os.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'hour' }), + os.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'day' }), + ]); + + const chart = { + perHour: perHour, + perDay: perDay + }; + + this.chart = chart; + + this.renderChart(); + }, + + setChart(el) { + this.canvas = el; + }, + + renderChart() { + if (this.chartInstance) { + this.chartInstance.destroy(); + } + + Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + this.chartInstance = new Chart(this.canvas, { + type: 'line', + data: { + labels: new Array(chartLimit).fill(0).map((_, i) => this.getDate(i).toLocaleString()).slice().reverse(), + datasets: this.data.series.map(x => ({ + label: x.name, + data: x.data.slice().reverse(), + pointRadius: 0, + lineTension: 0, + borderWidth: 2, + borderColor: x.color, + backgroundColor: alpha(x.color), + })) + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 16 + } + }, + legend: { + position: 'bottom', + labels: { + boxWidth: 16, + } + }, + scales: { + xAxes: [{ + gridLines: { + display: false + }, + ticks: { + display: false + } + }], + yAxes: [{ + position: 'right', + ticks: { + display: false + } + }] + }, + tooltips: { + intersect: false, + mode: 'index', + } + } + }); + }, + + getDate(ago: number) { + const y = this.now.getFullYear(); + const m = this.now.getMonth(); + const d = this.now.getDate(); + const h = this.now.getHours(); + + return this.chartSpan == 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago); + }, + + format(arr) { + return arr; + }, + + requestsChart(): any { + return { + series: [{ + name: 'In', + color: '#008FFB', + data: this.format(this.stats.requests.received) + }, { + name: 'Out (succ)', + color: '#00E396', + data: this.format(this.stats.requests.succeeded) + }, { + name: 'Out (fail)', + color: '#FEB019', + data: this.format(this.stats.requests.failed) + }] + }; + }, + + usersChart(total: boolean): any { + return { + series: [{ + name: 'Users', + color: '#008FFB', + data: this.format(total + ? this.stats.users.total + : sum(this.stats.users.inc, negate(this.stats.users.dec)) + ) + }] + }; + }, + + notesChart(total: boolean): any { + return { + series: [{ + name: 'Notes', + color: '#008FFB', + data: this.format(total + ? this.stats.notes.total + : sum(this.stats.notes.inc, negate(this.stats.notes.dec)) + ) + }] + }; + }, + + ffChart(total: boolean): any { + return { + series: [{ + name: 'Following', + color: '#008FFB', + data: this.format(total + ? this.stats.following.total + : sum(this.stats.following.inc, negate(this.stats.following.dec)) + ) + }, { + name: 'Followers', + color: '#00E396', + data: this.format(total + ? this.stats.followers.total + : sum(this.stats.followers.inc, negate(this.stats.followers.dec)) + ) + }] + }; + }, + + driveUsageChart(total: boolean): any { + return { + bytes: true, + series: [{ + name: 'Drive usage', + color: '#008FFB', + data: this.format(total + ? this.stats.drive.totalUsage + : sum(this.stats.drive.incUsage, negate(this.stats.drive.decUsage)) + ) + }] + }; + }, + + driveFilesChart(total: boolean): any { + return { + series: [{ + name: 'Drive files', + color: '#008FFB', + data: this.format(total + ? this.stats.drive.totalFiles + : sum(this.stats.drive.incFiles, negate(this.stats.drive.decFiles)) + ) + }] + }; + }, + } +}); +</script> + +<style lang="scss" scoped> +.fnfelxur { + padding: 16px; + + > img { + display: block; + margin: auto; + height: 64px; + border-radius: 8px; + } +} + +.cmhjzshl { + > .selects { + display: flex; + padding: 16px; + } +} +</style> diff --git a/src/client/pages/instance/abuses.vue b/src/client/pages/instance/abuses.vue index 736f05cc72..c8355b0683 100644 --- a/src/client/pages/instance/abuses.vue +++ b/src/client/pages/instance/abuses.vue @@ -36,7 +36,7 @@ <MkPagination :pagination="pagination" #default="{items}" ref="reports" style="margin-top: var(--margin);"> <div class="bcekxzvu _card _gap" v-for="report in items" :key="report.id"> <div class="_content target"> - <MkAvatar class="avatar" :user="report.targetUser"/> + <MkAvatar class="avatar" :user="report.targetUser" :show-indicator="true"/> <div class="info"> <MkUserName class="name" :user="report.targetUser"/> <div class="acct">@{{ acct(report.targetUser) }}</div> diff --git a/src/client/pages/instance/index.metrics.vue b/src/client/pages/instance/index.metrics.vue index 48844e0681..17ebf5d0d6 100644 --- a/src/client/pages/instance/index.metrics.vue +++ b/src/client/pages/instance/index.metrics.vue @@ -4,7 +4,7 @@ <template #header><Fa :icon="faHeartbeat"/> {{ $ts.metrics }}</template> <div class="_section" style="padding: 0 var(--margin);"> <div class="_content"> - <MkContainer :body-togglable="false" class="_gap"> + <MkContainer :foldable="false" class="_gap"> <template #header><Fa :icon="faMicrochip"/>{{ $ts.cpuAndMemory }}</template> <!-- <template #func> @@ -27,7 +27,7 @@ </div> </MkContainer> - <MkContainer :body-togglable="false" class="_gap"> + <MkContainer :foldable="false" class="_gap"> <template #header><Fa :icon="faHdd"/> {{ $ts.disk }}</template> <!-- <template #func> @@ -50,7 +50,7 @@ </div> </MkContainer> - <MkContainer :body-togglable="false" class="_gap"> + <MkContainer :foldable="false" class="_gap"> <template #header><Fa :icon="faExchangeAlt"/> {{ $ts.network }}</template> <!-- <template #func> @@ -78,7 +78,7 @@ <template #header><Fa :icon="faClipboardList"/> {{ $ts.jobQueue }}</template> <div class="vkyrmkwb" :style="{ gridTemplateRows: queueHeight }"> - <MkContainer :body-togglable="false" :scrollable="true" :resize-base-el="() => $el"> + <MkContainer :foldable="false" :scrollable="true" :resize-base-el="() => $el"> <template #header><Fa :icon="faExclamationTriangle"/> {{ $ts.delayed }}</template> <div class="_content"> diff --git a/src/client/pages/instance/index.vue b/src/client/pages/instance/index.vue index 54a0584ccc..731acd8f00 100644 --- a/src/client/pages/instance/index.vue +++ b/src/client/pages/instance/index.vue @@ -6,7 +6,7 @@ <div class="sboqnrfi" :style="{ gridTemplateRows: overviewHeight }"> <MkInstanceStats :chart-limit="300" :detailed="true" class="_gap" ref="stats"/> - <MkContainer :body-togglable="true" class="_gap"> + <MkContainer :foldable="true" class="_gap"> <template #header><Fa :icon="faInfoCircle"/>{{ $ts.instanceInfo }}</template> <div class="_content"> @@ -19,7 +19,7 @@ </div> </MkContainer> - <MkContainer :body-togglable="true" :scrollable="true" class="_gap" style="height: 300px;"> + <MkContainer :foldable="true" :scrollable="true" class="_gap" style="height: 300px;"> <template #header><Fa :icon="faDatabase"/>{{ $ts.database }}</template> <div class="_content" v-if="dbInfo"> diff --git a/src/client/pages/instance/user-dialog.vue b/src/client/pages/instance/user-dialog.vue index fb0e766c5a..a6bab5ecb8 100644 --- a/src/client/pages/instance/user-dialog.vue +++ b/src/client/pages/instance/user-dialog.vue @@ -8,7 +8,7 @@ <div class="vrcsvlkm" v-if="user && info"> <div class="_section"> <div class="banner" :style="bannerStyle"> - <MkAvatar class="avatar" :user="user"/> + <MkAvatar class="avatar" :user="user" :show-indicator="true"/> </div> </div> <div class="_section"> diff --git a/src/client/pages/instance/users.vue b/src/client/pages/instance/users.vue index e998971830..ea09b1bda0 100644 --- a/src/client/pages/instance/users.vue +++ b/src/client/pages/instance/users.vue @@ -54,7 +54,7 @@ <MkPagination :pagination="pagination" #default="{items}" class="users" ref="users"> <button class="user _panel _button _gap" v-for="user in items" :key="user.id" @click="show(user)"> - <MkAvatar class="avatar" :user="user" :disable-link="true"/> + <MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/> <div class="body"> <header> <MkUserName class="name" :user="user"/> diff --git a/src/client/pages/messaging/index.vue b/src/client/pages/messaging/index.vue index e5ad6b01a5..1e316e9090 100644 --- a/src/client/pages/messaging/index.vue +++ b/src/client/pages/messaging/index.vue @@ -12,7 +12,7 @@ v-anim="i" > <div> - <MkAvatar class="avatar" :user="message.groupId ? message.user : isMe(message) ? message.recipient : message.user"/> + <MkAvatar class="avatar" :user="message.groupId ? message.user : isMe(message) ? message.recipient : message.user" :show-indicator="true"/> <header v-if="message.groupId"> <span class="name">{{ message.group.name }}</span> <MkTime :time="message.createdAt" class="time"/> diff --git a/src/client/pages/messaging/messaging-room.message.vue b/src/client/pages/messaging/messaging-room.message.vue index 8c275d5e33..1228baff68 100644 --- a/src/client/pages/messaging/messaging-room.message.vue +++ b/src/client/pages/messaging/messaging-room.message.vue @@ -1,6 +1,6 @@ <template> <div class="thvuemwp" :class="{ isMe }" v-size="{ max: [400, 500] }"> - <MkAvatar class="avatar" :user="message.user"/> + <MkAvatar class="avatar" :user="message.user" :show-indicator="true"/> <div class="content"> <div class="balloon" :class="{ noText: message.text == null }" > <button class="delete-button" v-if="isMe" :title="$ts.delete" @click="del"> diff --git a/src/client/pages/my-groups/group.vue b/src/client/pages/my-groups/group.vue index 0631118ca3..90a60e5e2b 100644 --- a/src/client/pages/my-groups/group.vue +++ b/src/client/pages/my-groups/group.vue @@ -17,7 +17,7 @@ <div class="_content"> <div class="users"> <div class="user _panel" v-for="user in users" :key="user.id"> - <MkAvatar :user="user" class="avatar"/> + <MkAvatar :user="user" class="avatar" :show-indicator="true"/> <div class="body"> <MkUserName :user="user" class="name"/> <MkAcct :user="user" class="acct"/> diff --git a/src/client/pages/my-lists/list.vue b/src/client/pages/my-lists/list.vue index 21c8a696b9..2892150ffe 100644 --- a/src/client/pages/my-lists/list.vue +++ b/src/client/pages/my-lists/list.vue @@ -16,7 +16,7 @@ <div class="_content"> <div class="users"> <div class="user _panel" v-for="user in users" :key="user.id"> - <MkAvatar :user="user" class="avatar"/> + <MkAvatar :user="user" class="avatar" :show-indicator="true"/> <div class="body"> <MkUserName :user="user" class="name"/> <MkAcct :user="user" class="acct"/> diff --git a/src/client/pages/note.vue b/src/client/pages/note.vue index 871bdd7200..279dd96661 100644 --- a/src/client/pages/note.vue +++ b/src/client/pages/note.vue @@ -1,37 +1,37 @@ <template> <div class="fcuexfpr _root"> - <div v-if="note" class="note" v-anim> - <div class="_gap" v-if="showNext"> - <XNotes class="_content _noGap_" :pagination="next"/> - </div> - - <div class="main _gap"> - <MkButton v-if="!showNext && hasNext" class="load next" @click="showNext = true"><Fa :icon="faChevronUp"/></MkButton> - <div class="_content _gap"> - <MkRemoteCaution v-if="note.user.host != null" :href="note.url || note.uri" class="_gap"/> - <XNoteDetailed v-model:note="note" :key="note.id" class="_gap"/> + <transition name="fade" mode="out-in"> + <div v-if="note" class="note"> + <div class="_gap" v-if="showNext"> + <XNotes class="_content" :pagination="next" :no-gap="true"/> </div> - <div class="_content clips _gap" v-if="clips && clips.length > 0"> - <div class="title">{{ $ts.clip }}</div> - <MkA v-for="item in clips" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap"> - <b>{{ item.name }}</b> - <div v-if="item.description" class="description">{{ item.description }}</div> - <div class="user"> - <MkAvatar :user="item.user" class="avatar"/> <MkUserName :user="item.user" :nowrap="false"/> - </div> - </MkA> + + <div class="main _gap"> + <MkButton v-if="!showNext && hasNext" class="load next" @click="showNext = true"><Fa :icon="faChevronUp"/></MkButton> + <div class="_content _gap"> + <MkRemoteCaution v-if="note.user.host != null" :href="note.url || note.uri" class="_gap"/> + <XNoteDetailed v-model:note="note" :key="note.id" class="_gap"/> + </div> + <div class="_content clips _gap" v-if="clips && clips.length > 0"> + <div class="title">{{ $ts.clip }}</div> + <MkA v-for="item in clips" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap"> + <b>{{ item.name }}</b> + <div v-if="item.description" class="description">{{ item.description }}</div> + <div class="user"> + <MkAvatar :user="item.user" class="avatar" :show-indicator="true"/> <MkUserName :user="item.user" :nowrap="false"/> + </div> + </MkA> + </div> + <MkButton v-if="!showPrev && hasPrev" class="load prev" @click="showPrev = true"><Fa :icon="faChevronDown"/></MkButton> </div> - <MkButton v-if="!showPrev && hasPrev" class="load prev" @click="showPrev = true"><Fa :icon="faChevronDown"/></MkButton> - </div> - <div class="_gap" v-if="showPrev"> - <XNotes class="_content _noGap_" :pagination="prev"/> + <div class="_gap" v-if="showPrev"> + <XNotes class="_content" :pagination="prev" :no-gap="true"/> + </div> </div> - </div> - - <div v-if="error"> - <MkError @retry="fetch()"/> - </div> + <MkError v-else-if="error" @retry="fetch()"/> + <MkLoading v-else/> + </transition> </div> </template> @@ -106,6 +106,7 @@ export default defineComponent({ }, methods: { fetch() { + this.note = null; os.api('notes/show', { noteId: this.noteId }).then(note => { @@ -138,6 +139,15 @@ export default defineComponent({ </script> <style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + .fcuexfpr { > .note { > .main { diff --git a/src/client/pages/page-editor/page-editor.vue b/src/client/pages/page-editor/page-editor.vue index f8f81541ff..4583863a1c 100644 --- a/src/client/pages/page-editor/page-editor.vue +++ b/src/client/pages/page-editor/page-editor.vue @@ -8,7 +8,7 @@ <MkButton inline @click="del" class="delete" v-if="pageId && !readonly"><Fa :icon="faTrashAlt"/> {{ $ts.delete }}</MkButton> </div> - <MkContainer :body-togglable="true" :expanded="true" class="_gap"> + <MkContainer :foldable="true" :expanded="true" class="_gap"> <template #header><Fa :icon="faCog"/> {{ $ts._pages.pageSetting }}</template> <div style="padding: 16px;"> <MkInput v-model:value="title"> @@ -44,7 +44,7 @@ </div> </MkContainer> - <MkContainer :body-togglable="true" :expanded="true" class="_gap"> + <MkContainer :foldable="true" :expanded="true" class="_gap"> <template #header><Fa :icon="faStickyNote"/> {{ $ts._pages.contents }}</template> <div style="padding: 16px;"> <XBlocks class="content" v-model:value="content" :hpml="hpml"/> @@ -53,7 +53,7 @@ </div> </MkContainer> - <MkContainer :body-togglable="true" class="_gap"> + <MkContainer :foldable="true" class="_gap"> <template #header><Fa :icon="faMagic"/> {{ $ts._pages.variables }}</template> <div class="qmuvgica"> <XDraggable tag="div" class="variables" v-show="variables.length > 0" v-model="variables" item-key="name" handle=".drag-handle" :group="{ name: 'variables' }" animation="150" swap-threshold="0.5"> @@ -74,7 +74,7 @@ </div> </MkContainer> - <MkContainer :body-togglable="true" :expanded="true" class="_gap"> + <MkContainer :foldable="true" :expanded="true" class="_gap"> <template #header><Fa :icon="faCode"/> {{ $ts.script }}</template> <div> <MkTextarea class="_code" v-model:value="script"/> diff --git a/src/client/pages/reversi/index.vue b/src/client/pages/reversi/index.vue index d590bbeb9f..59b228f5f6 100644 --- a/src/client/pages/reversi/index.vue +++ b/src/client/pages/reversi/index.vue @@ -11,7 +11,7 @@ <template #header>{{ $ts.invitations }}</template> <div class="nfcacttm"> <button class="invitation _panel _button" v-for="invitation in invitations" tabindex="-1" @click="accept(invitation)"> - <MkAvatar class="avatar" :user="invitation.parent"/> + <MkAvatar class="avatar" :user="invitation.parent" :show-indicator="true"/> <span class="name"><b><MkUserName :user="invitation.parent"/></b></span> <span class="username">@{{ invitation.parent.username }}</span> <MkTime :time="invitation.createdAt" class="time"/> diff --git a/src/client/pages/scratchpad.vue b/src/client/pages/scratchpad.vue index 3664765d02..1a863e6b2e 100644 --- a/src/client/pages/scratchpad.vue +++ b/src/client/pages/scratchpad.vue @@ -5,7 +5,7 @@ <MkButton style="position: absolute; top: 8px; right: 8px;" @click="run()" primary><Fa :icon="faPlay"/></MkButton> </div> - <MkContainer :body-togglable="true" class="_gap"> + <MkContainer :foldable="true" class="_gap"> <template #header><Fa fixed-width/>{{ $ts.output }}</template> <div class="bepmlvbi"> <div v-for="log in logs" class="log" :key="log.id" :class="{ print: log.print }">{{ log.text }}</div> diff --git a/src/client/pages/settings/privacy.vue b/src/client/pages/settings/privacy.vue index 0542c527f9..c8df378410 100644 --- a/src/client/pages/settings/privacy.vue +++ b/src/client/pages/settings/privacy.vue @@ -5,6 +5,10 @@ <FormSwitch v-model:value="autoAcceptFollowed" :disabled="!isLocked" @update:value="save()">{{ $ts.autoAcceptFollowed }}</FormSwitch> <template #caption>{{ $ts.lockedAccountInfo }}</template> </FormGroup> + <FormSwitch v-model:value="hideOnlineStatus" @update:value="save()"> + {{ $ts.hideOnlineStatus }} + <template #desc>{{ $ts.hideOnlineStatusDescription }}</template> + </FormSwitch> <FormSwitch v-model:value="noCrawle" @update:value="save()"> {{ $ts.noCrawle }} <template #desc>{{ $ts.noCrawleDescription }}</template> @@ -58,6 +62,7 @@ export default defineComponent({ autoAcceptFollowed: false, noCrawle: false, isExplorable: false, + hideOnlineStatus: false, } }, @@ -72,6 +77,7 @@ export default defineComponent({ this.autoAcceptFollowed = this.$i.autoAcceptFollowed; this.noCrawle = this.$i.noCrawle; this.isExplorable = this.$i.isExplorable; + this.hideOnlineStatus = this.$i.hideOnlineStatus; }, mounted() { @@ -85,6 +91,7 @@ export default defineComponent({ autoAcceptFollowed: !!this.autoAcceptFollowed, noCrawle: !!this.noCrawle, isExplorable: !!this.isExplorable, + hideOnlineStatus: !!this.hideOnlineStatus, }); } } diff --git a/src/client/pages/user-ap-info.vue b/src/client/pages/user-ap-info.vue new file mode 100644 index 0000000000..648ecdb10a --- /dev/null +++ b/src/client/pages/user-ap-info.vue @@ -0,0 +1,125 @@ +<template> +<FormBase> + <FormSuspense :p="apPromiseFactory" v-slot="{ result: ap }"> + <FormGroup> + <template #label>ActivityPub</template> + <FormKeyValueView> + <template #key>Type</template> + <template #value><span class="_monospace">{{ ap.type }}</span></template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>URI</template> + <template #value><span class="_monospace">{{ ap.id }}</span></template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>URL</template> + <template #value><span class="_monospace">{{ ap.url }}</span></template> + </FormKeyValueView> + <FormGroup> + <FormKeyValueView> + <template #key>Inbox</template> + <template #value><span class="_monospace">{{ ap.inbox }}</span></template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>Shared Inbox</template> + <template #value><span class="_monospace">{{ ap.sharedInbox || ap.endpoints.sharedInbox }}</span></template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>Outbox</template> + <template #value><span class="_monospace">{{ ap.outbox }}</span></template> + </FormKeyValueView> + </FormGroup> + <FormTextarea readonly tall code pre :value="ap.publicKey.publicKeyPem"> + <span>Public Key</span> + </FormTextarea> + <FormKeyValueView> + <template #key>Discoverable</template> + <template #value>{{ ap.discoverable ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>ManuallyApprovesFollowers</template> + <template #value>{{ ap.manuallyApprovesFollowers ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + <FormObjectView tall :value="ap"> + <span>Raw</span> + </FormObjectView> + <FormGroup> + <FormLink :to="`https://${user.host}/.well-known/webfinger?resource=acct:${user.username}`" external>WebFinger</FormLink> + </FormGroup> + <FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ $ts.instanceInfo }}<template #suffix>{{ user.host }}</template></FormLink> + <FormKeyValueView v-else> + <template #key>{{ $ts.instanceInfo }}</template> + <template #value>(Local user)</template> + </FormKeyValueView> + </FormGroup> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import { faInfoCircle } from '@fortawesome/free-solid-svg-icons'; +import FormObjectView from '@client/components/form/object-view.vue'; +import FormTextarea from '@client/components/form/textarea.vue'; +import FormLink from '@client/components/form/link.vue'; +import FormBase from '@client/components/form/base.vue'; +import FormGroup from '@client/components/form/group.vue'; +import FormButton from '@client/components/form/button.vue'; +import FormKeyValueView from '@client/components/form/key-value-view.vue'; +import FormSuspense from '@client/components/form/suspense.vue'; +import * as os from '@client/os'; +import number from '@client/filters/number'; +import bytes from '@client/filters/bytes'; +import * as symbols from '@client/symbols'; +import { url } from '@client/config'; + +export default defineComponent({ + components: { + FormBase, + FormTextarea, + FormObjectView, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + FormSuspense, + }, + + props: { + userId: { + type: String, + required: true + } + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.userInfo, + icon: faInfoCircle + }, + user: null, + apPromiseFactory: null, + } + }, + + mounted() { + this.fetch(); + }, + + methods: { + number, + bytes, + + async fetch() { + this.user = await os.api('users/show', { + userId: this.userId + }); + + this.apPromiseFactory = () => os.api('ap/get', { + uri: this.user.uri || `${url}/users/${this.user.id}` + }); + } + } +}); +</script> diff --git a/src/client/pages/user-info.vue b/src/client/pages/user-info.vue new file mode 100644 index 0000000000..06f2e4270d --- /dev/null +++ b/src/client/pages/user-info.vue @@ -0,0 +1,103 @@ +<template> +<FormBase> + <FormGroup v-if="user"> + <template #label><MkAcct :user="user"/></template> + + <FormKeyValueView> + <template #key>ID</template> + <template #value><span class="_monospace">{{ user.id }}</span></template> + </FormKeyValueView> + + <FormGroup> + <FormLink :to="`/user-ap-info/${user.id}`">ActivityPub</FormLink> + + <FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ $ts.instanceInfo }}<template #suffix>{{ user.host }}</template></FormLink> + <FormKeyValueView v-else> + <template #key>{{ $ts.instanceInfo }}</template> + <template #value>(Local user)</template> + </FormKeyValueView> + </FormGroup> + + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts.updatedAt }}</template> + <template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template> + </FormKeyValueView> + </FormGroup> + + <FormObjectView tall :value="user"> + <span>Raw</span> + </FormObjectView> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { computed, defineAsyncComponent, defineComponent } from 'vue'; +import { faExternalLinkAlt, faInfoCircle } from '@fortawesome/free-solid-svg-icons'; +import FormObjectView from '@client/components/form/object-view.vue'; +import FormTextarea from '@client/components/form/textarea.vue'; +import FormLink from '@client/components/form/link.vue'; +import FormBase from '@client/components/form/base.vue'; +import FormGroup from '@client/components/form/group.vue'; +import FormButton from '@client/components/form/button.vue'; +import FormKeyValueView from '@client/components/form/key-value-view.vue'; +import FormSuspense from '@client/components/form/suspense.vue'; +import * as os from '@client/os'; +import number from '@client/filters/number'; +import bytes from '@client/filters/bytes'; +import * as symbols from '@client/symbols'; +import { url } from '@client/config'; + +export default defineComponent({ + components: { + FormBase, + FormTextarea, + FormObjectView, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + FormSuspense, + }, + + props: { + userId: { + type: String, + required: true + } + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => ({ + title: this.$ts.userInfo, + icon: faInfoCircle, + actions: this.user ? [this.user.url ? { + text: this.user.url, + icon: faExternalLinkAlt, + handler: () => { + window.open(this.user.url, '_blank'); + } + } : undefined].filter(x => x !== undefined) : [], + })), + user: null, + } + }, + + mounted() { + this.fetch(); + }, + + methods: { + number, + bytes, + + async fetch() { + this.user = await os.api('users/show', { + userId: this.userId + }); + } + } +}); +</script> diff --git a/src/client/pages/user/index.photos.vue b/src/client/pages/user/index.photos.vue index 54796bccbc..21d84cef4f 100644 --- a/src/client/pages/user/index.photos.vue +++ b/src/client/pages/user/index.photos.vue @@ -1,14 +1,16 @@ <template> -<MkContainer> +<MkContainer :max-height="300" :foldable="true"> <template #header><Fa :icon="faImage" style="margin-right: 0.5em;"/>{{ $ts.images }}</template> <div class="ujigsodd"> <MkLoading v-if="fetching"/> <div class="stream" v-if="!fetching && images.length > 0"> <MkA v-for="image in images" class="img" - :style="`background-image: url(${thumbnail(image.file)})`" :to="notePage(image.note)" - ></MkA> + :key="image.id" + > + <ImgWithBlurhash :hash="image.blurhash" :src="thumbnail(image.file)" :alt="image.name" :title="image.name"/> + </MkA> </div> <p class="empty" v-if="!fetching && images.length == 0">{{ $ts.nothing }}</p> </div> @@ -22,10 +24,12 @@ import { getStaticImageUrl } from '@client/scripts/get-static-image-url'; import notePage from '../../filters/note'; import * as os from '@client/os'; import MkContainer from '@client/components/ui/container.vue'; +import ImgWithBlurhash from '@client/components/img-with-blurhash.vue'; export default defineComponent({ components: { MkContainer, + ImgWithBlurhash, }, props: { user: { @@ -52,16 +56,14 @@ export default defineComponent({ userId: this.user.id, fileType: image, excludeNsfw: this.$store.state.nsfw !== 'ignore', - limit: 9, + limit: 10, }).then(notes => { for (const note of notes) { for (const file of note.files) { - if (this.images.length < 9) { - this.images.push({ - note, - file - }); - } + this.images.push({ + note, + file + }); } } this.fetching = false; @@ -83,20 +85,14 @@ export default defineComponent({ padding: 8px; > .stream { - display: flex; - justify-content: center; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + grid-gap: 6px; > .img { - flex: 1 1 33%; - width: 33%; - height: 90px; - box-sizing: border-box; - background-position: center center; - background-size: cover; - background-clip: content-box; - border: solid 2px transparent; + height: 128px; border-radius: 6px; + overflow: clip; } } diff --git a/src/client/pages/user/index.timeline.vue b/src/client/pages/user/index.timeline.vue index 4941abdade..287e6c8b22 100644 --- a/src/client/pages/user/index.timeline.vue +++ b/src/client/pages/user/index.timeline.vue @@ -5,7 +5,7 @@ <option value="replies">{{ $ts.notesAndReplies }}</option> <option value="files">{{ $ts.withFiles }}</option> </MkTab> - <XNotes ref="timeline" class="_noGap_" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)"/> + <XNotes ref="timeline" :no-gap="true" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)"/> </div> </template> diff --git a/src/client/pages/user/index.vue b/src/client/pages/user/index.vue index e8d54402ec..92656fff2c 100644 --- a/src/client/pages/user/index.vue +++ b/src/client/pages/user/index.vue @@ -1,6 +1,6 @@ <template> -<div> - <div class="ftskorzw wide _section" v-if="user && narrow === false"> +<transition name="fade" mode="out-in"> + <div class="ftskorzw wide" v-if="user && narrow === false"> <MkRemoteCaution v-if="user.host != null" :href="user.url" class="_gap"/> <div class="banner-container _gap" :style="style"> @@ -8,7 +8,7 @@ </div> <div class="contents"> <div class="side _forceContainerFull_"> - <MkAvatar class="avatar" :user="user" :disable-preview="true"/> + <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/> <div class="name"> <MkUserName :user="user" :nowrap="false" class="name"/> <MkAcct :user="user" :detail="true" class="acct"/> @@ -121,7 +121,7 @@ <MkFollowButton v-if="$i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/> </div> </div> - <MkAvatar class="avatar" :user="user" :disable-preview="true"/> + <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/> <div class="title"> <MkUserName :user="user" :nowrap="false" class="name"/> <div class="bottom"> @@ -212,10 +212,9 @@ <XPages v-else-if="page === 'pages'" :user="user" class="_gap"/> </div> </div> - <div v-else-if="error"> - <MkError @retry="fetch()"/> - </div> -</div> + <MkError v-else-if="error" @retry="fetch()"/> + <MkLoading v-else/> +</transition> </template> <script lang="ts"> @@ -279,6 +278,7 @@ export default defineComponent({ share: { title: this.user.name, }, + menu: () => getUserMenu(this.user), } : null), user: null, error: null, @@ -323,6 +323,7 @@ export default defineComponent({ fetch() { if (this.acct == null) return; + this.user = null; Progress.start(); os.api('users/show', parseAcct(this.acct)).then(user => { this.user = user; @@ -368,6 +369,15 @@ export default defineComponent({ </script> <style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + .ftskorzw.wide { max-width: 1150px; margin: 0 auto; diff --git a/src/client/router.ts b/src/client/router.ts index 3effb2edbe..bf45c806e2 100644 --- a/src/client/router.ts +++ b/src/client/router.ts @@ -72,6 +72,9 @@ export const router = createRouter({ { path: '/instance/abuses', component: page('instance/abuses') }, { path: '/notes/:note', name: 'note', component: page('note'), props: route => ({ noteId: route.params.note }) }, { path: '/tags/:tag', component: page('tag'), props: route => ({ tag: route.params.tag }) }, + { path: '/user-info/:user', component: page('user-info'), props: route => ({ userId: route.params.user }) }, + { path: '/user-ap-info/:user', component: page('user-ap-info'), props: route => ({ userId: route.params.user }) }, + { path: '/instance-info/:host', component: page('instance-info'), props: route => ({ host: route.params.host }) }, { path: '/games/reversi', component: page('reversi/index') }, { path: '/games/reversi/:gameId', component: page('reversi/game'), props: route => ({ gameId: route.params.gameId }) }, { path: '/mfm-cheat-sheet', component: page('mfm-cheat-sheet') }, diff --git a/src/client/scripts/get-user-menu.ts b/src/client/scripts/get-user-menu.ts index 163eff619c..0496e87502 100644 --- a/src/client/scripts/get-user-menu.ts +++ b/src/client/scripts/get-user-menu.ts @@ -1,4 +1,4 @@ -import { faAt, faListUl, faEye, faEyeSlash, faBan, faPencilAlt, faComments, faUsers, faMicrophoneSlash, faPlug, faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; +import { faAt, faListUl, faEye, faEyeSlash, faBan, faPencilAlt, faComments, faUsers, faMicrophoneSlash, faPlug, faExclamationCircle, faInfoCircle } from '@fortawesome/free-solid-svg-icons'; import { faSnowflake, faEnvelope } from '@fortawesome/free-regular-svg-icons'; import { i18n } from '@client/i18n'; import copyToClipboard from '@client/scripts/copy-to-clipboard'; @@ -127,6 +127,12 @@ export function getUserMenu(user) { copyToClipboard(`@${user.username}@${user.host || host}`); } }, { + icon: faInfoCircle, + text: i18n.locale.info, + action: () => { + os.pageWindow(`/user-info/${user.id}`); + } + }, { icon: faEnvelope, text: i18n.locale.sendMessage, action: () => { diff --git a/src/client/style.scss b/src/client/style.scss index 63433109ff..eadf56bf37 100644 --- a/src/client/style.scss +++ b/src/client/style.scss @@ -26,6 +26,7 @@ html { background-position: center; color: var(--fg); overflow: auto; + overflow-wrap: break-word; font-family: "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif; line-height: 1.35; text-size-adjust: 100%; @@ -88,10 +89,6 @@ html._themeChanging_ { } } -body { - overflow-wrap: break-word; -} - html, body { margin: 0; padding: 0; @@ -458,7 +455,7 @@ hr { } ._monospace { - font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace; + font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace !important; } ._code { diff --git a/src/client/ui/_common_/header.vue b/src/client/ui/_common_/header.vue index a60e1df73f..493deaeb96 100644 --- a/src/client/ui/_common_/header.vue +++ b/src/client/ui/_common_/header.vue @@ -7,7 +7,7 @@ <div class="titleContainer"> <div class="title"> <Fa v-if="info.icon" :icon="info.icon" :key="info.icon" class="icon"/> - <MkAvatar v-else-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true"/> + <MkAvatar v-else-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/> <MkUserName v-if="info.userName" :user="info.userName" :nowrap="false" class="text"/> <span v-else-if="info.title" class="text">{{ info.title }}</span> </div> diff --git a/src/client/ui/chat/notes.vue b/src/client/ui/chat/notes.vue index 45c2bd17a1..3ced6d8b3c 100644 --- a/src/client/ui/chat/notes.vue +++ b/src/client/ui/chat/notes.vue @@ -5,6 +5,8 @@ <div>{{ $ts.noNotes }}</div> </div> + <MkLoading v-if="fetching"/> + <MkError v-if="error" @retry="init()"/> <div v-show="more && reversed" style="margin-bottom: var(--margin);"> diff --git a/src/client/ui/default.vue b/src/client/ui/default.vue index 1745f1f3d4..c3dce0f323 100644 --- a/src/client/ui/default.vue +++ b/src/client/ui/default.vue @@ -310,6 +310,7 @@ export default defineComponent({ > .widgets { //--panelShadow: none; + width: 300px; @media (max-width: $widgets-hide-threshold) { display: none; diff --git a/src/client/ui/default.widgets.vue b/src/client/ui/default.widgets.vue index b12de841a7..e0f85f2459 100644 --- a/src/client/ui/default.widgets.vue +++ b/src/client/ui/default.widgets.vue @@ -2,8 +2,8 @@ <div class="efzpzdvf"> <XWidgets :edit="editMode" :widgets="$store.reactiveState.widgets.value" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/> - <button v-if="editMode" @click="editMode = false" class="_textButton" style="font-size: 0.9em;"><Fa :icon="faCheck"/> {{ $ts.editWidgetsExit }}</button> - <button v-else @click="editMode = true" class="_textButton" style="font-size: 0.9em;"><Fa :icon="faPencilAlt"/> {{ $ts.editWidgets }}</button> + <button v-if="editMode" @click="editMode = false" class="_textButton edit" style="font-size: 0.9em;"><Fa :icon="faCheck"/> {{ $ts.editWidgetsExit }}</button> + <button v-else @click="editMode = true" class="_textButton edit" style="font-size: 0.9em;"><Fa :icon="faPencilAlt"/> {{ $ts.editWidgets }}</button> </div> </template> @@ -62,18 +62,11 @@ export default defineComponent({ position: sticky; height: min-content; box-sizing: border-box; + padding-bottom: 8px; - > * { - margin: var(--margin) 0; - width: 300px; - - &:first-child { - margin-top: 0; - } - } - - > .add { - margin: 0 auto; + > .edit { + display: block; + margin: 16px auto; } } </style> diff --git a/src/client/ui/visitor/header.vue b/src/client/ui/visitor/header.vue index a66c4d5fb0..42598ce1c0 100644 --- a/src/client/ui/visitor/header.vue +++ b/src/client/ui/visitor/header.vue @@ -9,7 +9,7 @@ <div class="page active link" v-if="info"> <div class="title"> <Fa v-if="info.icon" :icon="info.icon" :key="info.icon" class="icon"/> - <MkAvatar v-else-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true"/> + <MkAvatar v-else-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/> <span v-if="info.title" class="text">{{ info.title }}</span> <MkUserName v-else-if="info.userName" :user="info.userName" :nowrap="false" class="text"/> </div> @@ -28,7 +28,7 @@ </button> <div class="title" v-if="info"> <Fa v-if="info.icon" :icon="info.icon" :key="info.icon" class="icon"/> - <MkAvatar v-else-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true"/> + <MkAvatar v-else-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/> <span v-if="info.title" class="text">{{ info.title }}</span> <MkUserName v-else-if="info.userName" :user="info.userName" :nowrap="false" class="text"/> </div> diff --git a/src/client/widgets/federation.vue b/src/client/widgets/federation.vue index f0a79a31a6..eb17915f08 100644 --- a/src/client/widgets/federation.vue +++ b/src/client/widgets/federation.vue @@ -1,5 +1,5 @@ <template> -<MkContainer :show-header="props.showHeader" :body-togglable="bodyTogglable" :scrollable="scrollable"> +<MkContainer :show-header="props.showHeader" :foldable="foldable" :scrollable="scrollable"> <template #header><Fa :icon="faGlobe"/>{{ $ts._widgets.federation }}</template> <div class="wbrkwalb"> @@ -42,7 +42,7 @@ export default defineComponent({ MkContainer, MkMiniChart }, props: { - bodyTogglable: { + foldable: { type: Boolean, required: false, default: false diff --git a/src/const.ts b/src/const.ts new file mode 100644 index 0000000000..43f59f1e4f --- /dev/null +++ b/src/const.ts @@ -0,0 +1,2 @@ +export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min +export const USER_ACTIVE_THRESHOLD = 1000 * 60 * 60 * 24 * 3; // 3days diff --git a/src/models/entities/user.ts b/src/models/entities/user.ts index 91fbe35d94..060ec06b9a 100644 --- a/src/models/entities/user.ts +++ b/src/models/entities/user.ts @@ -26,6 +26,17 @@ export class User { }) public lastFetchedAt: Date | null; + @Index() + @Column('timestamp with time zone', { + nullable: true + }) + public lastActiveDate: Date | null; + + @Column('boolean', { + default: false, + }) + public hideOnlineStatus: boolean; + @Column('varchar', { length: 128, comment: 'The username of the User.' diff --git a/src/models/repositories/user.ts b/src/models/repositories/user.ts index bb084f0245..0d59ed2545 100644 --- a/src/models/repositories/user.ts +++ b/src/models/repositories/user.ts @@ -7,6 +7,7 @@ import { SchemaType } from '@/misc/schema'; import { awaitAll } from '../../prelude/await-all'; import { populateEmojis } from '@/misc/populate-emojis'; import { getAntennas } from '@/misc/antenna-cache'; +import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const'; export type PackedUser = SchemaType<typeof packedUserSchema>; @@ -145,6 +146,17 @@ export class UserRepository extends Repository<User> { return count > 0; } + public getOnlineStatus(user: User): string { + if (user.hideOnlineStatus == null) return 'unknown'; + if (user.lastActiveDate == null) return 'unknown'; + const elapsed = Date.now() - user.lastActiveDate.getTime(); + return ( + elapsed < USER_ONLINE_THRESHOLD ? 'online' : + elapsed < USER_ACTIVE_THRESHOLD ? 'active' : + 'offline' + ); + } + public async pack( src: User['id'] | User, me?: { id: User['id'] } | null | undefined, @@ -192,11 +204,14 @@ export class UserRepository extends Repository<User> { themeColor: instance.themeColor, } : undefined) : undefined, emojis: populateEmojis(user.emojis, user.host), + onlineStatus: this.getOnlineStatus(user), ...(opts.detail ? { url: profile!.url, + uri: user.uri, createdAt: user.createdAt.toISOString(), updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null, + lastFetchedAt: user.lastFetchedAt?.toISOString(), bannerUrl: user.bannerUrl, bannerBlurhash: user.bannerBlurhash, bannerColor: null, // 後方互換性のため @@ -237,6 +252,7 @@ export class UserRepository extends Repository<User> { autoAcceptFollowed: profile!.autoAcceptFollowed, noCrawle: profile!.noCrawle, isExplorable: user.isExplorable, + hideOnlineStatus: user.hideOnlineStatus, hasUnreadSpecifiedNotes: NoteUnreads.count({ where: { userId: user.id, isSpecified: true }, take: 1 diff --git a/src/server/api/endpoints/ap/get.ts b/src/server/api/endpoints/ap/get.ts new file mode 100644 index 0000000000..c889d472bb --- /dev/null +++ b/src/server/api/endpoints/ap/get.ts @@ -0,0 +1,38 @@ +import $ from 'cafy'; +import define from '../../define'; +import Resolver from '../../../../remote/activitypub/resolver'; +import { ApiError } from '../../error'; + +export const meta = { + tags: ['federation'], + + desc: { + 'ja-JP': 'URIを指定してActivityPubオブジェクトを参照します。', + 'en-US': 'Browse to the ActivityPub object by specifying the URI.' + }, + + requireCredential: false as const, + + params: { + uri: { + validator: $.str, + desc: { + 'ja-JP': 'ActivityPubオブジェクトのURI' + } + }, + }, + + errors: { + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + } +}; + +export default define(meta, async (ps) => { + const resolver = new Resolver(); + const object = await resolver.resolve(ps.uri); + return object; +}); diff --git a/src/server/api/endpoints/federation/dns.ts b/src/server/api/endpoints/federation/dns.ts new file mode 100644 index 0000000000..a188f46ac1 --- /dev/null +++ b/src/server/api/endpoints/federation/dns.ts @@ -0,0 +1,43 @@ +import { promises as dns } from 'dns'; +import $ from 'cafy'; +import define from '../../define'; +import { Instances } from '../../../../models'; +import { toPuny } from '@/misc/convert-host'; + +const resolver = new dns.Resolver(); +resolver.setServers(['1.1.1.1']); + +export const meta = { + tags: ['federation'], + + requireCredential: false as const, + + params: { + host: { + validator: $.str + } + }, +}; + +export default define(meta, async (ps, me) => { + const instance = await Instances.findOneOrFail({ host: toPuny(ps.host) }); + + const [ + resolved4, + resolved6, + resolvedCname, + resolvedTxt, + ] = await Promise.all([ + resolver.resolve4(instance.host).catch(() => []), + resolver.resolve6(instance.host).catch(() => []), + resolver.resolveCname(instance.host).catch(() => []), + resolver.resolveTxt(instance.host).catch(() => []), + ]); + + return { + a: resolved4, + aaaa: resolved6, + cname: resolvedCname, + txt: resolvedTxt, + }; +}); diff --git a/src/server/api/endpoints/get-online-users-count.ts b/src/server/api/endpoints/get-online-users-count.ts index 150ac9e365..a13363055f 100644 --- a/src/server/api/endpoints/get-online-users-count.ts +++ b/src/server/api/endpoints/get-online-users-count.ts @@ -1,6 +1,7 @@ +import { USER_ONLINE_THRESHOLD } from '@/const'; +import { Users } from '@/models'; +import { MoreThan } from 'typeorm'; import define from '../define'; -import { redisClient } from '../../../db/redis'; -import config from '@/config'; export const meta = { tags: ['meta'], @@ -11,12 +12,12 @@ export const meta = { } }; -export default define(meta, (ps, user) => { - return new Promise((res, rej) => { - redisClient.pubsub('numsub', config.host, (_, x) => { - res({ - count: x[1] - }); - }); +export default define(meta, async () => { + const count = await Users.count({ + lastActiveDate: MoreThan(new Date(Date.now() - USER_ONLINE_THRESHOLD)) }); + + return { + count + }; }); diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts index c0ffd75e23..032dccd91a 100644 --- a/src/server/api/endpoints/i/update.ts +++ b/src/server/api/endpoints/i/update.ts @@ -96,6 +96,10 @@ export const meta = { validator: $.optional.bool, }, + hideOnlineStatus: { + validator: $.optional.bool, + }, + carefulBot: { validator: $.optional.bool, desc: { @@ -228,6 +232,7 @@ export default define(meta, async (ps, _user, token) => { if (ps.mutingNotificationTypes !== undefined) profileUpdates.mutingNotificationTypes = ps.mutingNotificationTypes as typeof notificationTypes[number][]; if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked; if (typeof ps.isExplorable === 'boolean') updates.isExplorable = ps.isExplorable; + if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus; if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot; if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot; if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed; diff --git a/src/server/api/streaming.ts b/src/server/api/streaming.ts index 81b83edcf5..7224c23570 100644 --- a/src/server/api/streaming.ts +++ b/src/server/api/streaming.ts @@ -6,6 +6,7 @@ import { ParsedUrlQuery } from 'querystring'; import authenticate from './authenticate'; import { EventEmitter } from 'events'; import { subsdcriber as redisClient } from '../../db/redis'; +import { Users } from '@/models'; module.exports = (server: http.Server) => { // Init websocket server @@ -45,5 +46,11 @@ module.exports = (server: http.Server) => { connection.send('pong'); } }); + + if (user) { + Users.update(user.id, { + lastActiveDate: new Date(), + }); + } }); }; |