diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-12 02:02:25 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-12 02:02:25 +0900 |
| commit | 0e4a111f81cceed275d9bec2695f6e401fb654d8 (patch) | |
| tree | 40874799472fa07416f17b50a398ac33b7771905 /packages/client/src/ui/chat | |
| parent | update deps (diff) | |
| download | misskey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.gz misskey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.bz2 misskey-0e4a111f81cceed275d9bec2695f6e401fb654d8.zip | |
refactoring
Resolve #7779
Diffstat (limited to 'packages/client/src/ui/chat')
| -rw-r--r-- | packages/client/src/ui/chat/date-separated-list.vue | 163 | ||||
| -rw-r--r-- | packages/client/src/ui/chat/header-clock.vue | 62 | ||||
| -rw-r--r-- | packages/client/src/ui/chat/index.vue | 467 | ||||
| -rw-r--r-- | packages/client/src/ui/chat/note-header.vue | 112 | ||||
| -rw-r--r-- | packages/client/src/ui/chat/note-preview.vue | 112 | ||||
| -rw-r--r-- | packages/client/src/ui/chat/note.sub.vue | 137 | ||||
| -rw-r--r-- | packages/client/src/ui/chat/note.vue | 1144 | ||||
| -rw-r--r-- | packages/client/src/ui/chat/notes.vue | 94 | ||||
| -rw-r--r-- | packages/client/src/ui/chat/pages/channel.vue | 259 | ||||
| -rw-r--r-- | packages/client/src/ui/chat/pages/timeline.vue | 221 | ||||
| -rw-r--r-- | packages/client/src/ui/chat/post-form.vue | 772 | ||||
| -rw-r--r-- | packages/client/src/ui/chat/side.vue | 157 | ||||
| -rw-r--r-- | packages/client/src/ui/chat/store.ts | 17 | ||||
| -rw-r--r-- | packages/client/src/ui/chat/sub-note-content.vue | 62 | ||||
| -rw-r--r-- | packages/client/src/ui/chat/widgets.vue | 62 |
15 files changed, 3841 insertions, 0 deletions
diff --git a/packages/client/src/ui/chat/date-separated-list.vue b/packages/client/src/ui/chat/date-separated-list.vue new file mode 100644 index 0000000000..b21e425aba --- /dev/null +++ b/packages/client/src/ui/chat/date-separated-list.vue @@ -0,0 +1,163 @@ +<script lang="ts"> +import { defineComponent, h, PropType, TransitionGroup } from 'vue'; +import MkAd from '@/components/global/ad.vue'; + +export default defineComponent({ + props: { + items: { + type: Array as PropType<{ id: string; createdAt: string; _shouldInsertAd_: boolean; }[]>, + required: true, + }, + reversed: { + type: Boolean, + required: false, + default: false + }, + ad: { + 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 ? 'div' : TransitionGroup, { + class: 'hmjzthxl', + name: this.reversed ? 'list-reversed' : 'list', + tag: 'div', + }, 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() + ) { + const separator = h('div', { + class: 'separator', + key: item.id + ':separator', + }, h('p', { + class: 'date' + }, [ + h('span', [ + h('i', { + class: 'fas fa-angle-up icon', + }), + getDateText(item.createdAt) + ]), + h('span', [ + getDateText(this.items[i + 1].createdAt), + h('i', { + class: 'fas fa-angle-down icon', + }) + ]) + ])); + + return [el, separator]; + } else { + if (this.ad && item._shouldInsertAd_) { + return [h(MkAd, { + class: 'a', // advertiseの意(ブロッカー対策) + key: item.id + ':ad', + prefer: ['horizontal', 'horizontal-big'], + }), el]; + } 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); + } + > .list-enter-from { + opacity: 0; + transform: translateY(-64px); + } + + > .list-reversed-enter-active, > .list-reversed-leave-active { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); + } + > .list-reversed-enter-from { + opacity: 0; + transform: translateY(64px); + } +} +</style> + +<style lang="scss"> +.hmjzthxl { + > .separator { + text-align: center; + position: relative; + + &:before { + content: ""; + display: block; + position: absolute; + top: 50%; + left: 0; + right: 0; + margin: auto; + width: calc(100% - 32px); + height: 1px; + background: var(--divider); + } + + > .date { + display: inline-block; + position: relative; + margin: 0; + padding: 0 16px; + line-height: 32px; + text-align: center; + font-size: 12px; + color: var(--dateLabelFg); + background: var(--panel); + + > span { + &:first-child { + margin-right: 8px; + + > .icon { + margin-right: 8px; + } + } + + &:last-child { + margin-left: 8px; + + > .icon { + margin-left: 8px; + } + } + } + } + } +} +</style> diff --git a/packages/client/src/ui/chat/header-clock.vue b/packages/client/src/ui/chat/header-clock.vue new file mode 100644 index 0000000000..3488289c21 --- /dev/null +++ b/packages/client/src/ui/chat/header-clock.vue @@ -0,0 +1,62 @@ +<template> +<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> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + data() { + return { + clock: null, + y: null, + m: null, + d: null, + hh: null, + mm: null, + ss: null, + showColon: true, + }; + }, + created() { + this.tick(); + this.clock = setInterval(this.tick, 1000); + }, + beforeUnmount() { + clearInterval(this.clock); + }, + 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'); + this.showColon = now.getSeconds() % 2 === 0; + } + } +}); +</script> + +<style lang="scss" scoped> +.acemodlh { + opacity: 0.7; + font-size: 0.85em; + line-height: 1em; + text-align: center; +} +</style> diff --git a/packages/client/src/ui/chat/index.vue b/packages/client/src/ui/chat/index.vue new file mode 100644 index 0000000000..e8d15b2cfc --- /dev/null +++ b/packages/client/src/ui/chat/index.vue @@ -0,0 +1,467 @@ +<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/messaging" v-tooltip="$ts.messaging"><i class="fas fa-comments icon"></i><span v-if="$i.hasUnreadMessagingMessage" class="indicator"><i class="fas fa-circle"></i></span></MkA> + <MkA class="item" to="/my/messages" v-tooltip="$ts.directNotes"><i class="fas fa-envelope icon"></i><span v-if="$i.hasUnreadSpecifiedNotes" class="indicator"><i class="fas fa-circle"></i></span></MkA> + <MkA class="item" to="/my/mentions" v-tooltip="$ts.mentions"><i class="fas fa-at icon"></i><span v-if="$i.hasUnreadMentions" class="indicator"><i class="fas fa-circle"></i></span></MkA> + <MkA class="item" to="/my/notifications" v-tooltip="$ts.notifications"><i class="fas fa-bell icon"></i><span v-if="$i.hasUnreadNotification" class="indicator"><i class="fas fa-circle"></i></span></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' }"><i class="fas fa-home icon"></i>{{ $ts._timelines.home }}</MkA> + <MkA to="/timeline/local" class="item" :class="{ active: tl === 'local' }"><i class="fas fa-comments icon"></i>{{ $ts._timelines.local }}</MkA> + <MkA to="/timeline/social" class="item" :class="{ active: tl === 'social' }"><i class="fas fa-share-alt icon"></i>{{ $ts._timelines.social }}</MkA> + <MkA to="/timeline/global" class="item" :class="{ active: tl === 'global' }"><i class="fas fa-globe icon"></i>{{ $ts._timelines.global }}</MkA> + </div> + </div> + <div class="container" v-if="followedChannels"> + <div class="header">{{ $ts.channel }} ({{ $ts.following }})<button class="_button add" @click="addChannel"><i class="fas fa-plus"></i></button></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 }"><i class="fas fa-satellite-dish icon"></i>{{ channel.name }}</MkA> + </div> + </div> + <div class="container" v-if="featuredChannels"> + <div class="header">{{ $ts.channel }}<button class="_button add" @click="addChannel"><i class="fas fa-plus"></i></button></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 }` }"><i class="fas fa-satellite-dish icon"></i>{{ channel.name }}</MkA> + </div> + </div> + <div class="container" v-if="lists"> + <div class="header">{{ $ts.lists }}<button class="_button add" @click="addList"><i class="fas fa-plus"></i></button></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 }` }"><i class="fas fa-list-ul icon"></i>{{ list.name }}</MkA> + </div> + </div> + <div class="container" v-if="antennas"> + <div class="header">{{ $ts.antennas }}<button class="_button add" @click="addAntenna"><i class="fas fa-plus"></i></button></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 }` }"><i class="fas fa-satellite icon"></i>{{ antenna.name }}</MkA> + </div> + </div> + <div class="container"> + <div class="body"> + <MkA to="/my/favorites" class="item"><i class="fas fa-star icon"></i>{{ $ts.favorites }}</MkA> + </div> + </div> + <MkAd class="a" :prefer="['square']"/> + </div> + <footer class="footer"> + <div class="left"> + <button class="_button menu" @click="showMenu"> + <i class="fas fa-bars icon"></i> + </button> + </div> + <div class="right"> + <button class="_button item search" @click="search" v-tooltip="$ts.search"> + <i class="fas fa-search icon"></i> + </button> + <MkA class="item" to="/settings" v-tooltip="$ts.settings"><i class="fas fa-cog icon"></i></MkA> + </div> + </footer> + </div> + + <main class="main" @contextmenu.stop="onContextmenu"> + <header class="header"> + <MkHeader class="header" :info="pageInfo" :menu="menu" :center="false" @click="onHeaderClick"/> + </header> + <router-view v-slot="{ Component }"> + <transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> + <keep-alive :include="['timeline']"> + <component :is="Component" :ref="changePage" class="body"/> + </keep-alive> + </transition> + </router-view> + </main> + + <XSide class="side" ref="side" @open="sideViewOpening = true" @close="sideViewOpening = false"/> + <div class="side widgets" :class="{ sideViewOpening }"> + <XWidgets/> + </div> + + <XCommon/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { instanceName, url } from '@/config'; +import XSidebar from '@/ui/_common_/sidebar.vue'; +import XWidgets from './widgets.vue'; +import XCommon from '../_common_/common.vue'; +import XSide from './side.vue'; +import XHeaderClock from './header-clock.vue'; +import * as os from '@/os'; +import { router } from '@/router'; +import { menuDef } from '@/menu'; +import { search } from '@/scripts/search'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { store } from './store'; +import * as symbols from '@/symbols'; +import { openAccountMenu } from '@/account'; + +export default defineComponent({ + components: { + XCommon, + XSidebar, + XWidgets, + XSide, // NOTE: dynamic importするとAsyncComponentWrapperが間に入るせいでref取得できなくて面倒になる + XHeaderClock, + }, + + provide() { + return { + sideViewHook: (path) => { + this.$refs.side.navigate(path); + } + }; + }, + + data() { + return { + pageInfo: null, + lists: null, + antennas: null, + followedChannels: null, + featuredChannels: null, + currentChannel: null, + menuDef: menuDef, + sideViewOpening: false, + instanceName, + }; + }, + + computed: { + menu() { + return [{ + icon: 'fas fa-columns', + text: this.$ts.openInSideView, + action: () => { + this.$refs.side.navigate(this.$route.path); + } + }, { + icon: 'fas fa-window-maximize', + text: this.$ts.openInWindow, + action: () => { + os.pageWindow(this.$route.path); + } + }]; + } + }, + + created() { + if (window.innerWidth < 1024) { + localStorage.setItem('ui', 'default'); + location.reload(); + } + + os.api('users/lists/list').then(lists => { + this.lists = lists; + }); + + os.api('antennas/list').then(antennas => { + this.antennas = antennas; + }); + + os.api('channels/followed', { limit: 20 }).then(channels => { + this.followedChannels = channels; + }); + + // TODO: pagination + os.api('channels/featured', { limit: 20 }).then(channels => { + this.featuredChannels = channels; + }); + }, + + methods: { + changePage(page) { + console.log(page); + if (page == null) return; + if (page[symbols.PAGE_INFO]) { + this.pageInfo = page[symbols.PAGE_INFO]; + document.title = `${this.pageInfo.title} | ${instanceName}`; + } + }, + + onTransition() { + if (window._scroll) window._scroll(); + }, + + showMenu() { + this.$refs.menu.show(); + }, + + post() { + os.post(); + }, + + search() { + search(); + }, + + back() { + history.back(); + }, + + 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', 'IMG', 'VIDEO', 'CANVAS'].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: 'fas fa-columns', + text: this.$ts.openInSideView, + action: () => { + this.$refs.side.navigate(path); + } + }, { + icon: 'fas fa-window-maximize', + text: this.$ts.openInWindow, + action: () => { + os.pageWindow(path); + } + }], e); + }, + + openAccountMenu, + } +}); +</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/ + height: calc(var(--vh, 1vh) * 100); + display: flex; + + > .nav { + display: flex; + flex-direction: column; + width: 250px; + height: 100vh; + border-right: solid 4px var(--divider); + + > .header, > .footer { + $padding: 8px; + display: flex; + align-items: center; + z-index: 1000; + height: $header-height; + padding: $padding; + box-sizing: border-box; + user-select: none; + + &.header { + border-bottom: solid 0.5px var(--divider); + } + + &.footer { + border-top: solid 0.5px var(--divider); + } + + > .left, > .right { + > .item, > .menu { + display: inline-flex; + vertical-align: middle; + height: ($header-height - ($padding * 2)); + width: ($header-height - ($padding * 2)); + box-sizing: border-box; + //opacity: 0.6; + position: relative; + border-radius: 5px; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + > .icon { + margin: auto; + } + + > .indicator { + position: absolute; + top: 8px; + right: 8px; + color: var(--indicator); + font-size: 8px; + line-height: 8px; + animation: blink 1s infinite; + } + } + } + + > .left { + flex: 1; + min-width: 0; + + > .account { + display: flex; + align-items: center; + padding: 0 8px; + + > .avatar { + width: 26px; + height: 26px; + margin-right: 8px; + } + + > .text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.9em; + } + } + } + + > .right { + margin-left: auto; + } + } + + > .body { + flex: 1; + min-width: 0; + overflow: auto; + + > .container { + margin-top: 8px; + margin-bottom: 8px; + + & + .container { + margin-top: 16px; + } + + > .header { + display: flex; + font-size: 0.9em; + padding: 8px 16px; + position: sticky; + top: 0; + background: var(--X17); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + z-index: 1; + color: var(--fgTransparentWeak); + + > .add { + margin-left: auto; + color: var(--fgTransparentWeak); + + &:hover { + color: var(--fg); + } + } + } + + > .body { + padding: 0 8px; + + > .item { + display: block; + padding: 6px 8px; + border-radius: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:hover { + text-decoration: none; + background: rgba(0, 0, 0, 0.05); + } + + &.active, &.active:hover { + background: var(--accent); + color: #fff !important; + } + + &.read { + color: var(--fgTransparent); + } + + > .icon { + margin-right: 8px; + opacity: 0.6; + } + } + } + } + + > .a { + margin: 12px; + } + } + } + + > .main { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + height: 100vh; + position: relative; + background: var(--panel); + + > .header { + z-index: 1000; + height: $header-height; + background-color: var(--panel); + border-bottom: solid 0.5px var(--divider); + user-select: none; + } + + > .body { + width: 100%; + box-sizing: border-box; + overflow: auto; + } + } + + > .side { + width: 350px; + border-left: solid 4px var(--divider); + background: var(--panel); + + &.widgets.sideViewOpening { + @media (max-width: 1400px) { + display: none; + } + } + } +} +</style> diff --git a/packages/client/src/ui/chat/note-header.vue b/packages/client/src/ui/chat/note-header.vue new file mode 100644 index 0000000000..8ab03501b2 --- /dev/null +++ b/packages/client/src/ui/chat/note-header.vue @@ -0,0 +1,112 @@ +<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"><i class="fas fa-bookmark"></i></span> + <span class="moderator" v-if="!note.user.isAdmin && note.user.isModerator"><i class="far fa-bookmark"></i></span> + <div class="info"> + <span class="mobile" v-if="note.viaMobile"><i class="fas fa-mobile-alt"></i></span> + <MkA class="created-at" :to="notePage(note)"> + <MkTime :time="note.createdAt"/> + </MkA> + <span class="visibility" v-if="note.visibility !== 'public'"> + <i v-if="note.visibility === 'home'" class="fas fa-home"></i> + <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> + <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> + </span> + <span class="localOnly" v-if="note.localOnly"><i class="fas fa-biohazard"></i></span> + </div> +</header> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +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 { + }; + }, + + 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 0.5px 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/packages/client/src/ui/chat/note-preview.vue b/packages/client/src/ui/chat/note-preview.vue new file mode 100644 index 0000000000..2a08a3d7f5 --- /dev/null +++ b/packages/client/src/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="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/packages/client/src/ui/chat/note.sub.vue b/packages/client/src/ui/chat/note.sub.vue new file mode 100644 index 0000000000..75d9d98088 --- /dev/null +++ b/packages/client/src/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="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 0.5px var(--divider); + margin-top: 10px; + } +} +</style> diff --git a/packages/client/src/ui/chat/note.vue b/packages/client/src/ui/chat/note.vue new file mode 100644 index 0000000000..c0b5cebd94 --- /dev/null +++ b/packages/client/src/ui/chat/note.vue @@ -0,0 +1,1144 @@ +<template> +<div + class="vfzoeqcg" + v-if="!muted" + v-show="!isDeleted" + :tabindex="!isDeleted ? '-1' : null" + :class="{ renote: isRenote, highlighted: appearNote._prId_ || appearNote._featuredId_, operating }" + v-hotkey="keymap" +> + <XSub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/> + <div class="info" v-if="pinned"><i class="fas fa-thumbtack"></i> {{ $ts.pinnedNote }}</div> + <div class="info" v-if="appearNote._prId_"><i class="fas fa-bullhorn"></i> {{ $ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ $ts.hideThisNote }} <i class="fas fa-times"></i></button></div> + <div class="info" v-if="appearNote._featuredId_"><i class="fas fa-bolt"></i> {{ $ts.featured }}</div> + <div class="renote" v-if="isRenote"> + <MkAvatar class="avatar" :user="note.user"/> + <i class="fas fa-retweet"></i> + <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"> + <i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i> + <MkTime :time="note.createdAt"/> + </button> + <span class="visibility" v-if="note.visibility !== 'public'"> + <i v-if="note.visibility === 'home'" class="fas fa-home"></i> + <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> + <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> + </span> + <span class="localOnly" v-if="note.localOnly"><i class="fas fa-biohazard"></i></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="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}`"><i class="fas fa-reply"></i></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"><XNoteSimple :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}`"><i class="fas fa-satellite-dish"></i> {{ appearNote.channel.name }}</MkA> + </div> + <XReactionsViewer :note="appearNote" ref="reactionsViewer"/> + <footer class="footer _panel"> + <button @click="reply()" class="button _button" v-tooltip="$ts.reply"> + <template v-if="appearNote.reply"><i class="fas fa-reply-all"></i></template> + <template v-else><i class="fas fa-reply"></i></template> + <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> + </button> + <button v-if="canRenote" @click="renote()" class="button _button" ref="renoteButton" v-tooltip="$ts.renote"> + <i class="fas fa-retweet"></i><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> + </button> + <button v-else class="button _button"> + <i class="fas fa-ban"></i> + </button> + <button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton" v-tooltip="$ts.reaction"> + <i class="fas fa-plus"></i> + </button> + <button v-if="appearNote.myReaction != null" class="button _button reacted" @click="undoReact(appearNote)" ref="reactButton" v-tooltip="$ts.reaction"> + <i class="fas fa-minus"></i> + </button> + <button class="button _button" @click="menu()" ref="menuButton"> + <i class="fas fa-ellipsis-h"></i> + </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 { defineAsyncComponent, defineComponent, markRaw } from 'vue'; +import * as mfm from 'mfm-js'; +import { sum } from '@/scripts/array'; +import XSub from './note.sub.vue'; +import XNoteHeader from './note-header.vue'; +import XNoteSimple 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'; +import { reactionPicker } from '@/scripts/reaction-picker'; +import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; + +export default defineComponent({ + components: { + XSub, + XNoteHeader, + XNoteSimple, + 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, + operating: false, + }; + }, + + 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) { + return extractUrlFromMfm(mfm.parse(this.appearNote.text)); + } 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) { + // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する + this.connection.send(document.body.contains(this.$el) ? 'sr' : '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(); + this.operating = true; + os.post({ + reply: this.appearNote, + animation: !viaKeyboard, + }, () => { + this.operating = false; + this.focus(); + }); + }, + + renote(viaKeyboard = false) { + pleaseLogin(); + this.operating = true; + this.blur(); + os.popupMenu([{ + text: this.$ts.renote, + icon: 'fas fa-retweet', + action: () => { + os.api('notes/create', { + renoteId: this.appearNote.id + }); + } + }, { + text: this.$ts.quote, + icon: 'fas fa-quote-right', + action: () => { + os.post({ + renote: this.appearNote, + }); + } + }], this.$refs.renoteButton, { + viaKeyboard + }).then(() => { + this.operating = false; + }); + }, + + 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, + }); + } + }); + }, + + async react(viaKeyboard = false) { + pleaseLogin(); + this.operating = true; + this.blur(); + reactionPicker.show(this.$refs.reactButton, reaction => { + os.api('notes/reactions/create', { + noteId: this.appearNote.id, + reaction: reaction + }); + }, () => { + this.operating = false; + this.focus(); + }); + }, + + 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: 'fas fa-copy', + text: this.$ts.copyContent, + action: this.copyContent + }, { + icon: 'fas fa-link', + text: this.$ts.copyLink, + action: this.copyLink + }, (this.appearNote.url || this.appearNote.uri) ? { + icon: 'fas fa-external-link-square-alt', + text: this.$ts.showOnRemote, + action: () => { + window.open(this.appearNote.url || this.appearNote.uri, '_blank'); + } + } : undefined, + { + icon: 'fas fa-share-alt', + text: this.$ts.share, + action: this.share + }, + null, + statePromise.then(state => state.isFavorited ? { + icon: 'fas fa-star', + text: this.$ts.unfavorite, + action: () => this.toggleFavorite(false) + } : { + icon: 'fas fa-star', + text: this.$ts.favorite, + action: () => this.toggleFavorite(true) + }), + { + icon: 'fas fa-paperclip', + text: this.$ts.clip, + action: () => this.clip() + }, + (this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? { + icon: 'fas fa-eye-slash', + text: this.$ts.unwatch, + action: () => this.toggleWatch(false) + } : { + icon: 'fas fa-eye', + text: this.$ts.watch, + action: () => this.toggleWatch(true) + }) : undefined, + this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? { + icon: 'fas fa-thumbtack', + text: this.$ts.unpin, + action: () => this.togglePin(false) + } : { + icon: 'fas fa-thumbtack', + text: this.$ts.pin, + action: () => this.togglePin(true) + } : undefined, + ...(this.$i.isModerator || this.$i.isAdmin ? [ + null, + { + icon: 'fas fa-bullhorn', + text: this.$ts.promote, + action: this.promote + }] + : [] + ), + ...(this.appearNote.userId != this.$i.id ? [ + null, + { + icon: 'fas fa-exclamation-circle', + 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: 'fas fa-edit', + text: this.$ts.deleteAndEdit, + action: this.delEdit + } : undefined, + { + icon: 'fas fa-trash-alt', + text: this.$ts.delete, + danger: true, + action: this.del + }] + : [] + )] + .filter(x => x !== undefined); + } else { + menu = [{ + icon: 'fas fa-copy', + text: this.$ts.copyContent, + action: this.copyContent + }, { + icon: 'fas fa-link', + text: this.$ts.copyLink, + action: this.copyLink + }, (this.appearNote.url || this.appearNote.uri) ? { + icon: 'fas fa-external-link-square-alt', + 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: 'fas fa-plug', + 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; + + if (this.$store.state.useReactionPickerForContextMenu) { + e.preventDefault(); + this.react(); + } else { + os.contextMenu(this.getMenu(), e).then(this.focus); + } + }, + + menu(viaKeyboard = false) { + this.operating = true; + os.popupMenu(this.getMenu(), this.$refs.menuButton, { + viaKeyboard + }).then(() => { + this.operating = false; + this.focus(); + }); + }, + + showRenoteMenu(viaKeyboard = false) { + if (!this.isMyRenote) return; + os.popupMenu([{ + text: this.$ts.unrenote, + icon: 'fas fa-trash-alt', + 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.popupMenu([{ + icon: 'fas fa-plus', + 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) + }); + }, + + share() { + navigator.share({ + title: this.$t('noteOf', { user: this.appearNote.user.name }), + text: this.appearNote.text, + url: `${url}/notes/${this.appearNote.id}` + }); + }, + + focus() { + this.$el.focus(); + }, + + blur() { + this.$el.blur(); + }, + + focusBefore() { + focusPrev(this.$el); + }, + + focusAfter() { + focusNext(this.$el); + }, + + userPage + } +}); +</script> + +<style lang="scss" scoped> +.vfzoeqcg { + position: relative; + contain: content; + + // これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、 + // 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう + // ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、 + // 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる + // 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?) + //content-visibility: auto; + //contain-intrinsic-size: 0 128px; + + &:focus-visible { + outline: none; + } + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &:hover, &.operating { + > .article > .main > .footer { + display: block; + } + } + + &.renote { + background: rgba(128, 255, 0, 0.05); + } + + &.highlighted { + background: rgba(255, 128, 0, 0.05); + } + + > .info { + display: flex; + align-items: center; + padding: 12px 16px 4px 16px; + line-height: 24px; + font-size: 85%; + white-space: pre; + color: #d28a3f; + + > i { + margin-right: 4px; + } + + > .hide { + margin-left: 16px; + color: inherit; + opacity: 0.7; + } + } + + > .info + .article { + padding-top: 8px; + } + + > .reply-to { + opacity: 0.7; + padding-bottom: 0; + } + + > .renote { + display: flex; + align-items: center; + padding: 12px 16px 4px 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; + } + + > i { + 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: 0; + 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); + } + } + + > .files { + max-width: 500px; + } + + > .url-preview { + margin-top: 8px; + max-width: 500px; + } + + > .poll { + font-size: 80%; + max-width: 500px; + } + + > .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 0.5px var(--divider); + } +} + +.muted { + padding: 8px 16px; + opacity: 0.7; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } +} +</style> diff --git a/packages/client/src/ui/chat/notes.vue b/packages/client/src/ui/chat/notes.vue new file mode 100644 index 0000000000..9103f717e6 --- /dev/null +++ b/packages/client/src/ui/chat/notes.vue @@ -0,0 +1,94 @@ +<template> +<div class=""> + <div class="_fullinfo" v-if="empty"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ $ts.noNotes }}</div> + </div> + + <MkLoading v-if="fetching"/> + + <MkError v-if="error" @retry="init()"/> + + <div v-show="more && reversed" style="margin-bottom: var(--margin);"> + <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> + </MkButton> + </div> + + <XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed" :ad="true"> + <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);"> + <MkButton style="margin: 0 auto;" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <template v-if="!moreFetching">{{ $ts.loadMore }}</template> + <template v-if="moreFetching"><MkLoading inline/></template> + </MkButton> + </div> +</div> +</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'; +import MkButton from '@/components/ui/button.vue'; + +export default defineComponent({ + components: { + XNote, XList, MkButton, + }, + + 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/packages/client/src/ui/chat/pages/channel.vue b/packages/client/src/ui/chat/pages/channel.vue new file mode 100644 index 0000000000..5152af20f9 --- /dev/null +++ b/packages/client/src/ui/chat/pages/channel.vue @@ -0,0 +1,259 @@ +<template> +<div v-if="channel" class="hhizbblb"> + <div class="info" v-if="date"> + <MkInfo>{{ $ts.showingPastTimeline }} <button class="_textButton clear" @click="timetravel()">{{ $ts.clear }}</button></MkInfo> + </div> + <div class="tl" ref="body"> + <div class="new" v-if="queue > 0" :style="{ width: width + 'px', bottom: bottom + 'px' }"><button class="_buttonPrimary" @click="goTop()">{{ $ts.newNoteRecived }}</button></div> + <XNotes class="tl" ref="tl" :pagination="pagination" @queue="queueUpdated" v-follow="true"/> + </div> + <div class="bottom"> + <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> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, markRaw } from 'vue'; +import * as Misskey from 'misskey-js'; +import XNotes from '../notes.vue'; +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'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XNotes, + XPostForm, + MkInfo, + }, + + directives: { + follow + }, + + provide() { + return { + inChannel: true + }; + }, + + props: { + channelId: { + type: String, + required: true + }, + }, + + data() { + return { + channel: null as Misskey.entities.Channel | null, + connection: null, + pagination: null, + baseQuery: { + includeMyRenotes: this.$store.state.showMyRenotes, + includeRenotedMyNotes: this.$store.state.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.showLocalRenotes + }, + queue: 0, + width: 0, + top: 0, + bottom: 0, + typers: [], + date: null, + [symbols.PAGE_INFO]: computed(() => ({ + title: this.channel ? this.channel.name : '-', + subtitle: this.channel ? this.channel.description : '-', + icon: 'fas fa-satellite-dish', + actions: [{ + icon: this.channel?.isFollowing ? 'fas fa-star' : 'far fa-star', + text: this.channel?.isFollowing ? this.$ts.unfollow : this.$ts.follow, + highlighted: this.channel?.isFollowing, + handler: this.toggleChannelFollow + }, { + icon: 'fas fa-search', + text: this.$ts.inChannelSearch, + handler: this.inChannelSearch + }, { + icon: 'fas fa-calendar-alt', + text: this.$ts.jumpToSpecifiedDate, + handler: this.timetravel + }] + })), + }; + }, + + async created() { + this.channel = await os.api('channels/show', { channelId: this.channelId }); + + const prepend = note => { + (this.$refs.tl as any).prepend(note); + + this.$emit('note'); + + sound.play(note.userId === this.$i.id ? 'noteMy' : 'note'); + }; + + this.connection = markRaw(os.stream.useChannel('channel', { + channelId: this.channelId + })); + 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 = { + endpoint: 'channels/timeline', + reversed: true, + limit: 10, + params: init => ({ + channelId: this.channelId, + untilDate: this.date?.getTime(), + ...this.baseQuery + }) + }; + }, + + mounted() { + + }, + + beforeUnmount() { + this.connection.dispose(); + }, + + methods: { + focus() { + this.$refs.body.focus(); + }, + + goTop() { + const container = getScrollContainer(this.$refs.body); + container.scrollTop = 0; + }, + + queueUpdated(q) { + 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; + }, + + async inChannelSearch() { + const { canceled, result: query } = await os.dialog({ + title: this.$ts.inChannelSearch, + input: true + }); + if (canceled || query == null || query === '') return; + router.push(`/search?q=${encodeURIComponent(query)}&channel=${this.channelId}`); + }, + + async toggleChannelFollow() { + if (this.channel.isFollowing) { + await os.apiWithDialog('channels/unfollow', { + channelId: this.channel.id + }); + this.channel.isFollowing = false; + } else { + await os.apiWithDialog('channels/follow', { + channelId: this.channel.id + }); + this.channel.isFollowing = true; + } + }, + + openChannelMenu(ev) { + os.popupMenu([{ + text: this.$ts.copyUrl, + icon: 'fas fa-link', + action: () => { + copyToClipboard(`${url}/channels/${this.currentChannel.id}`); + } + }], ev.currentTarget || ev.target); + }, + + timetravel(date?: Date) { + this.date = date; + this.$refs.tl.reload(); + } + } +}); +</script> + +<style lang="scss" scoped> +.hhizbblb { + display: flex; + flex-direction: column; + flex: 1; + overflow: auto; + + > .info { + padding: 16px 16px 0 16px; + } + + > .top { + padding: 16px 16px 0 16px; + } + + > .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); + + > .users { + > .user + .user:before { + content: ", "; + font-weight: normal; + } + + > .user:last-of-type:after { + content: " "; + } + } + } + } + + > .tl { + position: relative; + padding: 16px 0; + flex: 1; + min-width: 0; + overflow: auto; + + > .new { + position: fixed; + z-index: 1000; + + > button { + display: block; + margin: 16px auto; + padding: 8px 16px; + border-radius: 32px; + } + } + } +} +</style> diff --git a/packages/client/src/ui/chat/pages/timeline.vue b/packages/client/src/ui/chat/pages/timeline.vue new file mode 100644 index 0000000000..f4dfdf891e --- /dev/null +++ b/packages/client/src/ui/chat/pages/timeline.vue @@ -0,0 +1,221 @@ +<template> +<div class="dbiokgaf"> + <div class="info" v-if="date"> + <MkInfo>{{ $ts.showingPastTimeline }} <button class="_textButton clear" @click="timetravel()">{{ $ts.clear }}</button></MkInfo> + </div> + <div class="top"> + <XPostForm/> + </div> + <div class="tl" ref="body"> + <div class="new" v-if="queue > 0" :style="{ width: width + 'px', top: top + 'px' }"><button class="_buttonPrimary" @click="goTop()">{{ $ts.newNoteRecived }}</button></div> + <XNotes class="tl" ref="tl" :pagination="pagination" @queue="queueUpdated"/> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, markRaw } from 'vue'; +import XNotes from '../notes.vue'; +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'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XNotes, + XPostForm, + MkInfo, + }, + + directives: { + follow + }, + + props: { + src: { + type: String, + required: true + }, + }, + + 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: {}, + queue: 0, + width: 0, + top: 0, + bottom: 0, + typers: [], + date: null, + [symbols.PAGE_INFO]: computed(() => ({ + title: this.$ts.timeline, + icon: 'fas fa-home', + actions: [{ + icon: 'fas fa-calendar-alt', + text: this.$ts.jumpToSpecifiedDate, + handler: this.timetravel + }] + })), + }; + }, + + created() { + const prepend = note => { + (this.$refs.tl as any).prepend(note); + + this.$emit('note'); + + sound.play(note.userId === this.$i.id ? 'noteMy' : 'note'); + }; + + const onChangeFollowing = () => { + if (!this.$refs.tl.backed) { + this.$refs.tl.reload(); + } + }; + + let endpoint; + + if (this.src == 'home') { + endpoint = 'notes/timeline'; + this.connection = markRaw(os.stream.useChannel('homeTimeline')); + this.connection.on('note', prepend); + + this.connection2 = markRaw(os.stream.useChannel('main')); + this.connection2.on('follow', onChangeFollowing); + this.connection2.on('unfollow', onChangeFollowing); + } else if (this.src == 'local') { + endpoint = 'notes/local-timeline'; + this.connection = markRaw(os.stream.useChannel('localTimeline')); + this.connection.on('note', prepend); + } else if (this.src == 'social') { + endpoint = 'notes/hybrid-timeline'; + this.connection = markRaw(os.stream.useChannel('hybridTimeline')); + this.connection.on('note', prepend); + } else if (this.src == 'global') { + endpoint = 'notes/global-timeline'; + this.connection = markRaw(os.stream.useChannel('globalTimeline')); + this.connection.on('note', prepend); + } + + this.pagination = { + endpoint: endpoint, + limit: 10, + params: init => ({ + untilDate: this.date?.getTime(), + ...this.baseQuery, ...this.query + }) + }; + }, + + mounted() { + + }, + + beforeUnmount() { + this.connection.dispose(); + if (this.connection2) this.connection2.dispose(); + }, + + methods: { + focus() { + this.$refs.body.focus(); + }, + + goTop() { + const container = getScrollContainer(this.$refs.body); + container.scrollTop = 0; + }, + + queueUpdated(q) { + 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 { + display: flex; + flex-direction: column; + flex: 1; + overflow: auto; + + > .info { + padding: 16px 16px 0 16px; + } + + > .top { + padding: 16px 16px 0 16px; + } + + > .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); + + > .users { + > .user + .user:before { + content: ", "; + font-weight: normal; + } + + > .user:last-of-type:after { + content: " "; + } + } + } + } + + > .tl { + position: relative; + padding: 16px 0; + flex: 1; + min-width: 0; + overflow: auto; + + > .new { + position: fixed; + z-index: 1000; + + > button { + display: block; + margin: 16px auto; + padding: 8px 16px; + border-radius: 32px; + } + } + } +} +</style> diff --git a/packages/client/src/ui/chat/post-form.vue b/packages/client/src/ui/chat/post-form.vue new file mode 100644 index 0000000000..44461c4a58 --- /dev/null +++ b/packages/client/src/ui/chat/post-form.vue @@ -0,0 +1,772 @@ +<template> +<div class="pxiwixjf" + @dragover.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.stop="onDrop" +> + <div class="form"> + <div class="with-quote" v-if="quoteId"><i class="fas fa-quote-left"></i> {{ $ts.quoteAttached }}<button @click="quoteId = null"><i class="fas fa-times"></i></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)"><i class="fas fa-times"></i></button> + </span> + <button @click="addVisibleUser" class="_buttonPrimary"><i class="fas fa-plus fa-fw"></i></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"><i class="fas fa-photo-video"></i></button> + <button class="_button" @click="togglePoll" :class="{ active: poll }" v-tooltip="$ts.poll"><i class="fas fa-poll-h"></i></button> + <button class="_button" @click="useCw = !useCw" :class="{ active: useCw }" v-tooltip="$ts.useCw"><i class="fas fa-eye-slash"></i></button> + <button class="_button" @click="insertMention" v-tooltip="$ts.mention"><i class="fas fa-at"></i></button> + <button class="_button" @click="insertEmoji" v-tooltip="$ts.emoji"><i class="fas fa-laugh-squint"></i></button> + <button class="_button" @click="showActions" v-tooltip="$ts.plugin" v-if="postFormActions.length > 0"><i class="fas fa-plug"></i></button> + </div> + <div class="right"> + <span class="text-count" :class="{ over: textLength > max }">{{ max - textLength }}</span> + <span class="local-only" v-if="localOnly"><i class="fas fa-biohazard"></i></span> + <button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$ts.visibility" :disabled="channel != null"> + <span v-if="visibility === 'public'"><i class="fas fa-globe"></i></span> + <span v-if="visibility === 'home'"><i class="fas fa-home"></i></span> + <span v-if="visibility === 'followers'"><i class="fas fa-unlock"></i></span> + <span v-if="visibility === 'specified'"><i class="fas fa-envelope"></i></span> + </button> + <button class="submit _buttonPrimary" :disabled="!canPost" @click="post">{{ submitText }}<i :class="reply ? 'fas fa-reply' : renote ? 'fas fa-quote-right' : 'fas fa-paper-plane'"></i></button> + </div> + </footer> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import insertTextAtCursor from 'insert-text-at-cursor'; +import { length } from 'stringz'; +import { toASCII } from 'punycode/'; +import * as mfm from 'mfm-js'; +import { host, url } from '@/config'; +import { erase, unique } from '@/scripts/array'; +import { extractMentions } from '@/scripts/extract-mentions'; +import * as Acct from 'misskey-js/built/acct'; +import { formatTimeString } from '@/scripts/format-time-string'; +import { Autocomplete } from '@/scripts/autocomplete'; +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: { + 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 + }, + share: { + type: Boolean, + required: false, + default: false + }, + autofocus: { + type: Boolean, + required: false, + default: false + }, + }, + + 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: '', + typing: throttle(3000, () => { + if (this.channel) { + os.stream.send('typingOnChannel', { channel: this.channel }); + } + }), + postFormActions, + }; + }, + + 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 = mfm.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.share && !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'); + this.typing(); + }, + + onCompositionUpdate(e: CompositionEvent) { + this.imeText = e.data; + this.typing(); + }, + + 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() { + 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: 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 = mfm.parse(this.text).filter(x => x.type === 'hashtag').map(x => x.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, '@' + Acct.toString(user) + ' '); + }); + }, + + async insertEmoji(ev) { + os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text); + }, + + showActions(ev) { + os.popupMenu(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 0.5px 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 0.5px 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; + } + + > i { + margin-left: 6px; + } + } + } + } + } +} +</style> diff --git a/packages/client/src/ui/chat/side.vue b/packages/client/src/ui/chat/side.vue new file mode 100644 index 0000000000..73881b23c0 --- /dev/null +++ b/packages/client/src/ui/chat/side.vue @@ -0,0 +1,157 @@ +<template> +<div class="mrajymqm _narrow_" v-if="component"> + <header class="header" @contextmenu.prevent.stop="onContextmenu"> + <MkHeader class="title" :info="pageInfo" :center="false"/> + </header> + <component :is="component" v-bind="props" :ref="changePage" class="body"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { resolve } from '@/router'; +import { url } from '@/config'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + }, + + provide() { + return { + navHook: (path) => { + this.navigate(path); + } + }; + }, + + data() { + return { + path: null, + component: null, + props: {}, + pageInfo: null, + history: [], + }; + }, + + computed: { + url(): string { + return url + this.path; + } + }, + + methods: { + changePage(page) { + if (page == null) return; + if (page[symbols.PAGE_INFO]) { + this.pageInfo = page[symbols.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; + this.$emit('open'); + }, + + back() { + this.navigate(this.history.pop(), false); + }, + + close() { + this.path = null; + this.component = null; + this.props = {}; + this.$emit('close'); + }, + + onContextmenu(e) { + os.contextMenu([{ + type: 'label', + text: this.path, + }, { + icon: 'fas fa-expand-alt', + text: this.$ts.showInPage, + action: () => { + this.$router.push(this.path); + this.close(); + } + }, { + icon: 'fas fa-window-maximize', + text: this.$ts.openInWindow, + action: () => { + os.pageWindow(this.path); + this.close(); + } + }, null, { + icon: 'fas fa-external-link-alt', + text: this.$ts.openInNewTab, + action: () => { + window.open(this.url, '_blank'); + this.close(); + } + }, { + icon: 'fas fa-link', + text: this.$ts.copyLink, + action: () => { + copyToClipboard(this.url); + } + }], e); + } + } +}); +</script> + +<style lang="scss" scoped> +.mrajymqm { + $header-height: 54px; // TODO: どこかに集約したい + + --root-margin: 16px; + --margin: var(--marginHalf); + + height: 100%; + overflow: auto; + box-sizing: border-box; + + > .header { + display: flex; + position: sticky; + z-index: 1000; + top: 0; + height: $header-height; + width: 100%; + font-weight: bold; + //background-color: var(--panel); + -webkit-backdrop-filter: var(--blur, blur(32px)); + backdrop-filter: var(--blur, blur(32px)); + background-color: var(--header); + border-bottom: solid 0.5px var(--divider); + box-sizing: border-box; + + > ._button { + height: $header-height; + width: $header-height; + + &:hover { + color: var(--fgHighlighted); + } + } + + > .title { + flex: 1; + position: relative; + } + } + + > .body { + + } +} +</style> + diff --git a/packages/client/src/ui/chat/store.ts b/packages/client/src/ui/chat/store.ts new file mode 100644 index 0000000000..389d56afb6 --- /dev/null +++ b/packages/client/src/ui/chat/store.ts @@ -0,0 +1,17 @@ +import { markRaw } from 'vue'; +import { Storage } from '../../pizzax'; + +export const store = markRaw(new Storage('chatUi', { + widgets: { + where: 'account', + default: [] as { + name: string; + id: string; + data: Record<string, any>; + }[] + }, + tl: { + where: 'deviceAccount', + default: 'home' + }, +})); diff --git a/packages/client/src/ui/chat/sub-note-content.vue b/packages/client/src/ui/chat/sub-note-content.vue new file mode 100644 index 0000000000..9c169ea546 --- /dev/null +++ b/packages/client/src/ui/chat/sub-note-content.vue @@ -0,0 +1,62 @@ +<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}`"><i class="fas fa-reply"></i></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 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 { + }; + } +}); +</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/packages/client/src/ui/chat/widgets.vue b/packages/client/src/ui/chat/widgets.vue new file mode 100644 index 0000000000..6b12f9dac9 --- /dev/null +++ b/packages/client/src/ui/chat/widgets.vue @@ -0,0 +1,62 @@ +<template> +<div class="qydbhufi"> + <XWidgets :edit="edit" :widgets="widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="edit = false"/> + + <button v-if="edit" @click="edit = false" class="_textButton" style="font-size: 0.9em;">{{ $ts.editWidgetsExit }}</button> + <button v-else @click="edit = true" class="_textButton" style="font-size: 0.9em;">{{ $ts.editWidgets }}</button> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import XWidgets from '@/components/widgets.vue'; +import { store } from './store'; + +export default defineComponent({ + components: { + XWidgets, + }, + + data() { + return { + edit: false, + widgets: store.reactiveState.widgets + }; + }, + + methods: { + addWidget(widget) { + store.set('widgets', [widget, ...store.state.widgets]); + }, + + removeWidget(widget) { + store.set('widgets', store.state.widgets.filter(w => w.id != widget.id)); + }, + + updateWidget({ id, data }) { + // TODO: throttleしたい + store.set('widgets', store.state.widgets.map(w => w.id === id ? { + ...w, + data: data + } : w)); + }, + + updateWidgets(widgets) { + store.set('widgets', widgets); + } + } +}); +</script> + +<style lang="scss" scoped> +.qydbhufi { + height: 100%; + box-sizing: border-box; + overflow: auto; + padding: var(--margin); + + ::v-deep(._panel) { + box-shadow: none; + } +} +</style> |