diff --git a/src/client/components/global/avatar.vue b/src/client/components/global/avatar.vue index 9f8b0eeca..d2f25fa41 100644 --- a/src/client/components/global/avatar.vue +++ b/src/client/components/global/avatar.vue @@ -1,8 +1,8 @@ <template> -<span class="eiwwqkts" :class="{ cat }" :title="acct(user)" v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" @click="onClick"> +<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"/> </span> -<MkA class="eiwwqkts" :class="{ cat }" :to="userPage(user)" :title="acct(user)" :target="target" v-else v-user-preview="disablePreview ? undefined : user.id"> +<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"/> </MkA> </template> diff --git a/src/client/components/notes.vue b/src/client/components/notes.vue index 997380919..bd6d5bb4f 100644 --- a/src/client/components/notes.vue +++ b/src/client/components/notes.vue @@ -8,7 +8,7 @@ <MkError v-if="error" @retry="init()"/> <div v-show="more && reversed" style="margin-bottom: var(--margin);"> - <button class="_loadMore" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <button class="_buttonPrimary" @click="fetchMoreFeature" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> <template v-if="!moreFetching">{{ $ts.loadMore }}</template> <template v-if="moreFetching"><MkLoading inline/></template> </button> @@ -19,7 +19,7 @@ </XList> <div v-show="more && !reversed" style="margin-top: var(--margin);"> - <button class="_loadMore" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <button class="_buttonPrimary" 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> diff --git a/src/client/components/notifications.vue b/src/client/components/notifications.vue index 9759cc239..552b22dd3 100644 --- a/src/client/components/notifications.vue +++ b/src/client/components/notifications.vue @@ -5,7 +5,7 @@ <XNotification v-else :notification="notification" :with-time="true" :full="true" class="_panel notification" :key="notification.id"/> </XList> - <button class="_loadMore" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" v-show="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <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> diff --git a/src/client/components/sidebar.vue b/src/client/components/sidebar.vue index 251f68527..d07dd294a 100644 --- a/src/client/components/sidebar.vue +++ b/src/client/components/sidebar.vue @@ -55,6 +55,14 @@ import { sidebarDef } from '@/sidebar'; import { getAccounts, addAccount, login } from '@/account'; export default defineComponent({ + props: { + defaultHidden: { + type: Boolean, + required: false, + default: false, + } + }, + data() { return { host: host, @@ -63,7 +71,7 @@ export default defineComponent({ connection: null, menuDef: sidebarDef, iconOnly: false, - hidden: false, + hidden: this.defaultHidden, faGripVertical, faChevronLeft, faComments, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faBell, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faEnvelope, faListUl, faPlus, faUserClock, faLaugh, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faProjectDiagram }; }, @@ -112,7 +120,9 @@ export default defineComponent({ methods: { calcViewState() { this.iconOnly = (window.innerWidth <= 1279) || (this.$store.state.sidebarDisplay === 'icon'); - this.hidden = (window.innerWidth <= 650); + if (!this.defaultHidden) { + this.hidden = (window.innerWidth <= 650); + } }, show() { diff --git a/src/client/directives/follow-append.ts b/src/client/directives/follow-append.ts index 26f9e9f82..9490dcf78 100644 --- a/src/client/directives/follow-append.ts +++ b/src/client/directives/follow-append.ts @@ -3,12 +3,24 @@ import { getScrollContainer, getScrollPosition } from '@/scripts/scroll'; export default { mounted(src, binding, vn) { - const ro = new ResizeObserver((entries, observer) => { - const pos = getScrollPosition(src); - const container = getScrollContainer(src); + if (binding.value === false) return; + + let isBottom = true; + + const container = getScrollContainer(src)!; + container.addEventListener('scroll', () => { + const pos = getScrollPosition(container); const viewHeight = container.clientHeight; const height = container.scrollHeight; - if (pos + viewHeight > height - 32) { + isBottom = (pos + viewHeight > height - 32); + console.log(isBottom); + }, { passive: true }); + container.scrollTop = container.scrollHeight; + + const ro = new ResizeObserver((entries, observer) => { + console.log(isBottom); + if (isBottom) { + const height = container.scrollHeight; container.scrollTop = height; } }); @@ -20,6 +32,6 @@ export default { }, unmounted(src, binding, vn) { - src._ro_.unobserve(src); + if (src._ro_) src._ro_.unobserve(src); } } as Directive; diff --git a/src/client/init.ts b/src/client/init.ts index 17feca4c8..c3be85a85 100644 --- a/src/client/init.ts +++ b/src/client/init.ts @@ -182,6 +182,7 @@ const app = createApp(await ( !$i ? import('@/ui/visitor.vue') : ui === 'deck' ? import('@/ui/deck.vue') : ui === 'desktop' ? import('@/ui/desktop.vue') : + ui === 'chat' ? import('@/ui/chat/index.vue') : import('@/ui/default.vue') ).then(x => x.default)); diff --git a/src/client/scripts/paging.ts b/src/client/scripts/paging.ts index 3d9668f10..a8f122412 100644 --- a/src/client/scripts/paging.ts +++ b/src/client/scripts/paging.ts @@ -1,9 +1,11 @@ import { markRaw } from 'vue'; import * as os from '@/os'; -import { onScrollTop, isTopVisible } from './scroll'; +import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from './scroll'; const SECOND_FETCH_LIMIT = 30; +// reversed: items 配列の中身を逆順にする(新しい方が最後) + export default (opts) => ({ emits: ['queue'], @@ -122,10 +124,41 @@ export default (opts) => ({ limit: SECOND_FETCH_LIMIT + 1, ...(this.pagination.offsetMode ? { offset: this.offset, - } : this.pagination.reversed ? { - sinceId: this.items[0].id, } : { - untilId: this.items[this.items.length - 1].id, + untilId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id, + }), + }).then(items => { + for (const item of items) { + markRaw(item); + } + if (items.length > SECOND_FETCH_LIMIT) { + items.pop(); + this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items); + this.more = true; + } else { + this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items); + this.more = false; + } + this.offset += items.length; + this.moreFetching = false; + }, e => { + this.moreFetching = false; + }); + }, + + async fetchMoreFeature() { + if (!this.more || this.fetching || this.moreFetching || this.items.length === 0) return; + this.moreFetching = true; + let params = typeof this.pagination.params === 'function' ? this.pagination.params(false) : this.pagination.params; + if (params && params.then) params = await params; + const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint; + await os.api(endpoint, { + ...params, + limit: SECOND_FETCH_LIMIT + 1, + ...(this.pagination.offsetMode ? { + offset: this.offset, + } : { + sinceId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id, }), }).then(items => { for (const item of items) { @@ -147,25 +180,44 @@ export default (opts) => ({ }, prepend(item) { - const isTop = this.isBackTop || (document.body.contains(this.$el) && isTopVisible(this.$el)); - - if (isTop) { - // Prepend the item - this.items.unshift(item); - - // オーバーフローしたら古いアイテムは捨てる - if (this.items.length >= opts.displayLimit) { - this.items = this.items.slice(0, opts.displayLimit); - this.more = true; - } - } else { - this.queue.push(item); - onScrollTop(this.$el, () => { - for (const item of this.queue) { - this.prepend(item); + if (this.pagination.reversed) { + const container = getScrollContainer(this.$el); + const pos = getScrollPosition(this.$el); + const viewHeight = container.clientHeight; + const height = container.scrollHeight; + const isBottom = (pos + viewHeight > height - 32); + if (isBottom) { + // オーバーフローしたら古いアイテムは捨てる + if (this.items.length >= opts.displayLimit) { + this.items = this.items.slice(-opts.displayLimit); + this.more = true; } - this.queue = []; - }); + } else { + + } + this.items.push(item); + // TODO + } else { + const isTop = this.isBackTop || (document.body.contains(this.$el) && isTopVisible(this.$el)); + + if (isTop) { + // Prepend the item + this.items.unshift(item); + + // オーバーフローしたら古いアイテムは捨てる + if (this.items.length >= opts.displayLimit) { + this.items = this.items.slice(0, opts.displayLimit); + this.more = true; + } + } else { + this.queue.push(item); + onScrollTop(this.$el, () => { + for (const item of this.queue) { + this.prepend(item); + } + this.queue = []; + }); + } } }, diff --git a/src/client/scripts/scroll.ts b/src/client/scripts/scroll.ts index 18c336689..bc6d1530c 100644 --- a/src/client/scripts/scroll.ts +++ b/src/client/scripts/scroll.ts @@ -54,6 +54,14 @@ export function scroll(el: Element, top: number) { } } +export function scrollToTop(el: Element) { + scroll(el, 0); +} + +export function scrollToBottom(el: Element) { + scroll(el, 99999); // TODO: ちゃんと計算する +} + export function isBottom(el: Element, asobi = 0) { const container = getScrollContainer(el); const current = container diff --git a/src/client/sidebar.ts b/src/client/sidebar.ts index 98a70d2d1..d7822e9e0 100644 --- a/src/client/sidebar.ts +++ b/src/client/sidebar.ts @@ -141,6 +141,12 @@ export const sidebarDef = { localStorage.setItem('ui', 'deck'); location.reload(); } + }, { + text: 'Chat (β)', + action: () => { + localStorage.setItem('ui', 'chat'); + location.reload(); + } }, { text: i18n.locale.desktop + ' (β)', action: () => { diff --git a/src/client/style.scss b/src/client/style.scss index 1ac9b4e0b..14e8c8731 100644 --- a/src/client/style.scss +++ b/src/client/style.scss @@ -308,13 +308,6 @@ hr { box-shadow: none; } -._loadMore { - @extend ._panel; - @extend ._button; - width: 100%; - padding: 12px 0; -} - ._borderButton { @extend ._button; display: block; diff --git a/src/client/ui/_common_/header.vue b/src/client/ui/_common_/header.vue index f662f6144..f150653a8 100644 --- a/src/client/ui/_common_/header.vue +++ b/src/client/ui/_common_/header.vue @@ -1,5 +1,5 @@ <template> -<div class="fdidabkb" :style="`--height:${height};`"> +<div class="fdidabkb" :class="{ center }" :style="`--height:${height};`"> <transition :name="$store.state.animation ? 'header' : ''" mode="out-in" appear> <button class="_button back" v-if="withBack && canBack" @click.stop="back()"><Fa :icon="faChevronLeft"/></button> </transition> @@ -31,6 +31,11 @@ export default defineComponent({ required: false, default: true, }, + center: { + type: Boolean, + required: false, + default: true, + }, }, data() { @@ -67,7 +72,9 @@ export default defineComponent({ <style lang="scss" scoped> .fdidabkb { - text-align: center; + &.center { + text-align: center; + } > .back { height: var(--height); @@ -111,8 +118,13 @@ export default defineComponent({ right: 0; } + &.center { + > .titleContainer { + margin: 0 auto; + } + } + > .titleContainer { - margin: 0 auto; overflow: auto; white-space: nowrap; diff --git a/src/client/ui/chat/date-separated-list.vue b/src/client/ui/chat/date-separated-list.vue new file mode 100644 index 000000000..eb671510a --- /dev/null +++ b/src/client/ui/chat/date-separated-list.vue @@ -0,0 +1,154 @@ +<script lang="ts"> +import { defineComponent, h, TransitionGroup } from 'vue'; +import { faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; + +export default defineComponent({ + props: { + items: { + type: Array, + required: true, + }, + direction: { + type: String, + required: false, + default: 'down' + }, + reversed: { + type: Boolean, + required: false, + default: false + } + }, + + methods: { + focus() { + this.$slots.default[0].elm.focus(); + } + }, + + render() { + const getDateText = (time: string) => { + const date = new Date(time).getDate(); + const month = new Date(time).getMonth() + 1; + return this.$t('monthAndDay', { + month: month.toString(), + day: date.toString() + }); + } + + return h(!this.reversed ? TransitionGroup : 'div', !this.reversed ? { + class: 'hmjzthxl', + name: 'list', + tag: 'div', + 'data-direction': this.direction, + 'data-reversed': this.reversed ? 'true' : 'false', + } : { + class: 'hmjzthxl', + }, this.items.map((item, i) => { + const el = this.$slots.default({ + item: item + })[0]; + if (el.key == null && item.id) el.key = item.id; + + if ( + i != this.items.length - 1 && + new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate() && + !item._prId_ && + !this.items[i + 1]._prId_ && + !item._featuredId_ && + !this.items[i + 1]._featuredId_ + ) { + const separator = h('div', { + class: 'separator', + key: item.id + ':separator', + }, h('p', { + class: 'date' + }, [ + h('span', [ + h(FontAwesomeIcon, { + class: 'icon', + icon: faAngleUp, + }), + getDateText(item.createdAt) + ]), + h('span', [ + getDateText(this.items[i + 1].createdAt), + h(FontAwesomeIcon, { + class: 'icon', + icon: faAngleDown, + }) + ]) + ])); + + return [el, separator]; + } else { + return el; + } + })); + }, +}); +</script> + +<style lang="scss"> +.hmjzthxl { + > .list-move { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); + } + + > .list-enter-active { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); + } + + &[data-direction="up"] { + > .list-enter-from { + opacity: 0; + transform: translateY(64px); + } + } + + &[data-direction="down"] { + > .list-enter-from { + opacity: 0; + transform: translateY(-64px); + } + } +} +</style> + +<style lang="scss"> +.hmjzthxl { + > .separator { + text-align: center; + + > .date { + display: inline-block; + position: relative; + margin: 0; + padding: 0 16px; + line-height: 32px; + text-align: center; + font-size: 12px; + color: var(--dateLabelFg); + + > span { + &:first-child { + margin-right: 8px; + + > .icon { + margin-right: 8px; + } + } + + &:last-child { + margin-left: 8px; + + > .icon { + margin-left: 8px; + } + } + } + } + } +} +</style> diff --git a/src/client/ui/chat/index.vue b/src/client/ui/chat/index.vue new file mode 100644 index 000000000..a2df8ab13 --- /dev/null +++ b/src/client/ui/chat/index.vue @@ -0,0 +1,389 @@ +<template> +<div class="mk-app" @contextmenu.self.prevent="onContextmenu"> + <XSidebar ref="menu" class="menu" :default-hidden="true"/> + + <div class="nav"> + <header class="header"> + <div class="left"> + <button class="_button account" @click="openAccountMenu"> + <MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/> + </button> + </div> + <div class="right"> + <MkA class="item" to="/my/notifications"><Fa :icon="faBell"/></MkA> + </div> + </header> + <div class="body"> + <div class="container"> + <div class="header">{{ $ts.timeline }}</div> + <div class="body"> + <MkA to="/timeline/home" class="item" :class="{ active: tl === 'home' }"><Fa :icon="faHome" class="icon"/>{{ $ts._timelines.home }}</MkA> + <MkA to="/timeline/local" class="item" :class="{ active: tl === 'local' }"><Fa :icon="faHome" class="icon"/>{{ $ts._timelines.local }}</MkA> + <MkA to="/timeline/social" class="item" :class="{ active: tl === 'social' }"><Fa :icon="faHome" class="icon"/>{{ $ts._timelines.social }}</MkA> + <MkA to="/timeline/global" class="item" :class="{ active: tl === 'global' }"><Fa :icon="faHome" class="icon"/>{{ $ts._timelines.global }}</MkA> + </div> + </div> + <div class="container" v-if="lists"> + <div class="header">{{ $ts.lists }}</div> + <div class="body"> + <MkA v-for="list in lists" :key="list.id" :to="`/my/list/${ list.id }`" class="item" :class="{ active: tl === `list:${ list.id }` }"><Fa :icon="faListUl" class="icon"/>{{ list.name }}</MkA> + </div> + </div> + <div class="container" v-if="antennas"> + <div class="header">{{ $ts.antennas }}</div> + <div class="body"> + <MkA v-for="antenna in antennas" :key="antenna.id" :to="`/my/antenna/${ antenna.id }`" class="item" :class="{ active: tl === `antenna:${ antenna.id }` }"><Fa :icon="faSatellite" class="icon"/>{{ antenna.name }}</MkA> + </div> + </div> + <div class="container" v-if="followedChannels"> + <div class="header">{{ $ts.channel }}</div> + <div class="body"> + <MkA v-for="channel in followedChannels" :key="channel.id" :to="`/channels/${ channel.id }`" class="item" :class="{ active: tl === `channel:${ channel.id }`, read: !channel.hasUnreadNote }"><Fa :icon="faSatelliteDish" class="icon"/>{{ channel.name }}</MkA> + </div> + </div> + <div class="container" v-if="featuredChannels"> + <div class="header">{{ $ts.channel }}</div> + <div class="body"> + <MkA v-for="channel in featuredChannels" :key="channel.id" :to="`/channels/${ channel.id }`" class="item" :class="{ active: tl === `channel:${ channel.id }` }"><Fa :icon="faSatelliteDish" class="icon"/>{{ channel.name }}</MkA> + </div> + </div> + </div> + <footer class="footer"> + <div class="left"> + <button class="_button menu" @click="showMenu"> + <Fa :icon="faBars"/> + </button> + </div> + <div class="right"> + <MkA class="item" to="/settings"><Fa :icon="faCog"/></MkA> + </div> + </footer> + </div> + + <main class="main" @contextmenu.stop="onContextmenu"> + <header class="header" ref="header" @click="onHeaderClick"> + <div v-if="tl === 'home'"> + <Fa :icon="faHome" class="icon"/> + <div class="title">{{ $ts._timelines.home }}</div> + </div> + <div v-else-if="tl === 'local'"> + <Fa :icon="faShareAlt" class="icon"/> + <div class="title">{{ $ts._timelines.local }}</div> + </div> + <div v-else-if="tl === 'social'"> + <Fa :icon="faShareAlt" class="icon"/> + <div class="title">{{ $ts._timelines.social }}</div> + </div> + <div v-else-if="tl === 'global'"> + <Fa :icon="faShareAlt" class="icon"/> + <div class="title">{{ $ts._timelines.global }}</div> + </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> + </main> + + <XSide class="side" ref="side"/> + + <XCommon/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { faLayerGroup, faBars, faHome, faCircle, faWindowMaximize, faColumns, faPencilAlt, faShareAlt, faSatelliteDish, faListUl, faSatellite, faCog } from '@fortawesome/free-solid-svg-icons'; +import { faBell } from '@fortawesome/free-regular-svg-icons'; +import { instanceName } from '@/config'; +import XSidebar from '@/components/sidebar.vue'; +import XCommon from '../_common_/common.vue'; +import XSide from './side.vue'; +import XTimeline from './timeline.vue'; +import XPostForm from './post-form.vue'; +import * as os from '@/os'; +import { sidebarDef } from '@/sidebar'; + +export default defineComponent({ + components: { + XCommon, + XSidebar, + XSide, // NOTE: dynamic importするとAsyncComponentWrapperが間に入るせいでref取得できなくて面倒になる + XTimeline, + XPostForm, + }, + + provide() { + return { + navHook: (path) => { + switch (path) { + case '/timeline/home': this.tl = 'home'; return; + case '/timeline/local': this.tl = 'local'; return; + case '/timeline/social': this.tl = 'social'; return; + case '/timeline/global': this.tl = 'global'; return; + + default: + if (path.startsWith('/channels/')) { + this.tl = `channel:${ path.replace('/channels/', '') }`; + return; + } + //os.pageWindow(path); + this.$refs.side.navigate(path); + break; + } + }, + sideViewHook: (path) => { + this.$refs.side.navigate(path); + } + }; + }, + + data() { + return { + tl: 'home', + lists: null, + antennas: null, + followedChannels: null, + featuredChannels: null, + menuDef: sidebarDef, + faLayerGroup, faBars, faBell, faHome, faCircle, faPencilAlt, faShareAlt, faSatelliteDish, faListUl, faSatellite, faCog, + }; + }, + + created() { + os.api('users/lists/list').then(lists => { + this.lists = lists; + }); + + os.api('antennas/list').then(antennas => { + this.antennas = antennas; + }); + + os.api('channels/followed').then(channels => { + this.followedChannels = channels; + }); + + os.api('channels/featured').then(channels => { + this.featuredChannels = channels; + }); + }, + + methods: { + showMenu() { + this.$refs.menu.show(); + }, + + post() { + os.post(); + }, + + top() { + window.scroll({ top: 0, behavior: 'smooth' }); + }, + + onTransition() { + if (window._scroll) window._scroll(); + }, + + onHeaderClick() { + window.scroll({ top: 0, behavior: 'smooth' }); + }, + + onContextmenu(e) { + const isLink = (el: HTMLElement) => { + if (el.tagName === 'A') return true; + if (el.parentElement) { + return isLink(el.parentElement); + } + }; + if (isLink(e.target)) return; + if (['INPUT', 'TEXTAREA'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return; + if (window.getSelection().toString() !== '') return; + const path = this.$route.path; + os.contextMenu([{ + type: 'label', + text: path, + }, { + icon: faColumns, + text: this.$ts.openInSideView, + action: () => { + this.$refs.side.navigate(path); + } + }, { + icon: faWindowMaximize, + text: this.$ts.openInWindow, + action: () => { + os.pageWindow(path); + } + }], e); + }, + } +}); +</script> + +<style lang="scss" scoped> +.mk-app { + $header-height: 54px; // TODO: どこかに集約したい + $ui-font-size: 1em; // TODO: どこかに集約したい + + // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ + min-height: calc(var(--vh, 1vh) * 100); + box-sizing: border-box; + display: flex; + + > .nav { + display: flex; + flex-direction: column; + width: 250px; + height: 100vh; + border-right: solid 1px var(--divider); + + > .header, > .footer { + $padding: 8px; + display: flex; + z-index: 1000; + height: $header-height; + padding: $padding; + box-sizing: border-box; + line-height: ($header-height - ($padding * 2)); + user-select: none; + + &.header { + border-bottom: solid 1px var(--divider); + } + + &.footer { + border-top: solid 1px var(--divider); + } + + > .left { + > .account { + display: flex; + align-items: center; + padding: 0 8px; + + > .avatar { + width: 26px; + height: 26px; + margin-right: 8px; + } + } + } + + > .right { + margin-left: auto; + + > .item { + height: ($header-height - ($padding * 2)); + width: ($header-height - ($padding * 2)); + padding: 10px; + box-sizing: border-box; + margin-right: 4px; + opacity: 0.6; + } + } + } + + > .body { + flex: 1; + min-width: 0; + overflow: auto; + + > .container { + & + .container { + margin-top: 16px; + } + + > .header { + font-size: 0.9em; + padding: 8px 16px; + opacity: 0.7; + } + + > .body { + padding: 0 8px; + + > .item { + display: block; + padding: 6px 8px; + border-radius: 4px; + + &:hover { + text-decoration: none; + background: rgba(0, 0, 0, 0.05); + } + + &.active, &.active:hover { + background: var(--accent); + color: #fff; + } + + &.read { + opacity: 0.5; + } + + > .icon { + margin-right: 6px; + opacity: 0.6; + } + } + } + } + } + } + + > .main { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + height: 100vh; + position: relative; + background: var(--panel); + + > .header { + $padding: 8px; + z-index: 1000; + height: $header-height; + padding: $padding; + box-sizing: border-box; + line-height: ($header-height - ($padding * 2)); + font-weight: bold; + background-color: var(--header); + border-bottom: solid 1px var(--divider); + user-select: none; + + > div { + display: flex; + + > .icon { + height: ($header-height - ($padding * 2)); + width: ($header-height - ($padding * 2)); + padding: 10px; + box-sizing: border-box; + margin-right: 4px; + opacity: 0.6; + } + } + } + + > .footer { + padding: 16px; + } + + > .body { + flex: 1; + min-width: 0; + overflow: auto; + } + } + + > .side { + border-left: solid 1px var(--divider); + } +} +</style> diff --git a/src/client/ui/chat/note-header.vue b/src/client/ui/chat/note-header.vue new file mode 100644 index 000000000..cda8ae00e --- /dev/null +++ b/src/client/ui/chat/note-header.vue @@ -0,0 +1,115 @@ +<template> +<header class="dehvdgxo"> + <MkA class="name" :to="userPage(note.user)" v-user-preview="note.user.id"> + <MkUserName :user="note.user"/> + </MkA> + <span class="is-bot" v-if="note.user.isBot">bot</span> + <span class="username"><MkAcct :user="note.user"/></span> + <span class="admin" v-if="note.user.isAdmin"><Fa :icon="faBookmark"/></span> + <span class="moderator" v-if="!note.user.isAdmin && note.user.isModerator"><Fa :icon="farBookmark"/></span> + <div class="info"> + <span class="mobile" v-if="note.viaMobile"><Fa :icon="faMobileAlt"/></span> + <MkA class="created-at" :to="notePage(note)"> + <MkTime :time="note.createdAt"/> + </MkA> + <span class="visibility" v-if="note.visibility !== 'public'"> + <Fa v-if="note.visibility === 'home'" :icon="faHome"/> + <Fa v-if="note.visibility === 'followers'" :icon="faUnlock"/> + <Fa v-if="note.visibility === 'specified'" :icon="faEnvelope"/> + </span> + <span class="localOnly" v-if="note.localOnly"><Fa :icon="faBiohazard"/></span> + </div> +</header> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faHome, faUnlock, faEnvelope, faMobileAlt, faBookmark, faBiohazard } from '@fortawesome/free-solid-svg-icons'; +import { faBookmark as farBookmark } from '@fortawesome/free-regular-svg-icons'; +import notePage from '@/filters/note'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + note: { + type: Object, + required: true + }, + }, + + data() { + return { + faHome, faUnlock, faEnvelope, faMobileAlt, faBookmark, farBookmark, faBiohazard + }; + }, + + methods: { + notePage, + userPage + } +}); +</script> + +<style lang="scss" scoped> +.dehvdgxo { + display: flex; + align-items: baseline; + white-space: nowrap; + font-size: 0.9em; + + > .name { + display: block; + margin: 0 .5em 0 0; + padding: 0; + overflow: hidden; + font-size: 1em; + font-weight: bold; + text-decoration: none; + text-overflow: ellipsis; + + &:hover { + text-decoration: underline; + } + } + + > .is-bot { + flex-shrink: 0; + align-self: center; + margin: 0 .5em 0 0; + padding: 1px 6px; + font-size: 80%; + border: solid 1px var(--divider); + border-radius: 3px; + } + + > .admin, + > .moderator { + margin-right: 0.5em; + color: var(--badge); + } + + > .username { + margin: 0 .5em 0 0; + overflow: hidden; + text-overflow: ellipsis; + } + + > .info { + font-size: 0.9em; + opacity: 0.7; + + > .mobile { + margin-right: 8px; + } + + > .visibility { + margin-left: 8px; + } + + > .localOnly { + margin-left: 8px; + } + } +} +</style> diff --git a/src/client/ui/chat/note-preview.vue b/src/client/ui/chat/note-preview.vue new file mode 100644 index 000000000..486147370 --- /dev/null +++ b/src/client/ui/chat/note-preview.vue @@ -0,0 +1,112 @@ +<template> +<div class="hduudsxk"> + <MkAvatar class="avatar" :user="note.user"/> + <div class="main"> + <XNoteHeader class="header" :note="note" :mini="true"/> + <div class="body"> + <p v-if="note.cw != null" class="cw"> + <span class="text" v-if="note.cw != ''">{{ note.cw }}</span> + <XCwButton v-model:value="showContent" :note="note"/> + </p> + <div class="content" v-show="note.cw == null || showContent"> + <XSubNote-content class="text" :note="note"/> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XNoteHeader from './note-header.vue'; +import XSubNoteContent from './sub-note-content.vue'; +import XCwButton from '@/components/cw-button.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XNoteHeader, + XSubNoteContent, + XCwButton, + }, + + props: { + note: { + type: Object, + required: true + } + }, + + data() { + return { + showContent: false + }; + } +}); +</script> + +<style lang="scss" scoped> +.hduudsxk { + display: flex; + margin: 0; + padding: 0; + overflow: hidden; + font-size: 0.95em; + + > .avatar { + + @media (min-width: 350px) { + margin: 0 10px 0 0; + width: 44px; + height: 44px; + } + + @media (min-width: 500px) { + margin: 0 12px 0 0; + width: 48px; + height: 48px; + } + } + + > .avatar { + flex-shrink: 0; + display: block; + margin: 0 10px 0 0; + width: 40px; + height: 40px; + border-radius: 8px; + } + + > .main { + flex: 1; + min-width: 0; + + > .header { + margin-bottom: 2px; + } + + > .body { + + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + > .text { + cursor: default; + margin: 0; + padding: 0; + } + } + } + } +} +</style> diff --git a/src/client/ui/chat/note.sub.vue b/src/client/ui/chat/note.sub.vue new file mode 100644 index 000000000..6f365c29e --- /dev/null +++ b/src/client/ui/chat/note.sub.vue @@ -0,0 +1,137 @@ +<template> +<div class="wrpstxzv" :class="{ children }"> + <div class="main"> + <MkAvatar class="avatar" :user="note.user"/> + <div class="body"> + <XNoteHeader class="header" :note="note" :mini="true"/> + <div class="body"> + <p v-if="note.cw != null" class="cw"> + <Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis" /> + <XCwButton v-model:value="showContent" :note="note"/> + </p> + <div class="content" v-show="note.cw == null || showContent"> + <XSubNote-content class="text" :note="note"/> + </div> + </div> + </div> + </div> + <XSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :children="true"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XNoteHeader from './note-header.vue'; +import XSubNoteContent from './sub-note-content.vue'; +import XCwButton from '@/components/cw-button.vue'; +import * as os from '@/os'; + +export default defineComponent({ + name: 'XSub', + + components: { + XNoteHeader, + XSubNoteContent, + XCwButton, + }, + + props: { + note: { + type: Object, + required: true + }, + detail: { + type: Boolean, + required: false, + default: false + }, + children: { + type: Boolean, + required: false, + default: false + }, + // TODO + truncate: { + type: Boolean, + default: true + } + }, + + data() { + return { + showContent: false, + replies: [], + }; + }, + + created() { + if (this.detail) { + os.api('notes/children', { + noteId: this.note.id, + limit: 5 + }).then(replies => { + this.replies = replies; + }); + } + }, +}); +</script> + +<style lang="scss" scoped> +.wrpstxzv { + padding: 16px 16px; + font-size: 0.8em; + + &.children { + padding: 10px 0 0 16px; + font-size: 1em; + } + + > .main { + display: flex; + + > .avatar { + flex-shrink: 0; + display: block; + margin: 0 8px 0 0; + width: 36px; + height: 36px; + } + + > .body { + flex: 1; + min-width: 0; + + > .header { + margin-bottom: 2px; + } + + > .body { + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + > .text { + margin: 0; + padding: 0; + } + } + } + } + } + + > .reply { + border-left: solid 1px var(--divider); + margin-top: 10px; + } +} +</style> diff --git a/src/client/ui/chat/note.vue b/src/client/ui/chat/note.vue new file mode 100644 index 000000000..f17f45962 --- /dev/null +++ b/src/client/ui/chat/note.vue @@ -0,0 +1,1126 @@ +<template> +<div + class="note" + v-if="!muted" + v-show="!isDeleted" + :tabindex="!isDeleted ? '-1' : null" + :class="{ renote: isRenote }" + v-hotkey="keymap" +> + <XSub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/> + <div class="info" v-if="pinned"><Fa :icon="faThumbtack"/> {{ $ts.pinnedNote }}</div> + <div class="info" v-if="appearNote._prId_"><Fa :icon="faBullhorn"/> {{ $ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ $ts.hideThisNote }} <Fa :icon="faTimes"/></button></div> + <div class="info" v-if="appearNote._featuredId_"><Fa :icon="faBolt"/> {{ $ts.featured }}</div> + <div class="renote" v-if="isRenote"> + <MkAvatar class="avatar" :user="note.user"/> + <Fa :icon="faRetweet"/> + <I18n :src="$ts.renotedBy" tag="span"> + <template #user> + <MkA class="name" :to="userPage(note.user)" v-user-preview="note.userId"> + <MkUserName :user="note.user"/> + </MkA> + </template> + </I18n> + <div class="info"> + <button class="_button time" @click="showRenoteMenu()" ref="renoteTime"> + <Fa class="dropdownIcon" v-if="isMyRenote" :icon="faEllipsisH"/> + <MkTime :time="note.createdAt"/> + </button> + <span class="visibility" v-if="note.visibility !== 'public'"> + <Fa v-if="note.visibility === 'home'" :icon="faHome"/> + <Fa v-if="note.visibility === 'followers'" :icon="faUnlock"/> + <Fa v-if="note.visibility === 'specified'" :icon="faEnvelope"/> + </span> + <span class="localOnly" v-if="note.localOnly"><Fa :icon="faBiohazard"/></span> + </div> + </div> + <article class="article" @contextmenu.stop="onContextmenu"> + <MkAvatar class="avatar" :user="appearNote.user"/> + <div class="main"> + <XNoteHeader class="header" :note="appearNote" :mini="true"/> + <MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/> + <div class="body"> + <p v-if="appearNote.cw != null" class="cw"> + <Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> + <XCwButton v-model:value="showContent" :note="appearNote"/> + </p> + <div class="content" :class="{ collapsed }" v-show="appearNote.cw == null || showContent"> + <div class="text"> + <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $ts.private }})</span> + <MkA class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><Fa :icon="faReply"/></MkA> + <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> + <a class="rp" v-if="appearNote.renote != null">RN:</a> + </div> + <div class="files" v-if="appearNote.files.length > 0"> + <XMediaList :media-list="appearNote.files"/> + </div> + <XPoll v-if="appearNote.poll" :note="appearNote" ref="pollViewer" class="poll"/> + <MkUrlPreview v-for="url in urls" :url="url" :key="url" :compact="true" :detail="false" class="url-preview"/> + <div class="renote" v-if="appearNote.renote"><XNotePreview :note="appearNote.renote"/></div> + <button v-if="collapsed" class="fade _button" @click="collapsed = false"> + <span>{{ $ts.showMore }}</span> + </button> + </div> + <MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><Fa :icon="faSatelliteDish"/> {{ appearNote.channel.name }}</MkA> + </div> + <XReactionsViewer :note="appearNote" ref="reactionsViewer"/> + <footer class="footer _panel"> + <button @click="reply()" class="button _button"> + <template v-if="appearNote.reply"><Fa :icon="faReplyAll"/></template> + <template v-else><Fa :icon="faReply"/></template> + <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> + </button> + <button v-if="canRenote" @click="renote()" class="button _button" ref="renoteButton"> + <Fa :icon="faRetweet"/><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> + </button> + <button v-else class="button _button"> + <Fa :icon="faBan"/> + </button> + <button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton"> + <Fa :icon="faPlus"/> + </button> + <button v-if="appearNote.myReaction != null" class="button _button reacted" @click="undoReact(appearNote)" ref="reactButton"> + <Fa :icon="faMinus"/> + </button> + <button class="button _button" @click="menu()" ref="menuButton"> + <Fa :icon="faEllipsisH"/> + </button> + </footer> + </div> + </article> +</div> +<div v-else class="muted" @click="muted = false"> + <I18n :src="$ts.userSaysSomething" tag="small"> + <template #name> + <MkA class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.userId"> + <MkUserName :user="appearNote.user"/> + </MkA> + </template> + </I18n> +</div> +</template> + +<script lang="ts"> +import { computed, defineAsyncComponent, defineComponent, markRaw, ref } from 'vue'; +import { faSatelliteDish, faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug, faExclamationCircle, faPaperclip } from '@fortawesome/free-solid-svg-icons'; +import { faCopy, faTrashAlt, faEdit, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons'; +import { parse } from '../../../mfm/parse'; +import { sum, unique } from '../../../prelude/array'; +import XSub from './note.sub.vue'; +import XNoteHeader from './note-header.vue'; +import XNotePreview from './note-preview.vue'; +import XReactionsViewer from '@/components/reactions-viewer.vue'; +import XMediaList from '@/components/media-list.vue'; +import XCwButton from '@/components/cw-button.vue'; +import XPoll from '@/components/poll.vue'; +import { pleaseLogin } from '@/scripts/please-login'; +import { focusPrev, focusNext } from '@/scripts/focus'; +import { url } from '@/config'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { checkWordMute } from '@/scripts/check-word-mute'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; +import { noteActions, noteViewInterruptors } from '@/store'; + +function markRawAll(...xs) { + for (const x of xs) { + markRaw(x); + } +} + +markRawAll(faEdit, faBolt, faTimes, faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faBiohazard, faPlug, faSatelliteDish); + +export default defineComponent({ + components: { + XSub, + XNoteHeader, + XNotePreview, + XReactionsViewer, + XMediaList, + XCwButton, + XPoll, + MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')), + MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')), + }, + + inject: { + inChannel: { + default: null + }, + }, + + props: { + note: { + type: Object, + required: true + }, + pinned: { + type: Boolean, + required: false, + default: false + }, + }, + + emits: ['update:note'], + + data() { + return { + connection: null, + replies: [], + showContent: false, + collapsed: false, + isDeleted: false, + muted: false, + faEdit, faBolt, faTimes, faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faBiohazard, faPlug, faSatelliteDish + }; + }, + + computed: { + rs() { + return this.$store.state.reactions; + }, + keymap(): any { + return { + 'r': () => this.reply(true), + 'e|a|plus': () => this.react(true), + 'q': () => this.renote(true), + 'f|b': this.favorite, + 'delete|ctrl+d': this.del, + 'ctrl+q': this.renoteDirectly, + 'up|k|shift+tab': this.focusBefore, + 'down|j|tab': this.focusAfter, + 'esc': this.blur, + 'm|o': () => this.menu(true), + 's': this.toggleShowContent, + '1': () => this.reactDirectly(this.rs[0]), + '2': () => this.reactDirectly(this.rs[1]), + '3': () => this.reactDirectly(this.rs[2]), + '4': () => this.reactDirectly(this.rs[3]), + '5': () => this.reactDirectly(this.rs[4]), + '6': () => this.reactDirectly(this.rs[5]), + '7': () => this.reactDirectly(this.rs[6]), + '8': () => this.reactDirectly(this.rs[7]), + '9': () => this.reactDirectly(this.rs[8]), + '0': () => this.reactDirectly(this.rs[9]), + }; + }, + + isRenote(): boolean { + return (this.note.renote && + this.note.text == null && + this.note.fileIds.length == 0 && + this.note.poll == null); + }, + + appearNote(): any { + return this.isRenote ? this.note.renote : this.note; + }, + + isMyNote(): boolean { + return this.$i && (this.$i.id === this.appearNote.userId); + }, + + isMyRenote(): boolean { + return this.$i && (this.$i.id === this.note.userId); + }, + + canRenote(): boolean { + return ['public', 'home'].includes(this.appearNote.visibility) || this.isMyNote; + }, + + reactionsCount(): number { + return this.appearNote.reactions + ? sum(Object.values(this.appearNote.reactions)) + : 0; + }, + + urls(): string[] { + if (this.appearNote.text) { + const ast = parse(this.appearNote.text); + // TODO: 再帰的にURL要素がないか調べる + const urls = unique(ast + .filter(t => ((t.node.type == 'url' || t.node.type == 'link') && t.node.props.url && !t.node.props.silent)) + .map(t => t.node.props.url)); + + // unique without hash + // [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ] + const removeHash = x => x.replace(/#[^#]*$/, ''); + + return urls.reduce((array, url) => { + const removed = removeHash(url); + if (!array.map(x => removeHash(x)).includes(removed)) array.push(url); + return array; + }, []); + } else { + return null; + } + }, + + showTicker() { + if (this.$store.state.instanceTicker === 'always') return true; + if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true; + return false; + } + }, + + async created() { + if (this.$i) { + this.connection = os.stream; + } + + this.collapsed = this.appearNote.cw == null && this.appearNote.text && ( + (this.appearNote.text.split('\n').length > 9) || + (this.appearNote.text.length > 500) + ); + this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords); + + // plugin + if (noteViewInterruptors.length > 0) { + let result = this.note; + for (const interruptor of noteViewInterruptors) { + result = await interruptor.handler(JSON.parse(JSON.stringify(result))); + } + this.$emit('update:note', Object.freeze(result)); + } + }, + + mounted() { + this.capture(true); + + if (this.$i) { + this.connection.on('_connected_', this.onStreamConnected); + } + }, + + beforeUnmount() { + this.decapture(true); + + if (this.$i) { + this.connection.off('_connected_', this.onStreamConnected); + } + }, + + methods: { + updateAppearNote(v) { + this.$emit('update:note', Object.freeze(this.isRenote ? { + ...this.note, + renote: { + ...this.note.renote, + ...v + } + } : { + ...this.note, + ...v + })); + }, + + readPromo() { + os.api('promo/read', { + noteId: this.appearNote.id + }); + this.isDeleted = true; + }, + + capture(withHandler = false) { + if (this.$i) { + this.connection.send(document.body.contains(this.$el) ? 'sn' : 's', { id: this.appearNote.id }); + if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated); + } + }, + + decapture(withHandler = false) { + if (this.$i) { + this.connection.send('un', { + id: this.appearNote.id + }); + if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated); + } + }, + + onStreamConnected() { + this.capture(); + }, + + onStreamNoteUpdated(data) { + const { type, id, body } = data; + + if (id !== this.appearNote.id) return; + + switch (type) { + case 'reacted': { + const reaction = body.reaction; + + // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) + let n = { + ...this.appearNote, + }; + + if (body.emoji) { + const emojis = this.appearNote.emojis || []; + if (!emojis.includes(body.emoji)) { + n.emojis = [...emojis, body.emoji]; + } + } + + // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる + const currentCount = (this.appearNote.reactions || {})[reaction] || 0; + + // Increment the count + n.reactions = { + ...this.appearNote.reactions, + [reaction]: currentCount + 1 + }; + + if (body.userId === this.$i.id) { + n.myReaction = reaction; + } + + this.updateAppearNote(n); + break; + } + + case 'unreacted': { + const reaction = body.reaction; + + // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) + let n = { + ...this.appearNote, + }; + + // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる + const currentCount = (this.appearNote.reactions || {})[reaction] || 0; + + // Decrement the count + n.reactions = { + ...this.appearNote.reactions, + [reaction]: Math.max(0, currentCount - 1) + }; + + if (body.userId === this.$i.id) { + n.myReaction = null; + } + + this.updateAppearNote(n); + break; + } + + case 'pollVoted': { + const choice = body.choice; + + // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) + let n = { + ...this.appearNote, + }; + + const choices = [...this.appearNote.poll.choices]; + choices[choice] = { + ...choices[choice], + votes: choices[choice].votes + 1, + ...(body.userId === this.$i.id ? { + isVoted: true + } : {}) + }; + + n.poll = { + ...this.appearNote.poll, + choices: choices + }; + + this.updateAppearNote(n); + break; + } + + case 'deleted': { + this.isDeleted = true; + break; + } + } + }, + + reply(viaKeyboard = false) { + pleaseLogin(); + os.post({ + reply: this.appearNote, + animation: !viaKeyboard, + }, () => { + this.focus(); + }); + }, + + renote(viaKeyboard = false) { + pleaseLogin(); + this.blur(); + os.modalMenu([{ + text: this.$ts.renote, + icon: faRetweet, + action: () => { + os.api('notes/create', { + renoteId: this.appearNote.id + }); + } + }, { + text: this.$ts.quote, + icon: faQuoteRight, + action: () => { + os.post({ + renote: this.appearNote, + }); + } + }], this.$refs.renoteButton, { + viaKeyboard + }); + }, + + renoteDirectly() { + os.apiWithDialog('notes/create', { + renoteId: this.appearNote.id + }, undefined, (res: any) => { + os.dialog({ + type: 'success', + text: this.$ts.renoted, + }); + }, (e: Error) => { + if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') { + os.dialog({ + type: 'error', + text: this.$ts.cantRenote, + }); + } else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') { + os.dialog({ + type: 'error', + text: this.$ts.cantReRenote, + }); + } + }); + }, + + react(viaKeyboard = false) { + pleaseLogin(); + this.blur(); + os.popup(import('@/components/emoji-picker.vue'), { + src: this.$refs.reactButton, + asReactionPicker: true + }, { + done: reaction => { + if (reaction) { + os.api('notes/reactions/create', { + noteId: this.appearNote.id, + reaction: reaction + }); + } + this.focus(); + }, + }, 'closed'); + }, + + reactDirectly(reaction) { + os.api('notes/reactions/create', { + noteId: this.appearNote.id, + reaction: reaction + }); + }, + + undoReact(note) { + const oldReaction = note.myReaction; + if (!oldReaction) return; + os.api('notes/reactions/delete', { + noteId: note.id + }); + }, + + favorite() { + pleaseLogin(); + os.apiWithDialog('notes/favorites/create', { + noteId: this.appearNote.id + }, undefined, (res: any) => { + os.dialog({ + type: 'success', + text: this.$ts.favorited, + }); + }, (e: Error) => { + if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') { + os.dialog({ + type: 'error', + text: this.$ts.alreadyFavorited, + }); + } else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') { + os.dialog({ + type: 'error', + text: this.$ts.cantFavorite, + }); + } + }); + }, + + del() { + os.dialog({ + type: 'warning', + text: this.$ts.noteDeleteConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + os.api('notes/delete', { + noteId: this.appearNote.id + }); + }); + }, + + delEdit() { + os.dialog({ + type: 'warning', + text: this.$ts.deleteAndEditConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + os.api('notes/delete', { + noteId: this.appearNote.id + }); + + os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel }); + }); + }, + + toggleFavorite(favorite: boolean) { + os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { + noteId: this.appearNote.id + }); + }, + + toggleWatch(watch: boolean) { + os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', { + noteId: this.appearNote.id + }); + }, + + getMenu() { + let menu; + if (this.$i) { + const statePromise = os.api('notes/state', { + noteId: this.appearNote.id + }); + + menu = [{ + icon: faCopy, + text: this.$ts.copyContent, + action: this.copyContent + }, { + icon: faLink, + text: this.$ts.copyLink, + action: this.copyLink + }, (this.appearNote.url || this.appearNote.uri) ? { + icon: faExternalLinkSquareAlt, + text: this.$ts.showOnRemote, + action: () => { + window.open(this.appearNote.url || this.appearNote.uri, '_blank'); + } + } : undefined, + null, + statePromise.then(state => state.isFavorited ? { + icon: faStar, + text: this.$ts.unfavorite, + action: () => this.toggleFavorite(false) + } : { + icon: faStar, + text: this.$ts.favorite, + action: () => this.toggleFavorite(true) + }), + { + icon: faPaperclip, + text: this.$ts.clip, + action: () => this.clip() + }, + (this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? { + icon: faEyeSlash, + text: this.$ts.unwatch, + action: () => this.toggleWatch(false) + } : { + icon: faEye, + text: this.$ts.watch, + action: () => this.toggleWatch(true) + }) : undefined, + this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? { + icon: faThumbtack, + text: this.$ts.unpin, + action: () => this.togglePin(false) + } : { + icon: faThumbtack, + text: this.$ts.pin, + action: () => this.togglePin(true) + } : undefined, + ...(this.$i.isModerator || this.$i.isAdmin ? [ + null, + { + icon: faBullhorn, + text: this.$ts.promote, + action: this.promote + }] + : [] + ), + ...(this.appearNote.userId != this.$i.id ? [ + null, + { + icon: faExclamationCircle, + text: this.$ts.reportAbuse, + action: () => { + const u = `${url}/notes/${this.appearNote.id}`; + os.popup(import('@/components/abuse-report-window.vue'), { + user: this.appearNote.user, + initialComment: `Note: ${u}\n-----\n` + }, {}, 'closed'); + } + }] + : [] + ), + ...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [ + null, + this.appearNote.userId == this.$i.id ? { + icon: faEdit, + text: this.$ts.deleteAndEdit, + action: this.delEdit + } : undefined, + { + icon: faTrashAlt, + text: this.$ts.delete, + danger: true, + action: this.del + }] + : [] + )] + .filter(x => x !== undefined); + } else { + menu = [{ + icon: faCopy, + text: this.$ts.copyContent, + action: this.copyContent + }, { + icon: faLink, + text: this.$ts.copyLink, + action: this.copyLink + }, (this.appearNote.url || this.appearNote.uri) ? { + icon: faExternalLinkSquareAlt, + text: this.$ts.showOnRemote, + action: () => { + window.open(this.appearNote.url || this.appearNote.uri, '_blank'); + } + } : undefined] + .filter(x => x !== undefined); + } + + if (noteActions.length > 0) { + menu = menu.concat([null, ...noteActions.map(action => ({ + icon: faPlug, + text: action.title, + action: () => { + action.handler(this.appearNote); + } + }))]); + } + + return menu; + }, + + onContextmenu(e) { + const isLink = (el: HTMLElement) => { + if (el.tagName === 'A') return true; + if (el.parentElement) { + return isLink(el.parentElement); + } + }; + if (isLink(e.target)) return; + if (window.getSelection().toString() !== '') return; + os.contextMenu(this.getMenu(), e).then(this.focus); + }, + + menu(viaKeyboard = false) { + os.modalMenu(this.getMenu(), this.$refs.menuButton, { + viaKeyboard + }).then(this.focus); + }, + + showRenoteMenu(viaKeyboard = false) { + if (!this.isMyRenote) return; + os.modalMenu([{ + text: this.$ts.unrenote, + icon: faTrashAlt, + danger: true, + action: () => { + os.api('notes/delete', { + noteId: this.note.id + }); + this.isDeleted = true; + } + }], this.$refs.renoteTime, { + viaKeyboard: viaKeyboard + }); + }, + + toggleShowContent() { + this.showContent = !this.showContent; + }, + + copyContent() { + copyToClipboard(this.appearNote.text); + os.success(); + }, + + copyLink() { + copyToClipboard(`${url}/notes/${this.appearNote.id}`); + os.success(); + }, + + togglePin(pin: boolean) { + os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', { + noteId: this.appearNote.id + }, undefined, null, e => { + if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') { + os.dialog({ + type: 'error', + text: this.$ts.pinLimitExceeded + }); + } + }); + }, + + async clip() { + const clips = await os.api('clips/list'); + os.modalMenu([{ + icon: faPlus, + text: this.$ts.createNew, + action: async () => { + const { canceled, result } = await os.form(this.$ts.createNewClip, { + name: { + type: 'string', + label: this.$ts.name + }, + description: { + type: 'string', + required: false, + multiline: true, + label: this.$ts.description + }, + isPublic: { + type: 'boolean', + label: this.$ts.public, + default: false + } + }); + if (canceled) return; + + const clip = await os.apiWithDialog('clips/create', result); + + os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id }); + } + }, null, ...clips.map(clip => ({ + text: clip.name, + action: () => { + os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id }); + } + }))], this.$refs.menuButton, { + }).then(this.focus); + }, + + async promote() { + const { canceled, result: days } = await os.dialog({ + title: this.$ts.numberOfDays, + input: { type: 'number' } + }); + + if (canceled) return; + + os.apiWithDialog('admin/promo/create', { + noteId: this.appearNote.id, + expiresAt: Date.now() + (86400000 * days) + }); + }, + + focus() { + this.$el.focus(); + }, + + blur() { + this.$el.blur(); + }, + + focusBefore() { + focusPrev(this.$el); + }, + + focusAfter() { + focusNext(this.$el); + }, + + userPage + } +}); +</script> + +<style lang="scss" scoped> +.note { + position: relative; + transition: box-shadow 0.1s ease; + overflow: hidden; + contain: content; + + // これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、 + // 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう + // ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、 + // 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる + // 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?) + //content-visibility: auto; + //contain-intrinsic-size: 0 128px; + + &:focus { + outline: none; + } + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &:hover > .article > .main > .footer { + display: block; + } + + &.renote { + background: rgba(128, 255, 0, 0.05); + } + + > .info { + display: flex; + align-items: center; + padding: 16px 32px 8px 32px; + line-height: 24px; + font-size: 90%; + white-space: pre; + color: #d28a3f; + + > [data-icon] { + margin-right: 4px; + } + + > .hide { + margin-left: auto; + color: inherit; + } + } + + > .info + .article { + padding-top: 8px; + } + + > .reply-to { + opacity: 0.7; + padding-bottom: 0; + } + + > .renote { + display: flex; + align-items: center; + padding: 12px 16px 8px 16px; + line-height: 28px; + white-space: pre; + color: var(--renote); + font-size: 0.9em; + + > .avatar { + flex-shrink: 0; + display: inline-block; + width: 28px; + height: 28px; + margin: 0 8px 0 0; + border-radius: 6px; + } + + > [data-icon] { + margin-right: 4px; + } + + > span { + overflow: hidden; + flex-shrink: 1; + text-overflow: ellipsis; + white-space: nowrap; + + > .name { + font-weight: bold; + } + } + + > .info { + margin-left: 8px; + font-size: 0.9em; + opacity: 0.7; + + > .time { + flex-shrink: 0; + color: inherit; + + > .dropdownIcon { + margin-right: 4px; + } + } + + > .visibility { + margin-left: 8px; + } + + > .localOnly { + margin-left: 8px; + } + } + } + + > .renote + .article { + padding-top: 8px; + } + + > .article { + display: flex; + padding: 12px 16px; + + > .avatar { + flex-shrink: 0; + display: block; + //position: sticky; + //top: 72px; + margin: 0 14px 0 0; + width: 46px; + height: 46px; + } + + > .main { + flex: 1; + min-width: 0; + + > .body { + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + &.collapsed { + position: relative; + max-height: 9em; + overflow: hidden; + + > .fade { + display: block; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 64px; + background: linear-gradient(0deg, var(--panel), var(--X15)); + + > 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); + } + } + } + } + + > .text { + overflow-wrap: break-word; + + > .reply { + color: var(--accent); + margin-right: 0.5em; + } + + > .rp { + margin-left: 4px; + font-style: oblique; + color: var(--renote); + } + } + + > .url-preview { + margin-top: 8px; + } + + > .poll { + font-size: 80%; + } + + > .renote { + padding: 8px 0; + + > * { + padding: 16px; + border: dashed 1px var(--renote); + border-radius: 8px; + } + } + } + + > .channel { + opacity: 0.7; + font-size: 80%; + } + } + + > .footer { + display: none; + position: absolute; + top: 8px; + right: 8px; + padding: 0 6px; + opacity: 0.7; + + &:hover { + opacity: 1; + } + + > .button { + margin: 0; + padding: 8px; + opacity: 0.7; + + &:hover { + color: var(--accent); + } + + > .count { + display: inline; + margin: 0 0 0 8px; + opacity: 0.7; + } + + &.reacted { + color: var(--accent); + } + } + } + } + } + + > .reply { + border-top: solid 1px var(--divider); + } +} + +.muted { + padding: 8px 16px; + opacity: 0.7; +} +</style> diff --git a/src/client/ui/chat/notes.vue b/src/client/ui/chat/notes.vue new file mode 100644 index 000000000..1fa2870ce --- /dev/null +++ b/src/client/ui/chat/notes.vue @@ -0,0 +1,91 @@ +<template> +<div class="" :ref="mounted"> + <div class="_fullinfo" v-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-show="more && reversed" style="margin-bottom: var(--margin);"> + <button class="_buttonPrimary" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <template v-if="!moreFetching">{{ $ts.loadMore }}</template> + <template v-if="moreFetching"><MkLoading inline/></template> + </button> + </div> + + <XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed"> + <XNote :note="note" @update:note="updated(note, $event)" :key="note._featuredId_ || note._prId_ || note.id"/> + </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' }"> + <template v-if="!moreFetching">{{ $ts.loadMore }}</template> + <template v-if="moreFetching"><MkLoading inline/></template> + </button> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import paging from '@/scripts/paging'; +import XNote from './note.vue'; +import XList from './date-separated-list.vue'; + +export default defineComponent({ + components: { + XNote, XList, + }, + + mixins: [ + paging({ + before: (self) => { + self.$emit('before'); + }, + + after: (self, e) => { + self.$emit('after', e); + } + }), + ], + + props: { + pagination: { + required: true + }, + + prop: { + type: String, + required: false + } + }, + + emits: ['before', 'after'], + + computed: { + notes(): any[] { + return this.prop ? this.items.map(item => item[this.prop]) : this.items; + }, + + reversed(): boolean { + return this.pagination.reversed; + } + }, + + methods: { + updated(oldValue, newValue) { + const i = this.notes.findIndex(n => n === oldValue); + if (this.prop) { + this.items[i][this.prop] = newValue; + } else { + this.items[i] = newValue; + } + }, + + focus() { + this.$refs.notes.focus(); + } + } +}); +</script> diff --git a/src/client/ui/chat/post-form.vue b/src/client/ui/chat/post-form.vue new file mode 100644 index 000000000..f03e7ebb9 --- /dev/null +++ b/src/client/ui/chat/post-form.vue @@ -0,0 +1,771 @@ +<template> +<div class="pxiwixjf" + @dragover.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.stop="onDrop" +> + <div class="form"> + <div class="with-quote" v-if="quoteId"><Fa icon="quote-left"/> {{ $ts.quoteAttached }}<button @click="quoteId = null"><Fa icon="times"/></button></div> + <div v-if="visibility === 'specified'" class="to-specified"> + <span style="margin-right: 8px;">{{ $ts.recipient }}</span> + <div class="visibleUsers"> + <span v-for="u in visibleUsers" :key="u.id"> + <MkAcct :user="u"/> + <button class="_button" @click="removeVisibleUser(u)"><Fa :icon="faTimes"/></button> + </span> + <button @click="addVisibleUser" class="_buttonPrimary"><Fa :icon="faPlus" fixed-width/></button> + </div> + </div> + <input v-show="useCw" ref="cw" class="cw" v-model="cw" :placeholder="$ts.annotation" @keydown="onKeydown"> + <textarea v-model="text" class="text" :class="{ withCw: useCw }" ref="text" :disabled="posting" :placeholder="placeholder" @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd" /> + <XPostFormAttaches class="attaches" :files="files" @updated="updateFiles" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/> + <XPollEditor v-if="poll" :poll="poll" @destroyed="poll = null" @updated="onPollUpdate"/> + <footer> + <div class="left"> + <button class="_button" @click="chooseFileFrom" v-tooltip="$ts.attachFile"><Fa :icon="faPhotoVideo"/></button> + <button class="_button" @click="togglePoll" :class="{ active: poll }" v-tooltip="$ts.poll"><Fa :icon="faPollH"/></button> + <button class="_button" @click="useCw = !useCw" :class="{ active: useCw }" v-tooltip="$ts.useCw"><Fa :icon="faEyeSlash"/></button> + <button class="_button" @click="insertMention" v-tooltip="$ts.mention"><Fa :icon="faAt"/></button> + <button class="_button" @click="insertEmoji" v-tooltip="$ts.emoji"><Fa :icon="faLaughSquint"/></button> + <button class="_button" @click="showActions" v-tooltip="$ts.plugin" v-if="postFormActions.length > 0"><Fa :icon="faPlug"/></button> + </div> + <div class="right"> + <span class="text-count" :class="{ over: textLength > max }">{{ max - textLength }}</span> + <span class="local-only" v-if="localOnly"><Fa :icon="faBiohazard"/></span> + <button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$ts.visibility" :disabled="channel != null"> + <span v-if="visibility === 'public'"><Fa :icon="faGlobe"/></span> + <span v-if="visibility === 'home'"><Fa :icon="faHome"/></span> + <span v-if="visibility === 'followers'"><Fa :icon="faUnlock"/></span> + <span v-if="visibility === 'specified'"><Fa :icon="faEnvelope"/></span> + </button> + <button class="submit _buttonPrimary" :disabled="!canPost" @click="post">{{ submitText }}<Fa :icon="reply ? faReply : renote ? faQuoteRight : faPaperPlane"/></button> + </div> + </footer> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faPlus, faPhotoVideo, faAt, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons'; +import { faEyeSlash, faLaughSquint } from '@fortawesome/free-regular-svg-icons'; +import insertTextAtCursor from 'insert-text-at-cursor'; +import { length } from 'stringz'; +import { toASCII } from 'punycode'; +import { parse } from '../../../mfm/parse'; +import { host, url } from '@/config'; +import { erase, unique } from '../../../prelude/array'; +import extractMentions from '../../../misc/extract-mentions'; +import getAcct from '../../../misc/acct/render'; +import { formatTimeString } from '../../../misc/format-time-string'; +import { Autocomplete } from '@/scripts/autocomplete'; +import { noteVisibilities } from '../../../types'; +import * as os from '@/os'; +import { selectFile } from '@/scripts/select-file'; +import { notePostInterruptors, postFormActions } from '@/store'; + +export default defineComponent({ + components: { + XPostFormAttaches: defineAsyncComponent(() => import('@/components/post-form-attaches.vue')), + XPollEditor: defineAsyncComponent(() => import('@/components/poll-editor.vue')) + }, + + props: { + reply: { + type: Object, + required: false + }, + renote: { + type: Object, + required: false + }, + channel: { + type: String, + required: false + }, + mention: { + type: Object, + required: false + }, + specified: { + type: Object, + required: false + }, + initialText: { + type: String, + required: false + }, + initialNote: { + type: Object, + required: false + }, + instant: { + type: Boolean, + required: false, + default: false + }, + autofocus: { + type: Boolean, + required: false, + default: true + }, + }, + + emits: ['posted', 'cancel', 'esc'], + + data() { + return { + posting: false, + text: '', + files: [], + poll: null, + useCw: false, + cw: null, + localOnly: this.$store.state.rememberNoteVisibility ? this.$store.state.localOnly : this.$store.state.defaultNoteLocalOnly, + visibility: this.$store.state.rememberNoteVisibility ? this.$store.state.visibility : this.$store.state.defaultNoteVisibility, + visibleUsers: [], + autocomplete: null, + draghover: false, + quoteId: null, + recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'), + imeText: '', + postFormActions, + faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faAt, faBiohazard, faPlug + }; + }, + + computed: { + draftKey(): string { + let key = this.channel ? `channel:${this.channel}` : ''; + + if (this.renote) { + key += `renote:${this.renote.id}`; + } else if (this.reply) { + key += `reply:${this.reply.id}`; + } else { + key += 'note'; + } + + return key; + }, + + placeholder(): string { + if (this.renote) { + return this.$ts._postForm.quotePlaceholder; + } else if (this.reply) { + return this.$ts._postForm.replyPlaceholder; + } else if (this.channel) { + return this.$ts._postForm.channelPlaceholder; + } else { + const xs = [ + this.$ts._postForm._placeholders.a, + this.$ts._postForm._placeholders.b, + this.$ts._postForm._placeholders.c, + this.$ts._postForm._placeholders.d, + this.$ts._postForm._placeholders.e, + this.$ts._postForm._placeholders.f + ]; + return xs[Math.floor(Math.random() * xs.length)]; + } + }, + + submitText(): string { + return this.renote + ? this.$ts.quote + : this.reply + ? this.$ts.reply + : this.$ts.note; + }, + + textLength(): number { + return length((this.text + this.imeText).trim()); + }, + + canPost(): boolean { + return !this.posting && + (1 <= this.textLength || 1 <= this.files.length || !!this.poll || !!this.renote) && + (this.textLength <= this.max) && + (!this.poll || this.poll.choices.length >= 2); + }, + + max(): number { + return this.$instance ? this.$instance.maxNoteTextLength : 1000; + } + }, + + mounted() { + if (this.initialText) { + this.text = this.initialText; + } + + if (this.mention) { + this.text = this.mention.host ? `@${this.mention.username}@${toASCII(this.mention.host)}` : `@${this.mention.username}`; + this.text += ' '; + } + + if (this.reply && this.reply.user.host != null) { + this.text = `@${this.reply.user.username}@${toASCII(this.reply.user.host)} `; + } + + if (this.reply && this.reply.text != null) { + const ast = parse(this.reply.text); + + for (const x of extractMentions(ast)) { + const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`; + + // 自分は除外 + if (this.$i.username == x.username && x.host == null) continue; + if (this.$i.username == x.username && x.host == host) continue; + + // 重複は除外 + if (this.text.indexOf(`${mention} `) != -1) continue; + + this.text += `${mention} `; + } + } + + if (this.channel) { + this.visibility = 'public'; + this.localOnly = true; // TODO: チャンネルが連合するようになった折には消す + } + + // 公開以外へのリプライ時は元の公開範囲を引き継ぐ + if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) { + this.visibility = this.reply.visibility; + if (this.reply.visibility === 'specified') { + os.api('users/show', { + userIds: this.reply.visibleUserIds.filter(uid => uid !== this.$i.id && uid !== this.reply.userId) + }).then(users => { + this.visibleUsers.push(...users); + }); + + if (this.reply.userId !== this.$i.id) { + os.api('users/show', { userId: this.reply.userId }).then(user => { + this.visibleUsers.push(user); + }); + } + } + } + + if (this.specified) { + this.visibility = 'specified'; + this.visibleUsers.push(this.specified); + } + + // keep cw when reply + if (this.$store.state.keepCw && this.reply && this.reply.cw) { + this.useCw = true; + this.cw = this.reply.cw; + } + + if (this.autofocus) { + this.focus(); + + this.$nextTick(() => { + this.focus(); + }); + } + + // TODO: detach when unmount + new Autocomplete(this.$refs.text, this, { model: 'text' }); + new Autocomplete(this.$refs.cw, this, { model: 'cw' }); + + this.$nextTick(() => { + // 書きかけの投稿を復元 + if (!this.instant && !this.mention && !this.specified) { + const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftKey]; + if (draft) { + this.text = draft.data.text; + this.useCw = draft.data.useCw; + this.cw = draft.data.cw; + this.visibility = draft.data.visibility; + this.localOnly = draft.data.localOnly; + this.files = (draft.data.files || []).filter(e => e); + if (draft.data.poll) { + this.poll = draft.data.poll; + } + } + } + + // 削除して編集 + if (this.initialNote) { + const init = this.initialNote; + this.text = init.text ? init.text : ''; + this.files = init.files; + this.cw = init.cw; + this.useCw = init.cw != null; + if (init.poll) { + this.poll = init.poll; + } + this.visibility = init.visibility; + this.localOnly = init.localOnly; + this.quoteId = init.renote ? init.renote.id : null; + } + + this.$nextTick(() => this.watch()); + }); + }, + + methods: { + watch() { + this.$watch('text', () => this.saveDraft()); + this.$watch('useCw', () => this.saveDraft()); + this.$watch('cw', () => this.saveDraft()); + this.$watch('poll', () => this.saveDraft()); + this.$watch('files', () => this.saveDraft(), { deep: true }); + this.$watch('visibility', () => this.saveDraft()); + this.$watch('localOnly', () => this.saveDraft()); + }, + + togglePoll() { + if (this.poll) { + this.poll = null; + } else { + this.poll = { + choices: ['', ''], + multiple: false, + expiresAt: null, + expiredAfter: null, + }; + } + }, + + addTag(tag: string) { + insertTextAtCursor(this.$refs.text, ` #${tag} `); + }, + + focus() { + (this.$refs.text as any).focus(); + }, + + chooseFileFrom(ev) { + selectFile(ev.currentTarget || ev.target, this.$ts.attachFile, true).then(files => { + for (const file of files) { + this.files.push(file); + } + }); + }, + + detachFile(id) { + this.files = this.files.filter(x => x.id != id); + }, + + updateFiles(files) { + this.files = files; + }, + + updateFileSensitive(file, sensitive) { + this.files[this.files.findIndex(x => x.id === file.id)].isSensitive = sensitive; + }, + + updateFileName(file, name) { + this.files[this.files.findIndex(x => x.id === file.id)].name = name; + }, + + upload(file: File, name?: string) { + os.upload(file, this.$store.state.uploadFolder, name).then(res => { + this.files.push(res); + }); + }, + + onPollUpdate(poll) { + this.poll = poll; + this.saveDraft(); + }, + + setVisibility() { + if (this.channel) { + // TODO: information dialog + return; + } + + os.popup(import('@/components/visibility-picker.vue'), { + currentVisibility: this.visibility, + currentLocalOnly: this.localOnly, + src: this.$refs.visibilityButton + }, { + changeVisibility: visibility => { + this.visibility = visibility; + if (this.$store.state.rememberNoteVisibility) { + this.$store.set('visibility', visibility); + } + }, + changeLocalOnly: localOnly => { + this.localOnly = localOnly; + if (this.$store.state.rememberNoteVisibility) { + this.$store.set('localOnly', localOnly); + } + } + }, 'closed'); + }, + + addVisibleUser() { + os.selectUser().then(user => { + this.visibleUsers.push(user); + }); + }, + + removeVisibleUser(user) { + this.visibleUsers = erase(user, this.visibleUsers); + }, + + clear() { + this.text = ''; + this.files = []; + this.poll = null; + this.quoteId = null; + }, + + 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'); + }, + + onCompositionUpdate(e: CompositionEvent) { + this.imeText = e.data; + }, + + onCompositionEnd(e: CompositionEvent) { + this.imeText = ''; + }, + + async onPaste(e: ClipboardEvent) { + for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) { + if (item.kind == 'file') { + const file = item.getAsFile(); + const lio = file.name.lastIndexOf('.'); + const ext = lio >= 0 ? file.name.slice(lio) : ''; + const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; + this.upload(file, formatted); + } + } + + const paste = e.clipboardData.getData('text'); + + if (!this.renote && !this.quoteId && paste.startsWith(url + '/notes/')) { + e.preventDefault(); + + os.dialog({ + type: 'info', + text: this.$ts.quoteQuestion, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) { + insertTextAtCursor(this.$refs.text, paste); + return; + } + + this.quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1]; + }); + } + }, + + onDragover(e) { + if (!e.dataTransfer.items[0]) return; + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; + if (isFile || isDriveFile) { + e.preventDefault(); + this.draghover = true; + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } + }, + + onDragenter(e) { + this.draghover = true; + }, + + onDragleave(e) { + this.draghover = false; + }, + + onDrop(e): void { + this.draghover = false; + + // ファイルだったら + if (e.dataTransfer.files.length > 0) { + e.preventDefault(); + for (const x of Array.from(e.dataTransfer.files)) this.upload(x); + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + this.files.push(file); + e.preventDefault(); + } + //#endregion + }, + + saveDraft() { + if (this.instant) return; + + const data = JSON.parse(localStorage.getItem('drafts') || '{}'); + + data[this.draftKey] = { + updatedAt: new Date(), + data: { + text: this.text, + useCw: this.useCw, + cw: this.cw, + visibility: this.visibility, + localOnly: this.localOnly, + files: this.files, + poll: this.poll + } + }; + + localStorage.setItem('drafts', JSON.stringify(data)); + }, + + deleteDraft() { + const data = JSON.parse(localStorage.getItem('drafts') || '{}'); + + delete data[this.draftKey]; + + localStorage.setItem('drafts', JSON.stringify(data)); + }, + + async post() { + let data = { + text: this.text == '' ? undefined : this.text, + fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined, + replyId: this.reply ? this.reply.id : undefined, + renoteId: this.renote ? this.renote.id : this.quoteId ? this.quoteId : undefined, + channelId: this.channel ? this.channel : undefined, + poll: this.poll, + cw: this.useCw ? this.cw || '' : undefined, + localOnly: this.localOnly, + visibility: this.visibility, + visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined, + viaMobile: os.isMobile + }; + + // plugin + if (notePostInterruptors.length > 0) { + for (const interruptor of notePostInterruptors) { + data = await interruptor.handler(JSON.parse(JSON.stringify(data))); + } + } + + this.posting = true; + os.api('notes/create', data).then(() => { + this.clear(); + this.$nextTick(() => { + this.deleteDraft(); + this.$emit('posted'); + if (this.text && this.text != '') { + const hashtags = parse(this.text).filter(x => x.node.type === 'hashtag').map(x => x.node.props.hashtag); + const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[]; + localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history)))); + } + this.posting = false; + }); + }).catch(err => { + this.posting = false; + os.dialog({ + type: 'error', + text: err.message + '\n' + (err as any).id, + }); + }); + }, + + cancel() { + this.$emit('cancel'); + }, + + insertMention() { + os.selectUser().then(user => { + insertTextAtCursor(this.$refs.text, '@' + getAcct(user) + ' '); + }); + }, + + async insertEmoji(ev) { + os.pickEmoji(ev.currentTarget || ev.target).then(emoji => { + insertTextAtCursor(this.$refs.text, emoji); + }); + }, + + showActions(ev) { + os.modalMenu(postFormActions.map(action => ({ + text: action.title, + action: () => { + action.handler({ + text: this.text + }, (key, value) => { + if (key === 'text') { this.text = value; } + }); + } + })), ev.currentTarget || ev.target); + } + } +}); +</script> + +<style lang="scss" scoped> +.pxiwixjf { + position: relative; + border: solid 1px var(--divider); + border-radius: 8px; + + > .form { + > .preview { + padding: 16px; + } + + > .with-quote { + margin: 0 0 8px 0; + color: var(--accent); + + > button { + padding: 4px 8px; + color: var(--accentAlpha04); + + &:hover { + color: var(--accentAlpha06); + } + + &:active { + color: var(--accentDarken30); + } + } + } + + > .to-specified { + padding: 6px 24px; + margin-bottom: 8px; + overflow: auto; + white-space: nowrap; + + > .visibleUsers { + display: inline; + top: -1px; + font-size: 14px; + + > button { + padding: 4px; + border-radius: 8px; + } + + > span { + margin-right: 14px; + padding: 8px 0 8px 8px; + border-radius: 8px; + background: var(--X4); + + > button { + padding: 4px 8px; + } + } + } + } + + > .cw, + > .text { + display: block; + box-sizing: border-box; + padding: 16px; + margin: 0; + width: 100%; + font-size: 16px; + border: none; + border-radius: 0; + background: transparent; + color: var(--fg); + font-family: inherit; + + &:focus { + outline: none; + } + + &:disabled { + opacity: 0.5; + } + } + + > .cw { + z-index: 1; + padding-bottom: 8px; + border-bottom: solid 1px var(--divider); + } + + > .text { + max-width: 100%; + min-width: 100%; + min-height: 60px; + + &.withCw { + padding-top: 8px; + } + } + + > footer { + $height: 44px; + display: flex; + padding: 0 8px 8px 8px; + line-height: $height; + + > .left { + > button { + display: inline-block; + padding: 0; + margin: 0; + font-size: 16px; + width: $height; + height: $height; + border-radius: 6px; + + &:hover { + background: var(--X5); + } + + &.active { + color: var(--accent); + } + } + } + + > .right { + margin-left: auto; + + > .text-count { + opacity: 0.7; + } + + > .visibility { + width: $height; + margin: 0 8px; + + & + .localOnly { + margin-left: 0 !important; + } + } + + > .local-only { + margin: 0 0 0 12px; + opacity: 0.7; + } + + > .submit { + margin: 0; + padding: 0 12px; + line-height: 34px; + font-weight: bold; + border-radius: 4px; + + &:disabled { + opacity: 0.7; + } + + > [data-icon] { + margin-left: 6px; + } + } + } + } + } +} +</style> diff --git a/src/client/ui/chat/side.vue b/src/client/ui/chat/side.vue new file mode 100644 index 000000000..188123deb --- /dev/null +++ b/src/client/ui/chat/side.vue @@ -0,0 +1,165 @@ +<template> +<div class="qvzfzxam _narrow_" v-if="component"> + <div class="container"> + <header class="header" @contextmenu.prevent.stop="onContextmenu"> + <button class="_button" @click="back()" v-if="history.length > 0"><Fa :icon="faChevronLeft"/></button> + <XHeader class="title" :info="pageInfo" :with-back="false" :center="false"/> + <button class="_button" @click="close()"><Fa :icon="faTimes"/></button> + </header> + <component :is="component" v-bind="props" :ref="changePage"/> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faTimes, faChevronLeft, faExpandAlt, faWindowMaximize, faExternalLinkAlt, faLink } from '@fortawesome/free-solid-svg-icons'; +import XHeader from '../_common_/header.vue'; +import * as os from '@/os'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { resolve } from '@/router'; +import { url } from '@/config'; + +export default defineComponent({ + components: { + XHeader + }, + + provide() { + return { + navHook: (path) => { + this.navigate(path); + } + }; + }, + + data() { + return { + path: null, + component: null, + props: {}, + pageInfo: null, + history: [], + faTimes, faChevronLeft, + }; + }, + + computed: { + url(): string { + return url + this.path; + } + }, + + methods: { + changePage(page) { + if (page == null) return; + if (page.INFO) { + this.pageInfo = page.INFO; + } + }, + + navigate(path, record = true) { + if (record && this.path) this.history.push(this.path); + this.path = path; + const { component, props } = resolve(path); + this.component = component; + this.props = props; + }, + + back() { + this.navigate(this.history.pop(), false); + }, + + close() { + this.path = null; + this.component = null; + this.props = {}; + }, + + onContextmenu(e) { + os.contextMenu([{ + type: 'label', + text: this.path, + }, { + icon: faExpandAlt, + text: this.$ts.showInPage, + action: () => { + this.$router.push(this.path); + this.close(); + } + }, { + icon: faWindowMaximize, + text: this.$ts.openInWindow, + action: () => { + os.pageWindow(this.path); + this.close(); + } + }, null, { + icon: faExternalLinkAlt, + text: this.$ts.openInNewTab, + action: () => { + window.open(this.url, '_blank'); + this.close(); + } + }, { + icon: faLink, + text: this.$ts.copyLink, + action: () => { + copyToClipboard(this.url); + } + }], e); + } + } +}); +</script> + +<style lang="scss" scoped> +.qvzfzxam { + $header-height: 54px; // TODO: どこかに集約したい + + --section-padding: 16px; + --margin: var(--marginHalf); + + width: 390px; + + > .container { + position: fixed; + width: 390px; + height: 100vh; + overflow: auto; + box-sizing: border-box; + + > .header { + display: flex; + position: sticky; + z-index: 1000; + top: 0; + height: $header-height; + width: 100%; + line-height: $header-height; + font-weight: bold; + //background-color: var(--panel); + -webkit-backdrop-filter: blur(32px); + backdrop-filter: blur(32px); + background-color: var(--header); + border-bottom: solid 1px var(--divider); + box-sizing: border-box; + + > ._button { + height: $header-height; + width: $header-height; + + &:hover { + color: var(--fgHighlighted); + } + } + + > .title { + flex: 1; + position: relative; + } + } + } +} +</style> + diff --git a/src/client/ui/chat/sub-note-content.vue b/src/client/ui/chat/sub-note-content.vue new file mode 100644 index 000000000..7e742b8e5 --- /dev/null +++ b/src/client/ui/chat/sub-note-content.vue @@ -0,0 +1,64 @@ +<template> +<div class="wrmlmaau"> + <div class="body"> + <span v-if="note.isHidden" style="opacity: 0.5">({{ $ts.private }})</span> + <span v-if="note.deletedAt" style="opacity: 0.5">({{ $ts.deleted }})</span> + <MkA class="reply" v-if="note.replyId" :to="`/notes/${note.replyId}`"><Fa :icon="faReply"/></MkA> + <Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/> + <MkA class="rp" v-if="note.renoteId" :to="`/notes/${note.renoteId}`">RN: ...</MkA> + </div> + <details v-if="note.files.length > 0"> + <summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary> + <XMediaList :media-list="note.files"/> + </details> + <details v-if="note.poll"> + <summary>{{ $ts.poll }}</summary> + <XPoll :note="note"/> + </details> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faReply } from '@fortawesome/free-solid-svg-icons'; +import XPoll from '@/components/poll.vue'; +import XMediaList from '@/components/media-list.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XPoll, + XMediaList, + }, + props: { + note: { + type: Object, + required: true + } + }, + data() { + return { + faReply + }; + } +}); +</script> + +<style lang="scss" scoped> +.wrmlmaau { + overflow-wrap: break-word; + + > .body { + > .reply { + margin-right: 6px; + color: var(--accent); + } + + > .rp { + margin-left: 4px; + font-style: oblique; + color: var(--renote); + } + } +} +</style> diff --git a/src/client/ui/chat/timeline.vue b/src/client/ui/chat/timeline.vue new file mode 100644 index 000000000..3a32b9fae --- /dev/null +++ b/src/client/ui/chat/timeline.vue @@ -0,0 +1,190 @@ +<template> +<XNotes ref="tl" :pagination="pagination" @queue="$emit('queue', $event)" v-follow="pagination.reversed"/> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XNotes from './notes.vue'; +import * as os from '@/os'; +import * as sound from '@/scripts/sound'; +import { scrollToBottom } from '@/scripts/scroll'; +import follow from '@/directives/follow-append'; + +export default defineComponent({ + components: { + XNotes + }, + + directives: { + follow + }, + + provide() { + return { + inChannel: this.src === 'channel' + }; + }, + + props: { + src: { + type: String, + required: true + }, + list: { + type: String, + required: false + }, + antenna: { + type: String, + required: false + }, + channel: { + type: String, + required: false + }, + sound: { + type: Boolean, + required: false, + default: false, + } + }, + + emits: ['note', 'queue', 'before', 'after'], + + data() { + return { + connection: null, + connection2: null, + pagination: null, + baseQuery: { + includeMyRenotes: this.$store.state.showMyRenotes, + includeRenotedMyNotes: this.$store.state.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.showLocalRenotes + }, + query: {}, + }; + }, + + created() { + const prepend = note => { + (this.$refs.tl as any).prepend(note); + + this.$emit('note'); + + if (this.sound) { + sound.play(note.userId === this.$i.id ? 'noteMy' : 'note'); + } + }; + + const onUserAdded = () => { + (this.$refs.tl as any).reload(); + }; + + const onUserRemoved = () => { + (this.$refs.tl as any).reload(); + }; + + const onChangeFollowing = () => { + if (!this.$refs.tl.backed) { + this.$refs.tl.reload(); + } + }; + + let endpoint; + let reversed = false; + + if (this.src == 'antenna') { + endpoint = 'antennas/notes'; + this.query = { + antennaId: this.antenna + }; + this.connection = os.stream.connectToChannel('antenna', { + antennaId: this.antenna + }); + this.connection.on('note', prepend); + } else if (this.src == 'home') { + endpoint = 'notes/timeline'; + this.connection = os.stream.useSharedConnection('homeTimeline'); + this.connection.on('note', prepend); + + this.connection2 = os.stream.useSharedConnection('main'); + this.connection2.on('follow', onChangeFollowing); + this.connection2.on('unfollow', onChangeFollowing); + } else if (this.src == 'local') { + endpoint = 'notes/local-timeline'; + this.connection = os.stream.useSharedConnection('localTimeline'); + this.connection.on('note', prepend); + } else if (this.src == 'social') { + endpoint = 'notes/hybrid-timeline'; + this.connection = os.stream.useSharedConnection('hybridTimeline'); + this.connection.on('note', prepend); + } else if (this.src == 'global') { + endpoint = 'notes/global-timeline'; + this.connection = os.stream.useSharedConnection('globalTimeline'); + this.connection.on('note', prepend); + } else if (this.src == 'mentions') { + endpoint = 'notes/mentions'; + this.connection = os.stream.useSharedConnection('main'); + this.connection.on('mention', prepend); + } else if (this.src == 'directs') { + endpoint = 'notes/mentions'; + this.query = { + visibility: 'specified' + }; + const onNote = note => { + if (note.visibility == 'specified') { + prepend(note); + } + }; + this.connection = os.stream.useSharedConnection('main'); + this.connection.on('mention', onNote); + } else if (this.src == 'list') { + endpoint = 'notes/user-list-timeline'; + this.query = { + listId: this.list + }; + this.connection = os.stream.connectToChannel('userList', { + listId: this.list + }); + this.connection.on('note', prepend); + this.connection.on('userAdded', onUserAdded); + this.connection.on('userRemoved', onUserRemoved); + } else if (this.src == 'channel') { + endpoint = 'channels/timeline'; + reversed = true; + this.query = { + channelId: this.channel + }; + this.connection = os.stream.connectToChannel('channel', { + channelId: this.channel + }); + this.connection.on('note', prepend); + } + + this.pagination = { + endpoint: endpoint, + reversed, + limit: 10, + params: init => ({ + untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined), + ...this.baseQuery, ...this.query + }) + }; + }, + + mounted() { + + }, + + beforeUnmount() { + this.connection.dispose(); + if (this.connection2) this.connection2.dispose(); + }, + + methods: { + focus() { + this.$refs.tl.focus(); + }, + } +}); +</script>