diff options
Diffstat (limited to 'src/client')
23 files changed, 272 insertions, 84 deletions
diff --git a/src/client/components/note-detailed.vue b/src/client/components/note-detailed.vue index 1108bd2c27..434dd56ba3 100644 --- a/src/client/components/note-detailed.vue +++ b/src/client/components/note-detailed.vue @@ -756,7 +756,13 @@ export default defineComponent({ }; if (isLink(e.target)) return; if (window.getSelection().toString() !== '') return; - os.contextMenu(this.getMenu(), e).then(this.focus); + + if (this.$store.state.useReactionPickerForContextMenu) { + e.preventDefault(); + this.react(); + } else { + os.contextMenu(this.getMenu(), e).then(this.focus); + } }, menu(viaKeyboard = false) { diff --git a/src/client/components/note.vue b/src/client/components/note.vue index d532289857..24c374869d 100644 --- a/src/client/components/note.vue +++ b/src/client/components/note.vue @@ -731,7 +731,13 @@ export default defineComponent({ }; if (isLink(e.target)) return; if (window.getSelection().toString() !== '') return; - os.contextMenu(this.getMenu(), e).then(this.focus); + + if (this.$store.state.useReactionPickerForContextMenu) { + e.preventDefault(); + this.react(); + } else { + os.contextMenu(this.getMenu(), e).then(this.focus); + } }, menu(viaKeyboard = false) { diff --git a/src/client/components/notes.vue b/src/client/components/notes.vue index bd6d5bb4f5..332f00e5db 100644 --- a/src/client/components/notes.vue +++ b/src/client/components/notes.vue @@ -8,10 +8,10 @@ <MkError v-if="error" @retry="init()"/> <div v-show="more && reversed" style="margin-bottom: var(--margin);"> - <button class="_buttonPrimary" @click="fetchMoreFeature" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <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> - </button> + </MkButton> </div> <XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed"> @@ -19,10 +19,10 @@ </XList> <div v-show="more && !reversed" style="margin-top: var(--margin);"> - <button class="_buttonPrimary" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <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> - </button> + </MkButton> </div> </div> </template> @@ -32,10 +32,11 @@ import { defineComponent } from 'vue'; import paging from '@/scripts/paging'; import XNote from './note.vue'; import XList from './date-separated-list.vue'; +import MkButton from '@/components/ui/button.vue'; export default defineComponent({ components: { - XNote, XList, + XNote, XList, MkButton, }, mixins: [ diff --git a/src/client/components/post-form.vue b/src/client/components/post-form.vue index fa9aeff8af..7849095ba8 100644 --- a/src/client/components/post-form.vue +++ b/src/client/components/post-form.vue @@ -70,6 +70,7 @@ import * as os from '@/os'; import { selectFile } from '@/scripts/select-file'; import { notePostInterruptors, postFormActions } from '@/store'; import { isMobile } from '@/scripts/is-mobile'; +import { throttle } from 'throttle-debounce'; export default defineComponent({ components: { @@ -144,6 +145,11 @@ export default defineComponent({ quoteId: null, recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'), imeText: '', + typing: throttle(3000, () => { + if (this.channel) { + os.stream.send('typingOnChannel', { channel: this.channel.id }); + } + }), postFormActions, faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faAt, faBiohazard, faPlug }; @@ -434,10 +440,12 @@ export default defineComponent({ onKeydown(e: KeyboardEvent) { if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post(); if (e.which === 27) this.$emit('esc'); + this.typing(); }, onCompositionUpdate(e: CompositionEvent) { this.imeText = e.data; + this.typing(); }, onCompositionEnd(e: CompositionEvent) { diff --git a/src/client/components/ui/modal.vue b/src/client/components/ui/modal.vue index 69a83e002c..405fa4aaa5 100644 --- a/src/client/components/ui/modal.vue +++ b/src/client/components/ui/modal.vue @@ -70,6 +70,7 @@ export default defineComponent({ // TODO: ResizeObserver無くしたい new ResizeObserver((entries, observer) => { const rect = this.src.getBoundingClientRect(); + const width = popover.offsetWidth; const height = popover.offsetHeight; diff --git a/src/client/init.ts b/src/client/init.ts index c60b25359b..ce12849770 100644 --- a/src/client/init.ts +++ b/src/client/init.ts @@ -63,6 +63,9 @@ import { reloadChannel } from '@/scripts/unison-reload'; console.info(`Misskey v${version}`); +// boot.jsのやつを解除 +window.onerror = null; + if (_DEV_) { console.warn('Development mode!!!'); diff --git a/src/client/pages/messaging/messaging-room.form.vue b/src/client/pages/messaging/messaging-room.form.vue index e561cb3db5..258300dc52 100644 --- a/src/client/pages/messaging/messaging-room.form.vue +++ b/src/client/pages/messaging/messaging-room.form.vue @@ -7,6 +7,7 @@ v-model="text" ref="text" @keypress="onKeypress" + @compositionupdate="onCompositionUpdate" @paste="onPaste" :placeholder="$ts.inputMessageHere" ></textarea> @@ -29,6 +30,7 @@ import { formatTimeString } from '../../../misc/format-time-string'; import { selectFile } from '@/scripts/select-file'; import * as os from '@/os'; import { Autocomplete } from '@/scripts/autocomplete'; +import { throttle } from 'throttle-debounce'; export default defineComponent({ props: { @@ -46,6 +48,9 @@ export default defineComponent({ text: null, file: null, sending: false, + typing: throttle(3000, () => { + os.stream.send('typingOnMessaging', this.user ? { partner: this.user.id } : { group: this.group.id }); + }), faPaperPlane, faPhotoVideo, faLaughSquint }; }, @@ -147,11 +152,16 @@ export default defineComponent({ }, onKeypress(e) { + this.typing(); if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && this.canSend) { this.send(); } }, + onCompositionUpdate() { + this.typing(); + }, + chooseFile(e) { selectFile(e.currentTarget || e.target, this.$ts.selectFile, false).then(file => { this.file = file; diff --git a/src/client/pages/messaging/messaging-room.vue b/src/client/pages/messaging/messaging-room.vue index 7fdd0a201b..3921a081d1 100644 --- a/src/client/pages/messaging/messaging-room.vue +++ b/src/client/pages/messaging/messaging-room.vue @@ -16,6 +16,14 @@ </XList> </div> <footer> + <div class="typers" v-if="typers.length > 0"> + <I18n :src="$ts.typingUsers" text-tag="span" class="users"> + <template #users> + <b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b> + </template> + </I18n> + <MkEllipsis/> + </div> <transition name="fade"> <div class="new-message" v-show="showIndicator"> <button class="_buttonPrimary" @click="onIndicatorClick"><i><Fa :icon="faArrowCircleDown"/></i>{{ $ts.newMessageExists }}</button> @@ -86,6 +94,7 @@ const Component = defineComponent({ connection: null, showIndicator: false, timer: null, + typers: [], ilObserver: new IntersectionObserver( (entries) => entries.some((entry) => entry.isIntersecting) && !this.fetching @@ -142,6 +151,9 @@ const Component = defineComponent({ this.connection.on('message', this.onMessage); this.connection.on('read', this.onRead); this.connection.on('deleted', this.onDeleted); + this.connection.on('typers', typers => { + this.typers = typers.filter(u => u.id !== this.$i.id); + }); document.addEventListener('visibilitychange', this.onVisibilitychange); @@ -397,6 +409,7 @@ export default Component; > footer { width: 100%; + position: relative; > .new-message { position: absolute; @@ -422,6 +435,25 @@ export default Component; } } } + + > .typers { + position: absolute; + bottom: 100%; + padding: 0 8px 0 8px; + font-size: 0.9em; + color: var(--fgTransparentWeak); + + > .users { + > .user + .user:before { + content: ", "; + font-weight: normal; + } + + > .user:last-of-type:after { + content: " "; + } + } + } } } diff --git a/src/client/pages/settings/email-address.vue b/src/client/pages/settings/email-address.vue index 4aed9bf4c7..8ca0f119c5 100644 --- a/src/client/pages/settings/email-address.vue +++ b/src/client/pages/settings/email-address.vue @@ -60,7 +60,7 @@ export default defineComponent({ } }).then(({ canceled, result: password }) => { if (canceled) return; - os.api('i/update-email', { + os.apiWithDialog('i/update-email', { password: password, email: this.emailAddress, }); diff --git a/src/client/pages/settings/general.vue b/src/client/pages/settings/general.vue index 0e741d474c..90ff3e2c20 100644 --- a/src/client/pages/settings/general.vue +++ b/src/client/pages/settings/general.vue @@ -19,6 +19,7 @@ <template #label>{{ $ts.behavior }}</template> <FormSwitch v-model:value="imageNewTab">{{ $ts.openImageInNewTab }}</FormSwitch> <FormSwitch v-model:value="enableInfiniteScroll">{{ $ts.enableInfiniteScroll }}</FormSwitch> + <FormSwitch v-model:value="useReactionPickerForContextMenu">{{ $ts.useReactionPickerForContextMenu }}</FormSwitch> <FormSwitch v-model:value="disablePagesScript">{{ $ts.disablePagesScript }}</FormSwitch> </FormGroup> @@ -144,6 +145,7 @@ export default defineComponent({ chatOpenBehavior: ColdDeviceStorage.makeGetterSetter('chatOpenBehavior'), instanceTicker: defaultStore.makeGetterSetter('instanceTicker'), enableInfiniteScroll: defaultStore.makeGetterSetter('enableInfiniteScroll'), + useReactionPickerForContextMenu: defaultStore.makeGetterSetter('useReactionPickerForContextMenu'), }, watch: { diff --git a/src/client/scripts/paging.ts b/src/client/scripts/paging.ts index a8f122412c..6e3da94124 100644 --- a/src/client/scripts/paging.ts +++ b/src/client/scripts/paging.ts @@ -192,8 +192,6 @@ export default (opts) => ({ this.items = this.items.slice(-opts.displayLimit); this.more = true; } - } else { - } this.items.push(item); // TODO diff --git a/src/client/store.ts b/src/client/store.ts index bf042d8ab4..528e563fdd 100644 --- a/src/client/store.ts +++ b/src/client/store.ts @@ -144,6 +144,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: true }, + useReactionPickerForContextMenu: { + where: 'device', + default: true + }, showGapBetweenNotesInTimeline: { where: 'device', default: true diff --git a/src/client/ui/chat/date-separated-list.vue b/src/client/ui/chat/date-separated-list.vue index b209330656..65deb9e1c2 100644 --- a/src/client/ui/chat/date-separated-list.vue +++ b/src/client/ui/chat/date-separated-list.vue @@ -32,7 +32,7 @@ export default defineComponent({ }); } - return h(TransitionGroup, { + return h(this.reversed ? 'div' : TransitionGroup, { class: 'hmjzthxl', name: this.reversed ? 'list-reversed' : 'list', tag: 'div', diff --git a/src/client/ui/chat/header-clock.vue b/src/client/ui/chat/header-clock.vue index 65573d460b..3488289c21 100644 --- a/src/client/ui/chat/header-clock.vue +++ b/src/client/ui/chat/header-clock.vue @@ -1,12 +1,15 @@ <template> -<div class="_monospace"> - <span> +<div class="acemodlh _monospace"> + <div> + <span v-text="y"></span>/<span v-text="m"></span>/<span v-text="d"></span> + </div> + <div> <span v-text="hh"></span> <span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span> <span v-text="mm"></span> <span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span> <span v-text="ss"></span> - </span> + </div> </div> </template> @@ -18,6 +21,9 @@ export default defineComponent({ data() { return { clock: null, + y: null, + m: null, + d: null, hh: null, mm: null, ss: null, @@ -34,6 +40,9 @@ export default defineComponent({ methods: { tick() { const now = new Date(); + this.y = now.getFullYear().toString(); + this.m = (now.getMonth() + 1).toString().padStart(2, '0'); + this.d = now.getDate().toString().padStart(2, '0'); this.hh = now.getHours().toString().padStart(2, '0'); this.mm = now.getMinutes().toString().padStart(2, '0'); this.ss = now.getSeconds().toString().padStart(2, '0'); @@ -42,3 +51,12 @@ export default defineComponent({ } }); </script> + +<style lang="scss" scoped> +.acemodlh { + opacity: 0.7; + font-size: 0.85em; + line-height: 1em; + text-align: center; +} +</style> diff --git a/src/client/ui/chat/index.vue b/src/client/ui/chat/index.vue index 44f47447a7..26c81a1aa9 100644 --- a/src/client/ui/chat/index.vue +++ b/src/client/ui/chat/index.vue @@ -99,6 +99,9 @@ <div class="right"> <div class="instance">{{ instanceName }}</div> <XHeaderClock class="clock"/> + <button class="_button button timetravel" @click="timetravel" v-tooltip="$ts.jumpToSpecifiedDate"> + <Fa :icon="faCalendarAlt"/> + </button> <button class="_button button search" v-if="tl.startsWith('channel:') && currentChannel" @click="inChannelSearch" v-tooltip="$ts.inChannelSearch"> <Fa :icon="faSearch"/> </button> @@ -114,14 +117,9 @@ </button> </div> </header> - <div class="body"> - <XTimeline v-if="tl.startsWith('channel:')" src="channel" :key="tl" :channel="tl.replace('channel:', '')"/> - <XTimeline v-else :src="tl" :key="tl"/> - </div> - <footer class="footer"> - <XPostForm v-if="tl.startsWith('channel:')" :key="tl" :channel="tl.replace('channel:', '')"/> - <XPostForm v-else/> - </footer> + + <XTimeline class="body" ref="tl" v-if="tl.startsWith('channel:')" src="channel" :key="tl" :channel="tl.replace('channel:', '')"/> + <XTimeline class="body" ref="tl" v-else :src="tl" :key="tl"/> </main> <XSide class="side" ref="side" @open="sideViewOpening = true" @close="sideViewOpening = false"/> @@ -136,20 +134,20 @@ <script lang="ts"> import { defineComponent, defineAsyncComponent } from 'vue'; import { faLayerGroup, faBars, faHome, faCircle, faWindowMaximize, faColumns, faPencilAlt, faShareAlt, faSatelliteDish, faListUl, faSatellite, faCog, faSearch, faPlus, faStar, faAt, faLink, faEllipsisH, faGlobe } from '@fortawesome/free-solid-svg-icons'; -import { faBell, faStar as farStar, faEnvelope, faComments } from '@fortawesome/free-regular-svg-icons'; +import { faBell, faStar as farStar, faEnvelope, faComments, faCalendarAlt } from '@fortawesome/free-regular-svg-icons'; import { instanceName, url } from '@/config'; import XSidebar from '@/components/sidebar.vue'; import XWidgets from './widgets.vue'; import XCommon from '../_common_/common.vue'; import XSide from './side.vue'; import XTimeline from './timeline.vue'; -import XPostForm from './post-form.vue'; import XHeaderClock from './header-clock.vue'; import * as os from '@/os'; import { router } from '@/router'; import { sidebarDef } from '@/sidebar'; import { search } from '@/scripts/search'; import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { store } from './store'; export default defineComponent({ components: { @@ -158,7 +156,6 @@ export default defineComponent({ XWidgets, XSide, // NOTE: dynamic importするとAsyncComponentWrapperが間に入るせいでref取得できなくて面倒になる XTimeline, - XPostForm, XHeaderClock, }, @@ -189,7 +186,7 @@ export default defineComponent({ data() { return { - tl: 'home', + tl: store.state.tl, lists: null, antennas: null, followedChannels: null, @@ -198,7 +195,7 @@ export default defineComponent({ menuDef: sidebarDef, sideViewOpening: false, instanceName, - faLayerGroup, faBars, faBell, faHome, faCircle, faPencilAlt, faShareAlt, faSatelliteDish, faListUl, faSatellite, faCog, faSearch, faPlus, faStar, farStar, faAt, faLink, faEllipsisH, faGlobe, faComments, faEnvelope, + faLayerGroup, faBars, faBell, faHome, faCircle, faPencilAlt, faShareAlt, faSatelliteDish, faListUl, faSatellite, faCog, faSearch, faPlus, faStar, farStar, faAt, faLink, faEllipsisH, faGlobe, faComments, faEnvelope, faCalendarAlt, }; }, @@ -222,11 +219,12 @@ export default defineComponent({ this.antennas = antennas; }); - os.api('channels/followed').then(channels => { + os.api('channels/followed', { limit: 20 }).then(channels => { this.followedChannels = channels; }); - os.api('channels/featured').then(channels => { + // TODO: pagination + os.api('channels/featured', { limit: 20 }).then(channels => { this.featuredChannels = channels; }); @@ -236,6 +234,7 @@ export default defineComponent({ this.currentChannel = channel; }); } + store.set('tl', this.tl); }, { immediate: true }); }, @@ -248,6 +247,18 @@ export default defineComponent({ os.post(); }, + async timetravel() { + const { canceled, result: date } = await os.dialog({ + title: this.$ts.date, + input: { + type: 'date' + } + }); + if (canceled) return; + + this.$refs.tl.timetravel(new Date(date)); + }, + search() { search(); }, @@ -470,6 +481,9 @@ export default defineComponent({ display: block; padding: 6px 8px; border-radius: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; &:hover { text-decoration: none; @@ -581,16 +595,6 @@ export default defineComponent({ } } } - - > .footer { - padding: 0 16px 16px 16px; - } - - > .body { - flex: 1; - min-width: 0; - overflow: auto; - } } > .side { diff --git a/src/client/ui/chat/note.vue b/src/client/ui/chat/note.vue index f4c9f063dc..9312b99d27 100644 --- a/src/client/ui/chat/note.vue +++ b/src/client/ui/chat/note.vue @@ -741,7 +741,13 @@ export default defineComponent({ }; if (isLink(e.target)) return; if (window.getSelection().toString() !== '') return; - os.contextMenu(this.getMenu(), e).then(this.focus); + + if (this.$store.state.useReactionPickerForContextMenu) { + e.preventDefault(); + this.react(); + } else { + os.contextMenu(this.getMenu(), e).then(this.focus); + } }, menu(viaKeyboard = false) { @@ -1004,7 +1010,7 @@ export default defineComponent({ flex-shrink: 0; display: block; position: sticky; - top: 12px; + top: 0; margin: 0 14px 0 0; width: 46px; height: 46px; @@ -1085,6 +1091,7 @@ export default defineComponent({ > .poll { font-size: 80%; + max-width: 500px; } > .renote { diff --git a/src/client/ui/chat/notes.vue b/src/client/ui/chat/notes.vue index 1fa2870cee..3a169cc20a 100644 --- a/src/client/ui/chat/notes.vue +++ b/src/client/ui/chat/notes.vue @@ -1,5 +1,5 @@ <template> -<div class="" :ref="mounted"> +<div class=""> <div class="_fullinfo" v-if="empty"> <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> <div>{{ $ts.noNotes }}</div> @@ -8,10 +8,10 @@ <MkError v-if="error" @retry="init()"/> <div v-show="more && reversed" style="margin-bottom: var(--margin);"> - <button class="_buttonPrimary" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <MkButton style="margin: 0 auto;" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> <template v-if="!moreFetching">{{ $ts.loadMore }}</template> <template v-if="moreFetching"><MkLoading inline/></template> - </button> + </MkButton> </div> <XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed"> @@ -19,10 +19,10 @@ </XList> <div v-show="more && !reversed" style="margin-top: var(--margin);"> - <button class="_buttonPrimary" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <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> - </button> + </MkButton> </div> </div> </template> @@ -32,10 +32,11 @@ import { defineComponent } from 'vue'; import paging from '@/scripts/paging'; import XNote from './note.vue'; import XList from './date-separated-list.vue'; +import MkButton from '@/components/ui/button.vue'; export default defineComponent({ components: { - XNote, XList, + XNote, XList, MkButton, }, mixins: [ diff --git a/src/client/ui/chat/post-form.vue b/src/client/ui/chat/post-form.vue index 38fe48cc62..b0a31b097d 100644 --- a/src/client/ui/chat/post-form.vue +++ b/src/client/ui/chat/post-form.vue @@ -65,6 +65,7 @@ import * as os from '@/os'; import { selectFile } from '@/scripts/select-file'; import { notePostInterruptors, postFormActions } from '@/store'; import { isMobile } from '@/scripts/is-mobile'; +import { throttle } from 'throttle-debounce'; export default defineComponent({ components: { @@ -131,6 +132,11 @@ export default defineComponent({ quoteId: null, recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'), imeText: '', + typing: throttle(3000, () => { + if (this.channel) { + os.stream.send('typingOnChannel', { channel: this.channel }); + } + }), postFormActions, faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faAt, faBiohazard, faPlug }; @@ -421,10 +427,12 @@ export default defineComponent({ onKeydown(e: KeyboardEvent) { if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post(); if (e.which === 27) this.$emit('esc'); + this.typing(); }, onCompositionUpdate(e: CompositionEvent) { this.imeText = e.data; + this.typing(); }, onCompositionEnd(e: CompositionEvent) { diff --git a/src/client/ui/chat/store.ts b/src/client/ui/chat/store.ts index a869debd61..389d56afb6 100644 --- a/src/client/ui/chat/store.ts +++ b/src/client/ui/chat/store.ts @@ -10,4 +10,8 @@ export const store = markRaw(new Storage('chatUi', { data: Record<string, any>; }[] }, + tl: { + where: 'deviceAccount', + default: 'home' + }, })); diff --git a/src/client/ui/chat/timeline.vue b/src/client/ui/chat/timeline.vue index f96a48a776..232e749c1c 100644 --- a/src/client/ui/chat/timeline.vue +++ b/src/client/ui/chat/timeline.vue @@ -1,8 +1,25 @@ <template> -<div class="dbiokgaf"> +<div class="dbiokgaf info" v-if="date"> + <MkInfo>{{ $ts.showingPastTimeline }} <button class="_textButton clear" @click="timetravel()">{{ $ts.clear }}</button></MkInfo> +</div> +<div class="dbiokgaf top" v-if="['home', 'local', 'social', 'global'].includes(src)"> + <XPostForm/> +</div> +<div class="dbiokgaf tl" ref="body"> <div class="new" v-if="queue > 0" :style="{ width: width + 'px', [pagination.reversed ? 'bottom' : 'top']: pagination.reversed ? bottom + 'px' : top + 'px' }"><button class="_buttonPrimary" @click="goTop()">{{ $ts.newNoteRecived }}</button></div> <XNotes class="tl" ref="tl" :pagination="pagination" @queue="queueUpdated" v-follow="pagination.reversed"/> </div> +<div class="dbiokgaf bottom" v-if="src === 'channel'"> + <div class="typers" v-if="typers.length > 0"> + <I18n :src="$ts.typingUsers" text-tag="span" class="users"> + <template #users> + <b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b> + </template> + </I18n> + <MkEllipsis/> + </div> + <XPostForm :channel="channel"/> +</div> </template> <script lang="ts"> @@ -12,10 +29,14 @@ import * as os from '@/os'; import * as sound from '@/scripts/sound'; import { scrollToBottom, getScrollPosition, getScrollContainer } from '@/scripts/scroll'; import follow from '@/directives/follow-append'; +import XPostForm from './post-form.vue'; +import MkInfo from '@/components/ui/info.vue'; export default defineComponent({ components: { - XNotes + XNotes, + XPostForm, + MkInfo, }, directives: { @@ -45,11 +66,6 @@ export default defineComponent({ type: String, required: false }, - sound: { - type: Boolean, - required: false, - default: false, - } }, emits: ['note', 'queue', 'before', 'after'], @@ -69,6 +85,8 @@ export default defineComponent({ width: 0, top: 0, bottom: 0, + typers: [], + date: null }; }, @@ -78,9 +96,7 @@ export default defineComponent({ this.$emit('note'); - if (this.sound) { - sound.play(note.userId === this.$i.id ? 'noteMy' : 'note'); - } + sound.play(note.userId === this.$i.id ? 'noteMy' : 'note'); }; const onUserAdded = () => { @@ -166,6 +182,9 @@ export default defineComponent({ channelId: this.channel }); this.connection.on('note', prepend); + this.connection.on('typers', typers => { + this.typers = this.$i ? typers.filter(u => u.id !== this.$i.id) : typers; + }); } this.pagination = { @@ -173,7 +192,7 @@ export default defineComponent({ reversed, limit: 10, params: init => ({ - untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined), + untilDate: this.date?.getTime(), ...this.baseQuery, ...this.query }) }; @@ -190,34 +209,73 @@ export default defineComponent({ methods: { focus() { - this.$refs.tl.focus(); + this.$refs.body.focus(); }, goTop() { - const container = getScrollContainer(this.$el); + const container = getScrollContainer(this.$refs.body); container.scrollTop = 0; }, queueUpdated(q) { - if (this.$el.offsetWidth !== 0) { - const rect = this.$el.getBoundingClientRect(); - const scrollTop = getScrollPosition(this.$el); - this.width = this.$el.offsetWidth; - this.top = rect.top + scrollTop; - this.bottom = this.$el.offsetHeight; + if (this.$refs.body.offsetWidth !== 0) { + const rect = this.$refs.body.getBoundingClientRect(); + this.width = this.$refs.body.offsetWidth; + this.top = rect.top; + this.bottom = this.$refs.body.offsetHeight; } this.queue = q; }, + + timetravel(date?: Date) { + this.date = date; + this.$refs.tl.reload(); + } } }); </script> <style lang="scss" scoped> -.dbiokgaf { - padding: 16px 0; +.dbiokgaf.info{ + padding: 16px 16px 0 16px; +} + +.dbiokgaf.top { + padding: 16px 16px 0 16px; +} + +.dbiokgaf.bottom { + padding: 0 16px 16px 16px; + position: relative; + + > .typers { + position: absolute; + bottom: 100%; + padding: 0 8px 0 8px; + font-size: 0.9em; + background: var(--panel); + border-radius: 0 8px 0 0; + color: var(--fgTransparentWeak); - // TODO: これはノート追加アニメーションによるスクロール発生を抑えるために必要だが、position stickyが効かなくなるので、両者を両立させる良い方法を考える - overflow: hidden; + > .users { + > .user + .user:before { + content: ", "; + font-weight: normal; + } + + > .user:last-of-type:after { + content: " "; + } + } + } +} + +.dbiokgaf.tl { + position: relative; + padding: 16px 0; + flex: 1; + min-width: 0; + overflow: auto; > .new { position: fixed; diff --git a/src/client/ui/chat/widgets.vue b/src/client/ui/chat/widgets.vue index 6becaa22e3..6b12f9dac9 100644 --- a/src/client/ui/chat/widgets.vue +++ b/src/client/ui/chat/widgets.vue @@ -10,7 +10,7 @@ <script lang="ts"> import { defineComponent, defineAsyncComponent } from 'vue'; import XWidgets from '@/components/widgets.vue'; -import { store } from './store.ts'; +import { store } from './store'; export default defineComponent({ components: { @@ -34,6 +34,7 @@ export default defineComponent({ }, updateWidget({ id, data }) { + // TODO: throttleしたい store.set('widgets', store.state.widgets.map(w => w.id === id ? { ...w, data: data diff --git a/src/client/widgets/define.ts b/src/client/widgets/define.ts index b5498204b3..08a346d97c 100644 --- a/src/client/widgets/define.ts +++ b/src/client/widgets/define.ts @@ -1,4 +1,5 @@ import { defineComponent } from 'vue'; +import { throttle } from 'throttle-debounce'; import { Form } from '@/scripts/form'; import * as os from '@/os'; @@ -21,7 +22,10 @@ export default function <T extends Form>(data: { data() { return { - props: this.widget ? JSON.parse(JSON.stringify(this.widget.data)) : {} + props: this.widget ? JSON.parse(JSON.stringify(this.widget.data)) : {}, + save: throttle(3000, () => { + this.$emit('updateProps', this.props); + }), }; }, @@ -66,10 +70,6 @@ export default function <T extends Form>(data: { this.save(); }, - - save() { - this.$emit('updateProps', this.props); - } } }); } diff --git a/src/client/widgets/job-queue.vue b/src/client/widgets/job-queue.vue index 11bb20979b..b7bfb6de27 100644 --- a/src/client/widgets/job-queue.vue +++ b/src/client/widgets/job-queue.vue @@ -5,19 +5,19 @@ <div class="values"> <div> <div>Process</div> - <div>{{ number(inbox.activeSincePrevTick) }}</div> + <div :class="{ inc: inbox.activeSincePrevTick > prev.inbox.activeSincePrevTick, dec: inbox.activeSincePrevTick < prev.inbox.activeSincePrevTick }">{{ number(inbox.activeSincePrevTick) }}</div> </div> <div> <div>Active</div> - <div>{{ number(inbox.active) }}</div> + <div :class="{ inc: inbox.active > prev.inbox.active, dec: inbox.active < prev.inbox.active }">{{ number(inbox.active) }}</div> </div> <div> <div>Delayed</div> - <div>{{ number(inbox.delayed) }}</div> + <div :class="{ inc: inbox.delayed > prev.inbox.delayed, dec: inbox.delayed < prev.inbox.delayed }">{{ number(inbox.delayed) }}</div> </div> <div> <div>Waiting</div> - <div>{{ number(inbox.waiting) }}</div> + <div :class="{ inc: inbox.waiting > prev.inbox.waiting, dec: inbox.waiting < prev.inbox.waiting }">{{ number(inbox.waiting) }}</div> </div> </div> </div> @@ -26,19 +26,19 @@ <div class="values"> <div> <div>Process</div> - <div>{{ number(deliver.activeSincePrevTick) }}</div> + <div :class="{ inc: deliver.activeSincePrevTick > prev.deliver.activeSincePrevTick, dec: deliver.activeSincePrevTick < prev.deliver.activeSincePrevTick }">{{ number(deliver.activeSincePrevTick) }}</div> </div> <div> <div>Active</div> - <div>{{ number(deliver.active) }}</div> + <div :class="{ inc: deliver.active > prev.deliver.active, dec: deliver.active < prev.deliver.active }">{{ number(deliver.active) }}</div> </div> <div> <div>Delayed</div> - <div>{{ number(deliver.delayed) }}</div> + <div :class="{ inc: deliver.delayed > prev.deliver.delayed, dec: deliver.delayed < prev.deliver.delayed }">{{ number(deliver.delayed) }}</div> </div> <div> <div>Waiting</div> - <div>{{ number(deliver.waiting) }}</div> + <div :class="{ inc: deliver.waiting > prev.deliver.waiting, dec: deliver.waiting < prev.deliver.waiting }">{{ number(deliver.waiting) }}</div> </div> </div> </div> @@ -79,10 +79,15 @@ export default defineComponent({ waiting: 0, delayed: 0, }, + prev: {}, faExclamationTriangle, }; }, created() { + for (const domain of ['inbox', 'deliver']) { + this.prev[domain] = JSON.parse(JSON.stringify(this[domain])); + } + this.connection.on('stats', this.onStats); this.connection.on('statsLog', this.onStatsLog); @@ -99,6 +104,7 @@ export default defineComponent({ methods: { onStats(stats) { for (const domain of ['inbox', 'deliver']) { + this.prev[domain] = JSON.parse(JSON.stringify(this[domain])); this[domain].activeSincePrevTick = stats[domain].activeSincePrevTick; this[domain].active = stats[domain].active; this[domain].waiting = stats[domain].waiting; @@ -152,6 +158,16 @@ export default defineComponent({ > div:first-child { opacity: 0.7; } + + > div:last-child { + &.inc { + color: var(--warn); + } + + &.dec { + color: var(--success); + } + } } } } |