diff --git a/src/client/router.ts b/src/client/router.ts index 2081c1020c..225ee44e32 100644 --- a/src/client/router.ts +++ b/src/client/router.ts @@ -4,85 +4,114 @@ import MkLoading from '@client/pages/_loading_.vue'; import MkError from '@client/pages/_error_.vue'; import MkTimeline from '@client/pages/timeline.vue'; import { $i } from './account'; +import { ui } from '@client/config'; -const page = (path: string) => defineAsyncComponent({ - loader: () => import(`./pages/${path}.vue`), +const page = (path: string, ui?: string) => defineAsyncComponent({ + loader: ui ? () => import(`./ui/${ui}/pages/${path}.vue`) : () => import(`./pages/${path}.vue`), loadingComponent: MkLoading, errorComponent: MkError, }); let indexScrollPos = 0; +const defaultRoutes = [ + // NOTE: MkTimelineをdynamic importするとAsyncComponentWrapperが間に入るせいでkeep-aliveのコンポーネント指定が効かなくなる + { path: '/', name: 'index', component: $i ? MkTimeline : page('welcome') }, + { path: '/@:acct/:page?', name: 'user', component: page('user/index'), props: route => ({ acct: route.params.acct, page: route.params.page || 'index' }) }, + { path: '/@:user/pages/:page', component: page('page'), props: route => ({ pageName: route.params.page, username: route.params.user }) }, + { path: '/@:user/pages/:pageName/view-source', component: page('page-editor/page-editor'), props: route => ({ initUser: route.params.user, initPageName: route.params.pageName }) }, + { path: '/@:acct/room', props: true, component: page('room/room') }, + { path: '/settings/:page(.*)?', name: 'settings', component: page('settings/index'), props: route => ({ initialPage: route.params.page || null }) }, + { path: '/reset-password/:token?', component: page('reset-password'), props: route => ({ token: route.params.token }) }, + { path: '/announcements', component: page('announcements') }, + { path: '/about', component: page('about') }, + { path: '/about-misskey', component: page('about-misskey') }, + { path: '/featured', component: page('featured') }, + { path: '/docs', component: page('docs') }, + { path: '/theme-editor', component: page('theme-editor') }, + { path: '/advanced-theme-editor', component: page('advanced-theme-editor') }, + { path: '/docs/:doc(.*)', component: page('doc'), props: route => ({ doc: route.params.doc }) }, + { path: '/explore', component: page('explore') }, + { path: '/explore/tags/:tag', props: true, component: page('explore') }, + { path: '/federation', component: page('federation') }, + { path: '/emojis', component: page('emojis') }, + { path: '/search', component: page('search') }, + { path: '/pages', name: 'pages', component: page('pages') }, + { path: '/pages/new', component: page('page-editor/page-editor') }, + { path: '/pages/edit/:pageId', component: page('page-editor/page-editor'), props: route => ({ initPageId: route.params.pageId }) }, + { path: '/gallery', component: page('gallery/index') }, + { path: '/gallery/new', component: page('gallery/edit') }, + { path: '/gallery/:postId/edit', component: page('gallery/edit'), props: route => ({ postId: route.params.postId }) }, + { path: '/gallery/:postId', component: page('gallery/post'), props: route => ({ postId: route.params.postId }) }, + { path: '/channels', component: page('channels') }, + { path: '/channels/new', component: page('channel-editor') }, + { path: '/channels/:channelId/edit', component: page('channel-editor'), props: true }, + { path: '/channels/:channelId', component: page('channel'), props: route => ({ channelId: route.params.channelId }) }, + { path: '/clips/:clipId', component: page('clip'), props: route => ({ clipId: route.params.clipId }) }, + { path: '/my/notifications', component: page('notifications') }, + { path: '/my/favorites', component: page('favorites') }, + { path: '/my/messages', component: page('messages') }, + { path: '/my/mentions', component: page('mentions') }, + { path: '/my/messaging', name: 'messaging', component: page('messaging/index') }, + { path: '/my/messaging/:user', component: page('messaging/messaging-room'), props: route => ({ userAcct: route.params.user }) }, + { path: '/my/messaging/group/:group', component: page('messaging/messaging-room'), props: route => ({ groupId: route.params.group }) }, + { path: '/my/drive', name: 'drive', component: page('drive') }, + { path: '/my/drive/folder/:folder', component: page('drive') }, + { path: '/my/follow-requests', component: page('follow-requests') }, + { path: '/my/lists', component: page('my-lists/index') }, + { path: '/my/lists/:list', component: page('my-lists/list') }, + { path: '/my/groups', component: page('my-groups/index') }, + { path: '/my/groups/:group', component: page('my-groups/group'), props: route => ({ groupId: route.params.group }) }, + { path: '/my/antennas', component: page('my-antennas/index') }, + { path: '/my/antennas/create', component: page('my-antennas/create') }, + { path: '/my/antennas/:antennaId', component: page('my-antennas/edit'), props: true }, + { path: '/my/clips', component: page('my-clips/index') }, + { path: '/scratchpad', component: page('scratchpad') }, + { path: '/instance/:page(.*)?', component: page('instance/index'), props: route => ({ initialPage: route.params.page || null }) }, + { path: '/instance', component: page('instance/index') }, + { path: '/notes/:note', name: 'note', component: page('note'), props: route => ({ noteId: route.params.note }) }, + { path: '/tags/:tag', component: page('tag'), props: route => ({ tag: route.params.tag }) }, + { path: '/user-info/:user', component: page('user-info'), props: route => ({ userId: route.params.user }) }, + { path: '/user-ap-info/:user', component: page('user-ap-info'), props: route => ({ userId: route.params.user }) }, + { path: '/instance-info/:host', component: page('instance-info'), props: route => ({ host: route.params.host }) }, + { path: '/games/reversi', component: page('reversi/index') }, + { path: '/games/reversi/:gameId', component: page('reversi/game'), props: route => ({ gameId: route.params.gameId }) }, + { path: '/mfm-cheat-sheet', component: page('mfm-cheat-sheet') }, + { path: '/api-console', component: page('api-console') }, + { path: '/preview', component: page('preview') }, + { path: '/test', component: page('test') }, + { path: '/auth/:token', component: page('auth') }, + { path: '/miauth/:session', component: page('miauth') }, + { path: '/authorize-follow', component: page('follow') }, + { path: '/share', component: page('share') }, + { path: '/:catchAll(.*)', component: page('not-found') } +]; + +const chatRoutes = [ + { path: '/timeline', component: page('timeline', 'chat'), props: route => ({ src: 'home' }) }, + { path: '/timeline/home', component: page('timeline', 'chat'), props: route => ({ src: 'home' }) }, + { path: '/timeline/local', component: page('timeline', 'chat'), props: route => ({ src: 'local' }) }, + { path: '/timeline/social', component: page('timeline', 'chat'), props: route => ({ src: 'social' }) }, + { path: '/timeline/global', component: page('timeline', 'chat'), props: route => ({ src: 'global' }) }, + { path: '/channels/:channelId', component: page('channel', 'chat'), props: route => ({ channelId: route.params.channelId }) }, +]; + +function margeRoutes(routes: any[]) { + const result = defaultRoutes; + for (const route of routes) { + const found = result.findIndex(x => x.path === route.path); + if (found > -1) { + result[found] = route; + } else { + result.unshift(route); + } + } + return result; +} + export const router = createRouter({ history: createWebHistory(), - routes: [ - // NOTE: MkTimelineをdynamic importするとAsyncComponentWrapperが間に入るせいでkeep-aliveのコンポーネント指定が効かなくなる - { path: '/', name: 'index', component: $i ? MkTimeline : page('welcome') }, - { path: '/@:acct/:page?', name: 'user', component: page('user/index'), props: route => ({ acct: route.params.acct, page: route.params.page || 'index' }) }, - { path: '/@:user/pages/:page', component: page('page'), props: route => ({ pageName: route.params.page, username: route.params.user }) }, - { path: '/@:user/pages/:pageName/view-source', component: page('page-editor/page-editor'), props: route => ({ initUser: route.params.user, initPageName: route.params.pageName }) }, - { path: '/@:acct/room', props: true, component: page('room/room') }, - { path: '/settings/:page(.*)?', name: 'settings', component: page('settings/index'), props: route => ({ initialPage: route.params.page || null }) }, - { path: '/reset-password/:token?', component: page('reset-password'), props: route => ({ token: route.params.token }) }, - { path: '/announcements', component: page('announcements') }, - { path: '/about', component: page('about') }, - { path: '/about-misskey', component: page('about-misskey') }, - { path: '/featured', component: page('featured') }, - { path: '/docs', component: page('docs') }, - { path: '/theme-editor', component: page('theme-editor') }, - { path: '/advanced-theme-editor', component: page('advanced-theme-editor') }, - { path: '/docs/:doc(.*)', component: page('doc'), props: route => ({ doc: route.params.doc }) }, - { path: '/explore', component: page('explore') }, - { path: '/explore/tags/:tag', props: true, component: page('explore') }, - { path: '/search', component: page('search') }, - { path: '/pages', name: 'pages', component: page('pages') }, - { path: '/pages/new', component: page('page-editor/page-editor') }, - { path: '/pages/edit/:pageId', component: page('page-editor/page-editor'), props: route => ({ initPageId: route.params.pageId }) }, - { path: '/gallery', component: page('gallery/index') }, - { path: '/gallery/new', component: page('gallery/edit') }, - { path: '/gallery/:postId/edit', component: page('gallery/edit'), props: route => ({ postId: route.params.postId }) }, - { path: '/gallery/:postId', component: page('gallery/post'), props: route => ({ postId: route.params.postId }) }, - { path: '/channels', component: page('channels') }, - { path: '/channels/new', component: page('channel-editor') }, - { path: '/channels/:channelId/edit', component: page('channel-editor'), props: true }, - { path: '/channels/:channelId', component: page('channel'), props: route => ({ channelId: route.params.channelId }) }, - { path: '/clips/:clipId', component: page('clip'), props: route => ({ clipId: route.params.clipId }) }, - { path: '/my/notifications', component: page('notifications') }, - { path: '/my/favorites', component: page('favorites') }, - { path: '/my/messages', component: page('messages') }, - { path: '/my/mentions', component: page('mentions') }, - { path: '/my/messaging', name: 'messaging', component: page('messaging/index') }, - { path: '/my/messaging/:user', component: page('messaging/messaging-room'), props: route => ({ userAcct: route.params.user }) }, - { path: '/my/messaging/group/:group', component: page('messaging/messaging-room'), props: route => ({ groupId: route.params.group }) }, - { path: '/my/drive', name: 'drive', component: page('drive') }, - { path: '/my/drive/folder/:folder', component: page('drive') }, - { path: '/my/follow-requests', component: page('follow-requests') }, - { path: '/my/lists', component: page('my-lists/index') }, - { path: '/my/lists/:list', component: page('my-lists/list') }, - { path: '/my/groups', component: page('my-groups/index') }, - { path: '/my/groups/:group', component: page('my-groups/group'), props: route => ({ groupId: route.params.group }) }, - { path: '/my/antennas', component: page('my-antennas/index') }, - { path: '/my/clips', component: page('my-clips/index') }, - { path: '/scratchpad', component: page('scratchpad') }, - { path: '/instance/:page(.*)?', component: page('instance/index'), props: route => ({ initialPage: route.params.page || null }) }, - { path: '/instance', component: page('instance/index') }, - { path: '/notes/:note', name: 'note', component: page('note'), props: route => ({ noteId: route.params.note }) }, - { path: '/tags/:tag', component: page('tag'), props: route => ({ tag: route.params.tag }) }, - { path: '/user-info/:user', component: page('user-info'), props: route => ({ userId: route.params.user }) }, - { path: '/user-ap-info/:user', component: page('user-ap-info'), props: route => ({ userId: route.params.user }) }, - { path: '/instance-info/:host', component: page('instance-info'), props: route => ({ host: route.params.host }) }, - { path: '/games/reversi', component: page('reversi/index') }, - { path: '/games/reversi/:gameId', component: page('reversi/game'), props: route => ({ gameId: route.params.gameId }) }, - { path: '/mfm-cheat-sheet', component: page('mfm-cheat-sheet') }, - { path: '/api-console', component: page('api-console') }, - { path: '/preview', component: page('preview') }, - { path: '/test', component: page('test') }, - { path: '/auth/:token', component: page('auth') }, - { path: '/miauth/:session', component: page('miauth') }, - { path: '/authorize-follow', component: page('follow') }, - { path: '/share', component: page('share') }, - { path: '/:catchAll(.*)', component: page('not-found') } - ], + routes: margeRoutes(ui === 'chat' ? chatRoutes : []), // なんかHacky // 通常の使い方をすると scroll メソッドの behavior を設定できないため、自前で window.scroll するようにする scrollBehavior(to) { diff --git a/src/client/ui/chat/index.vue b/src/client/ui/chat/index.vue index 6e433de126..db663c4530 100644 --- a/src/client/ui/chat/index.vue +++ b/src/client/ui/chat/index.vue @@ -73,54 +73,16 @@ </div> <main class="main" @contextmenu.stop="onContextmenu"> - <header class="header" ref="header" @click="onHeaderClick"> - <div class="left"> - <template v-if="tl === 'home'"> - <i class="fas fa-home icon"></i> - <div class="title">{{ $ts._timelines.home }}</div> - </template> - <template v-else-if="tl === 'local'"> - <i class="fas fa-comments icon"></i> - <div class="title">{{ $ts._timelines.local }}</div> - </template> - <template v-else-if="tl === 'social'"> - <i class="fas fa-share-alt icon"></i> - <div class="title">{{ $ts._timelines.social }}</div> - </template> - <template v-else-if="tl === 'global'"> - <i class="fas fa-globe icon"></i> - <div class="title">{{ $ts._timelines.global }}</div> - </template> - <template v-else-if="tl.startsWith('channel:')"> - <i class="fas fa-satellite-dish icon"></i> - <div class="title" v-if="currentChannel">{{ currentChannel.name }}<div class="description">{{ currentChannel.description }}</div></div> - </template> - </div> - - <div class="right"> - <div class="instance">{{ instanceName }}</div> - <XHeaderClock class="clock"/> - <button class="_button button timetravel" @click="timetravel" v-tooltip="$ts.jumpToSpecifiedDate"> - <i class="fas fa-calendar-alt"></i> - </button> - <button class="_button button search" v-if="tl.startsWith('channel:') && currentChannel" @click="inChannelSearch" v-tooltip="$ts.inChannelSearch"> - <i class="fas fa-search"></i> - </button> - <button class="_button button search" v-else @click="search" v-tooltip="$ts.search"> - <i class="fas fa-search"></i> - </button> - <button class="_button button follow" v-if="tl.startsWith('channel:') && currentChannel" :class="{ followed: currentChannel.isFollowing }" @click="toggleChannelFollow" v-tooltip="currentChannel.isFollowing ? $ts.unfollow : $ts.follow"> - <i v-if="currentChannel.isFollowing" class="fas fa-star"></i> - <i v-else class="far fa-star"></i> - </button> - <button class="_button button menu" v-if="tl.startsWith('channel:') && currentChannel" @click="openChannelMenu"> - <i class="fas fa-ellipsis-h"></i> - </button> - </div> + <header class="header"> + <XHeader class="header" :info="pageInfo" :menu="menu" :center="false" :back-button="true" @back="back()" @click="onHeaderClick"/> </header> - - <XTimeline class="body" ref="tl" v-if="tl.startsWith('channel:')" src="channel" :key="tl" :channel="tl.replace('channel:', '')"/> - <XTimeline class="body" ref="tl" v-else :src="tl" :key="tl"/> + <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"/> @@ -139,7 +101,7 @@ import XSidebar from '@client/ui/_common_/sidebar.vue'; import XWidgets from './widgets.vue'; import XCommon from '../_common_/common.vue'; import XSide from './side.vue'; -import XTimeline from './timeline.vue'; +import XHeader from '../_common_/header.vue'; import XHeaderClock from './header-clock.vue'; import * as os from '@client/os'; import { router } from '@client/router'; @@ -147,6 +109,7 @@ import { menuDef } from '@client/menu'; import { search } from '@client/scripts/search'; import copyToClipboard from '@client/scripts/copy-to-clipboard'; import { store } from './store'; +import * as symbols from '@client/symbols'; export default defineComponent({ components: { @@ -154,29 +117,12 @@ export default defineComponent({ XSidebar, XWidgets, XSide, // NOTE: dynamic importするとAsyncComponentWrapperが間に入るせいでref取得できなくて面倒になる - XTimeline, + XHeader, XHeaderClock, }, provide() { return { - navHook: (path) => { - switch (path) { - case '/timeline/home': this.tl = 'home'; return; - case '/timeline/local': this.tl = 'local'; return; - case '/timeline/social': this.tl = 'social'; return; - case '/timeline/global': this.tl = 'global'; return; - - default: - if (path.startsWith('/channels/')) { - this.tl = `channel:${ path.replace('/channels/', '') }`; - return; - } - //os.pageWindow(path); - this.$refs.side.navigate(path); - break; - } - }, sideViewHook: (path) => { this.$refs.side.navigate(path); } @@ -185,7 +131,7 @@ export default defineComponent({ data() { return { - tl: store.state.tl, + pageInfo: null, lists: null, antennas: null, followedChannels: null, @@ -197,18 +143,30 @@ export default defineComponent({ }; }, + 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(); } - router.beforeEach((to, from) => { - this.$refs.side.navigate(to.fullPath); - // search?q=foo のようなクエリを受け取れるようにするため、return falseはできない - //return false; - }); - os.api('users/lists/list').then(lists => { this.lists = lists; }); @@ -225,18 +183,22 @@ export default defineComponent({ os.api('channels/featured', { limit: 20 }).then(channels => { this.featuredChannels = channels; }); - - this.$watch('tl', () => { - if (this.tl.startsWith('channel:')) { - os.api('channels/show', { channelId: this.tl.replace('channel:', '') }).then(channel => { - this.currentChannel = channel; - }); - } - store.set('tl', this.tl); - }, { immediate: true }); }, 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(); }, @@ -245,59 +207,18 @@ export default defineComponent({ os.post(); }, - async timetravel() { - const { canceled, result: date } = await os.dialog({ - title: this.$ts.date, - input: { - type: 'date' - } - }); - if (canceled) return; - - this.$refs.tl.timetravel(new Date(date)); - }, - search() { search(); }, - 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.currentChannel.id}`); + back() { + history.back(); }, top() { window.scroll({ top: 0, behavior: 'smooth' }); }, - async toggleChannelFollow() { - if (this.currentChannel.isFollowing) { - await os.apiWithDialog('channels/unfollow', { - channelId: this.currentChannel.id - }); - this.currentChannel.isFollowing = false; - } else { - await os.apiWithDialog('channels/follow', { - channelId: this.currentChannel.id - }); - this.currentChannel.isFollowing = true; - } - }, - - openChannelMenu(ev) { - os.modalMenu([{ - text: this.$ts.copyUrl, - icon: 'fas fa-link', - action: () => { - copyToClipboard(`${url}/channels/${this.currentChannel.id}`); - } - }], ev.currentTarget || ev.target); - }, - onTransition() { if (window._scroll) window._scroll(); }, @@ -516,87 +437,24 @@ export default defineComponent({ background: var(--panel); > .header { - $padding: 8px; - display: flex; z-index: 1000; height: $header-height; - padding: $padding; - box-sizing: border-box; background-color: var(--panel); border-bottom: solid 0.5px var(--divider); user-select: none; + } - > .left { - display: flex; - align-items: center; - flex: 1; - min-width: 0; - - > .icon { - height: ($header-height - ($padding * 2)); - width: ($header-height - ($padding * 2)); - padding: 10px; - box-sizing: border-box; - margin-right: 4px; - opacity: 0.6; - } - - > .title { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; - font-weight: bold; - - > .description { - opacity: 0.6; - font-size: 0.8em; - font-weight: normal; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } - } - - > .right { - display: flex; - align-items: center; - min-width: 0; - margin-left: auto; - padding-left: 8px; - - > .instance { - margin-right: 16px; - font-size: 0.9em; - } - - > .clock { - margin-right: 16px; - } - - > .button { - height: ($header-height - ($padding * 2)); - width: ($header-height - ($padding * 2)); - box-sizing: border-box; - position: relative; - border-radius: 5px; - - &:hover { - background: rgba(0, 0, 0, 0.05); - } - - &.follow.followed { - color: var(--accent); - } - } - } + > .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) { diff --git a/src/client/ui/chat/pages/channel.vue b/src/client/ui/chat/pages/channel.vue new file mode 100644 index 0000000000..76b334487e --- /dev/null +++ b/src/client/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 '@client/os'; +import * as sound from '@client/scripts/sound'; +import { scrollToBottom, getScrollPosition, getScrollContainer } from '@client/scripts/scroll'; +import follow from '@client/directives/follow-append'; +import XPostForm from '../post-form.vue'; +import MkInfo from '@client/components/ui/info.vue'; +import * as symbols from '@client/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.modalMenu([{ + 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/src/client/ui/chat/pages/timeline.vue b/src/client/ui/chat/pages/timeline.vue new file mode 100644 index 0000000000..0f9cd7f11e --- /dev/null +++ b/src/client/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 '@client/os'; +import * as sound from '@client/scripts/sound'; +import { scrollToBottom, getScrollPosition, getScrollContainer } from '@client/scripts/scroll'; +import follow from '@client/directives/follow-append'; +import XPostForm from '../post-form.vue'; +import MkInfo from '@client/components/ui/info.vue'; +import * as symbols from '@client/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/src/client/ui/chat/side.vue b/src/client/ui/chat/side.vue index 7ad39c7a19..5ccfad1b75 100644 --- a/src/client/ui/chat/side.vue +++ b/src/client/ui/chat/side.vue @@ -1,11 +1,9 @@ <template> <div class="mrajymqm _narrow_" v-if="component"> <header class="header" @contextmenu.prevent.stop="onContextmenu"> - <button class="_button" @click="back()" v-if="history.length > 0"><i class="fas fa-chevron-left"></i></button> - <XHeader class="title" :info="pageInfo" :with-back="false" :center="false"/> - <button class="_button" @click="close()"><i class="fas fa-times"></i></button> + <XHeader class="title" :info="pageInfo" :center="false" :back-button="history.length > 0" @back="back()" :close-button="true" @close="close()"/> </header> - <component :is="component" v-bind="props" :ref="changePage" class="_flat_"/> + <component :is="component" v-bind="props" :ref="changePage" class="body _flat_"/> </div> </template> @@ -130,7 +128,6 @@ export default defineComponent({ top: 0; height: $header-height; width: 100%; - line-height: $header-height; font-weight: bold; //background-color: var(--panel); -webkit-backdrop-filter: blur(32px); @@ -153,6 +150,10 @@ export default defineComponent({ position: relative; } } + + > .body { + + } } </style> diff --git a/src/client/ui/chat/timeline.vue b/src/client/ui/chat/timeline.vue deleted file mode 100644 index 0fbcbfb713..0000000000 --- a/src/client/ui/chat/timeline.vue +++ /dev/null @@ -1,292 +0,0 @@ -<template> -<div class="dbiokgaf info" v-if="date"> - <MkInfo>{{ $ts.showingPastTimeline }} <button class="_textButton clear" @click="timetravel()">{{ $ts.clear }}</button></MkInfo> -</div> -<div class="dbiokgaf top" v-if="['home', 'local', 'social', 'global'].includes(src)"> - <XPostForm/> -</div> -<div class="dbiokgaf tl" ref="body"> - <div class="new" v-if="queue > 0" :style="{ width: width + 'px', [pagination.reversed ? 'bottom' : 'top']: pagination.reversed ? bottom + 'px' : top + 'px' }"><button class="_buttonPrimary" @click="goTop()">{{ $ts.newNoteRecived }}</button></div> - <XNotes class="tl" ref="tl" :pagination="pagination" @queue="queueUpdated" v-follow="pagination.reversed"/> -</div> -<div class="dbiokgaf bottom" v-if="src === 'channel'"> - <div class="typers" v-if="typers.length > 0"> - <I18n :src="$ts.typingUsers" text-tag="span" class="users"> - <template #users> - <b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b> - </template> - </I18n> - <MkEllipsis/> - </div> - <XPostForm :channel="channel"/> -</div> -</template> - -<script lang="ts"> -import { defineComponent, markRaw } from 'vue'; -import XNotes from './notes.vue'; -import * as os from '@client/os'; -import * as sound from '@client/scripts/sound'; -import { scrollToBottom, getScrollPosition, getScrollContainer } from '@client/scripts/scroll'; -import follow from '@client/directives/follow-append'; -import XPostForm from './post-form.vue'; -import MkInfo from '@client/components/ui/info.vue'; - -export default defineComponent({ - components: { - XNotes, - XPostForm, - MkInfo, - }, - - directives: { - follow - }, - - provide() { - return { - inChannel: this.src === 'channel' - }; - }, - - props: { - src: { - type: String, - required: true - }, - list: { - type: String, - required: false - }, - antenna: { - type: String, - required: false - }, - channel: { - type: String, - required: false - }, - }, - - emits: ['note', 'queue', 'before', 'after'], - - data() { - return { - connection: null, - connection2: null, - pagination: null, - baseQuery: { - includeMyRenotes: this.$store.state.showMyRenotes, - includeRenotedMyNotes: this.$store.state.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.showLocalRenotes - }, - query: {}, - queue: 0, - width: 0, - top: 0, - bottom: 0, - typers: [], - date: null - }; - }, - - created() { - const prepend = note => { - (this.$refs.tl as any).prepend(note); - - this.$emit('note'); - - sound.play(note.userId === this.$i.id ? 'noteMy' : 'note'); - }; - - const onUserAdded = () => { - (this.$refs.tl as any).reload(); - }; - - const onUserRemoved = () => { - (this.$refs.tl as any).reload(); - }; - - const onChangeFollowing = () => { - if (!this.$refs.tl.backed) { - this.$refs.tl.reload(); - } - }; - - let endpoint; - let reversed = false; - - if (this.src == 'antenna') { - endpoint = 'antennas/notes'; - this.query = { - antennaId: this.antenna - }; - this.connection = markRaw(os.stream.useChannel('antenna', { - antennaId: this.antenna - })); - this.connection.on('note', prepend); - } else 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); - } else if (this.src == 'mentions') { - endpoint = 'notes/mentions'; - this.connection = markRaw(os.stream.useChannel('main')); - this.connection.on('mention', prepend); - } else if (this.src == 'directs') { - endpoint = 'notes/mentions'; - this.query = { - visibility: 'specified' - }; - const onNote = note => { - if (note.visibility == 'specified') { - prepend(note); - } - }; - this.connection = markRaw(os.stream.useChannel('main')); - this.connection.on('mention', onNote); - } else if (this.src == 'list') { - endpoint = 'notes/user-list-timeline'; - this.query = { - listId: this.list - }; - this.connection = markRaw(os.stream.useChannel('userList', { - listId: this.list - })); - this.connection.on('note', prepend); - this.connection.on('userAdded', onUserAdded); - this.connection.on('userRemoved', onUserRemoved); - } else if (this.src == 'channel') { - endpoint = 'channels/timeline'; - reversed = true; - this.query = { - channelId: this.channel - }; - this.connection = markRaw(os.stream.useChannel('channel', { - channelId: this.channel - })); - this.connection.on('note', prepend); - this.connection.on('typers', typers => { - this.typers = this.$i ? typers.filter(u => u.id !== this.$i.id) : typers; - }); - } - - this.pagination = { - endpoint: endpoint, - reversed, - 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.info{ - padding: 16px 16px 0 16px; -} - -.dbiokgaf.top { - padding: 16px 16px 0 16px; -} - -.dbiokgaf.bottom { - padding: 0 16px 16px 16px; - position: relative; - - > .typers { - position: absolute; - bottom: 100%; - padding: 0 8px 0 8px; - font-size: 0.9em; - background: var(--panel); - border-radius: 0 8px 0 0; - color: var(--fgTransparentWeak); - - > .users { - > .user + .user:before { - content: ", "; - font-weight: normal; - } - - > .user:last-of-type:after { - content: " "; - } - } - } -} - -.dbiokgaf.tl { - position: relative; - padding: 16px 0; - flex: 1; - min-width: 0; - overflow: auto; - - > .new { - position: fixed; - z-index: 1000; - - > button { - display: block; - margin: 16px auto; - padding: 8px 16px; - border-radius: 32px; - } - } -} -</style>