summaryrefslogtreecommitdiff
path: root/packages/client/src/ui/chat
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2021-11-12 02:02:25 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2021-11-12 02:02:25 +0900
commit0e4a111f81cceed275d9bec2695f6e401fb654d8 (patch)
tree40874799472fa07416f17b50a398ac33b7771905 /packages/client/src/ui/chat
parentupdate deps (diff)
downloadmisskey-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.vue163
-rw-r--r--packages/client/src/ui/chat/header-clock.vue62
-rw-r--r--packages/client/src/ui/chat/index.vue467
-rw-r--r--packages/client/src/ui/chat/note-header.vue112
-rw-r--r--packages/client/src/ui/chat/note-preview.vue112
-rw-r--r--packages/client/src/ui/chat/note.sub.vue137
-rw-r--r--packages/client/src/ui/chat/note.vue1144
-rw-r--r--packages/client/src/ui/chat/notes.vue94
-rw-r--r--packages/client/src/ui/chat/pages/channel.vue259
-rw-r--r--packages/client/src/ui/chat/pages/timeline.vue221
-rw-r--r--packages/client/src/ui/chat/post-form.vue772
-rw-r--r--packages/client/src/ui/chat/side.vue157
-rw-r--r--packages/client/src/ui/chat/store.ts17
-rw-r--r--packages/client/src/ui/chat/sub-note-content.vue62
-rw-r--r--packages/client/src/ui/chat/widgets.vue62
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>