Chat UI (#7197)
* wip * wip * wip * wip * refactor * Update note.vue * wip
This commit is contained in:
		
							parent
							
								
									6ce2231e70
								
							
						
					
					
						commit
						1eda7c8565
					
				
					 22 changed files with 3452 additions and 44 deletions
				
			
		|  | @ -1,8 +1,8 @@ | |||
| <template> | ||||
| <span class="eiwwqkts" :class="{ cat }" :title="acct(user)" v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" @click="onClick"> | ||||
| <span class="eiwwqkts _noSelect" :class="{ cat }" :title="acct(user)" v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" @click="onClick"> | ||||
| 	<img class="inner" :src="url" decoding="async"/> | ||||
| </span> | ||||
| <MkA class="eiwwqkts" :class="{ cat }" :to="userPage(user)" :title="acct(user)" :target="target" v-else v-user-preview="disablePreview ? undefined : user.id"> | ||||
| <MkA class="eiwwqkts _noSelect" :class="{ cat }" :to="userPage(user)" :title="acct(user)" :target="target" v-else v-user-preview="disablePreview ? undefined : user.id"> | ||||
| 	<img class="inner" :src="url" decoding="async"/> | ||||
| </MkA> | ||||
| </template> | ||||
|  |  | |||
|  | @ -8,7 +8,7 @@ | |||
| 	<MkError v-if="error" @retry="init()"/> | ||||
| 
 | ||||
| 	<div v-show="more && reversed" style="margin-bottom: var(--margin);"> | ||||
| 		<button class="_loadMore" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> | ||||
| 		<button class="_buttonPrimary" @click="fetchMoreFeature" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> | ||||
| 			<template v-if="!moreFetching">{{ $ts.loadMore }}</template> | ||||
| 			<template v-if="moreFetching"><MkLoading inline/></template> | ||||
| 		</button> | ||||
|  | @ -19,7 +19,7 @@ | |||
| 	</XList> | ||||
| 
 | ||||
| 	<div v-show="more && !reversed" style="margin-top: var(--margin);"> | ||||
| 		<button class="_loadMore" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> | ||||
| 		<button class="_buttonPrimary" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> | ||||
| 			<template v-if="!moreFetching">{{ $ts.loadMore }}</template> | ||||
| 			<template v-if="moreFetching"><MkLoading inline/></template> | ||||
| 		</button> | ||||
|  |  | |||
|  | @ -5,7 +5,7 @@ | |||
| 		<XNotification v-else :notification="notification" :with-time="true" :full="true" class="_panel notification" :key="notification.id"/> | ||||
| 	</XList> | ||||
| 
 | ||||
| 	<button class="_loadMore" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" v-show="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> | ||||
| 	<button class="_buttonPrimary" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" v-show="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> | ||||
| 		<template v-if="!moreFetching">{{ $ts.loadMore }}</template> | ||||
| 		<template v-if="moreFetching"><MkLoading inline/></template> | ||||
| 	</button> | ||||
|  |  | |||
|  | @ -55,6 +55,14 @@ import { sidebarDef } from '@/sidebar'; | |||
| import { getAccounts, addAccount, login } from '@/account'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
| 	props: { | ||||
| 		defaultHidden: { | ||||
| 			type: Boolean, | ||||
| 			required: false, | ||||
| 			default: false, | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	data() { | ||||
| 		return { | ||||
| 			host: host, | ||||
|  | @ -63,7 +71,7 @@ export default defineComponent({ | |||
| 			connection: null, | ||||
| 			menuDef: sidebarDef, | ||||
| 			iconOnly: false, | ||||
| 			hidden: false, | ||||
| 			hidden: this.defaultHidden, | ||||
| 			faGripVertical, faChevronLeft, faComments, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faBell, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faEnvelope, faListUl, faPlus, faUserClock, faLaugh, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faProjectDiagram | ||||
| 		}; | ||||
| 	}, | ||||
|  | @ -112,7 +120,9 @@ export default defineComponent({ | |||
| 	methods: { | ||||
| 		calcViewState() { | ||||
| 			this.iconOnly = (window.innerWidth <= 1279) || (this.$store.state.sidebarDisplay === 'icon'); | ||||
| 			this.hidden = (window.innerWidth <= 650); | ||||
| 			if (!this.defaultHidden) { | ||||
| 				this.hidden = (window.innerWidth <= 650); | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		show() { | ||||
|  |  | |||
|  | @ -3,12 +3,24 @@ import { getScrollContainer, getScrollPosition } from '@/scripts/scroll'; | |||
| 
 | ||||
| export default { | ||||
| 	mounted(src, binding, vn) { | ||||
| 		const ro = new ResizeObserver((entries, observer) => { | ||||
| 			const pos = getScrollPosition(src); | ||||
| 			const container = getScrollContainer(src); | ||||
| 		if (binding.value === false) return; | ||||
| 
 | ||||
| 		let isBottom = true; | ||||
| 
 | ||||
| 		const container = getScrollContainer(src)!; | ||||
| 		container.addEventListener('scroll', () => { | ||||
| 			const pos = getScrollPosition(container); | ||||
| 			const viewHeight = container.clientHeight; | ||||
| 			const height = container.scrollHeight; | ||||
| 			if (pos + viewHeight > height - 32) { | ||||
| 			isBottom = (pos + viewHeight > height - 32); | ||||
| 			console.log(isBottom); | ||||
| 		}, { passive: true }); | ||||
| 		container.scrollTop = container.scrollHeight; | ||||
| 
 | ||||
| 		const ro = new ResizeObserver((entries, observer) => { | ||||
| 			console.log(isBottom); | ||||
| 			if (isBottom) { | ||||
| 				const height = container.scrollHeight; | ||||
| 				container.scrollTop = height; | ||||
| 			} | ||||
| 		}); | ||||
|  | @ -20,6 +32,6 @@ export default { | |||
| 	}, | ||||
| 
 | ||||
| 	unmounted(src, binding, vn) { | ||||
| 		src._ro_.unobserve(src); | ||||
| 		if (src._ro_) src._ro_.unobserve(src); | ||||
| 	} | ||||
| } as Directive; | ||||
|  |  | |||
|  | @ -182,6 +182,7 @@ const app = createApp(await ( | |||
| 	!$i                               ? import('@/ui/visitor.vue') : | ||||
| 	ui === 'deck'                     ? import('@/ui/deck.vue') : | ||||
| 	ui === 'desktop'                  ? import('@/ui/desktop.vue') : | ||||
| 	ui === 'chat'                     ? import('@/ui/chat/index.vue') : | ||||
| 	import('@/ui/default.vue') | ||||
| ).then(x => x.default)); | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,9 +1,11 @@ | |||
| import { markRaw } from 'vue'; | ||||
| import * as os from '@/os'; | ||||
| import { onScrollTop, isTopVisible } from './scroll'; | ||||
| import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from './scroll'; | ||||
| 
 | ||||
| const SECOND_FETCH_LIMIT = 30; | ||||
| 
 | ||||
| // reversed: items 配列の中身を逆順にする(新しい方が最後)
 | ||||
| 
 | ||||
| export default (opts) => ({ | ||||
| 	emits: ['queue'], | ||||
| 
 | ||||
|  | @ -122,10 +124,41 @@ export default (opts) => ({ | |||
| 				limit: SECOND_FETCH_LIMIT + 1, | ||||
| 				...(this.pagination.offsetMode ? { | ||||
| 					offset: this.offset, | ||||
| 				} : this.pagination.reversed ? { | ||||
| 					sinceId: this.items[0].id, | ||||
| 				} : { | ||||
| 					untilId: this.items[this.items.length - 1].id, | ||||
| 					untilId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id, | ||||
| 				}), | ||||
| 			}).then(items => { | ||||
| 				for (const item of items) { | ||||
| 					markRaw(item); | ||||
| 				} | ||||
| 				if (items.length > SECOND_FETCH_LIMIT) { | ||||
| 					items.pop(); | ||||
| 					this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items); | ||||
| 					this.more = true; | ||||
| 				} else { | ||||
| 					this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items); | ||||
| 					this.more = false; | ||||
| 				} | ||||
| 				this.offset += items.length; | ||||
| 				this.moreFetching = false; | ||||
| 			}, e => { | ||||
| 				this.moreFetching = false; | ||||
| 			}); | ||||
| 		}, | ||||
| 
 | ||||
| 		async fetchMoreFeature() { | ||||
| 			if (!this.more || this.fetching || this.moreFetching || this.items.length === 0) return; | ||||
| 			this.moreFetching = true; | ||||
| 			let params = typeof this.pagination.params === 'function' ? this.pagination.params(false) : this.pagination.params; | ||||
| 			if (params && params.then) params = await params; | ||||
| 			const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint; | ||||
| 			await os.api(endpoint, { | ||||
| 				...params, | ||||
| 				limit: SECOND_FETCH_LIMIT + 1, | ||||
| 				...(this.pagination.offsetMode ? { | ||||
| 					offset: this.offset, | ||||
| 				} : { | ||||
| 					sinceId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id, | ||||
| 				}), | ||||
| 			}).then(items => { | ||||
| 				for (const item of items) { | ||||
|  | @ -147,25 +180,44 @@ export default (opts) => ({ | |||
| 		}, | ||||
| 
 | ||||
| 		prepend(item) { | ||||
| 			const isTop = this.isBackTop || (document.body.contains(this.$el) && isTopVisible(this.$el)); | ||||
| 
 | ||||
| 			if (isTop) { | ||||
| 				// Prepend the item
 | ||||
| 				this.items.unshift(item); | ||||
| 
 | ||||
| 				// オーバーフローしたら古いアイテムは捨てる
 | ||||
| 				if (this.items.length >= opts.displayLimit) { | ||||
| 					this.items = this.items.slice(0, opts.displayLimit); | ||||
| 					this.more = true; | ||||
| 				} | ||||
| 			} else { | ||||
| 				this.queue.push(item); | ||||
| 				onScrollTop(this.$el, () => { | ||||
| 					for (const item of this.queue) { | ||||
| 						this.prepend(item); | ||||
| 			if (this.pagination.reversed) { | ||||
| 				const container = getScrollContainer(this.$el); | ||||
| 				const pos = getScrollPosition(this.$el); | ||||
| 				const viewHeight = container.clientHeight; | ||||
| 				const height = container.scrollHeight; | ||||
| 				const isBottom = (pos + viewHeight > height - 32); | ||||
| 				if (isBottom) { | ||||
| 					// オーバーフローしたら古いアイテムは捨てる
 | ||||
| 					if (this.items.length >= opts.displayLimit) { | ||||
| 						this.items = this.items.slice(-opts.displayLimit); | ||||
| 						this.more = true; | ||||
| 					} | ||||
| 					this.queue = []; | ||||
| 				}); | ||||
| 				} else { | ||||
| 					 | ||||
| 				} | ||||
| 				this.items.push(item); | ||||
| 				// TODO
 | ||||
| 			} else { | ||||
| 				const isTop = this.isBackTop || (document.body.contains(this.$el) && isTopVisible(this.$el)); | ||||
| 
 | ||||
| 				if (isTop) { | ||||
| 					// Prepend the item
 | ||||
| 					this.items.unshift(item); | ||||
| 
 | ||||
| 					// オーバーフローしたら古いアイテムは捨てる
 | ||||
| 					if (this.items.length >= opts.displayLimit) { | ||||
| 						this.items = this.items.slice(0, opts.displayLimit); | ||||
| 						this.more = true; | ||||
| 					} | ||||
| 				} else { | ||||
| 					this.queue.push(item); | ||||
| 					onScrollTop(this.$el, () => { | ||||
| 						for (const item of this.queue) { | ||||
| 							this.prepend(item); | ||||
| 						} | ||||
| 						this.queue = []; | ||||
| 					}); | ||||
| 				} | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
|  |  | |||
|  | @ -54,6 +54,14 @@ export function scroll(el: Element, top: number) { | |||
| 	} | ||||
| } | ||||
| 
 | ||||
| export function scrollToTop(el: Element) { | ||||
| 	scroll(el, 0); | ||||
| } | ||||
| 
 | ||||
| export function scrollToBottom(el: Element) { | ||||
| 	scroll(el, 99999); // TODO: ちゃんと計算する
 | ||||
| } | ||||
| 
 | ||||
| export function isBottom(el: Element, asobi = 0) { | ||||
| 	const container = getScrollContainer(el); | ||||
| 	const current = container | ||||
|  |  | |||
|  | @ -141,6 +141,12 @@ export const sidebarDef = { | |||
| 					localStorage.setItem('ui', 'deck'); | ||||
| 					location.reload(); | ||||
| 				} | ||||
| 			}, { | ||||
| 				text: 'Chat (β)', | ||||
| 				action: () => { | ||||
| 					localStorage.setItem('ui', 'chat'); | ||||
| 					location.reload(); | ||||
| 				} | ||||
| 			}, { | ||||
| 				text: i18n.locale.desktop + ' (β)', | ||||
| 				action: () => { | ||||
|  |  | |||
|  | @ -308,13 +308,6 @@ hr { | |||
| 	box-shadow: none; | ||||
| } | ||||
| 
 | ||||
| ._loadMore { | ||||
| 	@extend ._panel; | ||||
| 	@extend ._button; | ||||
| 	width: 100%; | ||||
| 	padding: 12px 0; | ||||
| } | ||||
| 
 | ||||
| ._borderButton { | ||||
| 	@extend ._button; | ||||
| 	display: block; | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| <template> | ||||
| <div class="fdidabkb" :style="`--height:${height};`"> | ||||
| <div class="fdidabkb" :class="{ center }" :style="`--height:${height};`"> | ||||
| 	<transition :name="$store.state.animation ? 'header' : ''" mode="out-in" appear> | ||||
| 		<button class="_button back" v-if="withBack && canBack" @click.stop="back()"><Fa :icon="faChevronLeft"/></button> | ||||
| 	</transition> | ||||
|  | @ -31,6 +31,11 @@ export default defineComponent({ | |||
| 			required: false, | ||||
| 			default: true, | ||||
| 		}, | ||||
| 		center: { | ||||
| 			type: Boolean, | ||||
| 			required: false, | ||||
| 			default: true, | ||||
| 		}, | ||||
| 	}, | ||||
| 
 | ||||
| 	data() { | ||||
|  | @ -67,7 +72,9 @@ export default defineComponent({ | |||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| .fdidabkb { | ||||
| 	text-align: center; | ||||
| 	&.center { | ||||
| 		text-align: center; | ||||
| 	} | ||||
| 
 | ||||
| 	> .back { | ||||
| 		height: var(--height); | ||||
|  | @ -111,8 +118,13 @@ export default defineComponent({ | |||
| 		right: 0; | ||||
| 	} | ||||
| 
 | ||||
| 	&.center { | ||||
| 		> .titleContainer { | ||||
| 			margin: 0 auto; | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	> .titleContainer { | ||||
| 		margin: 0 auto; | ||||
| 		overflow: auto; | ||||
| 		white-space: nowrap; | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										154
									
								
								src/client/ui/chat/date-separated-list.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										154
									
								
								src/client/ui/chat/date-separated-list.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,154 @@ | |||
| <script lang="ts"> | ||||
| import { defineComponent, h, TransitionGroup } from 'vue'; | ||||
| import { faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons'; | ||||
| import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
| 	props: { | ||||
| 		items: { | ||||
| 			type: Array, | ||||
| 			required: true, | ||||
| 		}, | ||||
| 		direction: { | ||||
| 			type: String, | ||||
| 			required: false, | ||||
| 			default: 'down' | ||||
| 		}, | ||||
| 		reversed: { | ||||
| 			type: Boolean, | ||||
| 			required: false, | ||||
| 			default: false | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	methods: { | ||||
| 		focus() { | ||||
| 			this.$slots.default[0].elm.focus(); | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	render() { | ||||
| 		const getDateText = (time: string) => { | ||||
| 			const date = new Date(time).getDate(); | ||||
| 			const month = new Date(time).getMonth() + 1; | ||||
| 			return this.$t('monthAndDay', { | ||||
| 				month: month.toString(), | ||||
| 				day: date.toString() | ||||
| 			}); | ||||
| 		} | ||||
| 
 | ||||
| 		return h(!this.reversed ? TransitionGroup : 'div', !this.reversed ? { | ||||
| 			class: 'hmjzthxl', | ||||
| 			name: 'list', | ||||
| 			tag: 'div', | ||||
| 			'data-direction': this.direction, | ||||
| 			'data-reversed': this.reversed ? 'true' : 'false', | ||||
| 		} : { | ||||
| 			class: 'hmjzthxl', | ||||
| 		}, this.items.map((item, i) => { | ||||
| 			const el = this.$slots.default({ | ||||
| 				item: item | ||||
| 			})[0]; | ||||
| 			if (el.key == null && item.id) el.key = item.id; | ||||
| 
 | ||||
| 			if ( | ||||
| 				i != this.items.length - 1 && | ||||
| 				new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate() && | ||||
| 				!item._prId_ && | ||||
| 				!this.items[i + 1]._prId_ && | ||||
| 				!item._featuredId_ && | ||||
| 				!this.items[i + 1]._featuredId_ | ||||
| 			) { | ||||
| 				const separator = h('div', { | ||||
| 					class: 'separator', | ||||
| 					key: item.id + ':separator', | ||||
| 				}, h('p', { | ||||
| 					class: 'date' | ||||
| 				}, [ | ||||
| 					h('span', [ | ||||
| 						h(FontAwesomeIcon, { | ||||
| 							class: 'icon', | ||||
| 							icon: faAngleUp, | ||||
| 						}), | ||||
| 						getDateText(item.createdAt) | ||||
| 					]), | ||||
| 					h('span', [ | ||||
| 						getDateText(this.items[i + 1].createdAt), | ||||
| 						h(FontAwesomeIcon, { | ||||
| 							class: 'icon', | ||||
| 							icon: faAngleDown, | ||||
| 						}) | ||||
| 					]) | ||||
| 				])); | ||||
| 
 | ||||
| 				return [el, separator]; | ||||
| 			} else { | ||||
| 				return el; | ||||
| 			} | ||||
| 		})); | ||||
| 	}, | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss"> | ||||
| .hmjzthxl { | ||||
| 	> .list-move { | ||||
| 		transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); | ||||
| 	} | ||||
| 
 | ||||
| 	> .list-enter-active { | ||||
| 		transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); | ||||
| 	} | ||||
| 
 | ||||
| 	&[data-direction="up"] { | ||||
| 		> .list-enter-from { | ||||
| 			opacity: 0; | ||||
| 			transform: translateY(64px); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	&[data-direction="down"] { | ||||
| 		> .list-enter-from { | ||||
| 			opacity: 0; | ||||
| 			transform: translateY(-64px); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
| 
 | ||||
| <style lang="scss"> | ||||
| .hmjzthxl { | ||||
| 	> .separator { | ||||
| 		text-align: center; | ||||
| 
 | ||||
| 		> .date { | ||||
| 			display: inline-block; | ||||
| 			position: relative; | ||||
| 			margin: 0; | ||||
| 			padding: 0 16px; | ||||
| 			line-height: 32px; | ||||
| 			text-align: center; | ||||
| 			font-size: 12px; | ||||
| 			color: var(--dateLabelFg); | ||||
| 
 | ||||
| 			> span { | ||||
| 				&:first-child { | ||||
| 					margin-right: 8px; | ||||
| 
 | ||||
| 					> .icon { | ||||
| 						margin-right: 8px; | ||||
| 					} | ||||
| 				} | ||||
| 
 | ||||
| 				&:last-child { | ||||
| 					margin-left: 8px; | ||||
| 
 | ||||
| 					> .icon { | ||||
| 						margin-left: 8px; | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										389
									
								
								src/client/ui/chat/index.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										389
									
								
								src/client/ui/chat/index.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,389 @@ | |||
| <template> | ||||
| <div class="mk-app" @contextmenu.self.prevent="onContextmenu"> | ||||
| 	<XSidebar ref="menu" class="menu" :default-hidden="true"/> | ||||
| 
 | ||||
| 	<div class="nav"> | ||||
| 		<header class="header"> | ||||
| 			<div class="left"> | ||||
| 				<button class="_button account" @click="openAccountMenu"> | ||||
| 					<MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/> | ||||
| 				</button> | ||||
| 			</div> | ||||
| 			<div class="right"> | ||||
| 				<MkA class="item" to="/my/notifications"><Fa :icon="faBell"/></MkA> | ||||
| 			</div> | ||||
| 		</header> | ||||
| 		<div class="body"> | ||||
| 			<div class="container"> | ||||
| 				<div class="header">{{ $ts.timeline }}</div> | ||||
| 				<div class="body"> | ||||
| 					<MkA to="/timeline/home" class="item" :class="{ active: tl === 'home' }"><Fa :icon="faHome" class="icon"/>{{ $ts._timelines.home }}</MkA> | ||||
| 					<MkA to="/timeline/local" class="item" :class="{ active: tl === 'local' }"><Fa :icon="faHome" class="icon"/>{{ $ts._timelines.local }}</MkA> | ||||
| 					<MkA to="/timeline/social" class="item" :class="{ active: tl === 'social' }"><Fa :icon="faHome" class="icon"/>{{ $ts._timelines.social }}</MkA> | ||||
| 					<MkA to="/timeline/global" class="item" :class="{ active: tl === 'global' }"><Fa :icon="faHome" class="icon"/>{{ $ts._timelines.global }}</MkA> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 			<div class="container" v-if="lists"> | ||||
| 				<div class="header">{{ $ts.lists }}</div> | ||||
| 				<div class="body"> | ||||
| 					<MkA v-for="list in lists" :key="list.id" :to="`/my/list/${ list.id }`" class="item" :class="{ active: tl === `list:${ list.id }` }"><Fa :icon="faListUl" class="icon"/>{{ list.name }}</MkA> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 			<div class="container" v-if="antennas"> | ||||
| 				<div class="header">{{ $ts.antennas }}</div> | ||||
| 				<div class="body"> | ||||
| 					<MkA v-for="antenna in antennas" :key="antenna.id" :to="`/my/antenna/${ antenna.id }`" class="item" :class="{ active: tl === `antenna:${ antenna.id }` }"><Fa :icon="faSatellite" class="icon"/>{{ antenna.name }}</MkA> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 			<div class="container" v-if="followedChannels"> | ||||
| 				<div class="header">{{ $ts.channel }}</div> | ||||
| 				<div class="body"> | ||||
| 					<MkA v-for="channel in followedChannels" :key="channel.id" :to="`/channels/${ channel.id }`" class="item" :class="{ active: tl === `channel:${ channel.id }`, read: !channel.hasUnreadNote }"><Fa :icon="faSatelliteDish" class="icon"/>{{ channel.name }}</MkA> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 			<div class="container" v-if="featuredChannels"> | ||||
| 				<div class="header">{{ $ts.channel }}</div> | ||||
| 				<div class="body"> | ||||
| 					<MkA v-for="channel in featuredChannels" :key="channel.id" :to="`/channels/${ channel.id }`" class="item" :class="{ active: tl === `channel:${ channel.id }` }"><Fa :icon="faSatelliteDish" class="icon"/>{{ channel.name }}</MkA> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 		<footer class="footer"> | ||||
| 			<div class="left"> | ||||
| 				<button class="_button menu" @click="showMenu"> | ||||
| 					<Fa :icon="faBars"/> | ||||
| 				</button> | ||||
| 			</div> | ||||
| 			<div class="right"> | ||||
| 				<MkA class="item" to="/settings"><Fa :icon="faCog"/></MkA> | ||||
| 			</div> | ||||
| 		</footer> | ||||
| 	</div> | ||||
| 
 | ||||
| 	<main class="main" @contextmenu.stop="onContextmenu"> | ||||
| 		<header class="header" ref="header" @click="onHeaderClick"> | ||||
| 			<div v-if="tl === 'home'"> | ||||
| 				<Fa :icon="faHome" class="icon"/> | ||||
| 				<div class="title">{{ $ts._timelines.home }}</div> | ||||
| 			</div> | ||||
| 			<div v-else-if="tl === 'local'"> | ||||
| 				<Fa :icon="faShareAlt" class="icon"/> | ||||
| 				<div class="title">{{ $ts._timelines.local }}</div> | ||||
| 			</div> | ||||
| 			<div v-else-if="tl === 'social'"> | ||||
| 				<Fa :icon="faShareAlt" class="icon"/> | ||||
| 				<div class="title">{{ $ts._timelines.social }}</div> | ||||
| 			</div> | ||||
| 			<div v-else-if="tl === 'global'"> | ||||
| 				<Fa :icon="faShareAlt" class="icon"/> | ||||
| 				<div class="title">{{ $ts._timelines.global }}</div> | ||||
| 			</div> | ||||
| 		</header> | ||||
| 		<div class="body"> | ||||
| 			<XTimeline v-if="tl.startsWith('channel:')" src="channel" :key="tl" :channel="tl.replace('channel:', '')"/> | ||||
| 			<XTimeline v-else :src="tl" :key="tl"/> | ||||
| 		</div> | ||||
| 		<footer class="footer"> | ||||
| 			<XPostForm v-if="tl.startsWith('channel:')" :key="tl" :channel="tl.replace('channel:', '')"/> | ||||
| 			<XPostForm v-else/> | ||||
| 		</footer> | ||||
| 	</main> | ||||
| 
 | ||||
| 	<XSide class="side" ref="side"/> | ||||
| 
 | ||||
| 	<XCommon/> | ||||
| </div> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { defineComponent, defineAsyncComponent } from 'vue'; | ||||
| import { faLayerGroup, faBars, faHome, faCircle, faWindowMaximize, faColumns, faPencilAlt, faShareAlt, faSatelliteDish, faListUl, faSatellite, faCog } from '@fortawesome/free-solid-svg-icons'; | ||||
| import { faBell } from '@fortawesome/free-regular-svg-icons'; | ||||
| import { instanceName } from '@/config'; | ||||
| import XSidebar from '@/components/sidebar.vue'; | ||||
| import XCommon from '../_common_/common.vue'; | ||||
| import XSide from './side.vue'; | ||||
| import XTimeline from './timeline.vue'; | ||||
| import XPostForm from './post-form.vue'; | ||||
| import * as os from '@/os'; | ||||
| import { sidebarDef } from '@/sidebar'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
| 	components: { | ||||
| 		XCommon, | ||||
| 		XSidebar, | ||||
| 		XSide, // NOTE: dynamic importするとAsyncComponentWrapperが間に入るせいでref取得できなくて面倒になる | ||||
| 		XTimeline, | ||||
| 		XPostForm, | ||||
| 	}, | ||||
| 
 | ||||
| 	provide() { | ||||
| 		return { | ||||
| 			navHook: (path) => { | ||||
| 				switch (path) { | ||||
| 					case '/timeline/home': this.tl = 'home'; return; | ||||
| 					case '/timeline/local': this.tl = 'local'; return; | ||||
| 					case '/timeline/social': this.tl = 'social'; return; | ||||
| 					case '/timeline/global': this.tl = 'global'; return; | ||||
| 
 | ||||
| 					default: | ||||
| 						if (path.startsWith('/channels/')) { | ||||
| 							this.tl = `channel:${ path.replace('/channels/', '') }`; | ||||
| 							return; | ||||
| 						} | ||||
| 						//os.pageWindow(path); | ||||
| 						this.$refs.side.navigate(path); | ||||
| 						break; | ||||
| 				} | ||||
| 			}, | ||||
| 			sideViewHook: (path) => { | ||||
| 				this.$refs.side.navigate(path); | ||||
| 			} | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
| 	data() { | ||||
| 		return { | ||||
| 			tl: 'home', | ||||
| 			lists: null, | ||||
| 			antennas: null, | ||||
| 			followedChannels: null, | ||||
| 			featuredChannels: null, | ||||
| 			menuDef: sidebarDef, | ||||
| 			faLayerGroup, faBars, faBell, faHome, faCircle, faPencilAlt, faShareAlt, faSatelliteDish, faListUl, faSatellite, faCog, | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
| 	created() { | ||||
| 		os.api('users/lists/list').then(lists => { | ||||
| 			this.lists = lists; | ||||
| 		}); | ||||
| 
 | ||||
| 		os.api('antennas/list').then(antennas => { | ||||
| 			this.antennas = antennas; | ||||
| 		}); | ||||
| 
 | ||||
| 		os.api('channels/followed').then(channels => { | ||||
| 			this.followedChannels = channels; | ||||
| 		}); | ||||
| 
 | ||||
| 		os.api('channels/featured').then(channels => { | ||||
| 			this.featuredChannels = channels; | ||||
| 		}); | ||||
| 	}, | ||||
| 
 | ||||
| 	methods: { | ||||
| 		showMenu() { | ||||
| 			this.$refs.menu.show(); | ||||
| 		}, | ||||
| 
 | ||||
| 		post() { | ||||
| 			os.post(); | ||||
| 		}, | ||||
| 
 | ||||
| 		top() { | ||||
| 			window.scroll({ top: 0, behavior: 'smooth' }); | ||||
| 		}, | ||||
| 
 | ||||
| 		onTransition() { | ||||
| 			if (window._scroll) window._scroll(); | ||||
| 		}, | ||||
| 
 | ||||
| 		onHeaderClick() { | ||||
| 			window.scroll({ top: 0, behavior: 'smooth' }); | ||||
| 		}, | ||||
| 
 | ||||
| 		onContextmenu(e) { | ||||
| 			const isLink = (el: HTMLElement) => { | ||||
| 				if (el.tagName === 'A') return true; | ||||
| 				if (el.parentElement) { | ||||
| 					return isLink(el.parentElement); | ||||
| 				} | ||||
| 			}; | ||||
| 			if (isLink(e.target)) return; | ||||
| 			if (['INPUT', 'TEXTAREA'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return; | ||||
| 			if (window.getSelection().toString() !== '') return; | ||||
| 			const path = this.$route.path; | ||||
| 			os.contextMenu([{ | ||||
| 				type: 'label', | ||||
| 				text: path, | ||||
| 			}, { | ||||
| 				icon: faColumns, | ||||
| 				text: this.$ts.openInSideView, | ||||
| 				action: () => { | ||||
| 					this.$refs.side.navigate(path); | ||||
| 				} | ||||
| 			}, { | ||||
| 				icon: faWindowMaximize, | ||||
| 				text: this.$ts.openInWindow, | ||||
| 				action: () => { | ||||
| 					os.pageWindow(path); | ||||
| 				} | ||||
| 			}], e); | ||||
| 		}, | ||||
| 	} | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| .mk-app { | ||||
| 	$header-height: 54px; // TODO: どこかに集約したい | ||||
| 	$ui-font-size: 1em; // TODO: どこかに集約したい | ||||
| 
 | ||||
| 	// ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ | ||||
| 	min-height: calc(var(--vh, 1vh) * 100); | ||||
| 	box-sizing: border-box; | ||||
| 	display: flex; | ||||
| 
 | ||||
| 	> .nav { | ||||
| 		display: flex; | ||||
| 		flex-direction: column; | ||||
| 		width: 250px; | ||||
| 		height: 100vh; | ||||
| 		border-right: solid 1px var(--divider); | ||||
| 
 | ||||
| 		> .header, > .footer { | ||||
| 			$padding: 8px; | ||||
| 			display: flex; | ||||
| 			z-index: 1000; | ||||
| 			height: $header-height; | ||||
| 			padding: $padding; | ||||
| 			box-sizing: border-box; | ||||
| 			line-height: ($header-height - ($padding * 2)); | ||||
| 			user-select: none; | ||||
| 
 | ||||
| 			&.header { | ||||
| 				border-bottom: solid 1px var(--divider); | ||||
| 			} | ||||
| 
 | ||||
| 			&.footer { | ||||
| 				border-top: solid 1px var(--divider); | ||||
| 			} | ||||
| 
 | ||||
| 			> .left { | ||||
| 				> .account { | ||||
| 					display: flex; | ||||
| 					align-items: center; | ||||
| 					padding: 0 8px; | ||||
| 
 | ||||
| 					> .avatar { | ||||
| 						width: 26px; | ||||
| 						height: 26px; | ||||
| 						margin-right: 8px; | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 			> .right { | ||||
| 				margin-left: auto; | ||||
| 
 | ||||
| 				> .item { | ||||
| 					height: ($header-height - ($padding * 2)); | ||||
| 					width: ($header-height - ($padding * 2)); | ||||
| 					padding: 10px; | ||||
| 					box-sizing: border-box; | ||||
| 					margin-right: 4px; | ||||
| 					opacity: 0.6; | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		> .body { | ||||
| 			flex: 1; | ||||
| 			min-width: 0; | ||||
| 			overflow: auto; | ||||
| 
 | ||||
| 			> .container { | ||||
| 				& + .container { | ||||
| 					margin-top: 16px; | ||||
| 				} | ||||
| 
 | ||||
| 				> .header { | ||||
| 					font-size: 0.9em; | ||||
| 					padding: 8px 16px; | ||||
| 					opacity: 0.7; | ||||
| 				} | ||||
| 
 | ||||
| 				> .body { | ||||
| 					padding: 0 8px; | ||||
| 
 | ||||
| 					> .item { | ||||
| 						display: block; | ||||
| 						padding: 6px 8px; | ||||
| 						border-radius: 4px; | ||||
| 
 | ||||
| 						&:hover { | ||||
| 							text-decoration: none; | ||||
| 							background: rgba(0, 0, 0, 0.05); | ||||
| 						} | ||||
| 
 | ||||
| 						&.active, &.active:hover { | ||||
| 							background: var(--accent); | ||||
| 							color: #fff; | ||||
| 						} | ||||
| 
 | ||||
| 						&.read { | ||||
| 							opacity: 0.5; | ||||
| 						} | ||||
| 
 | ||||
| 						> .icon { | ||||
| 							margin-right: 6px; | ||||
| 							opacity: 0.6; | ||||
| 						} | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	> .main { | ||||
| 		display: flex; | ||||
| 		flex: 1; | ||||
| 		flex-direction: column; | ||||
| 		min-width: 0; | ||||
| 		height: 100vh; | ||||
| 		position: relative; | ||||
| 		background: var(--panel); | ||||
| 
 | ||||
| 		> .header { | ||||
| 			$padding: 8px; | ||||
| 			z-index: 1000; | ||||
| 			height: $header-height; | ||||
| 			padding: $padding; | ||||
| 			box-sizing: border-box; | ||||
| 			line-height: ($header-height - ($padding * 2)); | ||||
| 			font-weight: bold; | ||||
| 			background-color: var(--header); | ||||
| 			border-bottom: solid 1px var(--divider); | ||||
| 			user-select: none; | ||||
| 
 | ||||
| 			> div { | ||||
| 				display: flex; | ||||
| 
 | ||||
| 				> .icon { | ||||
| 					height: ($header-height - ($padding * 2)); | ||||
| 					width: ($header-height - ($padding * 2)); | ||||
| 					padding: 10px; | ||||
| 					box-sizing: border-box; | ||||
| 					margin-right: 4px; | ||||
| 					opacity: 0.6; | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		> .footer { | ||||
| 			padding: 16px; | ||||
| 		} | ||||
| 
 | ||||
| 		> .body { | ||||
| 			flex: 1; | ||||
| 			min-width: 0; | ||||
| 			overflow: auto; | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	> .side { | ||||
| 		border-left: solid 1px var(--divider); | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										115
									
								
								src/client/ui/chat/note-header.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								src/client/ui/chat/note-header.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,115 @@ | |||
| <template> | ||||
| <header class="dehvdgxo"> | ||||
| 	<MkA class="name" :to="userPage(note.user)" v-user-preview="note.user.id"> | ||||
| 		<MkUserName :user="note.user"/> | ||||
| 	</MkA> | ||||
| 	<span class="is-bot" v-if="note.user.isBot">bot</span> | ||||
| 	<span class="username"><MkAcct :user="note.user"/></span> | ||||
| 	<span class="admin" v-if="note.user.isAdmin"><Fa :icon="faBookmark"/></span> | ||||
| 	<span class="moderator" v-if="!note.user.isAdmin && note.user.isModerator"><Fa :icon="farBookmark"/></span> | ||||
| 	<div class="info"> | ||||
| 		<span class="mobile" v-if="note.viaMobile"><Fa :icon="faMobileAlt"/></span> | ||||
| 		<MkA class="created-at" :to="notePage(note)"> | ||||
| 			<MkTime :time="note.createdAt"/> | ||||
| 		</MkA> | ||||
| 		<span class="visibility" v-if="note.visibility !== 'public'"> | ||||
| 			<Fa v-if="note.visibility === 'home'" :icon="faHome"/> | ||||
| 			<Fa v-if="note.visibility === 'followers'" :icon="faUnlock"/> | ||||
| 			<Fa v-if="note.visibility === 'specified'" :icon="faEnvelope"/> | ||||
| 		</span> | ||||
| 		<span class="localOnly" v-if="note.localOnly"><Fa :icon="faBiohazard"/></span> | ||||
| 	</div> | ||||
| </header> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { defineComponent } from 'vue'; | ||||
| import { faHome, faUnlock, faEnvelope, faMobileAlt, faBookmark, faBiohazard } from '@fortawesome/free-solid-svg-icons'; | ||||
| import { faBookmark as farBookmark } from '@fortawesome/free-regular-svg-icons'; | ||||
| import notePage from '@/filters/note'; | ||||
| import { userPage } from '@/filters/user'; | ||||
| import * as os from '@/os'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
| 	props: { | ||||
| 		note: { | ||||
| 			type: Object, | ||||
| 			required: true | ||||
| 		}, | ||||
| 	}, | ||||
| 
 | ||||
| 	data() { | ||||
| 		return { | ||||
| 			faHome, faUnlock, faEnvelope, faMobileAlt, faBookmark, farBookmark, faBiohazard | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
| 	methods: { | ||||
| 		notePage, | ||||
| 		userPage | ||||
| 	} | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| .dehvdgxo { | ||||
| 	display: flex; | ||||
| 	align-items: baseline; | ||||
| 	white-space: nowrap; | ||||
| 	font-size: 0.9em; | ||||
| 
 | ||||
| 	> .name { | ||||
| 		display: block; | ||||
| 		margin: 0 .5em 0 0; | ||||
| 		padding: 0; | ||||
| 		overflow: hidden; | ||||
| 		font-size: 1em; | ||||
| 		font-weight: bold; | ||||
| 		text-decoration: none; | ||||
| 		text-overflow: ellipsis; | ||||
| 
 | ||||
| 		&:hover { | ||||
| 			text-decoration: underline; | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	> .is-bot { | ||||
| 		flex-shrink: 0; | ||||
| 		align-self: center; | ||||
| 		margin: 0 .5em 0 0; | ||||
| 		padding: 1px 6px; | ||||
| 		font-size: 80%; | ||||
| 		border: solid 1px var(--divider); | ||||
| 		border-radius: 3px; | ||||
| 	} | ||||
| 
 | ||||
| 	> .admin, | ||||
| 	> .moderator { | ||||
| 		margin-right: 0.5em; | ||||
| 		color: var(--badge); | ||||
| 	} | ||||
| 
 | ||||
| 	> .username { | ||||
| 		margin: 0 .5em 0 0; | ||||
| 		overflow: hidden; | ||||
| 		text-overflow: ellipsis; | ||||
| 	} | ||||
| 
 | ||||
| 	> .info { | ||||
| 		font-size: 0.9em; | ||||
| 		opacity: 0.7; | ||||
| 
 | ||||
| 		> .mobile { | ||||
| 			margin-right: 8px; | ||||
| 		} | ||||
| 
 | ||||
| 		> .visibility { | ||||
| 			margin-left: 8px; | ||||
| 		} | ||||
| 
 | ||||
| 		> .localOnly { | ||||
| 			margin-left: 8px; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										112
									
								
								src/client/ui/chat/note-preview.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								src/client/ui/chat/note-preview.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,112 @@ | |||
| <template> | ||||
| <div class="hduudsxk"> | ||||
| 	<MkAvatar class="avatar" :user="note.user"/> | ||||
| 	<div class="main"> | ||||
| 		<XNoteHeader class="header" :note="note" :mini="true"/> | ||||
| 		<div class="body"> | ||||
| 			<p v-if="note.cw != null" class="cw"> | ||||
| 				<span class="text" v-if="note.cw != ''">{{ note.cw }}</span> | ||||
| 				<XCwButton v-model:value="showContent" :note="note"/> | ||||
| 			</p> | ||||
| 			<div class="content" v-show="note.cw == null || showContent"> | ||||
| 				<XSubNote-content class="text" :note="note"/> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </div> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { defineComponent } from 'vue'; | ||||
| import XNoteHeader from './note-header.vue'; | ||||
| import XSubNoteContent from './sub-note-content.vue'; | ||||
| import XCwButton from '@/components/cw-button.vue'; | ||||
| import * as os from '@/os'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
| 	components: { | ||||
| 		XNoteHeader, | ||||
| 		XSubNoteContent, | ||||
| 		XCwButton, | ||||
| 	}, | ||||
| 
 | ||||
| 	props: { | ||||
| 		note: { | ||||
| 			type: Object, | ||||
| 			required: true | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	data() { | ||||
| 		return { | ||||
| 			showContent: false | ||||
| 		}; | ||||
| 	} | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| .hduudsxk { | ||||
| 	display: flex; | ||||
| 	margin: 0; | ||||
| 	padding: 0; | ||||
| 	overflow: hidden; | ||||
| 	font-size: 0.95em; | ||||
| 
 | ||||
| 	> .avatar { | ||||
| 
 | ||||
| 		@media (min-width: 350px) { | ||||
| 			margin: 0 10px 0 0; | ||||
| 			width: 44px; | ||||
| 			height: 44px; | ||||
| 		} | ||||
| 
 | ||||
| 		@media (min-width: 500px) { | ||||
| 			margin: 0 12px 0 0; | ||||
| 			width: 48px; | ||||
| 			height: 48px; | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	> .avatar { | ||||
| 		flex-shrink: 0; | ||||
| 		display: block; | ||||
| 		margin: 0 10px 0 0; | ||||
| 		width: 40px; | ||||
| 		height: 40px; | ||||
| 		border-radius: 8px; | ||||
| 	} | ||||
| 
 | ||||
| 	> .main { | ||||
| 		flex: 1; | ||||
| 		min-width: 0; | ||||
| 
 | ||||
| 		> .header { | ||||
| 			margin-bottom: 2px; | ||||
| 		} | ||||
| 
 | ||||
| 		> .body { | ||||
| 
 | ||||
| 			> .cw { | ||||
| 				cursor: default; | ||||
| 				display: block; | ||||
| 				margin: 0; | ||||
| 				padding: 0; | ||||
| 				overflow-wrap: break-word; | ||||
| 
 | ||||
| 				> .text { | ||||
| 					margin-right: 8px; | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 			> .content { | ||||
| 				> .text { | ||||
| 					cursor: default; | ||||
| 					margin: 0; | ||||
| 					padding: 0; | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										137
									
								
								src/client/ui/chat/note.sub.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								src/client/ui/chat/note.sub.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,137 @@ | |||
| <template> | ||||
| <div class="wrpstxzv" :class="{ children }"> | ||||
| 	<div class="main"> | ||||
| 		<MkAvatar class="avatar" :user="note.user"/> | ||||
| 		<div class="body"> | ||||
| 			<XNoteHeader class="header" :note="note" :mini="true"/> | ||||
| 			<div class="body"> | ||||
| 				<p v-if="note.cw != null" class="cw"> | ||||
| 					<Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis" /> | ||||
| 					<XCwButton v-model:value="showContent" :note="note"/> | ||||
| 				</p> | ||||
| 				<div class="content" v-show="note.cw == null || showContent"> | ||||
| 					<XSubNote-content class="text" :note="note"/> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
| 	<XSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :children="true"/> | ||||
| </div> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { defineComponent } from 'vue'; | ||||
| import XNoteHeader from './note-header.vue'; | ||||
| import XSubNoteContent from './sub-note-content.vue'; | ||||
| import XCwButton from '@/components/cw-button.vue'; | ||||
| import * as os from '@/os'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
| 	name: 'XSub', | ||||
| 
 | ||||
| 	components: { | ||||
| 		XNoteHeader, | ||||
| 		XSubNoteContent, | ||||
| 		XCwButton, | ||||
| 	}, | ||||
| 
 | ||||
| 	props: { | ||||
| 		note: { | ||||
| 			type: Object, | ||||
| 			required: true | ||||
| 		}, | ||||
| 		detail: { | ||||
| 			type: Boolean, | ||||
| 			required: false, | ||||
| 			default: false | ||||
| 		}, | ||||
| 		children: { | ||||
| 			type: Boolean, | ||||
| 			required: false, | ||||
| 			default: false | ||||
| 		}, | ||||
| 		// TODO | ||||
| 		truncate: { | ||||
| 			type: Boolean, | ||||
| 			default: true | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	data() { | ||||
| 		return { | ||||
| 			showContent: false, | ||||
| 			replies: [], | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
| 	created() { | ||||
| 		if (this.detail) { | ||||
| 			os.api('notes/children', { | ||||
| 				noteId: this.note.id, | ||||
| 				limit: 5 | ||||
| 			}).then(replies => { | ||||
| 				this.replies = replies; | ||||
| 			}); | ||||
| 		} | ||||
| 	}, | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| .wrpstxzv { | ||||
| 	padding: 16px 16px; | ||||
| 	font-size: 0.8em; | ||||
| 
 | ||||
| 	&.children { | ||||
| 		padding: 10px 0 0 16px; | ||||
| 		font-size: 1em; | ||||
| 	} | ||||
| 
 | ||||
| 	> .main { | ||||
| 		display: flex; | ||||
| 
 | ||||
| 		> .avatar { | ||||
| 			flex-shrink: 0; | ||||
| 			display: block; | ||||
| 			margin: 0 8px 0 0; | ||||
| 			width: 36px; | ||||
| 			height: 36px; | ||||
| 		} | ||||
| 
 | ||||
| 		> .body { | ||||
| 			flex: 1; | ||||
| 			min-width: 0; | ||||
| 
 | ||||
| 			> .header { | ||||
| 				margin-bottom: 2px; | ||||
| 			} | ||||
| 
 | ||||
| 			> .body { | ||||
| 				> .cw { | ||||
| 					cursor: default; | ||||
| 					display: block; | ||||
| 					margin: 0; | ||||
| 					padding: 0; | ||||
| 					overflow-wrap: break-word; | ||||
| 
 | ||||
| 					> .text { | ||||
| 						margin-right: 8px; | ||||
| 					} | ||||
| 				} | ||||
| 
 | ||||
| 				> .content { | ||||
| 					> .text { | ||||
| 						margin: 0; | ||||
| 						padding: 0; | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	> .reply { | ||||
| 		border-left: solid 1px var(--divider); | ||||
| 		margin-top: 10px; | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										1126
									
								
								src/client/ui/chat/note.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1126
									
								
								src/client/ui/chat/note.vue
									
										
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
							
								
								
									
										91
									
								
								src/client/ui/chat/notes.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								src/client/ui/chat/notes.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,91 @@ | |||
| <template> | ||||
| <div class="" :ref="mounted"> | ||||
| 	<div class="_fullinfo" v-if="empty"> | ||||
| 		<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> | ||||
| 		<div>{{ $ts.noNotes }}</div> | ||||
| 	</div> | ||||
| 
 | ||||
| 	<MkError v-if="error" @retry="init()"/> | ||||
| 
 | ||||
| 	<div v-show="more && reversed" style="margin-bottom: var(--margin);"> | ||||
| 		<button class="_buttonPrimary" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> | ||||
| 			<template v-if="!moreFetching">{{ $ts.loadMore }}</template> | ||||
| 			<template v-if="moreFetching"><MkLoading inline/></template> | ||||
| 		</button> | ||||
| 	</div> | ||||
| 
 | ||||
| 	<XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed"> | ||||
| 		<XNote :note="note" @update:note="updated(note, $event)" :key="note._featuredId_ || note._prId_ || note.id"/> | ||||
| 	</XList> | ||||
| 
 | ||||
| 	<div v-show="more && !reversed" style="margin-top: var(--margin);"> | ||||
| 		<button class="_buttonPrimary" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> | ||||
| 			<template v-if="!moreFetching">{{ $ts.loadMore }}</template> | ||||
| 			<template v-if="moreFetching"><MkLoading inline/></template> | ||||
| 		</button> | ||||
| 	</div> | ||||
| </div> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { defineComponent } from 'vue'; | ||||
| import paging from '@/scripts/paging'; | ||||
| import XNote from './note.vue'; | ||||
| import XList from './date-separated-list.vue'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
| 	components: { | ||||
| 		XNote, XList, | ||||
| 	}, | ||||
| 
 | ||||
| 	mixins: [ | ||||
| 		paging({ | ||||
| 			before: (self) => { | ||||
| 				self.$emit('before'); | ||||
| 			}, | ||||
| 
 | ||||
| 			after: (self, e) => { | ||||
| 				self.$emit('after', e); | ||||
| 			} | ||||
| 		}), | ||||
| 	], | ||||
| 
 | ||||
| 	props: { | ||||
| 		pagination: { | ||||
| 			required: true | ||||
| 		}, | ||||
| 
 | ||||
| 		prop: { | ||||
| 			type: String, | ||||
| 			required: false | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	emits: ['before', 'after'], | ||||
| 
 | ||||
| 	computed: { | ||||
| 		notes(): any[] { | ||||
| 			return this.prop ? this.items.map(item => item[this.prop]) : this.items; | ||||
| 		}, | ||||
| 
 | ||||
| 		reversed(): boolean { | ||||
| 			return this.pagination.reversed; | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	methods: { | ||||
| 		updated(oldValue, newValue) { | ||||
| 			const i = this.notes.findIndex(n => n === oldValue); | ||||
| 			if (this.prop) { | ||||
| 				this.items[i][this.prop] = newValue; | ||||
| 			} else { | ||||
| 				this.items[i] = newValue; | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		focus() { | ||||
| 			this.$refs.notes.focus(); | ||||
| 		} | ||||
| 	} | ||||
| }); | ||||
| </script> | ||||
							
								
								
									
										771
									
								
								src/client/ui/chat/post-form.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										771
									
								
								src/client/ui/chat/post-form.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,771 @@ | |||
| <template> | ||||
| <div class="pxiwixjf" | ||||
| 	@dragover.stop="onDragover" | ||||
| 	@dragenter="onDragenter" | ||||
| 	@dragleave="onDragleave" | ||||
| 	@drop.stop="onDrop" | ||||
| > | ||||
| 	<div class="form"> | ||||
| 		<div class="with-quote" v-if="quoteId"><Fa icon="quote-left"/> {{ $ts.quoteAttached }}<button @click="quoteId = null"><Fa icon="times"/></button></div> | ||||
| 		<div v-if="visibility === 'specified'" class="to-specified"> | ||||
| 			<span style="margin-right: 8px;">{{ $ts.recipient }}</span> | ||||
| 			<div class="visibleUsers"> | ||||
| 				<span v-for="u in visibleUsers" :key="u.id"> | ||||
| 					<MkAcct :user="u"/> | ||||
| 					<button class="_button" @click="removeVisibleUser(u)"><Fa :icon="faTimes"/></button> | ||||
| 				</span> | ||||
| 				<button @click="addVisibleUser" class="_buttonPrimary"><Fa :icon="faPlus" fixed-width/></button> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 		<input v-show="useCw" ref="cw" class="cw" v-model="cw" :placeholder="$ts.annotation" @keydown="onKeydown"> | ||||
| 		<textarea v-model="text" class="text" :class="{ withCw: useCw }" ref="text" :disabled="posting" :placeholder="placeholder" @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd" /> | ||||
| 		<XPostFormAttaches class="attaches" :files="files" @updated="updateFiles" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/> | ||||
| 		<XPollEditor v-if="poll" :poll="poll" @destroyed="poll = null" @updated="onPollUpdate"/> | ||||
| 		<footer> | ||||
| 			<div class="left"> | ||||
| 				<button class="_button" @click="chooseFileFrom" v-tooltip="$ts.attachFile"><Fa :icon="faPhotoVideo"/></button> | ||||
| 				<button class="_button" @click="togglePoll" :class="{ active: poll }" v-tooltip="$ts.poll"><Fa :icon="faPollH"/></button> | ||||
| 				<button class="_button" @click="useCw = !useCw" :class="{ active: useCw }" v-tooltip="$ts.useCw"><Fa :icon="faEyeSlash"/></button> | ||||
| 				<button class="_button" @click="insertMention" v-tooltip="$ts.mention"><Fa :icon="faAt"/></button> | ||||
| 				<button class="_button" @click="insertEmoji" v-tooltip="$ts.emoji"><Fa :icon="faLaughSquint"/></button> | ||||
| 				<button class="_button" @click="showActions" v-tooltip="$ts.plugin" v-if="postFormActions.length > 0"><Fa :icon="faPlug"/></button> | ||||
| 			</div> | ||||
| 			<div class="right"> | ||||
| 				<span class="text-count" :class="{ over: textLength > max }">{{ max - textLength }}</span> | ||||
| 				<span class="local-only" v-if="localOnly"><Fa :icon="faBiohazard"/></span> | ||||
| 				<button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$ts.visibility" :disabled="channel != null"> | ||||
| 					<span v-if="visibility === 'public'"><Fa :icon="faGlobe"/></span> | ||||
| 					<span v-if="visibility === 'home'"><Fa :icon="faHome"/></span> | ||||
| 					<span v-if="visibility === 'followers'"><Fa :icon="faUnlock"/></span> | ||||
| 					<span v-if="visibility === 'specified'"><Fa :icon="faEnvelope"/></span> | ||||
| 				</button> | ||||
| 				<button class="submit _buttonPrimary" :disabled="!canPost" @click="post">{{ submitText }}<Fa :icon="reply ? faReply : renote ? faQuoteRight : faPaperPlane"/></button> | ||||
| 			</div> | ||||
| 		</footer> | ||||
| 	</div> | ||||
| </div> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { defineComponent, defineAsyncComponent } from 'vue'; | ||||
| import { faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faPlus, faPhotoVideo, faAt, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons'; | ||||
| import { faEyeSlash, faLaughSquint } from '@fortawesome/free-regular-svg-icons'; | ||||
| import insertTextAtCursor from 'insert-text-at-cursor'; | ||||
| import { length } from 'stringz'; | ||||
| import { toASCII } from 'punycode'; | ||||
| import { parse } from '../../../mfm/parse'; | ||||
| import { host, url } from '@/config'; | ||||
| import { erase, unique } from '../../../prelude/array'; | ||||
| import extractMentions from '../../../misc/extract-mentions'; | ||||
| import getAcct from '../../../misc/acct/render'; | ||||
| import { formatTimeString } from '../../../misc/format-time-string'; | ||||
| import { Autocomplete } from '@/scripts/autocomplete'; | ||||
| import { noteVisibilities } from '../../../types'; | ||||
| import * as os from '@/os'; | ||||
| import { selectFile } from '@/scripts/select-file'; | ||||
| import { notePostInterruptors, postFormActions } from '@/store'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
| 	components: { | ||||
| 		XPostFormAttaches: defineAsyncComponent(() => import('@/components/post-form-attaches.vue')), | ||||
| 		XPollEditor: defineAsyncComponent(() => import('@/components/poll-editor.vue')) | ||||
| 	}, | ||||
| 
 | ||||
| 	props: { | ||||
| 		reply: { | ||||
| 			type: Object, | ||||
| 			required: false | ||||
| 		}, | ||||
| 		renote: { | ||||
| 			type: Object, | ||||
| 			required: false | ||||
| 		}, | ||||
| 		channel: { | ||||
| 			type: String, | ||||
| 			required: false | ||||
| 		}, | ||||
| 		mention: { | ||||
| 			type: Object, | ||||
| 			required: false | ||||
| 		}, | ||||
| 		specified: { | ||||
| 			type: Object, | ||||
| 			required: false | ||||
| 		}, | ||||
| 		initialText: { | ||||
| 			type: String, | ||||
| 			required: false | ||||
| 		}, | ||||
| 		initialNote: { | ||||
| 			type: Object, | ||||
| 			required: false | ||||
| 		}, | ||||
| 		instant: { | ||||
| 			type: Boolean, | ||||
| 			required: false, | ||||
| 			default: false | ||||
| 		}, | ||||
| 		autofocus: { | ||||
| 			type: Boolean, | ||||
| 			required: false, | ||||
| 			default: true | ||||
| 		}, | ||||
| 	}, | ||||
| 
 | ||||
| 	emits: ['posted', 'cancel', 'esc'], | ||||
| 
 | ||||
| 	data() { | ||||
| 		return { | ||||
| 			posting: false, | ||||
| 			text: '', | ||||
| 			files: [], | ||||
| 			poll: null, | ||||
| 			useCw: false, | ||||
| 			cw: null, | ||||
| 			localOnly: this.$store.state.rememberNoteVisibility ? this.$store.state.localOnly : this.$store.state.defaultNoteLocalOnly, | ||||
| 			visibility: this.$store.state.rememberNoteVisibility ? this.$store.state.visibility : this.$store.state.defaultNoteVisibility, | ||||
| 			visibleUsers: [], | ||||
| 			autocomplete: null, | ||||
| 			draghover: false, | ||||
| 			quoteId: null, | ||||
| 			recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'), | ||||
| 			imeText: '', | ||||
| 			postFormActions, | ||||
| 			faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faAt, faBiohazard, faPlug | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
| 	computed: { | ||||
| 		draftKey(): string { | ||||
| 			let key = this.channel ? `channel:${this.channel}` : ''; | ||||
| 
 | ||||
| 			if (this.renote) { | ||||
| 				key += `renote:${this.renote.id}`; | ||||
| 			} else if (this.reply) { | ||||
| 				key += `reply:${this.reply.id}`; | ||||
| 			} else { | ||||
| 				key += 'note'; | ||||
| 			} | ||||
| 
 | ||||
| 			return key; | ||||
| 		}, | ||||
| 
 | ||||
| 		placeholder(): string { | ||||
| 			if (this.renote) { | ||||
| 				return this.$ts._postForm.quotePlaceholder; | ||||
| 			} else if (this.reply) { | ||||
| 				return this.$ts._postForm.replyPlaceholder; | ||||
| 			} else if (this.channel) { | ||||
| 				return this.$ts._postForm.channelPlaceholder; | ||||
| 			} else { | ||||
| 				const xs = [ | ||||
| 					this.$ts._postForm._placeholders.a, | ||||
| 					this.$ts._postForm._placeholders.b, | ||||
| 					this.$ts._postForm._placeholders.c, | ||||
| 					this.$ts._postForm._placeholders.d, | ||||
| 					this.$ts._postForm._placeholders.e, | ||||
| 					this.$ts._postForm._placeholders.f | ||||
| 				]; | ||||
| 				return xs[Math.floor(Math.random() * xs.length)]; | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		submitText(): string { | ||||
| 			return this.renote | ||||
| 				? this.$ts.quote | ||||
| 				: this.reply | ||||
| 					? this.$ts.reply | ||||
| 					: this.$ts.note; | ||||
| 		}, | ||||
| 
 | ||||
| 		textLength(): number { | ||||
| 			return length((this.text + this.imeText).trim()); | ||||
| 		}, | ||||
| 
 | ||||
| 		canPost(): boolean { | ||||
| 			return !this.posting && | ||||
| 				(1 <= this.textLength || 1 <= this.files.length || !!this.poll || !!this.renote) && | ||||
| 				(this.textLength <= this.max) && | ||||
| 				(!this.poll || this.poll.choices.length >= 2); | ||||
| 		}, | ||||
| 
 | ||||
| 		max(): number { | ||||
| 			return this.$instance ? this.$instance.maxNoteTextLength : 1000; | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	mounted() { | ||||
| 		if (this.initialText) { | ||||
| 			this.text = this.initialText; | ||||
| 		} | ||||
| 
 | ||||
| 		if (this.mention) { | ||||
| 			this.text = this.mention.host ? `@${this.mention.username}@${toASCII(this.mention.host)}` : `@${this.mention.username}`; | ||||
| 			this.text += ' '; | ||||
| 		} | ||||
| 
 | ||||
| 		if (this.reply && this.reply.user.host != null) { | ||||
| 			this.text = `@${this.reply.user.username}@${toASCII(this.reply.user.host)} `; | ||||
| 		} | ||||
| 
 | ||||
| 		if (this.reply && this.reply.text != null) { | ||||
| 			const ast = parse(this.reply.text); | ||||
| 
 | ||||
| 			for (const x of extractMentions(ast)) { | ||||
| 				const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`; | ||||
| 
 | ||||
| 				// 自分は除外 | ||||
| 				if (this.$i.username == x.username && x.host == null) continue; | ||||
| 				if (this.$i.username == x.username && x.host == host) continue; | ||||
| 
 | ||||
| 				// 重複は除外 | ||||
| 				if (this.text.indexOf(`${mention} `) != -1) continue; | ||||
| 
 | ||||
| 				this.text += `${mention} `; | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		if (this.channel) { | ||||
| 			this.visibility = 'public'; | ||||
| 			this.localOnly = true; // TODO: チャンネルが連合するようになった折には消す | ||||
| 		} | ||||
| 
 | ||||
| 		// 公開以外へのリプライ時は元の公開範囲を引き継ぐ | ||||
| 		if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) { | ||||
| 			this.visibility = this.reply.visibility; | ||||
| 			if (this.reply.visibility === 'specified') { | ||||
| 				os.api('users/show', { | ||||
| 					userIds: this.reply.visibleUserIds.filter(uid => uid !== this.$i.id && uid !== this.reply.userId) | ||||
| 				}).then(users => { | ||||
| 					this.visibleUsers.push(...users); | ||||
| 				}); | ||||
| 
 | ||||
| 				if (this.reply.userId !== this.$i.id) { | ||||
| 					os.api('users/show', { userId: this.reply.userId }).then(user => { | ||||
| 						this.visibleUsers.push(user); | ||||
| 					}); | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		if (this.specified) { | ||||
| 			this.visibility = 'specified'; | ||||
| 			this.visibleUsers.push(this.specified); | ||||
| 		} | ||||
| 
 | ||||
| 		// keep cw when reply | ||||
| 		if (this.$store.state.keepCw && this.reply && this.reply.cw) { | ||||
| 			this.useCw = true; | ||||
| 			this.cw = this.reply.cw; | ||||
| 		} | ||||
| 
 | ||||
| 		if (this.autofocus) { | ||||
| 			this.focus(); | ||||
| 
 | ||||
| 			this.$nextTick(() => { | ||||
| 				this.focus(); | ||||
| 			}); | ||||
| 		} | ||||
| 
 | ||||
| 		// TODO: detach when unmount | ||||
| 		new Autocomplete(this.$refs.text, this, { model: 'text' }); | ||||
| 		new Autocomplete(this.$refs.cw, this, { model: 'cw' }); | ||||
| 
 | ||||
| 		this.$nextTick(() => { | ||||
| 			// 書きかけの投稿を復元 | ||||
| 			if (!this.instant && !this.mention && !this.specified) { | ||||
| 				const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftKey]; | ||||
| 				if (draft) { | ||||
| 					this.text = draft.data.text; | ||||
| 					this.useCw = draft.data.useCw; | ||||
| 					this.cw = draft.data.cw; | ||||
| 					this.visibility = draft.data.visibility; | ||||
| 					this.localOnly = draft.data.localOnly; | ||||
| 					this.files = (draft.data.files || []).filter(e => e); | ||||
| 					if (draft.data.poll) { | ||||
| 						this.poll = draft.data.poll; | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 			// 削除して編集 | ||||
| 			if (this.initialNote) { | ||||
| 				const init = this.initialNote; | ||||
| 				this.text = init.text ? init.text : ''; | ||||
| 				this.files = init.files; | ||||
| 				this.cw = init.cw; | ||||
| 				this.useCw = init.cw != null; | ||||
| 				if (init.poll) { | ||||
| 					this.poll = init.poll; | ||||
| 				} | ||||
| 				this.visibility = init.visibility; | ||||
| 				this.localOnly = init.localOnly; | ||||
| 				this.quoteId = init.renote ? init.renote.id : null; | ||||
| 			} | ||||
| 
 | ||||
| 			this.$nextTick(() => this.watch()); | ||||
| 		}); | ||||
| 	}, | ||||
| 
 | ||||
| 	methods: { | ||||
| 		watch() { | ||||
| 			this.$watch('text', () => this.saveDraft()); | ||||
| 			this.$watch('useCw', () => this.saveDraft()); | ||||
| 			this.$watch('cw', () => this.saveDraft()); | ||||
| 			this.$watch('poll', () => this.saveDraft()); | ||||
| 			this.$watch('files', () => this.saveDraft(), { deep: true }); | ||||
| 			this.$watch('visibility', () => this.saveDraft()); | ||||
| 			this.$watch('localOnly', () => this.saveDraft()); | ||||
| 		}, | ||||
| 
 | ||||
| 		togglePoll() { | ||||
| 			if (this.poll) { | ||||
| 				this.poll = null; | ||||
| 			} else { | ||||
| 				this.poll = { | ||||
| 					choices: ['', ''], | ||||
| 					multiple: false, | ||||
| 					expiresAt: null, | ||||
| 					expiredAfter: null, | ||||
| 				}; | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		addTag(tag: string) { | ||||
| 			insertTextAtCursor(this.$refs.text, ` #${tag} `); | ||||
| 		}, | ||||
| 
 | ||||
| 		focus() { | ||||
| 			(this.$refs.text as any).focus(); | ||||
| 		}, | ||||
| 
 | ||||
| 		chooseFileFrom(ev) { | ||||
| 			selectFile(ev.currentTarget || ev.target, this.$ts.attachFile, true).then(files => { | ||||
| 				for (const file of files) { | ||||
| 					this.files.push(file); | ||||
| 				} | ||||
| 			}); | ||||
| 		}, | ||||
| 
 | ||||
| 		detachFile(id) { | ||||
| 			this.files = this.files.filter(x => x.id != id); | ||||
| 		}, | ||||
| 
 | ||||
| 		updateFiles(files) { | ||||
| 			this.files = files; | ||||
| 		}, | ||||
| 
 | ||||
| 		updateFileSensitive(file, sensitive) { | ||||
| 			this.files[this.files.findIndex(x => x.id === file.id)].isSensitive = sensitive; | ||||
| 		}, | ||||
| 
 | ||||
| 		updateFileName(file, name) { | ||||
| 			this.files[this.files.findIndex(x => x.id === file.id)].name = name; | ||||
| 		}, | ||||
| 
 | ||||
| 		upload(file: File, name?: string) { | ||||
| 			os.upload(file, this.$store.state.uploadFolder, name).then(res => { | ||||
| 				this.files.push(res); | ||||
| 			}); | ||||
| 		}, | ||||
| 
 | ||||
| 		onPollUpdate(poll) { | ||||
| 			this.poll = poll; | ||||
| 			this.saveDraft(); | ||||
| 		}, | ||||
| 
 | ||||
| 		setVisibility() { | ||||
| 			if (this.channel) { | ||||
| 				// TODO: information dialog | ||||
| 				return; | ||||
| 			} | ||||
| 
 | ||||
| 			os.popup(import('@/components/visibility-picker.vue'), { | ||||
| 				currentVisibility: this.visibility, | ||||
| 				currentLocalOnly: this.localOnly, | ||||
| 				src: this.$refs.visibilityButton | ||||
| 			}, { | ||||
| 				changeVisibility: visibility => { | ||||
| 					this.visibility = visibility; | ||||
| 					if (this.$store.state.rememberNoteVisibility) { | ||||
| 						this.$store.set('visibility', visibility); | ||||
| 					} | ||||
| 				}, | ||||
| 				changeLocalOnly: localOnly => { | ||||
| 					this.localOnly = localOnly; | ||||
| 					if (this.$store.state.rememberNoteVisibility) { | ||||
| 						this.$store.set('localOnly', localOnly); | ||||
| 					} | ||||
| 				} | ||||
| 			}, 'closed'); | ||||
| 		}, | ||||
| 
 | ||||
| 		addVisibleUser() { | ||||
| 			os.selectUser().then(user => { | ||||
| 				this.visibleUsers.push(user); | ||||
| 			}); | ||||
| 		}, | ||||
| 
 | ||||
| 		removeVisibleUser(user) { | ||||
| 			this.visibleUsers = erase(user, this.visibleUsers); | ||||
| 		}, | ||||
| 
 | ||||
| 		clear() { | ||||
| 			this.text = ''; | ||||
| 			this.files = []; | ||||
| 			this.poll = null; | ||||
| 			this.quoteId = null; | ||||
| 		}, | ||||
| 
 | ||||
| 		onKeydown(e: KeyboardEvent) { | ||||
| 			if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post(); | ||||
| 			if (e.which === 27) this.$emit('esc'); | ||||
| 		}, | ||||
| 
 | ||||
| 		onCompositionUpdate(e: CompositionEvent) { | ||||
| 			this.imeText = e.data; | ||||
| 		}, | ||||
| 
 | ||||
| 		onCompositionEnd(e: CompositionEvent) { | ||||
| 			this.imeText = ''; | ||||
| 		}, | ||||
| 
 | ||||
| 		async onPaste(e: ClipboardEvent) { | ||||
| 			for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) { | ||||
| 				if (item.kind == 'file') { | ||||
| 					const file = item.getAsFile(); | ||||
| 					const lio = file.name.lastIndexOf('.'); | ||||
| 					const ext = lio >= 0 ? file.name.slice(lio) : ''; | ||||
| 					const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; | ||||
| 					this.upload(file, formatted); | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 			const paste = e.clipboardData.getData('text'); | ||||
| 
 | ||||
| 			if (!this.renote && !this.quoteId && paste.startsWith(url + '/notes/')) { | ||||
| 				e.preventDefault(); | ||||
| 
 | ||||
| 				os.dialog({ | ||||
| 					type: 'info', | ||||
| 					text: this.$ts.quoteQuestion, | ||||
| 					showCancelButton: true | ||||
| 				}).then(({ canceled }) => { | ||||
| 					if (canceled) { | ||||
| 						insertTextAtCursor(this.$refs.text, paste); | ||||
| 						return; | ||||
| 					} | ||||
| 
 | ||||
| 					this.quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1]; | ||||
| 				}); | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		onDragover(e) { | ||||
| 			if (!e.dataTransfer.items[0]) return; | ||||
| 			const isFile = e.dataTransfer.items[0].kind == 'file'; | ||||
| 			const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; | ||||
| 			if (isFile || isDriveFile) { | ||||
| 				e.preventDefault(); | ||||
| 				this.draghover = true; | ||||
| 				e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		onDragenter(e) { | ||||
| 			this.draghover = true; | ||||
| 		}, | ||||
| 
 | ||||
| 		onDragleave(e) { | ||||
| 			this.draghover = false; | ||||
| 		}, | ||||
| 
 | ||||
| 		onDrop(e): void { | ||||
| 			this.draghover = false; | ||||
| 
 | ||||
| 			// ファイルだったら | ||||
| 			if (e.dataTransfer.files.length > 0) { | ||||
| 				e.preventDefault(); | ||||
| 				for (const x of Array.from(e.dataTransfer.files)) this.upload(x); | ||||
| 				return; | ||||
| 			} | ||||
| 
 | ||||
| 			//#region ドライブのファイル | ||||
| 			const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); | ||||
| 			if (driveFile != null && driveFile != '') { | ||||
| 				const file = JSON.parse(driveFile); | ||||
| 				this.files.push(file); | ||||
| 				e.preventDefault(); | ||||
| 			} | ||||
| 			//#endregion | ||||
| 		}, | ||||
| 
 | ||||
| 		saveDraft() { | ||||
| 			if (this.instant) return; | ||||
| 
 | ||||
| 			const data = JSON.parse(localStorage.getItem('drafts') || '{}'); | ||||
| 
 | ||||
| 			data[this.draftKey] = { | ||||
| 				updatedAt: new Date(), | ||||
| 				data: { | ||||
| 					text: this.text, | ||||
| 					useCw: this.useCw, | ||||
| 					cw: this.cw, | ||||
| 					visibility: this.visibility, | ||||
| 					localOnly: this.localOnly, | ||||
| 					files: this.files, | ||||
| 					poll: this.poll | ||||
| 				} | ||||
| 			}; | ||||
| 
 | ||||
| 			localStorage.setItem('drafts', JSON.stringify(data)); | ||||
| 		}, | ||||
| 
 | ||||
| 		deleteDraft() { | ||||
| 			const data = JSON.parse(localStorage.getItem('drafts') || '{}'); | ||||
| 
 | ||||
| 			delete data[this.draftKey]; | ||||
| 
 | ||||
| 			localStorage.setItem('drafts', JSON.stringify(data)); | ||||
| 		}, | ||||
| 
 | ||||
| 		async post() { | ||||
| 			let data = { | ||||
| 				text: this.text == '' ? undefined : this.text, | ||||
| 				fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined, | ||||
| 				replyId: this.reply ? this.reply.id : undefined, | ||||
| 				renoteId: this.renote ? this.renote.id : this.quoteId ? this.quoteId : undefined, | ||||
| 				channelId: this.channel ? this.channel : undefined, | ||||
| 				poll: this.poll, | ||||
| 				cw: this.useCw ? this.cw || '' : undefined, | ||||
| 				localOnly: this.localOnly, | ||||
| 				visibility: this.visibility, | ||||
| 				visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined, | ||||
| 				viaMobile: os.isMobile | ||||
| 			}; | ||||
| 
 | ||||
| 			// plugin | ||||
| 			if (notePostInterruptors.length > 0) { | ||||
| 				for (const interruptor of notePostInterruptors) { | ||||
| 					data = await interruptor.handler(JSON.parse(JSON.stringify(data))); | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 			this.posting = true; | ||||
| 			os.api('notes/create', data).then(() => { | ||||
| 				this.clear(); | ||||
| 				this.$nextTick(() => { | ||||
| 					this.deleteDraft(); | ||||
| 					this.$emit('posted'); | ||||
| 					if (this.text && this.text != '') { | ||||
| 						const hashtags = parse(this.text).filter(x => x.node.type === 'hashtag').map(x => x.node.props.hashtag); | ||||
| 						const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[]; | ||||
| 						localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history)))); | ||||
| 					} | ||||
| 					this.posting = false; | ||||
| 				}); | ||||
| 			}).catch(err => { | ||||
| 				this.posting = false; | ||||
| 				os.dialog({ | ||||
| 					type: 'error', | ||||
| 					text: err.message + '\n' + (err as any).id, | ||||
| 				}); | ||||
| 			}); | ||||
| 		}, | ||||
| 
 | ||||
| 		cancel() { | ||||
| 			this.$emit('cancel'); | ||||
| 		}, | ||||
| 
 | ||||
| 		insertMention() { | ||||
| 			os.selectUser().then(user => { | ||||
| 				insertTextAtCursor(this.$refs.text, '@' + getAcct(user) + ' '); | ||||
| 			}); | ||||
| 		}, | ||||
| 
 | ||||
| 		async insertEmoji(ev) { | ||||
| 			os.pickEmoji(ev.currentTarget || ev.target).then(emoji => { | ||||
| 				insertTextAtCursor(this.$refs.text, emoji); | ||||
| 			}); | ||||
| 		}, | ||||
| 
 | ||||
| 		showActions(ev) { | ||||
| 			os.modalMenu(postFormActions.map(action => ({ | ||||
| 				text: action.title, | ||||
| 				action: () => { | ||||
| 					action.handler({ | ||||
| 						text: this.text | ||||
| 					}, (key, value) => { | ||||
| 						if (key === 'text') { this.text = value; } | ||||
| 					}); | ||||
| 				} | ||||
| 			})), ev.currentTarget || ev.target); | ||||
| 		} | ||||
| 	} | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| .pxiwixjf { | ||||
| 	position: relative; | ||||
| 	border: solid 1px var(--divider); | ||||
| 	border-radius: 8px; | ||||
| 
 | ||||
| 	> .form { | ||||
| 		> .preview { | ||||
| 			padding: 16px; | ||||
| 		} | ||||
| 
 | ||||
| 		> .with-quote { | ||||
| 			margin: 0 0 8px 0; | ||||
| 			color: var(--accent); | ||||
| 
 | ||||
| 			> button { | ||||
| 				padding: 4px 8px; | ||||
| 				color: var(--accentAlpha04); | ||||
| 
 | ||||
| 				&:hover { | ||||
| 					color: var(--accentAlpha06); | ||||
| 				} | ||||
| 
 | ||||
| 				&:active { | ||||
| 					color: var(--accentDarken30); | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		> .to-specified { | ||||
| 			padding: 6px 24px; | ||||
| 			margin-bottom: 8px; | ||||
| 			overflow: auto; | ||||
| 			white-space: nowrap; | ||||
| 
 | ||||
| 			> .visibleUsers { | ||||
| 				display: inline; | ||||
| 				top: -1px; | ||||
| 				font-size: 14px; | ||||
| 
 | ||||
| 				> button { | ||||
| 					padding: 4px; | ||||
| 					border-radius: 8px; | ||||
| 				} | ||||
| 
 | ||||
| 				> span { | ||||
| 					margin-right: 14px; | ||||
| 					padding: 8px 0 8px 8px; | ||||
| 					border-radius: 8px; | ||||
| 					background: var(--X4); | ||||
| 
 | ||||
| 					> button { | ||||
| 						padding: 4px 8px; | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		> .cw, | ||||
| 		> .text { | ||||
| 			display: block; | ||||
| 			box-sizing: border-box; | ||||
| 			padding: 16px; | ||||
| 			margin: 0; | ||||
| 			width: 100%; | ||||
| 			font-size: 16px; | ||||
| 			border: none; | ||||
| 			border-radius: 0; | ||||
| 			background: transparent; | ||||
| 			color: var(--fg); | ||||
| 			font-family: inherit; | ||||
| 
 | ||||
| 			&:focus { | ||||
| 				outline: none; | ||||
| 			} | ||||
| 
 | ||||
| 			&:disabled { | ||||
| 				opacity: 0.5; | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		> .cw { | ||||
| 			z-index: 1; | ||||
| 			padding-bottom: 8px; | ||||
| 			border-bottom: solid 1px var(--divider); | ||||
| 		} | ||||
| 
 | ||||
| 		> .text { | ||||
| 			max-width: 100%; | ||||
| 			min-width: 100%; | ||||
| 			min-height: 60px; | ||||
| 
 | ||||
| 			&.withCw { | ||||
| 				padding-top: 8px; | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		> footer { | ||||
| 			$height: 44px; | ||||
| 			display: flex; | ||||
| 			padding: 0 8px 8px 8px; | ||||
| 			line-height: $height; | ||||
| 
 | ||||
| 			> .left { | ||||
| 				> button { | ||||
| 					display: inline-block; | ||||
| 					padding: 0; | ||||
| 					margin: 0; | ||||
| 					font-size: 16px; | ||||
| 					width: $height; | ||||
| 					height: $height; | ||||
| 					border-radius: 6px; | ||||
| 
 | ||||
| 					&:hover { | ||||
| 						background: var(--X5); | ||||
| 					} | ||||
| 
 | ||||
| 					&.active { | ||||
| 						color: var(--accent); | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 			> .right { | ||||
| 				margin-left: auto; | ||||
| 
 | ||||
| 				> .text-count { | ||||
| 					opacity: 0.7; | ||||
| 				} | ||||
| 
 | ||||
| 				> .visibility { | ||||
| 					width: $height; | ||||
| 					margin: 0 8px; | ||||
| 
 | ||||
| 					& + .localOnly { | ||||
| 						margin-left: 0 !important; | ||||
| 					} | ||||
| 				} | ||||
| 				 | ||||
| 				> .local-only { | ||||
| 					margin: 0 0 0 12px; | ||||
| 					opacity: 0.7; | ||||
| 				} | ||||
| 
 | ||||
| 				> .submit { | ||||
| 					margin: 0; | ||||
| 					padding: 0 12px; | ||||
| 					line-height: 34px; | ||||
| 					font-weight: bold; | ||||
| 					border-radius: 4px; | ||||
| 
 | ||||
| 					&:disabled { | ||||
| 						opacity: 0.7; | ||||
| 					} | ||||
| 
 | ||||
| 					> [data-icon] { | ||||
| 						margin-left: 6px; | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										165
									
								
								src/client/ui/chat/side.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										165
									
								
								src/client/ui/chat/side.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,165 @@ | |||
| <template> | ||||
| <div class="qvzfzxam _narrow_" v-if="component"> | ||||
| 	<div class="container"> | ||||
| 		<header class="header" @contextmenu.prevent.stop="onContextmenu"> | ||||
| 			<button class="_button" @click="back()" v-if="history.length > 0"><Fa :icon="faChevronLeft"/></button> | ||||
| 			<XHeader class="title" :info="pageInfo" :with-back="false" :center="false"/> | ||||
| 			<button class="_button" @click="close()"><Fa :icon="faTimes"/></button> | ||||
| 		</header> | ||||
| 		<component :is="component" v-bind="props" :ref="changePage"/> | ||||
| 	</div> | ||||
| </div> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { defineComponent } from 'vue'; | ||||
| import { faTimes, faChevronLeft, faExpandAlt, faWindowMaximize, faExternalLinkAlt, faLink } from '@fortawesome/free-solid-svg-icons'; | ||||
| import XHeader from '../_common_/header.vue'; | ||||
| import * as os from '@/os'; | ||||
| import copyToClipboard from '@/scripts/copy-to-clipboard'; | ||||
| import { resolve } from '@/router'; | ||||
| import { url } from '@/config'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
| 	components: { | ||||
| 		XHeader | ||||
| 	}, | ||||
| 
 | ||||
| 	provide() { | ||||
| 		return { | ||||
| 			navHook: (path) => { | ||||
| 				this.navigate(path); | ||||
| 			} | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
| 	data() { | ||||
| 		return { | ||||
| 			path: null, | ||||
| 			component: null, | ||||
| 			props: {}, | ||||
| 			pageInfo: null, | ||||
| 			history: [], | ||||
| 			faTimes, faChevronLeft, | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
| 	computed: { | ||||
| 		url(): string { | ||||
| 			return url + this.path; | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	methods: { | ||||
| 		changePage(page) { | ||||
| 			if (page == null) return; | ||||
| 			if (page.INFO) { | ||||
| 				this.pageInfo = page.INFO; | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		navigate(path, record = true) { | ||||
| 			if (record && this.path) this.history.push(this.path); | ||||
| 			this.path = path; | ||||
| 			const { component, props } = resolve(path); | ||||
| 			this.component = component; | ||||
| 			this.props = props; | ||||
| 		}, | ||||
| 
 | ||||
| 		back() { | ||||
| 			this.navigate(this.history.pop(), false); | ||||
| 		}, | ||||
| 
 | ||||
| 		close() { | ||||
| 			this.path = null; | ||||
| 			this.component = null; | ||||
| 			this.props = {}; | ||||
| 		}, | ||||
| 
 | ||||
| 		onContextmenu(e) { | ||||
| 			os.contextMenu([{ | ||||
| 				type: 'label', | ||||
| 				text: this.path, | ||||
| 			}, { | ||||
| 				icon: faExpandAlt, | ||||
| 				text: this.$ts.showInPage, | ||||
| 				action: () => { | ||||
| 					this.$router.push(this.path); | ||||
| 					this.close(); | ||||
| 				} | ||||
| 			}, { | ||||
| 				icon: faWindowMaximize, | ||||
| 				text: this.$ts.openInWindow, | ||||
| 				action: () => { | ||||
| 					os.pageWindow(this.path); | ||||
| 					this.close(); | ||||
| 				} | ||||
| 			}, null, { | ||||
| 				icon: faExternalLinkAlt, | ||||
| 				text: this.$ts.openInNewTab, | ||||
| 				action: () => { | ||||
| 					window.open(this.url, '_blank'); | ||||
| 					this.close(); | ||||
| 				} | ||||
| 			}, { | ||||
| 				icon: faLink, | ||||
| 				text: this.$ts.copyLink, | ||||
| 				action: () => { | ||||
| 					copyToClipboard(this.url); | ||||
| 				} | ||||
| 			}], e); | ||||
| 		} | ||||
| 	} | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| .qvzfzxam { | ||||
| 	$header-height: 54px; // TODO: どこかに集約したい | ||||
| 
 | ||||
| 	--section-padding: 16px; | ||||
| 	--margin: var(--marginHalf); | ||||
| 
 | ||||
| 	width: 390px; | ||||
| 
 | ||||
| 	> .container { | ||||
| 		position: fixed; | ||||
| 		width: 390px; | ||||
| 		height: 100vh; | ||||
| 		overflow: auto; | ||||
| 		box-sizing: border-box; | ||||
| 
 | ||||
| 		> .header { | ||||
| 			display: flex; | ||||
| 			position: sticky; | ||||
| 			z-index: 1000; | ||||
| 			top: 0; | ||||
| 			height: $header-height; | ||||
| 			width: 100%; | ||||
| 			line-height: $header-height; | ||||
| 			font-weight: bold; | ||||
| 			//background-color: var(--panel); | ||||
| 			-webkit-backdrop-filter: blur(32px); | ||||
| 			backdrop-filter: blur(32px); | ||||
| 			background-color: var(--header); | ||||
| 			border-bottom: solid 1px var(--divider); | ||||
| 			box-sizing: border-box; | ||||
| 
 | ||||
| 			> ._button { | ||||
| 				height: $header-height; | ||||
| 				width: $header-height; | ||||
| 
 | ||||
| 				&:hover { | ||||
| 					color: var(--fgHighlighted); | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 			> .title { | ||||
| 				flex: 1; | ||||
| 				position: relative; | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
| 
 | ||||
							
								
								
									
										64
									
								
								src/client/ui/chat/sub-note-content.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								src/client/ui/chat/sub-note-content.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,64 @@ | |||
| <template> | ||||
| <div class="wrmlmaau"> | ||||
| 	<div class="body"> | ||||
| 		<span v-if="note.isHidden" style="opacity: 0.5">({{ $ts.private }})</span> | ||||
| 		<span v-if="note.deletedAt" style="opacity: 0.5">({{ $ts.deleted }})</span> | ||||
| 		<MkA class="reply" v-if="note.replyId" :to="`/notes/${note.replyId}`"><Fa :icon="faReply"/></MkA> | ||||
| 		<Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/> | ||||
| 		<MkA class="rp" v-if="note.renoteId" :to="`/notes/${note.renoteId}`">RN: ...</MkA> | ||||
| 	</div> | ||||
| 	<details v-if="note.files.length > 0"> | ||||
| 		<summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary> | ||||
| 		<XMediaList :media-list="note.files"/> | ||||
| 	</details> | ||||
| 	<details v-if="note.poll"> | ||||
| 		<summary>{{ $ts.poll }}</summary> | ||||
| 		<XPoll :note="note"/> | ||||
| 	</details> | ||||
| </div> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { defineComponent } from 'vue'; | ||||
| import { faReply } from '@fortawesome/free-solid-svg-icons'; | ||||
| import XPoll from '@/components/poll.vue'; | ||||
| import XMediaList from '@/components/media-list.vue'; | ||||
| import * as os from '@/os'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
| 	components: { | ||||
| 		XPoll, | ||||
| 		XMediaList, | ||||
| 	}, | ||||
| 	props: { | ||||
| 		note: { | ||||
| 			type: Object, | ||||
| 			required: true | ||||
| 		} | ||||
| 	}, | ||||
| 	data() { | ||||
| 		return { | ||||
| 			faReply | ||||
| 		}; | ||||
| 	} | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| .wrmlmaau { | ||||
| 	overflow-wrap: break-word; | ||||
| 
 | ||||
| 	> .body { | ||||
| 		> .reply { | ||||
| 			margin-right: 6px; | ||||
| 			color: var(--accent); | ||||
| 		} | ||||
| 
 | ||||
| 		> .rp { | ||||
| 			margin-left: 4px; | ||||
| 			font-style: oblique; | ||||
| 			color: var(--renote); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										190
									
								
								src/client/ui/chat/timeline.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										190
									
								
								src/client/ui/chat/timeline.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,190 @@ | |||
| <template> | ||||
| <XNotes ref="tl" :pagination="pagination" @queue="$emit('queue', $event)" v-follow="pagination.reversed"/> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { defineComponent } from 'vue'; | ||||
| import XNotes from './notes.vue'; | ||||
| import * as os from '@/os'; | ||||
| import * as sound from '@/scripts/sound'; | ||||
| import { scrollToBottom } from '@/scripts/scroll'; | ||||
| import follow from '@/directives/follow-append'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
| 	components: { | ||||
| 		XNotes | ||||
| 	}, | ||||
| 
 | ||||
| 	directives: { | ||||
| 		follow | ||||
| 	}, | ||||
| 	 | ||||
| 	provide() { | ||||
| 		return { | ||||
| 			inChannel: this.src === 'channel' | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
| 	props: { | ||||
| 		src: { | ||||
| 			type: String, | ||||
| 			required: true | ||||
| 		}, | ||||
| 		list: { | ||||
| 			type: String, | ||||
| 			required: false | ||||
| 		}, | ||||
| 		antenna: { | ||||
| 			type: String, | ||||
| 			required: false | ||||
| 		}, | ||||
| 		channel: { | ||||
| 			type: String, | ||||
| 			required: false | ||||
| 		}, | ||||
| 		sound: { | ||||
| 			type: Boolean, | ||||
| 			required: false, | ||||
| 			default: false, | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	emits: ['note', 'queue', 'before', 'after'], | ||||
| 
 | ||||
| 	data() { | ||||
| 		return { | ||||
| 			connection: null, | ||||
| 			connection2: null, | ||||
| 			pagination: null, | ||||
| 			baseQuery: { | ||||
| 				includeMyRenotes: this.$store.state.showMyRenotes, | ||||
| 				includeRenotedMyNotes: this.$store.state.showRenotedMyNotes, | ||||
| 				includeLocalRenotes: this.$store.state.showLocalRenotes | ||||
| 			}, | ||||
| 			query: {}, | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
| 	created() { | ||||
| 		const prepend = note => { | ||||
| 			(this.$refs.tl as any).prepend(note); | ||||
| 
 | ||||
| 			this.$emit('note'); | ||||
| 
 | ||||
| 			if (this.sound) { | ||||
| 				sound.play(note.userId === this.$i.id ? 'noteMy' : 'note'); | ||||
| 			} | ||||
| 		}; | ||||
| 
 | ||||
| 		const onUserAdded = () => { | ||||
| 			(this.$refs.tl as any).reload(); | ||||
| 		}; | ||||
| 
 | ||||
| 		const onUserRemoved = () => { | ||||
| 			(this.$refs.tl as any).reload(); | ||||
| 		}; | ||||
| 
 | ||||
| 		const onChangeFollowing = () => { | ||||
| 			if (!this.$refs.tl.backed) { | ||||
| 				this.$refs.tl.reload(); | ||||
| 			} | ||||
| 		}; | ||||
| 
 | ||||
| 		let endpoint; | ||||
| 		let reversed = false; | ||||
| 
 | ||||
| 		if (this.src == 'antenna') { | ||||
| 			endpoint = 'antennas/notes'; | ||||
| 			this.query = { | ||||
| 				antennaId: this.antenna | ||||
| 			}; | ||||
| 			this.connection = os.stream.connectToChannel('antenna', { | ||||
| 				antennaId: this.antenna | ||||
| 			}); | ||||
| 			this.connection.on('note', prepend); | ||||
| 		} else if (this.src == 'home') { | ||||
| 			endpoint = 'notes/timeline'; | ||||
| 			this.connection = os.stream.useSharedConnection('homeTimeline'); | ||||
| 			this.connection.on('note', prepend); | ||||
| 
 | ||||
| 			this.connection2 = os.stream.useSharedConnection('main'); | ||||
| 			this.connection2.on('follow', onChangeFollowing); | ||||
| 			this.connection2.on('unfollow', onChangeFollowing); | ||||
| 		} else if (this.src == 'local') { | ||||
| 			endpoint = 'notes/local-timeline'; | ||||
| 			this.connection = os.stream.useSharedConnection('localTimeline'); | ||||
| 			this.connection.on('note', prepend); | ||||
| 		} else if (this.src == 'social') { | ||||
| 			endpoint = 'notes/hybrid-timeline'; | ||||
| 			this.connection = os.stream.useSharedConnection('hybridTimeline'); | ||||
| 			this.connection.on('note', prepend); | ||||
| 		} else if (this.src == 'global') { | ||||
| 			endpoint = 'notes/global-timeline'; | ||||
| 			this.connection = os.stream.useSharedConnection('globalTimeline'); | ||||
| 			this.connection.on('note', prepend); | ||||
| 		} else if (this.src == 'mentions') { | ||||
| 			endpoint = 'notes/mentions'; | ||||
| 			this.connection = os.stream.useSharedConnection('main'); | ||||
| 			this.connection.on('mention', prepend); | ||||
| 		} else if (this.src == 'directs') { | ||||
| 			endpoint = 'notes/mentions'; | ||||
| 			this.query = { | ||||
| 				visibility: 'specified' | ||||
| 			}; | ||||
| 			const onNote = note => { | ||||
| 				if (note.visibility == 'specified') { | ||||
| 					prepend(note); | ||||
| 				} | ||||
| 			}; | ||||
| 			this.connection = os.stream.useSharedConnection('main'); | ||||
| 			this.connection.on('mention', onNote); | ||||
| 		} else if (this.src == 'list') { | ||||
| 			endpoint = 'notes/user-list-timeline'; | ||||
| 			this.query = { | ||||
| 				listId: this.list | ||||
| 			}; | ||||
| 			this.connection = os.stream.connectToChannel('userList', { | ||||
| 				listId: this.list | ||||
| 			}); | ||||
| 			this.connection.on('note', prepend); | ||||
| 			this.connection.on('userAdded', onUserAdded); | ||||
| 			this.connection.on('userRemoved', onUserRemoved); | ||||
| 		} else if (this.src == 'channel') { | ||||
| 			endpoint = 'channels/timeline'; | ||||
| 			reversed = true; | ||||
| 			this.query = { | ||||
| 				channelId: this.channel | ||||
| 			}; | ||||
| 			this.connection = os.stream.connectToChannel('channel', { | ||||
| 				channelId: this.channel | ||||
| 			}); | ||||
| 			this.connection.on('note', prepend); | ||||
| 		} | ||||
| 
 | ||||
| 		this.pagination = { | ||||
| 			endpoint: endpoint, | ||||
| 			reversed, | ||||
| 			limit: 10, | ||||
| 			params: init => ({ | ||||
| 				untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined), | ||||
| 				...this.baseQuery, ...this.query | ||||
| 			}) | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
| 	mounted() { | ||||
| 
 | ||||
| 	}, | ||||
| 
 | ||||
| 	beforeUnmount() { | ||||
| 		this.connection.dispose(); | ||||
| 		if (this.connection2) this.connection2.dispose(); | ||||
| 	}, | ||||
| 
 | ||||
| 	methods: { | ||||
| 		focus() { | ||||
| 			this.$refs.tl.focus(); | ||||
| 		}, | ||||
| 	} | ||||
| }); | ||||
| </script> | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue