Channel (#6621)
* wip * wip * wip * wip * wip * wip * wip * wip * wop * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * add notes * wip * wip * wip * wip * sound * wip * add kick_gaba2 * wip
This commit is contained in:
		
							parent
							
								
									122076e8ea
								
							
						
					
					
						commit
						9855405b89
					
				
					 70 changed files with 2191 additions and 184 deletions
				
			
		
							
								
								
									
										
											BIN
										
									
								
								src/client/assets/sounds/noizenecio/kick_gaba2.mp3
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/client/assets/sounds/noizenecio/kick_gaba2.mp3
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								src/client/assets/sounds/syuilo/reverved.mp3
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/client/assets/sounds/syuilo/reverved.mp3
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								src/client/assets/sounds/syuilo/ryukyu.mp3
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/client/assets/sounds/syuilo/ryukyu.mp3
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								src/client/assets/sounds/syuilo/square-pico.mp3
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/client/assets/sounds/syuilo/square-pico.mp3
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										141
									
								
								src/client/components/channel-follow-button.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								src/client/components/channel-follow-button.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,141 @@ | |||
| <template> | ||||
| <button class="hdcaacmi _button" | ||||
| 	:class="{ wait, active: isFollowing, full }" | ||||
| 	@click="onClick" | ||||
| 	:disabled="wait" | ||||
| > | ||||
| 	<template v-if="!wait"> | ||||
| 		<template v-if="isFollowing"> | ||||
| 			<span v-if="full">{{ $t('unfollow') }}</span><fa :icon="faMinus"/> | ||||
| 		</template> | ||||
| 		<template v-else> | ||||
| 			<span v-if="full">{{ $t('follow') }}</span><fa :icon="faPlus"/> | ||||
| 		</template> | ||||
| 	</template> | ||||
| 	<template v-else> | ||||
| 		<span v-if="full">{{ $t('processing') }}</span><fa :icon="faSpinner" pulse fixed-width/> | ||||
| 	</template> | ||||
| </button> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import Vue from 'vue'; | ||||
| import { faSpinner, faPlus, faMinus, } from '@fortawesome/free-solid-svg-icons'; | ||||
| 
 | ||||
| export default Vue.extend({ | ||||
| 	props: { | ||||
| 		channel: { | ||||
| 			type: Object, | ||||
| 			required: true | ||||
| 		}, | ||||
| 		full: { | ||||
| 			type: Boolean, | ||||
| 			required: false, | ||||
| 			default: false, | ||||
| 		}, | ||||
| 	}, | ||||
| 
 | ||||
| 	data() { | ||||
| 		return { | ||||
| 			isFollowing: this.channel.isFollowing, | ||||
| 			wait: false, | ||||
| 			faSpinner, faPlus, faMinus, | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
| 	methods: { | ||||
| 		async onClick() { | ||||
| 			this.wait = true; | ||||
| 
 | ||||
| 			try { | ||||
| 				if (this.isFollowing) { | ||||
| 					await this.$root.api('channels/unfollow', { | ||||
| 						channelId: this.channel.id | ||||
| 					}); | ||||
| 					this.isFollowing = false; | ||||
| 				} else { | ||||
| 					await this.$root.api('channels/follow', { | ||||
| 						channelId: this.channel.id | ||||
| 					}); | ||||
| 					this.isFollowing = true; | ||||
| 				} | ||||
| 			} catch (e) { | ||||
| 				console.error(e); | ||||
| 			} finally { | ||||
| 				this.wait = false; | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| .hdcaacmi { | ||||
| 	position: relative; | ||||
| 	display: inline-block; | ||||
| 	font-weight: bold; | ||||
| 	color: var(--accent); | ||||
| 	background: transparent; | ||||
| 	border: solid 1px var(--accent); | ||||
| 	padding: 0; | ||||
| 	height: 31px; | ||||
| 	font-size: 16px; | ||||
| 	border-radius: 32px; | ||||
| 	background: #fff; | ||||
| 
 | ||||
| 	&.full { | ||||
| 		padding: 0 8px 0 12px; | ||||
| 		font-size: 14px; | ||||
| 	} | ||||
| 
 | ||||
| 	&:not(.full) { | ||||
| 		width: 31px; | ||||
| 	} | ||||
| 
 | ||||
| 	&:focus { | ||||
| 		&:after { | ||||
| 			content: ""; | ||||
| 			pointer-events: none; | ||||
| 			position: absolute; | ||||
| 			top: -5px; | ||||
| 			right: -5px; | ||||
| 			bottom: -5px; | ||||
| 			left: -5px; | ||||
| 			border: 2px solid var(--focus); | ||||
| 			border-radius: 32px; | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	&:hover { | ||||
| 		//background: mix($primary, #fff, 20); | ||||
| 	} | ||||
| 
 | ||||
| 	&:active { | ||||
| 		//background: mix($primary, #fff, 40); | ||||
| 	} | ||||
| 
 | ||||
| 	&.active { | ||||
| 		color: #fff; | ||||
| 		background: var(--accent); | ||||
| 
 | ||||
| 		&:hover { | ||||
| 			background: var(--accentLighten); | ||||
| 			border-color: var(--accentLighten); | ||||
| 		} | ||||
| 
 | ||||
| 		&:active { | ||||
| 			background: var(--accentDarken); | ||||
| 			border-color: var(--accentDarken); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	&.wait { | ||||
| 		cursor: wait !important; | ||||
| 		opacity: 0.7; | ||||
| 	} | ||||
| 
 | ||||
| 	> span { | ||||
| 		margin-right: 6px; | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										144
									
								
								src/client/components/channel-preview.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										144
									
								
								src/client/components/channel-preview.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,144 @@ | |||
| <template> | ||||
| <router-link :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1"> | ||||
| 	<div class="banner" v-if="channel.bannerUrl" :style="`background-image: url('${channel.bannerUrl}')`"> | ||||
| 		<div class="fade"></div> | ||||
| 		<div class="name"><fa :icon="faSatelliteDish"/> {{ channel.name }}</div> | ||||
| 		<div class="status"> | ||||
| 			<div><fa :icon="faUsers" fixed-width/><i18n path="_channel.usersCount" tag="span" style="margin-left: 4px;"><b place="n">{{ channel.usersCount }}</b></i18n></div> | ||||
| 			<div><fa :icon="faPencilAlt" fixed-width/><i18n path="_channel.notesCount" tag="span" style="margin-left: 4px;"><b place="n">{{ channel.notesCount }}</b></i18n></div> | ||||
| 		</div> | ||||
| 	</div> | ||||
| 	<article v-if="channel.description"> | ||||
| 		<p :title="channel.description">{{ channel.description.length > 85 ? channel.description.slice(0, 85) + '…' : channel.description }}</p> | ||||
| 	</article> | ||||
| 	<footer> | ||||
| 		<span> | ||||
| 			{{ $t('updatedAt') }}: <mk-time :time="channel.lastNotedAt"/> | ||||
| 		</span> | ||||
| 	</footer> | ||||
| </router-link> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import Vue from 'vue'; | ||||
| import { faSatelliteDish, faUsers, faPencilAlt } from '@fortawesome/free-solid-svg-icons'; | ||||
| 
 | ||||
| export default Vue.extend({ | ||||
| 	props: { | ||||
| 		channel: { | ||||
| 			type: Object, | ||||
| 			required: true | ||||
| 		}, | ||||
| 	}, | ||||
| 
 | ||||
| 	data() { | ||||
| 		return { | ||||
| 			faSatelliteDish, faUsers, faPencilAlt, | ||||
| 		}; | ||||
| 	}, | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| .eftoefju { | ||||
| 	display: block; | ||||
| 	overflow: hidden; | ||||
| 	width: 100%; | ||||
| 	border: 1px solid var(--divider); | ||||
| 
 | ||||
| 	&:hover { | ||||
| 		text-decoration: none; | ||||
| 	} | ||||
| 
 | ||||
| 	> .banner { | ||||
| 		position: relative; | ||||
| 		width: 100%; | ||||
| 		height: 200px; | ||||
| 		background-position: center; | ||||
| 		background-size: cover; | ||||
| 
 | ||||
| 		> .fade { | ||||
| 			position: absolute; | ||||
| 			bottom: 0; | ||||
| 			left: 0; | ||||
| 			width: 100%; | ||||
| 			height: 64px; | ||||
| 			background: linear-gradient(0deg, var(--panel), var(--X15)); | ||||
| 		} | ||||
| 
 | ||||
| 		> .name { | ||||
| 			position: absolute; | ||||
| 			top: 16px; | ||||
| 			left: 16px; | ||||
| 			padding: 12px 16px; | ||||
| 			background: rgba(0, 0, 0, 0.7); | ||||
| 			color: #fff; | ||||
| 			font-size: 1.2em; | ||||
| 		} | ||||
| 
 | ||||
| 		> .status { | ||||
| 			position: absolute; | ||||
| 			z-index: 1; | ||||
| 			bottom: 16px; | ||||
| 			right: 16px; | ||||
| 			padding: 8px 12px; | ||||
| 			font-size: 80%; | ||||
| 			background: rgba(0, 0, 0, 0.7); | ||||
| 			border-radius: 6px; | ||||
| 			color: #fff; | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	> article { | ||||
| 		padding: 16px; | ||||
| 
 | ||||
| 		> p { | ||||
| 			margin: 0; | ||||
| 			font-size: 1em; | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	> footer { | ||||
| 		padding: 12px 16px; | ||||
| 		border-top: solid 1px var(--divider); | ||||
| 
 | ||||
| 		> span { | ||||
| 			opacity: 0.7; | ||||
| 			font-size: 0.9em; | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	@media (max-width: 550px) { | ||||
| 		font-size: 0.9em; | ||||
| 
 | ||||
| 		> .banner { | ||||
| 			height: 80px; | ||||
| 
 | ||||
| 			> .status { | ||||
| 				display: none; | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		> article { | ||||
| 			padding: 12px; | ||||
| 		} | ||||
| 
 | ||||
| 		> footer { | ||||
| 			display: none; | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	@media (max-width: 500px) { | ||||
| 		font-size: 0.8em; | ||||
| 
 | ||||
| 		> .banner { | ||||
| 			height: 70px; | ||||
| 		} | ||||
| 
 | ||||
| 		> article { | ||||
| 			padding: 8px; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| </style> | ||||
|  | @ -57,6 +57,7 @@ | |||
| 					<mk-url-preview v-for="url in urls" :url="url" :key="url" :compact="true" :detail="detail" class="url-preview"/> | ||||
| 					<div class="renote" v-if="appearNote.renote"><x-note-preview :note="appearNote.renote"/></div> | ||||
| 				</div> | ||||
| 				<router-link v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><fa :icon="faSatelliteDish"/> {{ appearNote.channel.name }}</router-link> | ||||
| 			</div> | ||||
| 			<footer class="footer"> | ||||
| 				<x-reactions-viewer :note="appearNote" ref="reactionsViewer"/> | ||||
|  | @ -96,7 +97,7 @@ | |||
| 
 | ||||
| <script lang="ts"> | ||||
| import Vue from 'vue'; | ||||
| import { faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons'; | ||||
| import { faSatelliteDish, faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons'; | ||||
| import { faCopy, faTrashAlt, faEdit, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons'; | ||||
| import { parse } from '../../mfm/parse'; | ||||
| import { sum, unique } from '../../prelude/array'; | ||||
|  | @ -133,6 +134,12 @@ export default Vue.extend({ | |||
| 		MkUrlPreview, | ||||
| 	}, | ||||
| 
 | ||||
| 	inject: { | ||||
| 		inChannel: { | ||||
| 			default: null | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	props: { | ||||
| 		note: { | ||||
| 			type: Object, | ||||
|  | @ -159,7 +166,7 @@ export default Vue.extend({ | |||
| 			isDeleted: false, | ||||
| 			muted: false, | ||||
| 			noteBody: this.$refs.noteBody, | ||||
| 			faEdit, faBolt, faTimes, faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faBiohazard, faPlug | ||||
| 			faEdit, faBolt, faTimes, faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faBiohazard, faPlug, faSatelliteDish | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
|  | @ -954,6 +961,11 @@ export default Vue.extend({ | |||
| 						} | ||||
| 					} | ||||
| 				} | ||||
| 
 | ||||
| 				> .channel { | ||||
| 					opacity: 0.7; | ||||
| 					font-size: 80%; | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 			> .footer { | ||||
|  |  | |||
|  | @ -10,7 +10,7 @@ | |||
| 		<div> | ||||
| 			<span class="local-only" v-if="localOnly" v-text="$t('_visibility.localOnly')" /> | ||||
| 			<span class="text-count" :class="{ over: trimmedLength(text) > max }">{{ max - trimmedLength(text) }}</span> | ||||
| 			<button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$t('visibility')"> | ||||
| 			<button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$t('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> | ||||
|  | @ -88,6 +88,10 @@ export default Vue.extend({ | |||
| 			type: Object, | ||||
| 			required: false | ||||
| 		}, | ||||
| 		channel: { | ||||
| 			type: Object, | ||||
| 			required: false | ||||
| 		}, | ||||
| 		mention: { | ||||
| 			type: Object, | ||||
| 			required: false | ||||
|  | @ -140,30 +144,38 @@ export default Vue.extend({ | |||
| 	}, | ||||
| 
 | ||||
| 	computed: { | ||||
| 		draftId(): string { | ||||
| 			return this.renote | ||||
| 				? `renote:${this.renote.id}` | ||||
| 				: this.reply | ||||
| 					? `reply:${this.reply.id}` | ||||
| 					: 'note'; | ||||
| 		draftKey(): string { | ||||
| 			let key = this.channel ? `channel:${this.channel.id}` : ''; | ||||
| 
 | ||||
| 			if (this.renote) { | ||||
| 				key += `renote:${this.renote.id}`; | ||||
| 			} else if (this.reply) { | ||||
| 				key += `reply:${this.reply.id}`; | ||||
| 			} else { | ||||
| 				key += 'note'; | ||||
| 			} | ||||
| 
 | ||||
| 			return key; | ||||
| 		}, | ||||
| 
 | ||||
| 		placeholder(): string { | ||||
| 			const xs = [ | ||||
| 				this.$t('_postForm._placeholders.a'), | ||||
| 				this.$t('_postForm._placeholders.b'), | ||||
| 				this.$t('_postForm._placeholders.c'), | ||||
| 				this.$t('_postForm._placeholders.d'), | ||||
| 				this.$t('_postForm._placeholders.e'), | ||||
| 				this.$t('_postForm._placeholders.f') | ||||
| 			]; | ||||
| 			const x = xs[Math.floor(Math.random() * xs.length)]; | ||||
| 
 | ||||
| 			return this.renote | ||||
| 				? this.$t('_postForm.quotePlaceholder') | ||||
| 				: this.reply | ||||
| 					? this.$t('_postForm.replyPlaceholder') | ||||
| 					: x; | ||||
| 			if (this.renote) { | ||||
| 				return this.$t('_postForm.quotePlaceholder'); | ||||
| 			} else if (this.reply) { | ||||
| 				return this.$t('_postForm.replyPlaceholder'); | ||||
| 			} else if (this.channel) { | ||||
| 				return this.$t('_postForm.channelPlaceholder'); | ||||
| 			} else { | ||||
| 				const xs = [ | ||||
| 					this.$t('_postForm._placeholders.a'), | ||||
| 					this.$t('_postForm._placeholders.b'), | ||||
| 					this.$t('_postForm._placeholders.c'), | ||||
| 					this.$t('_postForm._placeholders.d'), | ||||
| 					this.$t('_postForm._placeholders.e'), | ||||
| 					this.$t('_postForm._placeholders.f') | ||||
| 				]; | ||||
| 				return xs[Math.floor(Math.random() * xs.length)]; | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		submitText(): string { | ||||
|  | @ -224,9 +236,11 @@ export default Vue.extend({ | |||
| 		} | ||||
| 
 | ||||
| 		// デフォルト公開範囲 | ||||
| 		this.applyVisibility(this.$store.state.settings.rememberNoteVisibility ? this.$store.state.deviceUser.visibility : this.$store.state.settings.defaultNoteVisibility); | ||||
| 		if (this.channel == null) { | ||||
| 			this.applyVisibility(this.$store.state.settings.rememberNoteVisibility ? this.$store.state.deviceUser.visibility : this.$store.state.settings.defaultNoteVisibility); | ||||
| 
 | ||||
| 		this.localOnly = this.$store.state.settings.rememberNoteVisibility ? this.$store.state.deviceUser.localOnly : this.$store.state.settings.defaultNoteLocalOnly; | ||||
| 			this.localOnly = this.$store.state.settings.rememberNoteVisibility ? this.$store.state.deviceUser.localOnly : this.$store.state.settings.defaultNoteLocalOnly; | ||||
| 		} | ||||
| 
 | ||||
| 		// 公開以外へのリプライ時は元の公開範囲を引き継ぐ | ||||
| 		if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) { | ||||
|  | @ -266,7 +280,7 @@ export default Vue.extend({ | |||
| 		this.$nextTick(() => { | ||||
| 			// 書きかけの投稿を復元 | ||||
| 			if (!this.instant && !this.mention) { | ||||
| 				const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftId]; | ||||
| 				const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftKey]; | ||||
| 				if (draft) { | ||||
| 					this.text = draft.data.text; | ||||
| 					this.useCw = draft.data.useCw; | ||||
|  | @ -398,6 +412,10 @@ export default Vue.extend({ | |||
| 		}, | ||||
| 
 | ||||
| 		setVisibility() { | ||||
| 			if (this.channel) { | ||||
| 				// TODO: information dialog | ||||
| 				return; | ||||
| 			} | ||||
| 			const w = this.$root.new(MkVisibilityChooser, { | ||||
| 				source: this.$refs.visibilityButton, | ||||
| 				currentVisibility: this.visibility, | ||||
|  | @ -510,7 +528,7 @@ export default Vue.extend({ | |||
| 
 | ||||
| 			const data = JSON.parse(localStorage.getItem('drafts') || '{}'); | ||||
| 
 | ||||
| 			data[this.draftId] = { | ||||
| 			data[this.draftKey] = { | ||||
| 				updatedAt: new Date(), | ||||
| 				data: { | ||||
| 					text: this.text, | ||||
|  | @ -529,7 +547,7 @@ export default Vue.extend({ | |||
| 		deleteDraft() { | ||||
| 			const data = JSON.parse(localStorage.getItem('drafts') || '{}'); | ||||
| 
 | ||||
| 			delete data[this.draftId]; | ||||
| 			delete data[this.draftKey]; | ||||
| 
 | ||||
| 			localStorage.setItem('drafts', JSON.stringify(data)); | ||||
| 		}, | ||||
|  | @ -540,6 +558,7 @@ export default Vue.extend({ | |||
| 				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.id : undefined, | ||||
| 				poll: this.poll ? (this.$refs.poll as any).get() : undefined, | ||||
| 				cw: this.useCw ? this.cw || '' : undefined, | ||||
| 				localOnly: this.localOnly, | ||||
|  |  | |||
|  | @ -24,6 +24,10 @@ export default Vue.extend({ | |||
| 			type: String, | ||||
| 			required: false | ||||
| 		}, | ||||
| 		channel: { | ||||
| 			type: String, | ||||
| 			required: false | ||||
| 		}, | ||||
| 		sound: { | ||||
| 			type: Boolean, | ||||
| 			required: false, | ||||
|  | @ -31,6 +35,12 @@ export default Vue.extend({ | |||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	provide() { | ||||
| 		return { | ||||
| 			inChannel: this.src === 'channel' | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
| 	data() { | ||||
| 		return { | ||||
| 			connection: null, | ||||
|  | @ -117,6 +127,15 @@ export default Vue.extend({ | |||
| 			this.connection.on('note', prepend); | ||||
| 			this.connection.on('userAdded', onUserAdded); | ||||
| 			this.connection.on('userRemoved', onUserRemoved); | ||||
| 		} else if (this.src == 'channel') { | ||||
| 			endpoint = 'channels/timeline'; | ||||
| 			this.query = { | ||||
| 				channelId: this.channel | ||||
| 			}; | ||||
| 			this.connection = this.$root.stream.connectToChannel('channel', { | ||||
| 				channelId: this.channel | ||||
| 			}); | ||||
| 			this.connection.on('note', prepend); | ||||
| 		} | ||||
| 
 | ||||
| 		this.pagination = { | ||||
|  |  | |||
|  | @ -350,6 +350,20 @@ os.init(async () => { | |||
| 			app.sound('antenna'); | ||||
| 		}); | ||||
| 
 | ||||
| 		main.on('readAllChannels', () => { | ||||
| 			store.dispatch('mergeMe', { | ||||
| 				hasUnreadChannel: false | ||||
| 			}); | ||||
| 		}); | ||||
| 
 | ||||
| 		main.on('unreadChannel', () => { | ||||
| 			store.dispatch('mergeMe', { | ||||
| 				hasUnreadChannel: true | ||||
| 			}); | ||||
| 
 | ||||
| 			app.sound('channel'); | ||||
| 		}); | ||||
| 
 | ||||
| 		main.on('readAllAnnouncements', () => { | ||||
| 			store.dispatch('mergeMe', { | ||||
| 				hasUnreadAnnouncement: false | ||||
|  |  | |||
							
								
								
									
										128
									
								
								src/client/pages/channel-editor.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										128
									
								
								src/client/pages/channel-editor.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,128 @@ | |||
| <template> | ||||
| <div> | ||||
| 	<portal to="icon"><fa :icon="faSatelliteDish"/></portal> | ||||
| 	<portal to="title">{{ channelId ? $t('_channel.edit') : $t('_channel.create') }}</portal> | ||||
| 
 | ||||
| 	<div class="_card"> | ||||
| 		<div class="_content"> | ||||
| 			<mk-input v-model="name">{{ $t('name') }}</mk-input> | ||||
| 
 | ||||
| 			<mk-textarea v-model="description">{{ $t('description') }}</mk-textarea> | ||||
| 
 | ||||
| 			<div class="banner"> | ||||
| 				<mk-button v-if="bannerId == null" @click="setBannerImage"><fa :icon="faPlus"/> {{ $t('_channel.setBanner') }}</mk-button> | ||||
| 				<div v-else-if="bannerUrl"> | ||||
| 					<img :src="bannerUrl" style="width: 100%;"/> | ||||
| 					<mk-button @click="removeBannerImage()"><fa :icon="faTrashAlt"/> {{ $t('_channel.removeBanner') }}</mk-button> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 		<div class="_footer"> | ||||
| 			<mk-button @click="save()" primary><fa :icon="faSave"/> {{ channelId ? $t('save') : $t('create') }}</mk-button> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </div> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import Vue from 'vue'; | ||||
| import { faPlus, faSatelliteDish } from '@fortawesome/free-solid-svg-icons'; | ||||
| import { faSave, faTrashAlt } from '@fortawesome/free-regular-svg-icons'; | ||||
| import MkTextarea from '../components/ui/textarea.vue'; | ||||
| import MkButton from '../components/ui/button.vue'; | ||||
| import MkInput from '../components/ui/input.vue'; | ||||
| import { selectFile } from '../scripts/select-file'; | ||||
| 
 | ||||
| export default Vue.extend({ | ||||
| 	components: { | ||||
| 		MkTextarea, MkButton, MkInput, | ||||
| 	}, | ||||
| 
 | ||||
| 	props: { | ||||
| 		channelId: { | ||||
| 			type: String, | ||||
| 			required: false | ||||
| 		}, | ||||
| 	}, | ||||
| 
 | ||||
| 	data() { | ||||
| 		return { | ||||
| 			channel: null, | ||||
| 			name: null, | ||||
| 			description: null, | ||||
| 			bannerUrl: null, | ||||
| 			bannerId: null, | ||||
| 			faSave, faTrashAlt, faPlus,faSatelliteDish, | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
| 	watch: { | ||||
| 		async bannerId() { | ||||
| 			if (this.bannerId == null) { | ||||
| 				this.bannerUrl = null; | ||||
| 			} else { | ||||
| 				this.bannerUrl = (await this.$root.api('drive/files/show', { | ||||
| 					fileId: this.bannerId, | ||||
| 				})).url; | ||||
| 			} | ||||
| 		}, | ||||
| 	}, | ||||
| 
 | ||||
| 	async created() { | ||||
| 		if (this.channelId) { | ||||
| 			this.channel = await this.$root.api('channels/show', { | ||||
| 				channelId: this.channelId, | ||||
| 			}); | ||||
| 
 | ||||
| 			this.name = this.channel.name; | ||||
| 			this.description = this.channel.description; | ||||
| 			this.bannerId = this.channel.bannerId; | ||||
| 			this.bannerUrl = this.channel.bannerUrl; | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	methods: { | ||||
| 		save() { | ||||
| 			const params = { | ||||
| 				name: this.name, | ||||
| 				description: this.description, | ||||
| 				bannerId: this.bannerId, | ||||
| 			}; | ||||
| 
 | ||||
| 			if (this.channelId) { | ||||
| 				params.channelId = this.channelId; | ||||
| 				this.$root.api('channels/update', params) | ||||
| 				.then(channel => { | ||||
| 					this.$root.dialog({ | ||||
| 						type: 'success', | ||||
| 						iconOnly: true, autoClose: true | ||||
| 					}); | ||||
| 				}); | ||||
| 			} else { | ||||
| 				this.$root.api('channels/create', params) | ||||
| 				.then(channel => { | ||||
| 					this.$root.dialog({ | ||||
| 						type: 'success', | ||||
| 						iconOnly: true, autoClose: true | ||||
| 					}); | ||||
| 					this.$router.push(`/channels/${channel.id}`); | ||||
| 				}); | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		setBannerImage(e) { | ||||
| 			selectFile(this, e.currentTarget || e.target, null, false).then(file => { | ||||
| 				this.bannerId = file.id; | ||||
| 			}); | ||||
| 		}, | ||||
| 
 | ||||
| 		removeBannerImage() { | ||||
| 			this.bannerId = null; | ||||
| 		} | ||||
| 	} | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| 
 | ||||
| </style> | ||||
							
								
								
									
										190
									
								
								src/client/pages/channel.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										190
									
								
								src/client/pages/channel.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,190 @@ | |||
| <template> | ||||
| <div v-if="channel"> | ||||
| 	<portal to="icon"><fa :icon="faSatelliteDish"/></portal> | ||||
| 	<portal to="title">{{ channel.name }}</portal> | ||||
| 
 | ||||
| 	<div class="wpgynlbz _panel _vMargin" :class="{ hide: !showBanner }"> | ||||
| 		<x-channel-follow-button :channel="channel" :full="true" class="subscribe"/> | ||||
| 		<button class="_button toggle" @click="() => showBanner = !showBanner"> | ||||
| 			<template v-if="showBanner"><fa :icon="faAngleUp"/></template> | ||||
| 			<template v-else><fa :icon="faAngleDown"/></template> | ||||
| 		</button> | ||||
| 		<div class="hideOverlay" v-if="!showBanner"> | ||||
| 		</div> | ||||
| 		<div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner"> | ||||
| 			<div class="status"> | ||||
| 				<div><fa :icon="faUsers" fixed-width/><i18n path="_channel.usersCount" tag="span" style="margin-left: 4px;"><b place="n">{{ channel.usersCount }}</b></i18n></div> | ||||
| 				<div><fa :icon="faPencilAlt" fixed-width/><i18n path="_channel.notesCount" tag="span" style="margin-left: 4px;"><b place="n">{{ channel.notesCount }}</b></i18n></div> | ||||
| 			</div> | ||||
| 			<div class="fade"></div> | ||||
| 		</div> | ||||
| 		<div class="description" v-if="channel.description"> | ||||
| 			<mfm :text="channel.description" :is-note="false" :i="$store.state.i"/> | ||||
| 		</div> | ||||
| 	</div> | ||||
| 
 | ||||
| 	<x-post-form :channel="channel" class="post-form _panel _vMargin" fixed/> | ||||
| 
 | ||||
| 	<x-timeline class="_vMargin" src="channel" :channel="channelId" @before="before" @after="after"/> | ||||
| </div> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import Vue from 'vue'; | ||||
| import { faSatelliteDish, faUsers, faPencilAlt, faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons'; | ||||
| import {  } from '@fortawesome/free-regular-svg-icons'; | ||||
| import MkContainer from '../components/ui/container.vue'; | ||||
| import XPostForm from '../components/post-form.vue'; | ||||
| import XTimeline from '../components/timeline.vue'; | ||||
| import XChannelFollowButton from '../components/channel-follow-button.vue'; | ||||
| 
 | ||||
| export default Vue.extend({ | ||||
| 	metaInfo() { | ||||
| 		return { | ||||
| 			title: this.$t('channel') as string | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
| 	components: { | ||||
| 		MkContainer, | ||||
| 		XPostForm, | ||||
| 		XTimeline, | ||||
| 		XChannelFollowButton | ||||
| 	}, | ||||
| 
 | ||||
| 	props: { | ||||
| 		channelId: { | ||||
| 			type: String, | ||||
| 			required: true | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	data() { | ||||
| 		return { | ||||
| 			channel: null, | ||||
| 			showBanner: true, | ||||
| 			pagination: { | ||||
| 				endpoint: 'channels/timeline', | ||||
| 				limit: 10, | ||||
| 				params: () => ({ | ||||
| 					channelId: this.channelId, | ||||
| 				}) | ||||
| 			}, | ||||
| 			faSatelliteDish, faUsers, faPencilAlt, faAngleUp, faAngleDown, | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
| 	watch: { | ||||
| 		channelId: { | ||||
| 			async handler() { | ||||
| 				this.channel = await this.$root.api('channels/show', { | ||||
| 					channelId: this.channelId, | ||||
| 				}); | ||||
| 			}, | ||||
| 			immediate: true | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	created() { | ||||
| 
 | ||||
| 	}, | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| .wpgynlbz { | ||||
| 	> .subscribe { | ||||
| 		position: absolute; | ||||
| 		z-index: 1; | ||||
| 		top: 16px; | ||||
| 		left: 16px; | ||||
| 	} | ||||
| 
 | ||||
| 	> .toggle { | ||||
| 		position: absolute; | ||||
| 		z-index: 2; | ||||
|     top: 8px; | ||||
| 		right: 8px; | ||||
| 		font-size: 1.2em; | ||||
| 		width: 48px; | ||||
| 		height: 48px; | ||||
| 		color: #fff; | ||||
| 		background: rgba(0, 0, 0, 0.5); | ||||
| 		border-radius: 100%; | ||||
| 		 | ||||
| 		> [data-icon] { | ||||
| 			vertical-align: middle; | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	> .banner { | ||||
| 		position: relative; | ||||
| 		height: 200px; | ||||
| 		background-position: center; | ||||
| 		background-size: cover; | ||||
| 
 | ||||
| 		> .fade { | ||||
| 			position: absolute; | ||||
| 			bottom: 0; | ||||
| 			left: 0; | ||||
| 			width: 100%; | ||||
| 			height: 64px; | ||||
| 			background: linear-gradient(0deg, var(--panel), var(--X15)); | ||||
| 		} | ||||
| 
 | ||||
| 		> .status { | ||||
| 			position: absolute; | ||||
| 			z-index: 1; | ||||
| 			bottom: 16px; | ||||
| 			right: 16px; | ||||
| 			padding: 8px 12px; | ||||
| 			font-size: 80%; | ||||
| 			background: rgba(0, 0, 0, 0.7); | ||||
| 			border-radius: 6px; | ||||
| 			color: #fff; | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	> .description { | ||||
| 		padding: 16px; | ||||
| 	} | ||||
| 
 | ||||
| 	> .hideOverlay { | ||||
| 		position: absolute; | ||||
| 		z-index: 1; | ||||
| 		top: 0; | ||||
| 		left: 0; | ||||
| 		width: 100%; | ||||
| 		height: 100%; | ||||
| 		-webkit-backdrop-filter: blur(16px); | ||||
| 		backdrop-filter: blur(16px); | ||||
| 		background: rgba(0, 0, 0, 0.3); | ||||
| 	} | ||||
| 
 | ||||
| 	&.hide { | ||||
| 		> .subscribe { | ||||
| 			display: none; | ||||
| 		} | ||||
| 
 | ||||
| 		> .toggle { | ||||
| 			top: 0; | ||||
| 			right: 0; | ||||
| 			height: 100%; | ||||
| 			background: transparent; | ||||
| 		} | ||||
| 
 | ||||
| 		> .banner { | ||||
| 			height: 42px; | ||||
| 			filter: blur(8px); | ||||
| 
 | ||||
| 			> * { | ||||
| 				display: none; | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		> .description { | ||||
| 			display: none; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										86
									
								
								src/client/pages/channels.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								src/client/pages/channels.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,86 @@ | |||
| <template> | ||||
| <div> | ||||
| 	<portal to="icon"><fa :icon="faSatelliteDish"/></portal> | ||||
| 	<portal to="title">{{ $t('channel') }}</portal> | ||||
| 
 | ||||
| 	<mk-tab v-model="tab" :items="[{ label: $t('_channel.featured'), value: 'featured', icon: faFireAlt }, { label: $t('_channel.following'), value: 'following', icon: faHeart }, { label: $t('_channel.owned'), value: 'owned', icon: faEdit }]"/> | ||||
| 
 | ||||
| 	<div class="grwlizim featured" v-if="tab === 'featured'"> | ||||
| 		<mk-pagination :pagination="featuredPagination" #default="{items}"> | ||||
| 			<mk-channel-preview v-for="channel in items" class="uveselbe" :channel="channel" :key="channel.id"/> | ||||
| 		</mk-pagination> | ||||
| 	</div> | ||||
| 
 | ||||
| 	<div class="grwlizim following" v-if="tab === 'following'"> | ||||
| 		<mk-pagination :pagination="followingPagination" #default="{items}"> | ||||
| 			<mk-channel-preview v-for="channel in items" class="uveselbe" :channel="channel" :key="channel.id"/> | ||||
| 		</mk-pagination> | ||||
| 	</div> | ||||
| 
 | ||||
| 	<div class="grwlizim owned" v-if="tab === 'owned'"> | ||||
| 		<mk-button class="new" @click="create()"><fa :icon="faPlus"/></mk-button> | ||||
| 		<mk-pagination :pagination="ownedPagination" #default="{items}"> | ||||
| 			<mk-channel-preview v-for="channel in items" class="uveselbe" :channel="channel" :key="channel.id"/> | ||||
| 		</mk-pagination> | ||||
| 	</div> | ||||
| </div> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import Vue from 'vue'; | ||||
| import { faSatelliteDish, faPlus, faEdit, faFireAlt } from '@fortawesome/free-solid-svg-icons'; | ||||
| import { faHeart } from '@fortawesome/free-regular-svg-icons'; | ||||
| import MkChannelPreview from '../components/channel-preview.vue'; | ||||
| import MkPagination from '../components/ui/pagination.vue'; | ||||
| import MkButton from '../components/ui/button.vue'; | ||||
| import MkTab from '../components/tab.vue'; | ||||
| 
 | ||||
| export default Vue.extend({ | ||||
| 	components: { | ||||
| 		MkChannelPreview, MkPagination, MkButton, MkTab | ||||
| 	}, | ||||
| 	data() { | ||||
| 		return { | ||||
| 			tab: 'featured', | ||||
| 			featuredPagination: { | ||||
| 				endpoint: 'channels/featured', | ||||
| 				limit: 5, | ||||
| 			}, | ||||
| 			followingPagination: { | ||||
| 				endpoint: 'channels/followed', | ||||
| 				limit: 5, | ||||
| 			}, | ||||
| 			ownedPagination: { | ||||
| 				endpoint: 'channels/owned', | ||||
| 				limit: 5, | ||||
| 			}, | ||||
| 			faSatelliteDish, faPlus, faEdit, faHeart, faFireAlt | ||||
| 		}; | ||||
| 	}, | ||||
| 	methods: { | ||||
| 		create() { | ||||
| 			this.$router.push(`/channels/new`); | ||||
| 		} | ||||
| 	} | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| .grwlizim { | ||||
| 	padding: 16px 0; | ||||
| 
 | ||||
| 	&.my .uveselbe:first-child { | ||||
| 		margin-top: 16px; | ||||
| 	} | ||||
| 
 | ||||
| 	.uveselbe:not(:last-child) { | ||||
| 		margin-bottom: 8px; | ||||
| 	} | ||||
| 
 | ||||
| 	@media (min-width: 500px) { | ||||
| 		.uveselbe:not(:last-child) { | ||||
| 			margin-bottom: 16px; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
|  | @ -2,14 +2,15 @@ | |||
| <div class="mk-home" v-hotkey.global="keymap"> | ||||
| 	<portal to="header" v-if="showTitle"> | ||||
| 		<button @click="choose" class="_button _kjvfvyph_"> | ||||
| 			<i><fa v-if="$store.state.i.hasUnreadAntenna" :icon="faCircle"/></i> | ||||
| 			<i><fa v-if="$store.state.i.hasUnreadAntenna || $store.state.i.hasUnreadChannel" :icon="faCircle"/></i> | ||||
| 			<fa v-if="src === 'home'" :icon="faHome"/> | ||||
| 			<fa v-if="src === 'local'" :icon="faComments"/> | ||||
| 			<fa v-if="src === 'social'" :icon="faShareAlt"/> | ||||
| 			<fa v-if="src === 'global'" :icon="faGlobe"/> | ||||
| 			<fa v-if="src === 'list'" :icon="faListUl"/> | ||||
| 			<fa v-if="src === 'antenna'" :icon="faSatellite"/> | ||||
| 			<span style="margin-left: 8px;">{{ src === 'list' ? list.name : src === 'antenna' ? antenna.name : $t('_timelines.' + src) }}</span> | ||||
| 			<fa v-if="src === 'channel'" :icon="faSatelliteDish"/> | ||||
| 			<span style="margin-left: 8px;">{{ src === 'list' ? list.name : src === 'antenna' ? antenna.name : src === 'channel' ? channel.name : $t('_timelines.' + src) }}</span> | ||||
| 			<fa :icon="menuOpened ? faAngleUp : faAngleDown" style="margin-left: 8px;"/> | ||||
| 		</button> | ||||
| 	</portal> | ||||
|  | @ -19,13 +20,13 @@ | |||
| 	<x-tutorial class="tutorial" v-if="$store.state.settings.tutorial != -1"/> | ||||
| 
 | ||||
| 	<x-post-form class="post-form _panel" fixed v-if="$store.state.device.showFixedPostForm"/> | ||||
| 	<x-timeline ref="tl" :key="src === 'list' ? `list:${list.id}` : src === 'antenna' ? `antenna:${antenna.id}` : src" :src="src" :list="list ? list.id : null" :antenna="antenna ? antenna.id : null" :sound="true" @before="before()" @after="after()" @queue="queueUpdated"/> | ||||
| 	<x-timeline ref="tl" :key="src === 'list' ? `list:${list.id}` : src === 'antenna' ? `antenna:${antenna.id}` : src === 'channel' ? `channel:${channel.id}` : src" :src="src" :list="list ? list.id : null" :antenna="antenna ? antenna.id : null" :channel="channel ? channel.id : null" :sound="true" @before="before()" @after="after()" @queue="queueUpdated"/> | ||||
| </div> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import Vue from 'vue'; | ||||
| import { faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faListUl, faSatellite, faCircle } from '@fortawesome/free-solid-svg-icons'; | ||||
| import { faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faListUl, faSatellite, faSatelliteDish, faCircle } from '@fortawesome/free-solid-svg-icons'; | ||||
| import { faComments } from '@fortawesome/free-regular-svg-icons'; | ||||
| import Progress from '../scripts/loading'; | ||||
| import XTimeline from '../components/timeline.vue'; | ||||
|  | @ -57,10 +58,11 @@ export default Vue.extend({ | |||
| 			src: 'home', | ||||
| 			list: null, | ||||
| 			antenna: null, | ||||
| 			channel: null, | ||||
| 			menuOpened: false, | ||||
| 			queue: 0, | ||||
| 			width: 0, | ||||
| 			faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faComments, faListUl, faSatellite, faCircle | ||||
| 			faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faComments, faListUl, faSatellite, faSatelliteDish, faCircle | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
|  | @ -79,16 +81,20 @@ export default Vue.extend({ | |||
| 	watch: { | ||||
| 		src() { | ||||
| 			this.showNav = false; | ||||
| 			this.saveSrc(); | ||||
| 		}, | ||||
| 		list(x) { | ||||
| 			this.showNav = false; | ||||
| 			this.saveSrc(); | ||||
| 			if (x != null) this.antenna = null; | ||||
| 			if (x != null) this.channel = null; | ||||
| 		}, | ||||
| 		antenna(x) { | ||||
| 			this.showNav = false; | ||||
| 			this.saveSrc(); | ||||
| 			if (x != null) this.list = null; | ||||
| 			if (x != null) this.channel = null; | ||||
| 		}, | ||||
| 		channel(x) { | ||||
| 			this.showNav = false; | ||||
| 			if (x != null) this.antenna = null; | ||||
| 			if (x != null) this.list = null; | ||||
| 		}, | ||||
| 	}, | ||||
|  | @ -99,6 +105,8 @@ export default Vue.extend({ | |||
| 			this.list = this.$store.state.deviceUser.tl.arg; | ||||
| 		} else if (this.src === 'antenna') { | ||||
| 			this.antenna = this.$store.state.deviceUser.tl.arg; | ||||
| 		} else if (this.src === 'channel') { | ||||
| 			this.channel = this.$store.state.deviceUser.tl.arg; | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
|  | @ -127,9 +135,10 @@ export default Vue.extend({ | |||
| 		async choose(ev) { | ||||
| 			if (this.meta == null) return; | ||||
| 			this.menuOpened = true; | ||||
| 			const [antennas, lists] = await Promise.all([ | ||||
| 			const [antennas, lists, channels] = await Promise.all([ | ||||
| 				this.$root.api('antennas/list'), | ||||
| 				this.$root.api('users/lists/list') | ||||
| 				this.$root.api('users/lists/list'), | ||||
| 				this.$root.api('channels/followed'), | ||||
| 			]); | ||||
| 			const antennaItems = antennas.map(antenna => ({ | ||||
| 				text: antenna.name, | ||||
|  | @ -137,7 +146,8 @@ export default Vue.extend({ | |||
| 				indicate: antenna.hasUnreadNote, | ||||
| 				action: () => { | ||||
| 					this.antenna = antenna; | ||||
| 					this.setSrc('antenna'); | ||||
| 					this.src = 'antenna'; | ||||
| 					this.saveSrc(); | ||||
| 				} | ||||
| 			})); | ||||
| 			const listItems = lists.map(list => ({ | ||||
|  | @ -145,27 +155,40 @@ export default Vue.extend({ | |||
| 				icon: faListUl, | ||||
| 				action: () => { | ||||
| 					this.list = list; | ||||
| 					this.setSrc('list'); | ||||
| 					this.src = 'list'; | ||||
| 					this.saveSrc(); | ||||
| 				} | ||||
| 			})); | ||||
| 			const channelItems = channels.map(channel => ({ | ||||
| 				text: channel.name, | ||||
| 				icon: faSatelliteDish, | ||||
| 				indicate: channel.hasUnreadNote, | ||||
| 				action: () => { | ||||
| 					// NOTE: チャンネルタイムラインをこのコンポーネントで表示するようにすると投稿フォームはどうするかなどの問題が生じるのでとりあえずページ遷移で | ||||
| 					//this.channel = channel; | ||||
| 					//this.src = 'channel'; | ||||
| 					//this.saveSrc(); | ||||
| 					this.$router.push(`/channels/${channel.id}`); | ||||
| 				} | ||||
| 			})); | ||||
| 			this.$root.menu({ | ||||
| 				items: [{ | ||||
| 					text: this.$t('_timelines.home'), | ||||
| 					icon: faHome, | ||||
| 					action: () => { this.setSrc('home') } | ||||
| 					action: () => { this.src = 'home'; this.saveSrc(); } | ||||
| 				}, this.meta.disableLocalTimeline && !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin ? undefined : { | ||||
| 					text: this.$t('_timelines.local'), | ||||
| 					icon: faComments, | ||||
| 					action: () => { this.setSrc('local') } | ||||
| 					action: () => { this.src = 'local'; this.saveSrc(); } | ||||
| 				}, this.meta.disableLocalTimeline && !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin ? undefined : { | ||||
| 					text: this.$t('_timelines.social'), | ||||
| 					icon: faShareAlt, | ||||
| 					action: () => { this.setSrc('social') } | ||||
| 					action: () => { this.src = 'social'; this.saveSrc(); } | ||||
| 				}, this.meta.disableGlobalTimeline && !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin ? undefined : { | ||||
| 					text: this.$t('_timelines.global'), | ||||
| 					icon: faGlobe, | ||||
| 					action: () => { this.setSrc('global') } | ||||
| 				}, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems], | ||||
| 					action: () => { this.src = 'global'; this.saveSrc(); } | ||||
| 				}, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems, channelItems.length > 0 ? null : undefined, ...channelItems], | ||||
| 				fixed: true, | ||||
| 				noCenter: true, | ||||
| 				source: ev.currentTarget || ev.target | ||||
|  | @ -174,14 +197,13 @@ export default Vue.extend({ | |||
| 			}); | ||||
| 		}, | ||||
| 
 | ||||
| 		setSrc(src) { | ||||
| 			this.src = src; | ||||
| 		}, | ||||
| 
 | ||||
| 		saveSrc() { | ||||
| 			this.$store.commit('deviceUser/setTl', { | ||||
| 				src: this.src, | ||||
| 				arg: this.src == 'list' ? this.list : this.antenna | ||||
| 				arg: | ||||
| 					this.src === 'list' ? this.list : | ||||
| 					this.src === 'antenna' ? this.antenna : | ||||
| 					this.channel | ||||
| 			}); | ||||
| 		}, | ||||
| 
 | ||||
|  |  | |||
|  | @ -53,7 +53,7 @@ export default Vue.extend({ | |||
| 		}; | ||||
| 	}, | ||||
| 	computed: { | ||||
| 		draftId(): string { | ||||
| 		draftKey(): string { | ||||
| 			return this.user ? 'user:' + this.user.id : 'group:' + this.group.id; | ||||
| 		}, | ||||
| 		canSend(): boolean { | ||||
|  | @ -79,7 +79,7 @@ export default Vue.extend({ | |||
| 		autosize(this.$refs.text); | ||||
| 
 | ||||
| 		// 書きかけの投稿を復元 | ||||
| 		const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftId]; | ||||
| 		const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftKey]; | ||||
| 		if (draft) { | ||||
| 			this.text = draft.data.text; | ||||
| 			this.file = draft.data.file; | ||||
|  | @ -199,7 +199,7 @@ export default Vue.extend({ | |||
| 		saveDraft() { | ||||
| 			const data = JSON.parse(localStorage.getItem('message_drafts') || '{}'); | ||||
| 
 | ||||
| 			data[this.draftId] = { | ||||
| 			data[this.draftKey] = { | ||||
| 				updatedAt: new Date(), | ||||
| 				data: { | ||||
| 					text: this.text, | ||||
|  | @ -213,7 +213,7 @@ export default Vue.extend({ | |||
| 		deleteDraft() { | ||||
| 			const data = JSON.parse(localStorage.getItem('message_drafts') || '{}'); | ||||
| 
 | ||||
| 			delete data[this.draftId]; | ||||
| 			delete data[this.draftKey]; | ||||
| 
 | ||||
| 			localStorage.setItem('message_drafts', JSON.stringify(data)); | ||||
| 		}, | ||||
|  |  | |||
|  | @ -3,11 +3,11 @@ | |||
| 	<portal to="icon"><fa :icon="faCog"/></portal> | ||||
| 	<portal to="title">{{ $t('accountSettings') }}</portal> | ||||
| 
 | ||||
| 	<x-profile-setting/> | ||||
| 	<x-privacy-setting/> | ||||
| 	<x-reaction-setting/> | ||||
| 	<x-profile-setting class="_vMargin"/> | ||||
| 	<x-privacy-setting class="_vMargin"/> | ||||
| 	<x-reaction-setting class="_vMargin"/> | ||||
| 
 | ||||
| 	<section class="_card"> | ||||
| 	<section class="_card _vMargin"> | ||||
| 		<div class="_title"><fa :icon="faCog"/> {{ $t('general') }}</div> | ||||
| 		<div class="_content"> | ||||
| 			<mk-switch v-model="$store.state.i.autoWatch" @change="onChangeAutoWatch"> | ||||
|  | @ -24,14 +24,14 @@ | |||
| 		</div> | ||||
| 	</section> | ||||
| 
 | ||||
| 	<x-import-export/> | ||||
| 	<x-drive/> | ||||
| 	<x-mute-block/> | ||||
| 	<x-word-mute/> | ||||
| 	<x-security/> | ||||
| 	<x-2fa/> | ||||
| 	<x-integration/> | ||||
| 	<x-api/> | ||||
| 	<x-import-export class="_vMargin"/> | ||||
| 	<x-drive class="_vMargin"/> | ||||
| 	<x-mute-block class="_vMargin"/> | ||||
| 	<x-word-mute class="_vMargin"/> | ||||
| 	<x-security class="_vMargin"/> | ||||
| 	<x-2fa class="_vMargin"/> | ||||
| 	<x-integration class="_vMargin"/> | ||||
| 	<x-api class="_vMargin"/> | ||||
| 
 | ||||
| 	<router-link class="_panel _buttonPrimary" to="/my/apps" style="margin: var(--margin) auto;">{{ $t('installedApps') }}</router-link> | ||||
| 
 | ||||
|  |  | |||
|  | @ -5,11 +5,11 @@ | |||
| 
 | ||||
| 	<router-link v-if="$store.getters.isSignedIn" class="_panel _buttonPrimary" to="/my/settings" style="margin-bottom: var(--margin);">{{ $t('accountSettings') }}</router-link> | ||||
| 
 | ||||
| 	<x-theme/> | ||||
| 	<x-theme class="_vMargin"/> | ||||
| 
 | ||||
| 	<x-sidebar/> | ||||
| 	<x-sidebar class="_vMargin"/> | ||||
| 
 | ||||
| 	<x-plugins/> | ||||
| 	<x-plugins class="_vMargin"/> | ||||
| 
 | ||||
| 	<section class="_card _vMargin"> | ||||
| 		<div class="_title"><fa :icon="faMusic"/> {{ $t('sounds') }}</div> | ||||
|  | @ -50,6 +50,11 @@ | |||
| 				<option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> | ||||
| 				<template #text><button class="_textButton" @click="listen(sfxAntenna)" v-if="sfxAntenna"><fa :icon="faPlay"/> {{ $t('listen') }}</button></template> | ||||
| 			</mk-select> | ||||
| 			<mk-select v-model="sfxChannel"> | ||||
| 				<template #label>{{ $t('_sfx.channel') }}</template> | ||||
| 				<option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> | ||||
| 				<template #text><button class="_textButton" @click="listen(sfxChannel)" v-if="sfxChannel"><fa :icon="faPlay"/> {{ $t('listen') }}</button></template> | ||||
| 			</mk-select> | ||||
| 		</div> | ||||
| 	</section> | ||||
| 
 | ||||
|  | @ -142,10 +147,14 @@ const sounds = [ | |||
| 	'syuilo/pirori', | ||||
| 	'syuilo/pirori-wet', | ||||
| 	'syuilo/pirori-square-wet', | ||||
| 	'syuilo/square-pico', | ||||
| 	'syuilo/reverved', | ||||
| 	'syuilo/ryukyu', | ||||
| 	'aisha/1', | ||||
| 	'aisha/2', | ||||
| 	'aisha/3', | ||||
| 	'noizenecio/kick_gaba', | ||||
| 	'noizenecio/kick_gaba2', | ||||
| ]; | ||||
| 
 | ||||
| export default Vue.extend({ | ||||
|  | @ -272,6 +281,11 @@ export default Vue.extend({ | |||
| 			set(value) { this.$store.commit('device/set', { key: 'sfxAntenna', value }); } | ||||
| 		}, | ||||
| 
 | ||||
| 		sfxChannel: { | ||||
| 			get() { return this.$store.state.device.sfxChannel; }, | ||||
| 			set(value) { this.$store.commit('device/set', { key: 'sfxChannel', value }); } | ||||
| 		}, | ||||
| 
 | ||||
| 		volumeIcon: { | ||||
| 			get() { | ||||
| 				return this.sfxVolume === 0 ? faVolumeMute : faVolumeUp; | ||||
|  |  | |||
|  | @ -29,6 +29,10 @@ export const router = new VueRouter({ | |||
| 		{ path: '/explore', component: page('explore') }, | ||||
| 		{ path: '/explore/tags/:tag', props: true, component: page('explore') }, | ||||
| 		{ path: '/search', component: page('search') }, | ||||
| 		{ 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: true }, | ||||
| 		{ path: '/my/notifications', component: page('notifications') }, | ||||
| 		{ path: '/my/favorites', component: page('favorites') }, | ||||
| 		{ path: '/my/messages', component: page('messages') }, | ||||
|  |  | |||
|  | @ -1,7 +1,7 @@ | |||
| import Vuex from 'vuex'; | ||||
| import createPersistedState from 'vuex-persistedstate'; | ||||
| import * as nestedProperty from 'nested-property'; | ||||
| import { faTerminal, faHashtag, faBroadcastTower, faFireAlt, faSearch, faStar, faAt, faListUl, faUserClock, faUsers, faCloud, faGamepad, faFileAlt, faSatellite, faDoorClosed, faColumns } from '@fortawesome/free-solid-svg-icons'; | ||||
| import { faSatelliteDish, faTerminal, faHashtag, faBroadcastTower, faFireAlt, faSearch, faStar, faAt, faListUl, faUserClock, faUsers, faCloud, faGamepad, faFileAlt, faSatellite, faDoorClosed, faColumns } from '@fortawesome/free-solid-svg-icons'; | ||||
| import { faBell, faEnvelope, faComments } from '@fortawesome/free-regular-svg-icons'; | ||||
| import { AiScript, utils, values } from '@syuilo/aiscript'; | ||||
| import { apiUrl, deckmode } from './config'; | ||||
|  | @ -90,6 +90,7 @@ export const defaultDeviceSettings = { | |||
| 	sfxChat: 'syuilo/pope1', | ||||
| 	sfxChatBg: 'syuilo/waon', | ||||
| 	sfxAntenna: 'syuilo/triple', | ||||
| 	sfxChannel: 'syuilo/square-pico', | ||||
| 	userData: {}, | ||||
| }; | ||||
| 
 | ||||
|  | @ -213,6 +214,11 @@ export default () => new Vuex.Store({ | |||
| 				get show() { return getters.isSignedIn; }, | ||||
| 				to: '/my/pages', | ||||
| 			}, | ||||
| 			channels: { | ||||
| 				title: 'channel', | ||||
| 				icon: faSatelliteDish, | ||||
| 				to: '/channels', | ||||
| 			}, | ||||
| 			games: { | ||||
| 				title: 'games', | ||||
| 				icon: faGamepad, | ||||
|  |  | |||
|  | @ -72,5 +72,6 @@ | |||
| 		X12: 'rgba(255, 255, 255, 0.1)', | ||||
| 		X13: 'rgba(255, 255, 255, 0.15)', | ||||
| 		X14: ':alpha<0.5<@navBg', | ||||
| 		X15: ':alpha<0<@panel', | ||||
| 	}, | ||||
| } | ||||
|  |  | |||
|  | @ -72,5 +72,6 @@ | |||
| 		X12: 'rgba(0, 0, 0, 0.1)', | ||||
| 		X13: 'rgba(0, 0, 0, 0.15)', | ||||
| 		X14: ':alpha<0.5<@navBg', | ||||
| 		X15: ':alpha<0<@panel', | ||||
| 	}, | ||||
| } | ||||
|  |  | |||
|  | @ -38,7 +38,7 @@ import { FollowRequest } from '../models/entities/follow-request'; | |||
| import { Emoji } from '../models/entities/emoji'; | ||||
| import { ReversiGame } from '../models/entities/games/reversi/game'; | ||||
| import { ReversiMatching } from '../models/entities/games/reversi/matching'; | ||||
| import { UserNotePining } from '../models/entities/user-note-pinings'; | ||||
| import { UserNotePining } from '../models/entities/user-note-pining'; | ||||
| import { Poll } from '../models/entities/poll'; | ||||
| import { UserKeypair } from '../models/entities/user-keypair'; | ||||
| import { UserPublickey } from '../models/entities/user-publickey'; | ||||
|  | @ -60,6 +60,9 @@ import { PromoRead } from '../models/entities/promo-read'; | |||
| import { program } from '../argv'; | ||||
| import { Relay } from '../models/entities/relay'; | ||||
| import { MutedNote } from '../models/entities/muted-note'; | ||||
| import { Channel } from '../models/entities/channel'; | ||||
| import { ChannelFollowing } from '../models/entities/channel-following'; | ||||
| import { ChannelNotePining } from '../models/entities/channel-note-pining'; | ||||
| 
 | ||||
| const sqlLogger = dbLogger.createSubLogger('sql', 'white', false); | ||||
| 
 | ||||
|  | @ -153,6 +156,9 @@ export const entities = [ | |||
| 	ReversiMatching, | ||||
| 	Relay, | ||||
| 	MutedNote, | ||||
| 	Channel, | ||||
| 	ChannelFollowing, | ||||
| 	ChannelNotePining, | ||||
| 	...charts as any | ||||
| ]; | ||||
| 
 | ||||
|  |  | |||
|  | @ -25,4 +25,6 @@ export const kinds = [ | |||
| 	'read:page-likes', | ||||
| 	'read:user-groups', | ||||
| 	'write:user-groups', | ||||
| 	'read:channels', | ||||
| 	'write:channels', | ||||
| ]; | ||||
|  |  | |||
							
								
								
									
										43
									
								
								src/models/entities/channel-following.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/models/entities/channel-following.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,43 @@ | |||
| import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; | ||||
| import { User } from './user'; | ||||
| import { id } from '../id'; | ||||
| import { Channel } from './channel'; | ||||
| 
 | ||||
| @Entity() | ||||
| @Index(['followerId', 'followeeId'], { unique: true }) | ||||
| export class ChannelFollowing { | ||||
| 	@PrimaryColumn(id()) | ||||
| 	public id: string; | ||||
| 
 | ||||
| 	@Index() | ||||
| 	@Column('timestamp with time zone', { | ||||
| 		comment: 'The created date of the ChannelFollowing.' | ||||
| 	}) | ||||
| 	public createdAt: Date; | ||||
| 
 | ||||
| 	@Index() | ||||
| 	@Column({ | ||||
| 		...id(), | ||||
| 		comment: 'The followee channel ID.' | ||||
| 	}) | ||||
| 	public followeeId: Channel['id']; | ||||
| 
 | ||||
| 	@ManyToOne(type => Channel, { | ||||
| 		onDelete: 'CASCADE' | ||||
| 	}) | ||||
| 	@JoinColumn() | ||||
| 	public followee: Channel | null; | ||||
| 
 | ||||
| 	@Index() | ||||
| 	@Column({ | ||||
| 		...id(), | ||||
| 		comment: 'The follower user ID.' | ||||
| 	}) | ||||
| 	public followerId: User['id']; | ||||
| 
 | ||||
| 	@ManyToOne(type => User, { | ||||
| 		onDelete: 'CASCADE' | ||||
| 	}) | ||||
| 	@JoinColumn() | ||||
| 	public follower: User | null; | ||||
| } | ||||
							
								
								
									
										35
									
								
								src/models/entities/channel-note-pining.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/models/entities/channel-note-pining.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,35 @@ | |||
| import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; | ||||
| import { Note } from './note'; | ||||
| import { Channel } from './channel'; | ||||
| import { id } from '../id'; | ||||
| 
 | ||||
| @Entity() | ||||
| @Index(['channelId', 'noteId'], { unique: true }) | ||||
| export class ChannelNotePining { | ||||
| 	@PrimaryColumn(id()) | ||||
| 	public id: string; | ||||
| 
 | ||||
| 	@Column('timestamp with time zone', { | ||||
| 		comment: 'The created date of the ChannelNotePining.' | ||||
| 	}) | ||||
| 	public createdAt: Date; | ||||
| 
 | ||||
| 	@Index() | ||||
| 	@Column(id()) | ||||
| 	public channelId: Channel['id']; | ||||
| 
 | ||||
| 	@ManyToOne(type => Channel, { | ||||
| 		onDelete: 'CASCADE' | ||||
| 	}) | ||||
| 	@JoinColumn() | ||||
| 	public channel: Channel | null; | ||||
| 
 | ||||
| 	@Column(id()) | ||||
| 	public noteId: Note['id']; | ||||
| 
 | ||||
| 	@ManyToOne(type => Note, { | ||||
| 		onDelete: 'CASCADE' | ||||
| 	}) | ||||
| 	@JoinColumn() | ||||
| 	public note: Note | null; | ||||
| } | ||||
							
								
								
									
										74
									
								
								src/models/entities/channel.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								src/models/entities/channel.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,74 @@ | |||
| import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; | ||||
| import { User } from './user'; | ||||
| import { id } from '../id'; | ||||
| import { DriveFile } from './drive-file'; | ||||
| 
 | ||||
| @Entity() | ||||
| export class Channel { | ||||
| 	@PrimaryColumn(id()) | ||||
| 	public id: string; | ||||
| 
 | ||||
| 	@Index() | ||||
| 	@Column('timestamp with time zone', { | ||||
| 		comment: 'The created date of the Channel.' | ||||
| 	}) | ||||
| 	public createdAt: Date; | ||||
| 
 | ||||
| 	@Index() | ||||
| 	@Column('timestamp with time zone', { | ||||
| 		nullable: true | ||||
| 	}) | ||||
| 	public lastNotedAt: Date | null; | ||||
| 
 | ||||
| 	@Index() | ||||
| 	@Column({ | ||||
| 		...id(), | ||||
| 		comment: 'The owner ID.' | ||||
| 	}) | ||||
| 	public userId: User['id']; | ||||
| 
 | ||||
| 	@ManyToOne(type => User, { | ||||
| 		onDelete: 'SET NULL' | ||||
| 	}) | ||||
| 	@JoinColumn() | ||||
| 	public user: User | null; | ||||
| 
 | ||||
| 	@Column('varchar', { | ||||
| 		length: 128, | ||||
| 		comment: 'The name of the Channel.' | ||||
| 	}) | ||||
| 	public name: string; | ||||
| 
 | ||||
| 	@Column('varchar', { | ||||
| 		length: 2048, nullable: true, | ||||
| 		comment: 'The description of the Channel.' | ||||
| 	}) | ||||
| 	public description: string | null; | ||||
| 
 | ||||
| 	@Column({ | ||||
| 		...id(), | ||||
| 		nullable: true, | ||||
| 		comment: 'The ID of banner Channel.' | ||||
| 	}) | ||||
| 	public bannerId: DriveFile['id'] | null; | ||||
| 
 | ||||
| 	@ManyToOne(type => DriveFile, { | ||||
| 		onDelete: 'SET NULL' | ||||
| 	}) | ||||
| 	@JoinColumn() | ||||
| 	public banner: DriveFile | null; | ||||
| 
 | ||||
| 	@Index() | ||||
| 	@Column('integer', { | ||||
| 		default: 0, | ||||
| 		comment: 'The count of notes.' | ||||
| 	}) | ||||
| 	public notesCount: number; | ||||
| 
 | ||||
| 	@Index() | ||||
| 	@Column('integer', { | ||||
| 		default: 0, | ||||
| 		comment: 'The count of users.' | ||||
| 	}) | ||||
| 	public usersCount: number; | ||||
| } | ||||
|  | @ -2,6 +2,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typ | |||
| import { User } from './user'; | ||||
| import { Note } from './note'; | ||||
| import { id } from '../id'; | ||||
| import { Channel } from './channel'; | ||||
| 
 | ||||
| @Entity() | ||||
| @Index(['userId', 'noteId'], { unique: true }) | ||||
|  | @ -29,15 +30,34 @@ export class NoteUnread { | |||
| 	@JoinColumn() | ||||
| 	public note: Note | null; | ||||
| 
 | ||||
| 	/** | ||||
| 	 * メンションか否か | ||||
| 	 */ | ||||
| 	@Index() | ||||
| 	@Column('boolean') | ||||
| 	public isMentioned: boolean; | ||||
| 
 | ||||
| 	/** | ||||
| 	 * ダイレクト投稿か否か | ||||
| 	 */ | ||||
| 	@Index() | ||||
| 	@Column('boolean') | ||||
| 	public isSpecified: boolean; | ||||
| 
 | ||||
| 	//#region Denormalized fields
 | ||||
| 	@Index() | ||||
| 	@Column({ | ||||
| 		...id(), | ||||
| 		comment: '[Denormalized]' | ||||
| 	}) | ||||
| 	public noteUserId: User['id']; | ||||
| 
 | ||||
| 	/** | ||||
| 	 * ダイレクト投稿か | ||||
| 	 */ | ||||
| 	@Column('boolean') | ||||
| 	public isSpecified: boolean; | ||||
| 	@Index() | ||||
| 	@Column({ | ||||
| 		...id(), | ||||
| 		nullable: true, | ||||
| 		comment: '[Denormalized]' | ||||
| 	}) | ||||
| 	public noteChannelId: Channel['id'] | null; | ||||
| 	//#endregion
 | ||||
| } | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ import { User } from './user'; | |||
| import { DriveFile } from './drive-file'; | ||||
| import { id } from '../id'; | ||||
| import { noteVisibilities } from '../../types'; | ||||
| 
 | ||||
| import { Channel } from './channel'; | ||||
| 
 | ||||
| @Entity() | ||||
| @Index('IDX_NOTE_TAGS', { synchronize: false }) | ||||
|  | @ -173,6 +173,20 @@ export class Note { | |||
| 	}) | ||||
| 	public hasPoll: boolean; | ||||
| 
 | ||||
| 	@Index() | ||||
| 	@Column({ | ||||
| 		...id(), | ||||
| 		nullable: true, default: null, | ||||
| 		comment: 'The ID of source channel.' | ||||
| 	}) | ||||
| 	public channelId: Channel['id'] | null; | ||||
| 
 | ||||
| 	@ManyToOne(type => Channel, { | ||||
| 		onDelete: 'CASCADE' | ||||
| 	}) | ||||
| 	@JoinColumn() | ||||
| 	public channel: Channel | null; | ||||
| 
 | ||||
| 	//#region Denormalized fields
 | ||||
| 	@Index() | ||||
| 	@Column('varchar', { | ||||
|  |  | |||
|  | @ -15,7 +15,7 @@ import { DriveFileRepository } from './repositories/drive-file'; | |||
| import { DriveFolderRepository } from './repositories/drive-folder'; | ||||
| import { Log } from './entities/log'; | ||||
| import { AccessToken } from './entities/access-token'; | ||||
| import { UserNotePining } from './entities/user-note-pinings'; | ||||
| import { UserNotePining } from './entities/user-note-pining'; | ||||
| import { SigninRepository } from './repositories/signin'; | ||||
| import { MessagingMessageRepository } from './repositories/messaging-message'; | ||||
| import { ReversiGameRepository } from './repositories/games/reversi/game'; | ||||
|  | @ -53,7 +53,10 @@ import { PromoNote } from './entities/promo-note'; | |||
| import { PromoRead } from './entities/promo-read'; | ||||
| import { EmojiRepository } from './repositories/emoji'; | ||||
| import { RelayRepository } from './repositories/relay'; | ||||
| import { ChannelRepository } from './repositories/channel'; | ||||
| import { MutedNote } from './entities/muted-note'; | ||||
| import { ChannelFollowing } from './entities/channel-following'; | ||||
| import { ChannelNotePining } from './entities/channel-note-pining'; | ||||
| 
 | ||||
| export const Announcements = getRepository(Announcement); | ||||
| export const AnnouncementReads = getRepository(AnnouncementRead); | ||||
|  | @ -110,3 +113,6 @@ export const PromoNotes = getRepository(PromoNote); | |||
| export const PromoReads = getRepository(PromoRead); | ||||
| export const Relays = getCustomRepository(RelayRepository); | ||||
| export const MutedNotes = getRepository(MutedNote); | ||||
| export const Channels = getCustomRepository(ChannelRepository); | ||||
| export const ChannelFollowings = getRepository(ChannelFollowing); | ||||
| export const ChannelNotePinings = getRepository(ChannelNotePining); | ||||
|  |  | |||
							
								
								
									
										101
									
								
								src/models/repositories/channel.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								src/models/repositories/channel.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,101 @@ | |||
| import { EntityRepository, Repository } from 'typeorm'; | ||||
| import { Channel } from '../entities/channel'; | ||||
| import { ensure } from '../../prelude/ensure'; | ||||
| import { SchemaType } from '../../misc/schema'; | ||||
| import { DriveFiles, ChannelFollowings, NoteUnreads } from '..'; | ||||
| import { User } from '../entities/user'; | ||||
| 
 | ||||
| export type PackedChannel = SchemaType<typeof packedChannelSchema>; | ||||
| 
 | ||||
| @EntityRepository(Channel) | ||||
| export class ChannelRepository extends Repository<Channel> { | ||||
| 	public async pack( | ||||
| 		src: Channel['id'] | Channel, | ||||
| 		me?: User['id'] | User | null | undefined, | ||||
| 	): Promise<PackedChannel> { | ||||
| 		const channel = typeof src === 'object' ? src : await this.findOne(src).then(ensure); | ||||
| 		const meId = me ? typeof me === 'string' ? me : me.id : null; | ||||
| 
 | ||||
| 		const banner = channel.bannerId ? await DriveFiles.findOne(channel.bannerId) : null; | ||||
| 
 | ||||
| 		const hasUnreadNote = me ? (await NoteUnreads.findOne({ noteChannelId: channel.id, userId: meId })) != null : undefined; | ||||
| 
 | ||||
| 		const following = await ChannelFollowings.findOne({ | ||||
| 			followerId: meId, | ||||
| 			followeeId: channel.id, | ||||
| 		}); | ||||
| 
 | ||||
| 		return { | ||||
| 			id: channel.id, | ||||
| 			createdAt: channel.createdAt.toISOString(), | ||||
| 			lastNotedAt: channel.lastNotedAt ? channel.lastNotedAt.toISOString() : null, | ||||
| 			name: channel.name, | ||||
| 			description: channel.description, | ||||
| 			userId: channel.userId, | ||||
| 			bannerUrl: banner ? DriveFiles.getPublicUrl(banner, false) : null, | ||||
| 			usersCount: channel.usersCount, | ||||
| 			notesCount: channel.notesCount, | ||||
| 
 | ||||
| 			...(me ? { | ||||
| 				isFollowing: following != null, | ||||
| 				hasUnreadNote, | ||||
| 			} : {}) | ||||
| 		}; | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| export const packedChannelSchema = { | ||||
| 	type: 'object' as const, | ||||
| 	optional: false as const, nullable: false as const, | ||||
| 	properties: { | ||||
| 		id: { | ||||
| 			type: 'string' as const, | ||||
| 			optional: false as const, nullable: false as const, | ||||
| 			format: 'id', | ||||
| 			description: 'The unique identifier for this Channel.', | ||||
| 			example: 'xxxxxxxxxx', | ||||
| 		}, | ||||
| 		createdAt: { | ||||
| 			type: 'string' as const, | ||||
| 			optional: false as const, nullable: false as const, | ||||
| 			format: 'date-time', | ||||
| 			description: 'The date that the Channel was created.' | ||||
| 		}, | ||||
| 		lastNotedAt: { | ||||
| 			type: 'string' as const, | ||||
| 			optional: false as const, nullable: true as const, | ||||
| 			format: 'date-time', | ||||
| 		}, | ||||
| 		name: { | ||||
| 			type: 'string' as const, | ||||
| 			optional: false as const, nullable: false as const, | ||||
| 			description: 'The name of the Channel.' | ||||
| 		}, | ||||
| 		description: { | ||||
| 			type: 'string' as const, | ||||
| 			nullable: true as const, optional: false as const, | ||||
| 		}, | ||||
| 		bannerUrl: { | ||||
| 			type: 'string' as const, | ||||
| 			format: 'url', | ||||
| 			nullable: true as const, optional: false as const, | ||||
| 		}, | ||||
| 		notesCount: { | ||||
| 			type: 'number' as const, | ||||
| 			nullable: false as const, optional: false as const, | ||||
| 		}, | ||||
| 		usersCount: { | ||||
| 			type: 'number' as const, | ||||
| 			nullable: false as const, optional: false as const, | ||||
| 		}, | ||||
| 		isFollowing: { | ||||
| 			type: 'boolean' as const, | ||||
| 			optional: true as const, nullable: false as const, | ||||
| 		}, | ||||
| 		userId: { | ||||
| 			type: 'string' as const, | ||||
| 			nullable: false as const, optional: false as const, | ||||
| 			format: 'id', | ||||
| 		}, | ||||
| 	}, | ||||
| }; | ||||
|  | @ -1,7 +1,7 @@ | |||
| import { EntityRepository, Repository, In } from 'typeorm'; | ||||
| import { Note } from '../entities/note'; | ||||
| import { User } from '../entities/user'; | ||||
| import { Emojis, Users, PollVotes, DriveFiles, NoteReactions, Followings, Polls } from '..'; | ||||
| import { Emojis, Users, PollVotes, DriveFiles, NoteReactions, Followings, Polls, Channels } from '..'; | ||||
| import { ensure } from '../../prelude/ensure'; | ||||
| import { SchemaType } from '../../misc/schema'; | ||||
| import { awaitAll } from '../../prelude/await-all'; | ||||
|  | @ -207,6 +207,12 @@ export class NoteRepository extends Repository<Note> { | |||
| 			text = `【${note.name}】\n${(note.text || '').trim()}\n\n${note.url || note.uri}`; | ||||
| 		} | ||||
| 
 | ||||
| 		const channel = note.channelId | ||||
| 			? note.channel | ||||
| 				? note.channel | ||||
| 				: await Channels.findOne(note.channelId) | ||||
| 			: null; | ||||
| 
 | ||||
| 		const packed = await awaitAll({ | ||||
| 			id: note.id, | ||||
| 			createdAt: note.createdAt.toISOString(), | ||||
|  | @ -227,6 +233,11 @@ export class NoteRepository extends Repository<Note> { | |||
| 			files: DriveFiles.packMany(note.fileIds), | ||||
| 			replyId: note.replyId, | ||||
| 			renoteId: note.renoteId, | ||||
| 			channelId: note.channelId || undefined, | ||||
| 			channel: channel ? { | ||||
| 				id: channel.id, | ||||
| 				name: channel.name, | ||||
| 			} : undefined, | ||||
| 			mentions: note.mentions.length > 0 ? note.mentions : undefined, | ||||
| 			uri: note.uri || undefined, | ||||
| 			url: note.url || undefined, | ||||
|  | @ -391,6 +402,16 @@ export const packedNoteSchema = { | |||
| 			type: 'object' as const, | ||||
| 			optional: true as const, nullable: true as const, | ||||
| 		}, | ||||
| 
 | ||||
| 		channelId: { | ||||
| 			type: 'string' as const, | ||||
| 			optional: true as const, nullable: true as const, | ||||
| 			format: 'id', | ||||
| 			example: 'xxxxxxxxxx', | ||||
| 		}, | ||||
| 		channel: { | ||||
| 			type: 'object' as const, | ||||
| 			optional: true as const, nullable: true as const, | ||||
| 			ref: 'Channel' | ||||
| 		}, | ||||
| 	}, | ||||
| }; | ||||
|  |  | |||
|  | @ -1,7 +1,7 @@ | |||
| import $ from 'cafy'; | ||||
| import { EntityRepository, Repository, In, Not } from 'typeorm'; | ||||
| import { User, ILocalUser, IRemoteUser } from '../entities/user'; | ||||
| import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, Antennas, AntennaNotes } from '..'; | ||||
| import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, Antennas, AntennaNotes, ChannelFollowings } from '..'; | ||||
| import { ensure } from '../../prelude/ensure'; | ||||
| import config from '../../config'; | ||||
| import { SchemaType } from '../../misc/schema'; | ||||
|  | @ -107,6 +107,17 @@ export class UserRepository extends Repository<User> { | |||
| 		return unread != null; | ||||
| 	} | ||||
| 
 | ||||
| 	public async getHasUnreadChannel(userId: User['id']): Promise<boolean> { | ||||
| 		const channels = await ChannelFollowings.find({ followerId: userId }); | ||||
| 
 | ||||
| 		const unread = channels.length > 0 ? await NoteUnreads.findOne({ | ||||
| 			userId: userId, | ||||
| 			noteChannelId: In(channels.map(x => x.id)), | ||||
| 		}) : null; | ||||
| 
 | ||||
| 		return unread != null; | ||||
| 	} | ||||
| 
 | ||||
| 	public async getHasUnreadNotification(userId: User['id']): Promise<boolean> { | ||||
| 		const mute = await Mutings.find({ | ||||
| 			muterId: userId | ||||
|  | @ -139,7 +150,6 @@ export class UserRepository extends Repository<User> { | |||
| 		options?: { | ||||
| 			detail?: boolean, | ||||
| 			includeSecrets?: boolean, | ||||
| 			includeHasUnreadNotes?: boolean | ||||
| 		} | ||||
| 	): Promise<PackedUser> { | ||||
| 		const opts = Object.assign({ | ||||
|  | @ -181,17 +191,6 @@ export class UserRepository extends Repository<User> { | |||
| 				select: ['name', 'host', 'url', 'aliases'] | ||||
| 			}) : [], | ||||
| 
 | ||||
| 			...(opts.includeHasUnreadNotes ? { | ||||
| 				hasUnreadSpecifiedNotes: NoteUnreads.count({ | ||||
| 					where: { userId: user.id, isSpecified: true }, | ||||
| 					take: 1 | ||||
| 				}).then(count => count > 0), | ||||
| 				hasUnreadMentions: NoteUnreads.count({ | ||||
| 					where: { userId: user.id }, | ||||
| 					take: 1 | ||||
| 				}).then(count => count > 0), | ||||
| 			} : {}), | ||||
| 
 | ||||
| 			...(opts.detail ? { | ||||
| 				url: profile!.url, | ||||
| 				createdAt: user.createdAt.toISOString(), | ||||
|  | @ -233,8 +232,17 @@ export class UserRepository extends Repository<User> { | |||
| 				alwaysMarkNsfw: profile!.alwaysMarkNsfw, | ||||
| 				carefulBot: profile!.carefulBot, | ||||
| 				autoAcceptFollowed: profile!.autoAcceptFollowed, | ||||
| 				hasUnreadSpecifiedNotes: NoteUnreads.count({ | ||||
| 					where: { userId: user.id, isSpecified: true }, | ||||
| 					take: 1 | ||||
| 				}).then(count => count > 0), | ||||
| 				hasUnreadMentions: NoteUnreads.count({ | ||||
| 					where: { userId: user.id, isMentioned: true }, | ||||
| 					take: 1 | ||||
| 				}).then(count => count > 0), | ||||
| 				hasUnreadAnnouncement: this.getHasUnreadAnnouncement(user.id), | ||||
| 				hasUnreadAntenna: this.getHasUnreadAntenna(user.id), | ||||
| 				hasUnreadChannel: this.getHasUnreadChannel(user.id), | ||||
| 				hasUnreadMessagingMessage: this.getHasUnreadMessagingMessage(user.id), | ||||
| 				hasUnreadNotification: this.getHasUnreadNotification(user.id), | ||||
| 				hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), | ||||
|  | @ -276,7 +284,6 @@ export class UserRepository extends Repository<User> { | |||
| 		options?: { | ||||
| 			detail?: boolean, | ||||
| 			includeSecrets?: boolean, | ||||
| 			includeHasUnreadNotes?: boolean | ||||
| 		} | ||||
| 	) { | ||||
| 		return Promise.all(users.map(u => this.pack(u, me, options))); | ||||
|  |  | |||
|  | @ -15,7 +15,7 @@ import { updateUsertags } from '../../../services/update-hashtag'; | |||
| import { Users, UserNotePinings, Instances, DriveFiles, Followings, UserProfiles, UserPublickeys } from '../../../models'; | ||||
| import { User, IRemoteUser } from '../../../models/entities/user'; | ||||
| import { Emoji } from '../../../models/entities/emoji'; | ||||
| import { UserNotePining } from '../../../models/entities/user-note-pinings'; | ||||
| import { UserNotePining } from '../../../models/entities/user-note-pining'; | ||||
| import { genId } from '../../../misc/gen-id'; | ||||
| import { instanceChart, usersChart } from '../../../services/chart'; | ||||
| import { UserPublickey } from '../../../models/entities/user-publickey'; | ||||
|  |  | |||
							
								
								
									
										24
									
								
								src/server/api/common/generate-channel-query.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/server/api/common/generate-channel-query.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,24 @@ | |||
| import { User } from '../../../models/entities/user'; | ||||
| import { ChannelFollowings } from '../../../models'; | ||||
| import { Brackets, SelectQueryBuilder } from 'typeorm'; | ||||
| 
 | ||||
| export function generateChannelQuery(q: SelectQueryBuilder<any>, me?: User | null) { | ||||
| 	if (me == null) { | ||||
| 		q.andWhere('note.channelId IS NULL'); | ||||
| 	} else { | ||||
| 		q.leftJoinAndSelect('note.channel', 'channel'); | ||||
| 
 | ||||
| 		const channelFollowingQuery = ChannelFollowings.createQueryBuilder('channelFollowing') | ||||
| 			.select('channelFollowing.followeeId') | ||||
| 			.where('channelFollowing.followerId = :followerId', { followerId: me.id }); | ||||
| 
 | ||||
| 		q.andWhere(new Brackets(qb => { qb | ||||
| 			// チャンネルのノートではない
 | ||||
| 			.where('note.channelId IS NULL') | ||||
| 			// または自分がフォローしているチャンネルのノート
 | ||||
| 			.orWhere(`note.channelId IN (${ channelFollowingQuery.getQuery() })`); | ||||
| 		})); | ||||
| 
 | ||||
| 		q.setParameters(channelFollowingQuery.getParameters()); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										68
									
								
								src/server/api/endpoints/channels/create.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								src/server/api/endpoints/channels/create.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,68 @@ | |||
| import $ from 'cafy'; | ||||
| import define from '../../define'; | ||||
| import { ApiError } from '../../error'; | ||||
| import { Channels, DriveFiles } from '../../../../models'; | ||||
| import { Channel } from '../../../../models/entities/channel'; | ||||
| import { genId } from '../../../../misc/gen-id'; | ||||
| import { ID } from '../../../../misc/cafy-id'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	tags: ['channels'], | ||||
| 
 | ||||
| 	requireCredential: true as const, | ||||
| 
 | ||||
| 	kind: 'write:channels', | ||||
| 
 | ||||
| 	params: { | ||||
| 		name: { | ||||
| 			validator: $.str.range(1, 128) | ||||
| 		}, | ||||
| 
 | ||||
| 		description: { | ||||
| 			validator: $.nullable.optional.str.range(1, 2048) | ||||
| 		}, | ||||
| 
 | ||||
| 		bannerId: { | ||||
| 			validator: $.nullable.optional.type(ID), | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	res: { | ||||
| 		type: 'object' as const, | ||||
| 		optional: false as const, nullable: false as const, | ||||
| 		ref: 'Channel', | ||||
| 	}, | ||||
| 
 | ||||
| 	errors: { | ||||
| 		noSuchFile: { | ||||
| 			message: 'No such file.', | ||||
| 			code: 'NO_SUCH_FILE', | ||||
| 			id: 'cd1e9f3e-5a12-4ab4-96f6-5d0a2cc32050' | ||||
| 		}, | ||||
| 	} | ||||
| }; | ||||
| 
 | ||||
| export default define(meta, async (ps, user) => { | ||||
| 	let banner = null; | ||||
| 	if (ps.bannerId != null) { | ||||
| 		banner = await DriveFiles.findOne({ | ||||
| 			id: ps.bannerId, | ||||
| 			userId: user.id | ||||
| 		}); | ||||
| 
 | ||||
| 		if (banner == null) { | ||||
| 			throw new ApiError(meta.errors.noSuchFile); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	const channel = await Channels.save({ | ||||
| 		id: genId(), | ||||
| 		createdAt: new Date(), | ||||
| 		userId: user.id, | ||||
| 		name: ps.name, | ||||
| 		description: ps.description || null, | ||||
| 		bannerId: banner ? banner.id : null, | ||||
| 	} as Channel); | ||||
| 
 | ||||
| 	return await Channels.pack(channel, user); | ||||
| }); | ||||
							
								
								
									
										28
									
								
								src/server/api/endpoints/channels/featured.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/server/api/endpoints/channels/featured.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | |||
| import define from '../../define'; | ||||
| import { Channels } from '../../../../models'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	tags: ['channels'], | ||||
| 
 | ||||
| 	requireCredential: false as const, | ||||
| 
 | ||||
| 	res: { | ||||
| 		type: 'array' as const, | ||||
| 		optional: false as const, nullable: false as const, | ||||
| 		items: { | ||||
| 			type: 'object' as const, | ||||
| 			optional: false as const, nullable: false as const, | ||||
| 			ref: 'Channel', | ||||
| 		} | ||||
| 	}, | ||||
| }; | ||||
| 
 | ||||
| export default define(meta, async (ps, me) => { | ||||
| 	const query = Channels.createQueryBuilder('channel') | ||||
| 		.where('channel.lastNotedAt IS NOT NULL') | ||||
| 		.orderBy('channel.lastNotedAt', 'DESC'); | ||||
| 
 | ||||
| 	const channels = await query.take(10).getMany(); | ||||
| 
 | ||||
| 	return await Promise.all(channels.map(x => Channels.pack(x, me))); | ||||
| }); | ||||
							
								
								
									
										45
									
								
								src/server/api/endpoints/channels/follow.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								src/server/api/endpoints/channels/follow.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,45 @@ | |||
| import $ from 'cafy'; | ||||
| import { ID } from '../../../../misc/cafy-id'; | ||||
| import define from '../../define'; | ||||
| import { ApiError } from '../../error'; | ||||
| import { Channels, ChannelFollowings } from '../../../../models'; | ||||
| import { genId } from '../../../../misc/gen-id'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	tags: ['channels'], | ||||
| 
 | ||||
| 	requireCredential: true as const, | ||||
| 
 | ||||
| 	kind: 'write:channels', | ||||
| 
 | ||||
| 	params: { | ||||
| 		channelId: { | ||||
| 			validator: $.type(ID), | ||||
| 		}, | ||||
| 	}, | ||||
| 
 | ||||
| 	errors: { | ||||
| 		noSuchChannel: { | ||||
| 			message: 'No such channel.', | ||||
| 			code: 'NO_SUCH_CHANNEL', | ||||
| 			id: 'c0031718-d573-4e85-928e-10039f1fbb68' | ||||
| 		}, | ||||
| 	} | ||||
| }; | ||||
| 
 | ||||
| export default define(meta, async (ps, user) => { | ||||
| 	const channel = await Channels.findOne({ | ||||
| 		id: ps.channelId, | ||||
| 	}); | ||||
| 
 | ||||
| 	if (channel == null) { | ||||
| 		throw new ApiError(meta.errors.noSuchChannel); | ||||
| 	} | ||||
| 
 | ||||
| 	await ChannelFollowings.save({ | ||||
| 		id: genId(), | ||||
| 		createdAt: new Date(), | ||||
| 		followerId: user.id, | ||||
| 		followeeId: channel.id, | ||||
| 	}); | ||||
| }); | ||||
							
								
								
									
										28
									
								
								src/server/api/endpoints/channels/followed.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/server/api/endpoints/channels/followed.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | |||
| import define from '../../define'; | ||||
| import { Channels, ChannelFollowings } from '../../../../models'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	tags: ['channels', 'account'], | ||||
| 
 | ||||
| 	requireCredential: true as const, | ||||
| 
 | ||||
| 	kind: 'read:channels', | ||||
| 
 | ||||
| 	res: { | ||||
| 		type: 'array' as const, | ||||
| 		optional: false as const, nullable: false as const, | ||||
| 		items: { | ||||
| 			type: 'object' as const, | ||||
| 			optional: false as const, nullable: false as const, | ||||
| 			ref: 'Channel', | ||||
| 		} | ||||
| 	}, | ||||
| }; | ||||
| 
 | ||||
| export default define(meta, async (ps, me) => { | ||||
| 	const followings = await ChannelFollowings.find({ | ||||
| 		followerId: me.id, | ||||
| 	}); | ||||
| 
 | ||||
| 	return await Promise.all(followings.map(x => Channels.pack(x.followeeId, me))); | ||||
| }); | ||||
							
								
								
									
										28
									
								
								src/server/api/endpoints/channels/owned.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/server/api/endpoints/channels/owned.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | |||
| import define from '../../define'; | ||||
| import { Channels } from '../../../../models'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	tags: ['channels', 'account'], | ||||
| 
 | ||||
| 	requireCredential: true as const, | ||||
| 
 | ||||
| 	kind: 'read:channels', | ||||
| 
 | ||||
| 	res: { | ||||
| 		type: 'array' as const, | ||||
| 		optional: false as const, nullable: false as const, | ||||
| 		items: { | ||||
| 			type: 'object' as const, | ||||
| 			optional: false as const, nullable: false as const, | ||||
| 			ref: 'Channel', | ||||
| 		} | ||||
| 	}, | ||||
| }; | ||||
| 
 | ||||
| export default define(meta, async (ps, me) => { | ||||
| 	const channels = await Channels.find({ | ||||
| 		userId: me.id, | ||||
| 	}); | ||||
| 
 | ||||
| 	return await Promise.all(channels.map(x => Channels.pack(x, me))); | ||||
| }); | ||||
							
								
								
									
										0
									
								
								src/server/api/endpoints/channels/pin-note.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/server/api/endpoints/channels/pin-note.ts
									
										
									
									
									
										Normal file
									
								
							
							
								
								
									
										43
									
								
								src/server/api/endpoints/channels/show.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/server/api/endpoints/channels/show.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,43 @@ | |||
| import $ from 'cafy'; | ||||
| import { ID } from '../../../../misc/cafy-id'; | ||||
| import define from '../../define'; | ||||
| import { ApiError } from '../../error'; | ||||
| import { Channels } from '../../../../models'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	tags: ['channels'], | ||||
| 
 | ||||
| 	requireCredential: false as const, | ||||
| 
 | ||||
| 	params: { | ||||
| 		channelId: { | ||||
| 			validator: $.type(ID), | ||||
| 		}, | ||||
| 	}, | ||||
| 
 | ||||
| 	res: { | ||||
| 		type: 'object' as const, | ||||
| 		optional: false as const, nullable: false as const, | ||||
| 		ref: 'Channel', | ||||
| 	}, | ||||
| 
 | ||||
| 	errors: { | ||||
| 		noSuchChannel: { | ||||
| 			message: 'No such channel.', | ||||
| 			code: 'NO_SUCH_CHANNEL', | ||||
| 			id: '6f6c314b-7486-4897-8966-c04a66a02923' | ||||
| 		}, | ||||
| 	} | ||||
| }; | ||||
| 
 | ||||
| export default define(meta, async (ps, me) => { | ||||
| 	const channel = await Channels.findOne({ | ||||
| 		id: ps.channelId, | ||||
| 	}); | ||||
| 
 | ||||
| 	if (channel == null) { | ||||
| 		throw new ApiError(meta.errors.noSuchChannel); | ||||
| 	} | ||||
| 
 | ||||
| 	return await Channels.pack(channel, me); | ||||
| }); | ||||
							
								
								
									
										99
									
								
								src/server/api/endpoints/channels/timeline.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								src/server/api/endpoints/channels/timeline.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,99 @@ | |||
| import $ from 'cafy'; | ||||
| import { ID } from '../../../../misc/cafy-id'; | ||||
| import define from '../../define'; | ||||
| import { ApiError } from '../../error'; | ||||
| import { Notes, Channels } from '../../../../models'; | ||||
| import { makePaginationQuery } from '../../common/make-pagination-query'; | ||||
| import { activeUsersChart } from '../../../../services/chart'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	tags: ['notes', 'channels'], | ||||
| 
 | ||||
| 	requireCredential: false as const, | ||||
| 
 | ||||
| 	params: { | ||||
| 		channelId: { | ||||
| 			validator: $.type(ID), | ||||
| 			desc: { | ||||
| 				'ja-JP': 'チャンネルのID' | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		limit: { | ||||
| 			validator: $.optional.num.range(1, 100), | ||||
| 			default: 10, | ||||
| 			desc: { | ||||
| 				'ja-JP': '最大数' | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		sinceId: { | ||||
| 			validator: $.optional.type(ID), | ||||
| 			desc: { | ||||
| 				'ja-JP': '指定すると、その投稿を基点としてより新しい投稿を取得します' | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		untilId: { | ||||
| 			validator: $.optional.type(ID), | ||||
| 			desc: { | ||||
| 				'ja-JP': '指定すると、その投稿を基点としてより古い投稿を取得します' | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		sinceDate: { | ||||
| 			validator: $.optional.num, | ||||
| 			desc: { | ||||
| 				'ja-JP': '指定した時間を基点としてより新しい投稿を取得します。数値は、1970年1月1日 00:00:00 UTC から指定した日時までの経過時間をミリ秒単位で表します。' | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		untilDate: { | ||||
| 			validator: $.optional.num, | ||||
| 			desc: { | ||||
| 				'ja-JP': '指定した時間を基点としてより古い投稿を取得します。数値は、1970年1月1日 00:00:00 UTC から指定した日時までの経過時間をミリ秒単位で表します。' | ||||
| 			} | ||||
| 		}, | ||||
| 	}, | ||||
| 
 | ||||
| 	res: { | ||||
| 		type: 'array' as const, | ||||
| 		optional: false as const, nullable: false as const, | ||||
| 		items: { | ||||
| 			type: 'object' as const, | ||||
| 			optional: false as const, nullable: false as const, | ||||
| 			ref: 'Note', | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	errors: { | ||||
| 		noSuchChannel: { | ||||
| 			message: 'No such channel.', | ||||
| 			code: 'NO_SUCH_CHANNEL', | ||||
| 			id: '4d0eeeba-a02c-4c3c-9966-ef60d38d2e7f' | ||||
| 		} | ||||
| 	} | ||||
| }; | ||||
| 
 | ||||
| export default define(meta, async (ps, user) => { | ||||
| 	const channel = await Channels.findOne({ | ||||
| 		id: ps.channelId, | ||||
| 	}); | ||||
| 
 | ||||
| 	if (channel == null) { | ||||
| 		throw new ApiError(meta.errors.noSuchChannel); | ||||
| 	} | ||||
| 
 | ||||
| 	//#region Construct query
 | ||||
| 	const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) | ||||
| 		.andWhere('note.channelId = :channelId', { channelId: channel.id }) | ||||
| 		.leftJoinAndSelect('note.user', 'user') | ||||
| 		.leftJoinAndSelect('note.channel', 'channel'); | ||||
| 	//#endregion
 | ||||
| 
 | ||||
| 	const timeline = await query.take(ps.limit!).getMany(); | ||||
| 
 | ||||
| 	activeUsersChart.update(user); | ||||
| 
 | ||||
| 	return await Notes.packMany(timeline, user); | ||||
| }); | ||||
							
								
								
									
										42
									
								
								src/server/api/endpoints/channels/unfollow.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								src/server/api/endpoints/channels/unfollow.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,42 @@ | |||
| import $ from 'cafy'; | ||||
| import { ID } from '../../../../misc/cafy-id'; | ||||
| import define from '../../define'; | ||||
| import { ApiError } from '../../error'; | ||||
| import { Channels, ChannelFollowings } from '../../../../models'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	tags: ['channels'], | ||||
| 
 | ||||
| 	requireCredential: true as const, | ||||
| 
 | ||||
| 	kind: 'write:channels', | ||||
| 
 | ||||
| 	params: { | ||||
| 		channelId: { | ||||
| 			validator: $.type(ID), | ||||
| 		}, | ||||
| 	}, | ||||
| 
 | ||||
| 	errors: { | ||||
| 		noSuchChannel: { | ||||
| 			message: 'No such channel.', | ||||
| 			code: 'NO_SUCH_CHANNEL', | ||||
| 			id: '19959ee9-0153-4c51-bbd9-a98c49dc59d6' | ||||
| 		}, | ||||
| 	} | ||||
| }; | ||||
| 
 | ||||
| export default define(meta, async (ps, user) => { | ||||
| 	const channel = await Channels.findOne({ | ||||
| 		id: ps.channelId, | ||||
| 	}); | ||||
| 
 | ||||
| 	if (channel == null) { | ||||
| 		throw new ApiError(meta.errors.noSuchChannel); | ||||
| 	} | ||||
| 
 | ||||
| 	await ChannelFollowings.delete({ | ||||
| 		followerId: user.id, | ||||
| 		followeeId: channel.id, | ||||
| 	}); | ||||
| }); | ||||
							
								
								
									
										93
									
								
								src/server/api/endpoints/channels/update.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								src/server/api/endpoints/channels/update.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,93 @@ | |||
| import $ from 'cafy'; | ||||
| import { ID } from '../../../../misc/cafy-id'; | ||||
| import define from '../../define'; | ||||
| import { ApiError } from '../../error'; | ||||
| import { Channels, DriveFiles } from '../../../../models'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	tags: ['channels'], | ||||
| 
 | ||||
| 	requireCredential: true as const, | ||||
| 
 | ||||
| 	kind: 'write:channels', | ||||
| 
 | ||||
| 	params: { | ||||
| 		channelId: { | ||||
| 			validator: $.type(ID), | ||||
| 		}, | ||||
| 
 | ||||
| 		name: { | ||||
| 			validator: $.optional.str.range(1, 128) | ||||
| 		}, | ||||
| 
 | ||||
| 		description: { | ||||
| 			validator: $.nullable.optional.str.range(1, 2048) | ||||
| 		}, | ||||
| 
 | ||||
| 		bannerId: { | ||||
| 			validator: $.nullable.optional.type(ID), | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	res: { | ||||
| 		type: 'object' as const, | ||||
| 		optional: false as const, nullable: false as const, | ||||
| 		ref: 'Channel', | ||||
| 	}, | ||||
| 
 | ||||
| 	errors: { | ||||
| 		noSuchChannel: { | ||||
| 			message: 'No such channel.', | ||||
| 			code: 'NO_SUCH_CHANNEL', | ||||
| 			id: 'f9c5467f-d492-4c3c-9a8d-a70dacc86512' | ||||
| 		}, | ||||
| 
 | ||||
| 		accessDenied: { | ||||
| 			message: 'You do not have edit privilege of the channel.', | ||||
| 			code: 'ACCESS_DENIED', | ||||
| 			id: '1fb7cb09-d46a-4fdf-b8df-057788cce513' | ||||
| 		}, | ||||
| 
 | ||||
| 		noSuchFile: { | ||||
| 			message: 'No such file.', | ||||
| 			code: 'NO_SUCH_FILE', | ||||
| 			id: 'e86c14a4-0da2-4032-8df3-e737a04c7f3b' | ||||
| 		}, | ||||
| 	} | ||||
| }; | ||||
| 
 | ||||
| export default define(meta, async (ps, me) => { | ||||
| 	const channel = await Channels.findOne({ | ||||
| 		id: ps.channelId, | ||||
| 	}); | ||||
| 
 | ||||
| 	if (channel == null) { | ||||
| 		throw new ApiError(meta.errors.noSuchChannel); | ||||
| 	} | ||||
| 
 | ||||
| 	if (channel.userId !== me.id) { | ||||
| 		throw new ApiError(meta.errors.accessDenied); | ||||
| 	} | ||||
| 
 | ||||
| 	let banner = undefined; | ||||
| 	if (ps.bannerId != null) { | ||||
| 		banner = await DriveFiles.findOne({ | ||||
| 			id: ps.bannerId, | ||||
| 			userId: me.id | ||||
| 		}); | ||||
| 
 | ||||
| 		if (banner == null) { | ||||
| 			throw new ApiError(meta.errors.noSuchFile); | ||||
| 		} | ||||
| 	} else if (ps.bannerId === null) { | ||||
| 		banner = null; | ||||
| 	} | ||||
| 
 | ||||
| 	await Channels.update(channel.id, { | ||||
| 		...(ps.name !== undefined ? { name: ps.name } : {}), | ||||
| 		...(ps.description !== undefined ? { description: ps.description } : {}), | ||||
| 		...(banner ? { bannerId: banner.id } : {}), | ||||
| 	}); | ||||
| 
 | ||||
| 	return await Channels.pack(channel.id, me); | ||||
| }); | ||||
|  | @ -24,7 +24,6 @@ export default define(meta, async (ps, user, token) => { | |||
| 
 | ||||
| 	return await Users.pack(user, user, { | ||||
| 		detail: true, | ||||
| 		includeHasUnreadNotes: true, | ||||
| 		includeSecrets: isSecure | ||||
| 	}); | ||||
| }); | ||||
|  |  | |||
|  | @ -7,11 +7,12 @@ import { fetchMeta } from '../../../../misc/fetch-meta'; | |||
| import { ApiError } from '../../error'; | ||||
| import { ID } from '../../../../misc/cafy-id'; | ||||
| import { User } from '../../../../models/entities/user'; | ||||
| import { Users, DriveFiles, Notes } from '../../../../models'; | ||||
| import { Users, DriveFiles, Notes, Channels } from '../../../../models'; | ||||
| import { DriveFile } from '../../../../models/entities/drive-file'; | ||||
| import { Note } from '../../../../models/entities/note'; | ||||
| import { DB_MAX_NOTE_TEXT_LENGTH } from '../../../../misc/hard-limits'; | ||||
| import { noteVisibilities } from '../../../../types'; | ||||
| import { Channel } from '../../../../models/entities/channel'; | ||||
| 
 | ||||
| let maxNoteTextLength = 500; | ||||
| 
 | ||||
|  | @ -128,19 +129,26 @@ export const meta = { | |||
| 		}, | ||||
| 
 | ||||
| 		replyId: { | ||||
| 			validator: $.optional.type(ID), | ||||
| 			validator: $.optional.nullable.type(ID), | ||||
| 			desc: { | ||||
| 				'ja-JP': '返信対象' | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		renoteId: { | ||||
| 			validator: $.optional.type(ID), | ||||
| 			validator: $.optional.nullable.type(ID), | ||||
| 			desc: { | ||||
| 				'ja-JP': 'Renote対象' | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		channelId: { | ||||
| 			validator: $.optional.nullable.type(ID), | ||||
| 			desc: { | ||||
| 				'ja-JP': 'チャンネル' | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		poll: { | ||||
| 			validator: $.optional.obj({ | ||||
| 				choices: $.arr($.str) | ||||
|  | @ -206,7 +214,13 @@ export const meta = { | |||
| 			message: 'Poll is already expired.', | ||||
| 			code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL', | ||||
| 			id: '04da457d-b083-4055-9082-955525eda5a5' | ||||
| 		} | ||||
| 		}, | ||||
| 
 | ||||
| 		noSuchChannel: { | ||||
| 			message: 'No such channel.', | ||||
| 			code: 'NO_SUCH_CHANNEL', | ||||
| 			id: 'b1653923-5453-4edc-b786-7c4f39bb0bbb' | ||||
| 		}, | ||||
| 	} | ||||
| }; | ||||
| 
 | ||||
|  | @ -269,6 +283,15 @@ export default define(meta, async (ps, user) => { | |||
| 		throw new ApiError(meta.errors.contentRequired); | ||||
| 	} | ||||
| 
 | ||||
| 	let channel: Channel | undefined; | ||||
| 	if (ps.channelId != null) { | ||||
| 		channel = await Channels.findOne(ps.channelId); | ||||
| 
 | ||||
| 		if (channel == null) { | ||||
| 			throw new ApiError(meta.errors.noSuchChannel); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// 投稿を作成
 | ||||
| 	const note = await create(user, { | ||||
| 		createdAt: new Date(), | ||||
|  | @ -286,6 +309,7 @@ export default define(meta, async (ps, user) => { | |||
| 		localOnly: ps.localOnly, | ||||
| 		visibility: ps.visibility, | ||||
| 		visibleUsers, | ||||
| 		channel, | ||||
| 		apMentions: ps.noExtractMentions ? [] : undefined, | ||||
| 		apHashtags: ps.noExtractHashtags ? [] : undefined, | ||||
| 		apEmojis: ps.noExtractEmojis ? [] : undefined, | ||||
|  |  | |||
|  | @ -80,6 +80,7 @@ export default define(meta, async (ps, user) => { | |||
| 	const query = makePaginationQuery(Notes.createQueryBuilder('note'), | ||||
| 			ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) | ||||
| 		.andWhere('note.visibility = \'public\'') | ||||
| 		.andWhere('note.channelId IS NULL') | ||||
| 		.leftJoinAndSelect('note.user', 'user'); | ||||
| 
 | ||||
| 	generateRepliesQuery(query, user); | ||||
|  |  | |||
|  | @ -13,6 +13,7 @@ import { generateRepliesQuery } from '../../common/generate-replies-query'; | |||
| import { injectPromo } from '../../common/inject-promo'; | ||||
| import { injectFeatured } from '../../common/inject-featured'; | ||||
| import { generateMutedNoteQuery } from '../../common/generate-muted-note-query'; | ||||
| import { generateChannelQuery } from '../../common/generate-channel-query'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	desc: { | ||||
|  | @ -131,6 +132,7 @@ export default define(meta, async (ps, user) => { | |||
| 		.leftJoinAndSelect('note.user', 'user') | ||||
| 		.setParameters(followingQuery.getParameters()); | ||||
| 
 | ||||
| 	generateChannelQuery(query, user); | ||||
| 	generateRepliesQuery(query, user); | ||||
| 	generateVisibilityQuery(query, user); | ||||
| 	generateMutedUserQuery(query, user); | ||||
|  |  | |||
|  | @ -13,6 +13,7 @@ import { generateRepliesQuery } from '../../common/generate-replies-query'; | |||
| import { injectPromo } from '../../common/inject-promo'; | ||||
| import { injectFeatured } from '../../common/inject-featured'; | ||||
| import { generateMutedNoteQuery } from '../../common/generate-muted-note-query'; | ||||
| import { generateChannelQuery } from '../../common/generate-channel-query'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	desc: { | ||||
|  | @ -99,6 +100,7 @@ export default define(meta, async (ps, user) => { | |||
| 		.andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)') | ||||
| 		.leftJoinAndSelect('note.user', 'user'); | ||||
| 
 | ||||
| 	generateChannelQuery(query, user); | ||||
| 	generateRepliesQuery(query, user); | ||||
| 	generateVisibilityQuery(query, user); | ||||
| 	if (user) generateMutedUserQuery(query, user); | ||||
|  |  | |||
|  | @ -11,6 +11,7 @@ import { generateRepliesQuery } from '../../common/generate-replies-query'; | |||
| import { injectPromo } from '../../common/inject-promo'; | ||||
| import { injectFeatured } from '../../common/inject-featured'; | ||||
| import { generateMutedNoteQuery } from '../../common/generate-muted-note-query'; | ||||
| import { generateChannelQuery } from '../../common/generate-channel-query'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	desc: { | ||||
|  | @ -124,6 +125,7 @@ export default define(meta, async (ps, user) => { | |||
| 		.leftJoinAndSelect('note.user', 'user') | ||||
| 		.setParameters(followingQuery.getParameters()); | ||||
| 
 | ||||
| 	generateChannelQuery(query, user); | ||||
| 	generateRepliesQuery(query, user); | ||||
| 	generateVisibilityQuery(query, user); | ||||
| 	generateMutedUserQuery(query, user); | ||||
|  |  | |||
|  | @ -27,6 +27,10 @@ export default abstract class Channel { | |||
| 		return this.connection.muting; | ||||
| 	} | ||||
| 
 | ||||
| 	protected get followingChannels() { | ||||
| 		return this.connection.followingChannels; | ||||
| 	} | ||||
| 
 | ||||
| 	protected get subscriber() { | ||||
| 		return this.connection.subscriber; | ||||
| 	} | ||||
|  |  | |||
							
								
								
									
										49
									
								
								src/server/api/stream/channels/channel.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								src/server/api/stream/channels/channel.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,49 @@ | |||
| import autobind from 'autobind-decorator'; | ||||
| import Channel from '../channel'; | ||||
| import { Notes } from '../../../../models'; | ||||
| import { isMutedUserRelated } from '../../../../misc/is-muted-user-related'; | ||||
| import { PackedNote } from '../../../../models/repositories/note'; | ||||
| 
 | ||||
| export default class extends Channel { | ||||
| 	public readonly chName = 'channel'; | ||||
| 	public static shouldShare = false; | ||||
| 	public static requireCredential = false; | ||||
| 	private channelId: string; | ||||
| 
 | ||||
| 	@autobind | ||||
| 	public async init(params: any) { | ||||
| 		this.channelId = params.channelId as string; | ||||
| 
 | ||||
| 		// Subscribe stream
 | ||||
| 		this.subscriber.on('notesStream', this.onNote); | ||||
| 	} | ||||
| 
 | ||||
| 	@autobind | ||||
| 	private async onNote(note: PackedNote) { | ||||
| 		if (note.channelId !== this.channelId) return; | ||||
| 
 | ||||
| 		// リプライなら再pack
 | ||||
| 		if (note.replyId != null) { | ||||
| 			note.reply = await Notes.pack(note.replyId, this.user, { | ||||
| 				detail: true | ||||
| 			}); | ||||
| 		} | ||||
| 		// Renoteなら再pack
 | ||||
| 		if (note.renoteId != null) { | ||||
| 			note.renote = await Notes.pack(note.renoteId, this.user, { | ||||
| 				detail: true | ||||
| 			}); | ||||
| 		} | ||||
| 
 | ||||
| 		// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
 | ||||
| 		if (isMutedUserRelated(note, this.muting)) return; | ||||
| 
 | ||||
| 		this.send('note', note); | ||||
| 	} | ||||
| 
 | ||||
| 	@autobind | ||||
| 	public dispose() { | ||||
| 		// Unsubscribe events
 | ||||
| 		this.subscriber.off('notesStream', this.onNote); | ||||
| 	} | ||||
| } | ||||
|  | @ -25,6 +25,7 @@ export default class extends Channel { | |||
| 	@autobind | ||||
| 	private async onNote(note: PackedNote) { | ||||
| 		if (note.visibility !== 'public') return; | ||||
| 		if (note.channelId != null) return; | ||||
| 
 | ||||
| 		// リプライなら再pack
 | ||||
| 		if (note.replyId != null) { | ||||
|  |  | |||
|  | @ -18,8 +18,12 @@ export default class extends Channel { | |||
| 
 | ||||
| 	@autobind | ||||
| 	private async onNote(note: PackedNote) { | ||||
| 		// その投稿のユーザーをフォローしていなかったら弾く
 | ||||
| 		if (this.user!.id !== note.userId && !this.following.includes(note.userId)) return; | ||||
| 		if (note.channelId) { | ||||
| 			if (!this.followingChannels.includes(note.channelId)) return; | ||||
| 		} else { | ||||
| 			// その投稿のユーザーをフォローしていなかったら弾く
 | ||||
| 			if ((this.user!.id !== note.userId) && !this.following.includes(note.userId)) return; | ||||
| 		} | ||||
| 
 | ||||
| 		if (['followers', 'specified'].includes(note.visibility)) { | ||||
| 			note = await Notes.pack(note.id, this.user!, { | ||||
|  |  | |||
|  | @ -23,11 +23,15 @@ export default class extends Channel { | |||
| 
 | ||||
| 	@autobind | ||||
| 	private async onNote(note: PackedNote) { | ||||
| 		// 自分自身の投稿 または その投稿のユーザーをフォローしている または 全体公開のローカルの投稿 の場合だけ
 | ||||
| 		// チャンネルの投稿ではなく、自分自身の投稿 または
 | ||||
| 		// チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または
 | ||||
| 		// チャンネルの投稿ではなく、全体公開のローカルの投稿 または
 | ||||
| 		// フォローしているチャンネルの投稿 の場合だけ
 | ||||
| 		if (!( | ||||
| 			this.user!.id === note.userId || | ||||
| 			this.following.includes(note.userId) || | ||||
| 			((note.user as PackedUser).host == null && note.visibility === 'public') | ||||
| 			(note.channelId == null && this.user!.id === note.userId) || | ||||
| 			(note.channelId == null && this.following.includes(note.userId)) || | ||||
| 			(note.channelId == null && ((note.user as PackedUser).host == null && note.visibility === 'public')) || | ||||
| 			(note.channelId != null && this.followingChannels.includes(note.channelId)) | ||||
| 		)) return; | ||||
| 
 | ||||
| 		if (['followers', 'specified'].includes(note.visibility)) { | ||||
|  |  | |||
|  | @ -11,6 +11,7 @@ import messaging from './messaging'; | |||
| import messagingIndex from './messaging-index'; | ||||
| import drive from './drive'; | ||||
| import hashtag from './hashtag'; | ||||
| import channel from './channel'; | ||||
| import admin from './admin'; | ||||
| import gamesReversi from './games/reversi'; | ||||
| import gamesReversiGame from './games/reversi-game'; | ||||
|  | @ -29,6 +30,7 @@ export default { | |||
| 	messagingIndex, | ||||
| 	drive, | ||||
| 	hashtag, | ||||
| 	channel, | ||||
| 	admin, | ||||
| 	gamesReversi, | ||||
| 	gamesReversiGame | ||||
|  |  | |||
|  | @ -27,6 +27,7 @@ export default class extends Channel { | |||
| 	private async onNote(note: PackedNote) { | ||||
| 		if ((note.user as PackedUser).host !== null) return; | ||||
| 		if (note.visibility !== 'public') return; | ||||
| 		if (note.channelId != null && !this.followingChannels.includes(note.channelId)) return; | ||||
| 
 | ||||
| 		// リプライなら再pack
 | ||||
| 		if (note.replyId != null) { | ||||
|  |  | |||
|  | @ -7,7 +7,8 @@ import Channel from './channel'; | |||
| import channels from './channels'; | ||||
| import { EventEmitter } from 'events'; | ||||
| import { User } from '../../../models/entities/user'; | ||||
| import { Users, Followings, Mutings, UserProfiles } from '../../../models'; | ||||
| import { Channel as ChannelModel } from '../../../models/entities/channel'; | ||||
| import { Users, Followings, Mutings, UserProfiles, ChannelFollowings } from '../../../models'; | ||||
| import { ApiError } from '../error'; | ||||
| import { AccessToken } from '../../../models/entities/access-token'; | ||||
| import { UserProfile } from '../../../models/entities/user-profile'; | ||||
|  | @ -20,6 +21,7 @@ export default class Connection { | |||
| 	public userProfile?: UserProfile; | ||||
| 	public following: User['id'][] = []; | ||||
| 	public muting: User['id'][] = []; | ||||
| 	public followingChannels: ChannelModel['id'][] = []; | ||||
| 	public token?: AccessToken; | ||||
| 	private wsConnection: websocket.connection; | ||||
| 	public subscriber: EventEmitter; | ||||
|  | @ -27,6 +29,7 @@ export default class Connection { | |||
| 	private subscribingNotes: any = {}; | ||||
| 	private followingClock: NodeJS.Timer; | ||||
| 	private mutingClock: NodeJS.Timer; | ||||
| 	private followingChannelsClock: NodeJS.Timer; | ||||
| 	private userProfileClock: NodeJS.Timer; | ||||
| 
 | ||||
| 	constructor( | ||||
|  | @ -53,6 +56,9 @@ export default class Connection { | |||
| 			this.updateMuting(); | ||||
| 			this.mutingClock = setInterval(this.updateMuting, 5000); | ||||
| 
 | ||||
| 			this.updateFollowingChannels(); | ||||
| 			this.followingChannelsClock = setInterval(this.updateFollowingChannels, 5000); | ||||
| 
 | ||||
| 			this.updateUserProfile(); | ||||
| 			this.userProfileClock = setInterval(this.updateUserProfile, 5000); | ||||
| 		} | ||||
|  | @ -268,6 +274,18 @@ export default class Connection { | |||
| 		this.muting = mutings.map(x => x.muteeId); | ||||
| 	} | ||||
| 
 | ||||
| 	@autobind | ||||
| 	private async updateFollowingChannels() { | ||||
| 		const followings = await ChannelFollowings.find({ | ||||
| 			where: { | ||||
| 				followerId: this.user!.id | ||||
| 			}, | ||||
| 			select: ['followeeId'] | ||||
| 		}); | ||||
| 
 | ||||
| 		this.followingChannels = followings.map(x => x.followeeId); | ||||
| 	} | ||||
| 
 | ||||
| 	@autobind | ||||
| 	private async updateUserProfile() { | ||||
| 		this.userProfile = await UserProfiles.findOne({ | ||||
|  | @ -286,6 +304,7 @@ export default class Connection { | |||
| 
 | ||||
| 		if (this.followingClock) clearInterval(this.followingClock); | ||||
| 		if (this.mutingClock) clearInterval(this.mutingClock); | ||||
| 		if (this.followingChannelsClock) clearInterval(this.followingChannelsClock); | ||||
| 		if (this.userProfileClock) clearInterval(this.userProfileClock); | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -17,7 +17,7 @@ import packFeed from './feed'; | |||
| import { fetchMeta } from '../../misc/fetch-meta'; | ||||
| import { genOpenapiSpec } from '../api/openapi/gen-spec'; | ||||
| import config from '../../config'; | ||||
| import { Users, Notes, Emojis, UserProfiles, Pages } from '../../models'; | ||||
| import { Users, Notes, Emojis, UserProfiles, Pages, Channels } from '../../models'; | ||||
| import parseAcct from '../../misc/acct/parse'; | ||||
| import getNoteSummary from '../../misc/get-note-summary'; | ||||
| import { ensure } from '../../prelude/ensure'; | ||||
|  | @ -188,7 +188,7 @@ router.get('/@:user.json', async ctx => { | |||
| 	} | ||||
| }); | ||||
| 
 | ||||
| //#region for crawlers
 | ||||
| //#region SSR (for crawlers)
 | ||||
| // User
 | ||||
| router.get(['/@:user', '/@:user/:sub'], async (ctx, next) => { | ||||
| 	const { username, host } = parseAcct(ctx.params.user); | ||||
|  | @ -297,6 +297,28 @@ router.get('/@:user/pages/:page', async ctx => { | |||
| 
 | ||||
| 	ctx.status = 404; | ||||
| }); | ||||
| 
 | ||||
| // Channel
 | ||||
| router.get('/channels/:channel', async ctx => { | ||||
| 	const channel = await Channels.findOne({ | ||||
| 		id: ctx.params.channel, | ||||
| 	}); | ||||
| 
 | ||||
| 	if (channel) { | ||||
| 		const _channel = await Channels.pack(channel); | ||||
| 		const meta = await fetchMeta(); | ||||
| 		await ctx.render('channel', { | ||||
| 			channel: _channel, | ||||
| 			instanceName: meta.name || 'Misskey' | ||||
| 		}); | ||||
| 
 | ||||
| 		ctx.set('Cache-Control', 'public, max-age=180'); | ||||
| 
 | ||||
| 		return; | ||||
| 	} | ||||
| 
 | ||||
| 	ctx.status = 404; | ||||
| }); | ||||
| //#endregion
 | ||||
| 
 | ||||
| router.get('/info', async ctx => { | ||||
|  |  | |||
							
								
								
									
										21
									
								
								src/server/web/views/channel.pug
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/server/web/views/channel.pug
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,21 @@ | |||
| extends ./base | ||||
| 
 | ||||
| block vars | ||||
| 	- const title = channel.name; | ||||
| 	- const url = `${config.url}/channels/${channel.id}`; | ||||
| 
 | ||||
| block title | ||||
| 	= `${title} | ${instanceName}` | ||||
| 
 | ||||
| block desc | ||||
| 	meta(name='description' content= channel.description) | ||||
| 
 | ||||
| block og | ||||
| 	meta(property='og:type'        content='article') | ||||
| 	meta(property='og:title'       content= title) | ||||
| 	meta(property='og:description' content= channel.description) | ||||
| 	meta(property='og:url'         content= url) | ||||
| 	meta(property='og:image'       content= channel.bannerUrl) | ||||
| 
 | ||||
| block meta | ||||
| 	meta(name='twitter:card' content='summary') | ||||
|  | @ -28,6 +28,7 @@ export async function addNoteToAntenna(antenna: Antenna, note: Note, noteUser: U | |||
| 			select: ['muteeId'] | ||||
| 		}); | ||||
| 
 | ||||
| 		// Copy
 | ||||
| 		const _note: Note = { | ||||
| 			...note | ||||
| 		}; | ||||
|  |  | |||
|  | @ -6,7 +6,7 @@ import { IdentifiableError } from '../../misc/identifiable-error'; | |||
| import { User } from '../../models/entities/user'; | ||||
| import { Note } from '../../models/entities/note'; | ||||
| import { Notes, UserNotePinings, Users } from '../../models'; | ||||
| import { UserNotePining } from '../../models/entities/user-note-pinings'; | ||||
| import { UserNotePining } from '../../models/entities/user-note-pining'; | ||||
| import { genId } from '../../misc/gen-id'; | ||||
| import { deliverToFollowers } from '../../remote/activitypub/deliver-manager'; | ||||
| import { deliverToRelays } from '../relay'; | ||||
|  |  | |||
|  | @ -17,7 +17,7 @@ import extractMentions from '../../misc/extract-mentions'; | |||
| import extractEmojis from '../../misc/extract-emojis'; | ||||
| import extractHashtags from '../../misc/extract-hashtags'; | ||||
| import { Note, IMentionedRemoteUsers } from '../../models/entities/note'; | ||||
| import { Mutings, Users, NoteWatchings, Notes, Instances, UserProfiles, Antennas, Followings, MutedNotes } from '../../models'; | ||||
| import { Mutings, Users, NoteWatchings, Notes, Instances, UserProfiles, Antennas, Followings, MutedNotes, Channels, ChannelFollowings } from '../../models'; | ||||
| import { DriveFile } from '../../models/entities/drive-file'; | ||||
| import { App } from '../../models/entities/app'; | ||||
| import { Not, getConnection, In } from 'typeorm'; | ||||
|  | @ -33,6 +33,7 @@ import { checkWordMute } from '../../misc/check-word-mute'; | |||
| import { addNoteToAntenna } from '../add-note-to-antenna'; | ||||
| import { countSameRenotes } from '../../misc/count-same-renotes'; | ||||
| import { deliverToRelays } from '../relay'; | ||||
| import { Channel } from '../../models/entities/channel'; | ||||
| 
 | ||||
| type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; | ||||
| 
 | ||||
|  | @ -102,6 +103,7 @@ type Option = { | |||
| 	cw?: string | null; | ||||
| 	visibility?: string; | ||||
| 	visibleUsers?: User[] | null; | ||||
| 	channel?: Channel | null; | ||||
| 	apMentions?: User[] | null; | ||||
| 	apHashtags?: string[] | null; | ||||
| 	apEmojis?: string[] | null; | ||||
|  | @ -111,13 +113,31 @@ type Option = { | |||
| }; | ||||
| 
 | ||||
| export default async (user: User, data: Option, silent = false) => new Promise<Note>(async (res, rej) => { | ||||
| 	// チャンネル外にリプライしたら対象のスコープに合わせる
 | ||||
| 	// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
 | ||||
| 	if (data.reply && data.channel && data.reply.channelId !== data.channel.id) { | ||||
| 		if (data.reply.channelId) { | ||||
| 			data.channel = await Channels.findOne(data.reply.channelId); | ||||
| 		} else { | ||||
| 			data.channel = null; | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// チャンネル内にリプライしたら対象のスコープに合わせる
 | ||||
| 	// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
 | ||||
| 	if (data.reply && (data.channel == null) && data.reply.channelId) { | ||||
| 		data.channel = await Channels.findOne(data.reply.channelId); | ||||
| 	} | ||||
| 
 | ||||
| 	if (data.createdAt == null) data.createdAt = new Date(); | ||||
| 	if (data.visibility == null) data.visibility = 'public'; | ||||
| 	if (data.viaMobile == null) data.viaMobile = false; | ||||
| 	if (data.localOnly == null) data.localOnly = false; | ||||
| 	if (data.channel != null) data.visibility = 'public'; | ||||
| 	if (data.channel != null) data.visibleUsers = []; | ||||
| 
 | ||||
| 	// サイレンス
 | ||||
| 	if (user.isSilenced && data.visibility === 'public') { | ||||
| 	if (user.isSilenced && data.visibility === 'public' && data.channel == null) { | ||||
| 		data.visibility = 'home'; | ||||
| 	} | ||||
| 
 | ||||
|  | @ -142,12 +162,12 @@ export default async (user: User, data: Option, silent = false) => new Promise<N | |||
| 	} | ||||
| 
 | ||||
| 	// ローカルのみをRenoteしたらローカルのみにする
 | ||||
| 	if (data.renote && data.renote.localOnly) { | ||||
| 	if (data.renote && data.renote.localOnly && data.channel == null) { | ||||
| 		data.localOnly = true; | ||||
| 	} | ||||
| 
 | ||||
| 	// ローカルのみにリプライしたらローカルのみにする
 | ||||
| 	if (data.reply && data.reply.localOnly) { | ||||
| 	if (data.reply && data.reply.localOnly && data.channel == null) { | ||||
| 		data.localOnly = true; | ||||
| 	} | ||||
| 
 | ||||
|  | @ -255,6 +275,18 @@ export default async (user: User, data: Option, silent = false) => new Promise<N | |||
| 		} | ||||
| 	}); | ||||
| 
 | ||||
| 	// Channel
 | ||||
| 	if (note.channelId) { | ||||
| 		ChannelFollowings.find({ followeeId: note.channelId }).then(followings => { | ||||
| 			for (const following of followings) { | ||||
| 				insertNoteUnread(following.followerId, note, { | ||||
| 					isSpecified: false, | ||||
| 					isMentioned: false, | ||||
| 				}); | ||||
| 			} | ||||
| 		}); | ||||
| 	} | ||||
| 
 | ||||
| 	if (data.reply) { | ||||
| 		saveReply(data.reply, note); | ||||
| 	} | ||||
|  | @ -273,11 +305,23 @@ export default async (user: User, data: Option, silent = false) => new Promise<N | |||
| 			if (data.visibleUsers == null) throw new Error('invalid param'); | ||||
| 
 | ||||
| 			for (const u of data.visibleUsers) { | ||||
| 				insertNoteUnread(u, note, true); | ||||
| 				// ローカルユーザーのみ
 | ||||
| 				if (!Users.isLocalUser(u)) continue; | ||||
| 
 | ||||
| 				insertNoteUnread(u.id, note, { | ||||
| 					isSpecified: true, | ||||
| 					isMentioned: false, | ||||
| 				}); | ||||
| 			} | ||||
| 		} else { | ||||
| 			for (const u of mentionedUsers) { | ||||
| 				insertNoteUnread(u, note, false); | ||||
| 				// ローカルユーザーのみ
 | ||||
| 				if (!Users.isLocalUser(u)) continue; | ||||
| 
 | ||||
| 				insertNoteUnread(u.id, note, { | ||||
| 					isSpecified: false, | ||||
| 					isMentioned: true, | ||||
| 				}); | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
|  | @ -379,6 +423,24 @@ export default async (user: User, data: Option, silent = false) => new Promise<N | |||
| 		//#endregion
 | ||||
| 	} | ||||
| 
 | ||||
| 	if (data.channel) { | ||||
| 		Channels.increment({ id: data.channel.id }, 'notesCount', 1); | ||||
| 		Channels.update(data.channel.id, { | ||||
| 			lastNotedAt: new Date(), | ||||
| 		}); | ||||
| 
 | ||||
| 		Notes.count({ | ||||
| 			userId: user.id, | ||||
| 			channelId: data.channel.id, | ||||
| 		}).then(count => { | ||||
| 			// この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる
 | ||||
| 			// TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい
 | ||||
| 			if (count === 1) { | ||||
| 				Channels.increment({ id: data.channel.id }, 'usersCount', 1); | ||||
| 			} | ||||
| 		}); | ||||
| 	} | ||||
| 
 | ||||
| 	// Register to search database
 | ||||
| 	index(note); | ||||
| }); | ||||
|  | @ -405,6 +467,7 @@ async function insertNote(user: User, data: Option, tags: string[], emojis: stri | |||
| 		fileIds: data.files ? data.files.map(file => file.id) : [], | ||||
| 		replyId: data.reply ? data.reply.id : null, | ||||
| 		renoteId: data.renote ? data.renote.id : null, | ||||
| 		channelId: data.channel ? data.channel.id : null, | ||||
| 		name: data.name, | ||||
| 		text: data.text, | ||||
| 		hasPoll: data.poll != null, | ||||
|  |  | |||
|  | @ -2,71 +2,104 @@ import { publishMainStream } from '../stream'; | |||
| import { Note } from '../../models/entities/note'; | ||||
| import { User } from '../../models/entities/user'; | ||||
| import { NoteUnreads, Antennas, AntennaNotes, Users } from '../../models'; | ||||
| 
 | ||||
| // TODO: 状態が変化していない場合は各種イベントを送信しない
 | ||||
| import { Not, IsNull } from 'typeorm'; | ||||
| 
 | ||||
| /** | ||||
|  * Mark a note as read | ||||
|  */ | ||||
| export default ( | ||||
| export default async function( | ||||
| 	userId: User['id'], | ||||
| 	noteId: Note['id'] | ||||
| ) => new Promise<any>(async (resolve, reject) => { | ||||
| 	// Remove document
 | ||||
| 	/*const res = */await NoteUnreads.delete({ | ||||
| 		userId: userId, | ||||
| 		noteId: noteId | ||||
| 	}); | ||||
| 
 | ||||
| 	// v11 TODO: https://github.com/typeorm/typeorm/issues/2415
 | ||||
| 	//if (res.affected === 0) {
 | ||||
| 	//	return;
 | ||||
| 	//}
 | ||||
| 
 | ||||
| 	const [count1, count2] = await Promise.all([ | ||||
| 		NoteUnreads.count({ | ||||
| ) { | ||||
| 	async function careNoteUnreads() { | ||||
| 		const exist = await NoteUnreads.findOne({ | ||||
| 			userId: userId, | ||||
| 			isSpecified: false | ||||
| 		}), | ||||
| 		NoteUnreads.count({ | ||||
| 			userId: userId, | ||||
| 			isSpecified: true | ||||
| 		}) | ||||
| 	]); | ||||
| 
 | ||||
| 	if (count1 === 0) { | ||||
| 		// 全て既読になったイベントを発行
 | ||||
| 		publishMainStream(userId, 'readAllUnreadMentions'); | ||||
| 	} | ||||
| 
 | ||||
| 	if (count2 === 0) { | ||||
| 		// 全て既読になったイベントを発行
 | ||||
| 		publishMainStream(userId, 'readAllUnreadSpecifiedNotes'); | ||||
| 	} | ||||
| 
 | ||||
| 	const antennas = await Antennas.find({ userId }); | ||||
| 
 | ||||
| 	await Promise.all(antennas.map(async antenna => { | ||||
| 		await AntennaNotes.update({ | ||||
| 			antennaId: antenna.id, | ||||
| 			noteId: noteId | ||||
| 		}, { | ||||
| 			read: true | ||||
| 			noteId: noteId, | ||||
| 		}); | ||||
| 
 | ||||
| 		const count = await AntennaNotes.count({ | ||||
| 			antennaId: antenna.id, | ||||
| 			read: false | ||||
| 		if (!exist) return; | ||||
| 
 | ||||
| 		// Remove the record
 | ||||
| 		await NoteUnreads.delete({ | ||||
| 			userId: userId, | ||||
| 			noteId: noteId, | ||||
| 		}); | ||||
| 
 | ||||
| 		if (count === 0) { | ||||
| 			publishMainStream(userId, 'readAntenna', antenna); | ||||
| 		if (exist.isMentioned) { | ||||
| 			NoteUnreads.count({ | ||||
| 				userId: userId, | ||||
| 				isMentioned: true | ||||
| 			}).then(mentionsCount => { | ||||
| 				if (mentionsCount === 0) { | ||||
| 					// 全て既読になったイベントを発行
 | ||||
| 					publishMainStream(userId, 'readAllUnreadMentions'); | ||||
| 				} | ||||
| 			}); | ||||
| 		} | ||||
| 	})); | ||||
| 
 | ||||
| 	Users.getHasUnreadAntenna(userId).then(unread => { | ||||
| 		if (!unread) { | ||||
| 			publishMainStream(userId, 'readAllAntennas'); | ||||
| 		if (exist.isSpecified) { | ||||
| 			NoteUnreads.count({ | ||||
| 				userId: userId, | ||||
| 				isSpecified: true | ||||
| 			}).then(specifiedCount => { | ||||
| 				if (specifiedCount === 0) { | ||||
| 					// 全て既読になったイベントを発行
 | ||||
| 					publishMainStream(userId, 'readAllUnreadSpecifiedNotes'); | ||||
| 				} | ||||
| 			}); | ||||
| 		} | ||||
| 	}); | ||||
| }); | ||||
| 
 | ||||
| 		if (exist.noteChannelId) { | ||||
| 			NoteUnreads.count({ | ||||
| 				userId: userId, | ||||
| 				noteChannelId: Not(IsNull()) | ||||
| 			}).then(channelNoteCount => { | ||||
| 				if (channelNoteCount === 0) { | ||||
| 					// 全て既読になったイベントを発行
 | ||||
| 					publishMainStream(userId, 'readAllChannels'); | ||||
| 				} | ||||
| 			}); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	async function careAntenna() { | ||||
| 		const beforeUnread = await Users.getHasUnreadAntenna(userId); | ||||
| 		if (!beforeUnread) return; | ||||
| 
 | ||||
| 		const antennas = await Antennas.find({ userId }); | ||||
| 
 | ||||
| 		await Promise.all(antennas.map(async antenna => { | ||||
| 			const countBefore = await AntennaNotes.count({ | ||||
| 				antennaId: antenna.id, | ||||
| 				read: false | ||||
| 			}); | ||||
| 
 | ||||
| 			if (countBefore === 0) return; | ||||
| 
 | ||||
| 			await AntennaNotes.update({ | ||||
| 				antennaId: antenna.id, | ||||
| 				noteId: noteId | ||||
| 			}, { | ||||
| 				read: true | ||||
| 			}); | ||||
| 
 | ||||
| 			const countAfter = await AntennaNotes.count({ | ||||
| 				antennaId: antenna.id, | ||||
| 				read: false | ||||
| 			}); | ||||
| 
 | ||||
| 			if (countAfter === 0) { | ||||
| 				publishMainStream(userId, 'readAntenna', antenna); | ||||
| 			} | ||||
| 		})); | ||||
| 
 | ||||
| 		Users.getHasUnreadAntenna(userId).then(unread => { | ||||
| 			if (!unread) { | ||||
| 				publishMainStream(userId, 'readAllAntennas'); | ||||
| 			} | ||||
| 		}); | ||||
| 	} | ||||
| 
 | ||||
| 	careNoteUnreads(); | ||||
| 	careAntenna(); | ||||
| } | ||||
|  |  | |||
|  | @ -1,16 +1,18 @@ | |||
| import { Note } from '../../models/entities/note'; | ||||
| import { publishMainStream } from '../stream'; | ||||
| import { User } from '../../models/entities/user'; | ||||
| import { Mutings, NoteUnreads, Users } from '../../models'; | ||||
| import { Mutings, NoteUnreads } from '../../models'; | ||||
| import { genId } from '../../misc/gen-id'; | ||||
| 
 | ||||
| export default async function(user: User, note: Note, isSpecified = false) { | ||||
| 	// ローカルユーザーのみ
 | ||||
| 	if (!Users.isLocalUser(user)) return; | ||||
| 
 | ||||
| export default async function(userId: User['id'], note: Note, params: { | ||||
| 	// NOTE: isSpecifiedがtrueならisMentionedは必ずfalse
 | ||||
| 	isSpecified: boolean; | ||||
| 	isMentioned: boolean; | ||||
| }) { | ||||
| 	//#region ミュートしているなら無視
 | ||||
| 	// TODO: 現在の仕様ではChannelにミュートは適用されないのでよしなにケアする
 | ||||
| 	const mute = await Mutings.find({ | ||||
| 		muterId: user.id | ||||
| 		muterId: userId | ||||
| 	}); | ||||
| 	if (mute.map(m => m.muteeId).includes(note.userId)) return; | ||||
| 	//#endregion
 | ||||
|  | @ -18,20 +20,27 @@ export default async function(user: User, note: Note, isSpecified = false) { | |||
| 	const unread = await NoteUnreads.save({ | ||||
| 		id: genId(), | ||||
| 		noteId: note.id, | ||||
| 		userId: user.id, | ||||
| 		isSpecified, | ||||
| 		noteUserId: note.userId | ||||
| 		userId: userId, | ||||
| 		isSpecified: params.isSpecified, | ||||
| 		isMentioned: params.isMentioned, | ||||
| 		noteChannelId: note.channelId, | ||||
| 		noteUserId: note.userId, | ||||
| 	}); | ||||
| 
 | ||||
| 	// 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する
 | ||||
| 	setTimeout(async () => { | ||||
| 		const exist = await NoteUnreads.findOne(unread.id); | ||||
| 
 | ||||
| 		if (exist == null) return; | ||||
| 
 | ||||
| 		publishMainStream(user.id, 'unreadMention', note.id); | ||||
| 
 | ||||
| 		if (isSpecified) { | ||||
| 			publishMainStream(user.id, 'unreadSpecifiedNote', note.id); | ||||
| 		if (params.isMentioned) { | ||||
| 			publishMainStream(userId, 'unreadMention', note.id); | ||||
| 		} | ||||
| 		if (params.isSpecified) { | ||||
| 			publishMainStream(userId, 'unreadSpecifiedNote', note.id); | ||||
| 		} | ||||
| 		if (note.channelId) { | ||||
| 			publishMainStream(userId, 'unreadChannel', note.id); | ||||
| 		} | ||||
| 	}, 2000); | ||||
| } | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue