Improve chat UI (wip)
This commit is contained in:
		
							parent
							
								
									c03e2febb0
								
							
						
					
					
						commit
						f565c5f730
					
				
					 6 changed files with 637 additions and 561 deletions
				
			
		|  | @ -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) { | ||||
|  |  | |||
|  | @ -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) { | ||||
|  |  | |||
							
								
								
									
										259
									
								
								src/client/ui/chat/pages/channel.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										259
									
								
								src/client/ui/chat/pages/channel.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -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> | ||||
							
								
								
									
										221
									
								
								src/client/ui/chat/pages/timeline.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										221
									
								
								src/client/ui/chat/pages/timeline.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -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> | ||||
|  | @ -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> | ||||
| 
 | ||||
|  |  | |||
|  | @ -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> | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue