wip: refactor(client): migrate paging components to composition api
This commit is contained in:
		
							parent
							
								
									27778f839a
								
							
						
					
					
						commit
						28193f12ca
					
				
					 14 changed files with 791 additions and 1596 deletions
				
			
		|  | @ -10,13 +10,13 @@ | ||||||
| 					<XCwButton v-model="showContent" :note="note"/> | 					<XCwButton v-model="showContent" :note="note"/> | ||||||
| 				</p> | 				</p> | ||||||
| 				<div v-show="note.cw == null || showContent" class="content"> | 				<div v-show="note.cw == null || showContent" class="content"> | ||||||
| 					<XSubNote-content class="text" :note="note"/> | 					<MkNoteSubNoteContent class="text" :note="note"/> | ||||||
| 				</div> | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
| 		</div> | 		</div> | ||||||
| 	</div> | 	</div> | ||||||
| 	<template v-if="depth < 5"> | 	<template v-if="depth < 5"> | ||||||
| 		<XSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :depth="depth + 1"/> | 		<MkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :depth="depth + 1"/> | ||||||
| 	</template> | 	</template> | ||||||
| 	<div v-else class="more"> | 	<div v-else class="more"> | ||||||
| 		<MkA class="text _link" :to="notePage(note)">{{ $ts.continueThread }} <i class="fas fa-angle-double-right"></i></MkA> | 		<MkA class="text _link" :to="notePage(note)">{{ $ts.continueThread }} <i class="fas fa-angle-double-right"></i></MkA> | ||||||
|  | @ -24,63 +24,36 @@ | ||||||
| </div> | </div> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script lang="ts"> | <script lang="ts" setup> | ||||||
| import { defineComponent } from 'vue'; | import { } from 'vue'; | ||||||
|  | import * as misskey from 'misskey-js'; | ||||||
| import { notePage } from '@/filters/note'; | import { notePage } from '@/filters/note'; | ||||||
| import XNoteHeader from './note-header.vue'; | import XNoteHeader from './note-header.vue'; | ||||||
| import XSubNoteContent from './sub-note-content.vue'; | import MkNoteSubNoteContent from './sub-note-content.vue'; | ||||||
| import XCwButton from './cw-button.vue'; | import XCwButton from './cw-button.vue'; | ||||||
| import * as os from '@/os'; | import * as os from '@/os'; | ||||||
| 
 | 
 | ||||||
| export default defineComponent({ | const props = withDefaults(defineProps<{ | ||||||
| 	name: 'XSub', | 	note: misskey.entities.Note; | ||||||
|  | 	detail?: boolean; | ||||||
| 
 | 
 | ||||||
| 	components: { | 	// how many notes are in between this one and the note being viewed in detail | ||||||
| 		XNoteHeader, | 	depth?: number; | ||||||
| 		XSubNoteContent, | }>(), { | ||||||
| 		XCwButton, | 	depth: 1, | ||||||
| 	}, |  | ||||||
| 
 |  | ||||||
| 	props: { |  | ||||||
| 		note: { |  | ||||||
| 			type: Object, |  | ||||||
| 			required: true |  | ||||||
| 		}, |  | ||||||
| 		detail: { |  | ||||||
| 			type: Boolean, |  | ||||||
| 			required: false, |  | ||||||
| 			default: false |  | ||||||
| 		}, |  | ||||||
| 		// how many notes are in between this one and the note being viewed in detail |  | ||||||
| 		depth: { |  | ||||||
| 			type: Number, |  | ||||||
| 			required: false, |  | ||||||
| 			default: 1 |  | ||||||
| 		}, |  | ||||||
| 	}, |  | ||||||
| 
 |  | ||||||
| 	data() { |  | ||||||
| 		return { |  | ||||||
| 			showContent: false, |  | ||||||
| 			replies: [], |  | ||||||
| 		}; |  | ||||||
| 	}, |  | ||||||
| 
 |  | ||||||
| 	created() { |  | ||||||
| 		if (this.detail) { |  | ||||||
| 			os.api('notes/children', { |  | ||||||
| 				noteId: this.note.id, |  | ||||||
| 				limit: 5 |  | ||||||
| 			}).then(replies => { |  | ||||||
| 				this.replies = replies; |  | ||||||
| 			}); |  | ||||||
| 		} |  | ||||||
| 	}, |  | ||||||
| 
 |  | ||||||
| 	methods: { |  | ||||||
| 		notePage, |  | ||||||
| 	} |  | ||||||
| }); | }); | ||||||
|  | 
 | ||||||
|  | let showContent = $ref(false); | ||||||
|  | let replies: misskey.entities.Note[] = $ref([]); | ||||||
|  | 
 | ||||||
|  | if (props.detail) { | ||||||
|  | 	os.api('notes/children', { | ||||||
|  | 		noteId: props.note.id, | ||||||
|  | 		limit: 5 | ||||||
|  | 	}).then(res => { | ||||||
|  | 		replies = res; | ||||||
|  | 	}); | ||||||
|  | } | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <style lang="scss" scoped> | <style lang="scss" scoped> | ||||||
|  | @ -8,8 +8,8 @@ | ||||||
| 	:tabindex="!isDeleted ? '-1' : null" | 	:tabindex="!isDeleted ? '-1' : null" | ||||||
| 	:class="{ renote: isRenote }" | 	:class="{ renote: isRenote }" | ||||||
| > | > | ||||||
| 	<XSub v-for="note in conversation" :key="note.id" class="reply-to-more" :note="note"/> | 	<MkNoteSub v-for="note in conversation" :key="note.id" class="reply-to-more" :note="note"/> | ||||||
| 	<XSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/> | 	<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/> | ||||||
| 	<div v-if="isRenote" class="renote"> | 	<div v-if="isRenote" class="renote"> | ||||||
| 		<MkAvatar class="avatar" :user="note.user"/> | 		<MkAvatar class="avatar" :user="note.user"/> | ||||||
| 		<i class="fas fa-retweet"></i> | 		<i class="fas fa-retweet"></i> | ||||||
|  | @ -107,7 +107,7 @@ | ||||||
| 			</footer> | 			</footer> | ||||||
| 		</div> | 		</div> | ||||||
| 	</article> | 	</article> | ||||||
| 	<XSub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/> | 	<MkNoteSub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/> | ||||||
| </div> | </div> | ||||||
| <div v-else class="_panel muted" @click="muted = false"> | <div v-else class="_panel muted" @click="muted = false"> | ||||||
| 	<I18n :src="$ts.userSaysSomething" tag="small"> | 	<I18n :src="$ts.userSaysSomething" tag="small"> | ||||||
|  | @ -120,765 +120,171 @@ | ||||||
| </div> | </div> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script lang="ts"> | <script lang="ts" setup> | ||||||
| import { defineAsyncComponent, defineComponent, markRaw } from 'vue'; | import { computed, inject, onMounted, onUnmounted, reactive, ref } from 'vue'; | ||||||
| import * as mfm from 'mfm-js'; | import * as mfm from 'mfm-js'; | ||||||
| import { sum } from '@/scripts/array'; | import * as misskey from 'misskey-js'; | ||||||
| import XSub from './note.sub.vue'; | import MkNoteSub from './MkNoteSub.vue'; | ||||||
| import XNoteHeader from './note-header.vue'; |  | ||||||
| import XNoteSimple from './note-simple.vue'; | import XNoteSimple from './note-simple.vue'; | ||||||
| import XReactionsViewer from './reactions-viewer.vue'; | import XReactionsViewer from './reactions-viewer.vue'; | ||||||
| import XMediaList from './media-list.vue'; | import XMediaList from './media-list.vue'; | ||||||
| import XCwButton from './cw-button.vue'; | import XCwButton from './cw-button.vue'; | ||||||
| import XPoll from './poll.vue'; | import XPoll from './poll.vue'; | ||||||
| import XRenoteButton from './renote-button.vue'; | import XRenoteButton from './renote-button.vue'; | ||||||
|  | import MkUrlPreview from '@/components/url-preview.vue'; | ||||||
|  | import MkInstanceTicker from '@/components/instance-ticker.vue'; | ||||||
| import { pleaseLogin } from '@/scripts/please-login'; | import { pleaseLogin } from '@/scripts/please-login'; | ||||||
| import { focusPrev, focusNext } from '@/scripts/focus'; |  | ||||||
| import { url } from '@/config'; |  | ||||||
| import copyToClipboard from '@/scripts/copy-to-clipboard'; |  | ||||||
| import { checkWordMute } from '@/scripts/check-word-mute'; | import { checkWordMute } from '@/scripts/check-word-mute'; | ||||||
| import { userPage } from '@/filters/user'; | import { userPage } from '@/filters/user'; | ||||||
| import { notePage } from '@/filters/note'; | import { notePage } from '@/filters/note'; | ||||||
| import * as os from '@/os'; | import * as os from '@/os'; | ||||||
| import { stream } from '@/stream'; | import { defaultStore, noteViewInterruptors } from '@/store'; | ||||||
| import { noteActions, noteViewInterruptors } from '@/store'; |  | ||||||
| import { reactionPicker } from '@/scripts/reaction-picker'; | import { reactionPicker } from '@/scripts/reaction-picker'; | ||||||
| import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; | import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; | ||||||
| 
 | import { $i } from '@/account'; | ||||||
| // TODO: note.vueとほぼ同じなので共通化したい | import { i18n } from '@/i18n'; | ||||||
| export default defineComponent({ | import { getNoteMenu } from '@/scripts/get-note-menu'; | ||||||
| 	components: { | import { useNoteCapture } from '@/scripts/use-note-capture'; | ||||||
| 		XSub, | 
 | ||||||
| 		XNoteHeader, | const props = defineProps<{ | ||||||
| 		XNoteSimple, | 	note: misskey.entities.Note; | ||||||
| 		XReactionsViewer, | 	pinned?: boolean; | ||||||
| 		XMediaList, | }>(); | ||||||
| 		XCwButton, | 
 | ||||||
| 		XPoll, | const inChannel = inject('inChannel', null); | ||||||
| 		XRenoteButton, | 
 | ||||||
| 		MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')), | const isRenote = ( | ||||||
| 		MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')), | 	props.note.renote != null && | ||||||
| 	}, | 	props.note.text == null && | ||||||
| 
 | 	props.note.fileIds.length === 0 && | ||||||
| 	inject: { | 	props.note.poll == null | ||||||
| 		inChannel: { | ); | ||||||
| 			default: null | 
 | ||||||
| 		}, | const el = ref<HTMLElement>(); | ||||||
| 	}, | const menuButton = ref<HTMLElement>(); | ||||||
| 
 | const renoteButton = ref<InstanceType<typeof XRenoteButton>>(); | ||||||
| 	props: { | const renoteTime = ref<HTMLElement>(); | ||||||
| 		note: { | const reactButton = ref<HTMLElement>(); | ||||||
| 			type: Object, | let appearNote = $ref(isRenote ? props.note.renote as misskey.entities.Note : props.note); | ||||||
| 			required: true | const isMyRenote = $i && ($i.id === props.note.userId); | ||||||
| 		}, | const showContent = ref(false); | ||||||
| 	}, | const isDeleted = ref(false); | ||||||
| 
 | const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords)); | ||||||
| 	emits: ['update:note'], | const translation = ref(null); | ||||||
| 
 | const translating = ref(false); | ||||||
| 	data() { | const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null; | ||||||
| 		return { | const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); | ||||||
| 			connection: null, | const conversation = ref<misskey.entities.Note[]>([]); | ||||||
| 			conversation: [], | const replies = ref<misskey.entities.Note[]>([]); | ||||||
| 			replies: [], | 
 | ||||||
| 			showContent: false, | const keymap = { | ||||||
| 			isDeleted: false, | 	'r': () => reply(true), | ||||||
| 			muted: false, | 	'e|a|plus': () => react(true), | ||||||
| 			translation: null, | 	'q': () => renoteButton.value.renote(true), | ||||||
| 			translating: false, | 	'esc': blur, | ||||||
| 			notePage, | 	'm|o': () => menu(true), | ||||||
| 		}; | 	's': () => showContent.value != showContent.value, | ||||||
| 	}, | }; | ||||||
| 
 | 
 | ||||||
| 	computed: { | useNoteCapture({ | ||||||
| 		rs() { | 	appearNote: $$(appearNote), | ||||||
| 			return this.$store.state.reactions; | 	rootEl: el, | ||||||
| 		}, |  | ||||||
| 		keymap(): any { |  | ||||||
| 			return { |  | ||||||
| 				'r': () => this.reply(true), |  | ||||||
| 				'e|a|plus': () => this.react(true), |  | ||||||
| 				'q': () => this.$refs.renoteButton.renote(true), |  | ||||||
| 				'f|b': this.favorite, |  | ||||||
| 				'delete|ctrl+d': this.del, |  | ||||||
| 				'ctrl+q': this.renoteDirectly, |  | ||||||
| 				'up|k|shift+tab': this.focusBefore, |  | ||||||
| 				'down|j|tab': this.focusAfter, |  | ||||||
| 				'esc': this.blur, |  | ||||||
| 				'm|o': () => this.menu(true), |  | ||||||
| 				's': this.toggleShowContent, |  | ||||||
| 				'1': () => this.reactDirectly(this.rs[0]), |  | ||||||
| 				'2': () => this.reactDirectly(this.rs[1]), |  | ||||||
| 				'3': () => this.reactDirectly(this.rs[2]), |  | ||||||
| 				'4': () => this.reactDirectly(this.rs[3]), |  | ||||||
| 				'5': () => this.reactDirectly(this.rs[4]), |  | ||||||
| 				'6': () => this.reactDirectly(this.rs[5]), |  | ||||||
| 				'7': () => this.reactDirectly(this.rs[6]), |  | ||||||
| 				'8': () => this.reactDirectly(this.rs[7]), |  | ||||||
| 				'9': () => this.reactDirectly(this.rs[8]), |  | ||||||
| 				'0': () => this.reactDirectly(this.rs[9]), |  | ||||||
| 			}; |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		isRenote(): boolean { |  | ||||||
| 			return (this.note.renote && |  | ||||||
| 				this.note.text == null && |  | ||||||
| 				this.note.fileIds.length == 0 && |  | ||||||
| 				this.note.poll == null); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		appearNote(): any { |  | ||||||
| 			return this.isRenote ? this.note.renote : this.note; |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		isMyNote(): boolean { |  | ||||||
| 			return this.$i && (this.$i.id === this.appearNote.userId); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		isMyRenote(): boolean { |  | ||||||
| 			return this.$i && (this.$i.id === this.note.userId); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		reactionsCount(): number { |  | ||||||
| 			return this.appearNote.reactions |  | ||||||
| 				? sum(Object.values(this.appearNote.reactions)) |  | ||||||
| 				: 0; |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		urls(): string[] { |  | ||||||
| 			if (this.appearNote.text) { |  | ||||||
| 				return extractUrlFromMfm(mfm.parse(this.appearNote.text)); |  | ||||||
| 			} else { |  | ||||||
| 				return null; |  | ||||||
| 			} |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		showTicker() { |  | ||||||
| 			if (this.$store.state.instanceTicker === 'always') return true; |  | ||||||
| 			if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true; |  | ||||||
| 			return false; |  | ||||||
| 		} |  | ||||||
| 	}, |  | ||||||
| 
 |  | ||||||
| 	async created() { |  | ||||||
| 		if (this.$i) { |  | ||||||
| 			this.connection = stream; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords); |  | ||||||
| 
 |  | ||||||
| 		// plugin |  | ||||||
| 		if (noteViewInterruptors.length > 0) { |  | ||||||
| 			let result = this.note; |  | ||||||
| 			for (const interruptor of noteViewInterruptors) { |  | ||||||
| 				result = await interruptor.handler(JSON.parse(JSON.stringify(result))); |  | ||||||
| 			} |  | ||||||
| 			this.$emit('update:note', Object.freeze(result)); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		os.api('notes/children', { |  | ||||||
| 			noteId: this.appearNote.id, |  | ||||||
| 			limit: 30 |  | ||||||
| 		}).then(replies => { |  | ||||||
| 			this.replies = replies; |  | ||||||
| 		}); |  | ||||||
| 
 |  | ||||||
| 		if (this.appearNote.replyId) { |  | ||||||
| 			os.api('notes/conversation', { |  | ||||||
| 				noteId: this.appearNote.replyId |  | ||||||
| 			}).then(conversation => { |  | ||||||
| 				this.conversation = conversation.reverse(); |  | ||||||
| 			}); |  | ||||||
| 		} |  | ||||||
| 	}, |  | ||||||
| 
 |  | ||||||
| 	mounted() { |  | ||||||
| 		this.capture(true); |  | ||||||
| 
 |  | ||||||
| 		if (this.$i) { |  | ||||||
| 			this.connection.on('_connected_', this.onStreamConnected); |  | ||||||
| 		} |  | ||||||
| 	}, |  | ||||||
| 
 |  | ||||||
| 	beforeUnmount() { |  | ||||||
| 		this.decapture(true); |  | ||||||
| 
 |  | ||||||
| 		if (this.$i) { |  | ||||||
| 			this.connection.off('_connected_', this.onStreamConnected); |  | ||||||
| 		} |  | ||||||
| 	}, |  | ||||||
| 
 |  | ||||||
| 	methods: { |  | ||||||
| 		updateAppearNote(v) { |  | ||||||
| 			this.$emit('update:note', Object.freeze(this.isRenote ? { |  | ||||||
| 				...this.note, |  | ||||||
| 				renote: { |  | ||||||
| 					...this.note.renote, |  | ||||||
| 					...v |  | ||||||
| 				} |  | ||||||
| 			} : { |  | ||||||
| 				...this.note, |  | ||||||
| 				...v |  | ||||||
| 			})); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		readPromo() { |  | ||||||
| 			os.api('promo/read', { |  | ||||||
| 				noteId: this.appearNote.id |  | ||||||
| 			}); |  | ||||||
| 			this.isDeleted = true; |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		capture(withHandler = false) { |  | ||||||
| 			if (this.$i) { |  | ||||||
| 				// TODO: このノートがストリーミング経由で流れてきた場合のみ sr する |  | ||||||
| 				this.connection.send(document.body.contains(this.$el) ? 'sr' : 's', { id: this.appearNote.id }); |  | ||||||
| 				if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated); |  | ||||||
| 			} |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		decapture(withHandler = false) { |  | ||||||
| 			if (this.$i) { |  | ||||||
| 				this.connection.send('un', { |  | ||||||
| 					id: this.appearNote.id |  | ||||||
| 				}); |  | ||||||
| 				if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated); |  | ||||||
| 			} |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		onStreamConnected() { |  | ||||||
| 			this.capture(); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		onStreamNoteUpdated(data) { |  | ||||||
| 			const { type, id, body } = data; |  | ||||||
| 
 |  | ||||||
| 			if (id !== this.appearNote.id) return; |  | ||||||
| 
 |  | ||||||
| 			switch (type) { |  | ||||||
| 				case 'reacted': { |  | ||||||
| 					const reaction = body.reaction; |  | ||||||
| 
 |  | ||||||
| 					// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) |  | ||||||
| 					let n = { |  | ||||||
| 						...this.appearNote, |  | ||||||
| 					}; |  | ||||||
| 
 |  | ||||||
| 					if (body.emoji) { |  | ||||||
| 						const emojis = this.appearNote.emojis || []; |  | ||||||
| 						if (!emojis.includes(body.emoji)) { |  | ||||||
| 							n.emojis = [...emojis, body.emoji]; |  | ||||||
| 						} |  | ||||||
| 					} |  | ||||||
| 
 |  | ||||||
| 					// TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる |  | ||||||
| 					const currentCount = (this.appearNote.reactions || {})[reaction] || 0; |  | ||||||
| 
 |  | ||||||
| 					// Increment the count |  | ||||||
| 					n.reactions = { |  | ||||||
| 						...this.appearNote.reactions, |  | ||||||
| 						[reaction]: currentCount + 1 |  | ||||||
| 					}; |  | ||||||
| 
 |  | ||||||
| 					if (body.userId === this.$i.id) { |  | ||||||
| 						n.myReaction = reaction; |  | ||||||
| 					} |  | ||||||
| 
 |  | ||||||
| 					this.updateAppearNote(n); |  | ||||||
| 					break; |  | ||||||
| 				} |  | ||||||
| 
 |  | ||||||
| 				case 'unreacted': { |  | ||||||
| 					const reaction = body.reaction; |  | ||||||
| 
 |  | ||||||
| 					// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) |  | ||||||
| 					let n = { |  | ||||||
| 						...this.appearNote, |  | ||||||
| 					}; |  | ||||||
| 
 |  | ||||||
| 					// TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる |  | ||||||
| 					const currentCount = (this.appearNote.reactions || {})[reaction] || 0; |  | ||||||
| 
 |  | ||||||
| 					// Decrement the count |  | ||||||
| 					n.reactions = { |  | ||||||
| 						...this.appearNote.reactions, |  | ||||||
| 						[reaction]: Math.max(0, currentCount - 1) |  | ||||||
| 					}; |  | ||||||
| 
 |  | ||||||
| 					if (body.userId === this.$i.id) { |  | ||||||
| 						n.myReaction = null; |  | ||||||
| 					} |  | ||||||
| 
 |  | ||||||
| 					this.updateAppearNote(n); |  | ||||||
| 					break; |  | ||||||
| 				} |  | ||||||
| 
 |  | ||||||
| 				case 'pollVoted': { |  | ||||||
| 					const choice = body.choice; |  | ||||||
| 
 |  | ||||||
| 					// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) |  | ||||||
| 					let n = { |  | ||||||
| 						...this.appearNote, |  | ||||||
| 					}; |  | ||||||
| 
 |  | ||||||
| 					const choices = [...this.appearNote.poll.choices]; |  | ||||||
| 					choices[choice] = { |  | ||||||
| 						...choices[choice], |  | ||||||
| 						votes: choices[choice].votes + 1, |  | ||||||
| 						...(body.userId === this.$i.id ? { |  | ||||||
| 							isVoted: true |  | ||||||
| 						} : {}) |  | ||||||
| 					}; |  | ||||||
| 
 |  | ||||||
| 					n.poll = { |  | ||||||
| 						...this.appearNote.poll, |  | ||||||
| 						choices: choices |  | ||||||
| 					}; |  | ||||||
| 
 |  | ||||||
| 					this.updateAppearNote(n); |  | ||||||
| 					break; |  | ||||||
| 				} |  | ||||||
| 
 |  | ||||||
| 				case 'deleted': { |  | ||||||
| 					this.isDeleted = true; |  | ||||||
| 					break; |  | ||||||
| 				} |  | ||||||
| 			} |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		reply(viaKeyboard = false) { |  | ||||||
| 			pleaseLogin(); |  | ||||||
| 			os.post({ |  | ||||||
| 				reply: this.appearNote, |  | ||||||
| 				animation: !viaKeyboard, |  | ||||||
| 			}, () => { |  | ||||||
| 				this.focus(); |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		renoteDirectly() { |  | ||||||
| 			os.apiWithDialog('notes/create', { |  | ||||||
| 				renoteId: this.appearNote.id |  | ||||||
| 			}, undefined, (res: any) => { |  | ||||||
| 				os.alert({ |  | ||||||
| 					type: 'success', |  | ||||||
| 					text: this.$ts.renoted, |  | ||||||
| 				}); |  | ||||||
| 			}, (e: Error) => { |  | ||||||
| 				if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') { |  | ||||||
| 					os.alert({ |  | ||||||
| 						type: 'error', |  | ||||||
| 						text: this.$ts.cantRenote, |  | ||||||
| 					}); |  | ||||||
| 				} else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') { |  | ||||||
| 					os.alert({ |  | ||||||
| 						type: 'error', |  | ||||||
| 						text: this.$ts.cantReRenote, |  | ||||||
| 					}); |  | ||||||
| 				} |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		react(viaKeyboard = false) { |  | ||||||
| 			pleaseLogin(); |  | ||||||
| 			this.blur(); |  | ||||||
| 			reactionPicker.show(this.$refs.reactButton, reaction => { |  | ||||||
| 				os.api('notes/reactions/create', { |  | ||||||
| 					noteId: this.appearNote.id, |  | ||||||
| 					reaction: reaction |  | ||||||
| 				}); |  | ||||||
| 			}, () => { |  | ||||||
| 				this.focus(); |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		reactDirectly(reaction) { |  | ||||||
| 			os.api('notes/reactions/create', { |  | ||||||
| 				noteId: this.appearNote.id, |  | ||||||
| 				reaction: reaction |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		undoReact(note) { |  | ||||||
| 			const oldReaction = note.myReaction; |  | ||||||
| 			if (!oldReaction) return; |  | ||||||
| 			os.api('notes/reactions/delete', { |  | ||||||
| 				noteId: note.id |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		favorite() { |  | ||||||
| 			pleaseLogin(); |  | ||||||
| 			os.apiWithDialog('notes/favorites/create', { |  | ||||||
| 				noteId: this.appearNote.id |  | ||||||
| 			}, undefined, (res: any) => { |  | ||||||
| 				os.alert({ |  | ||||||
| 					type: 'success', |  | ||||||
| 					text: this.$ts.favorited, |  | ||||||
| 				}); |  | ||||||
| 			}, (e: Error) => { |  | ||||||
| 				if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') { |  | ||||||
| 					os.alert({ |  | ||||||
| 						type: 'error', |  | ||||||
| 						text: this.$ts.alreadyFavorited, |  | ||||||
| 					}); |  | ||||||
| 				} else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') { |  | ||||||
| 					os.alert({ |  | ||||||
| 						type: 'error', |  | ||||||
| 						text: this.$ts.cantFavorite, |  | ||||||
| 					}); |  | ||||||
| 				} |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		del() { |  | ||||||
| 			os.confirm({ |  | ||||||
| 				type: 'warning', |  | ||||||
| 				text: this.$ts.noteDeleteConfirm, |  | ||||||
| 			}).then(({ canceled }) => { |  | ||||||
| 				if (canceled) return; |  | ||||||
| 
 |  | ||||||
| 				os.api('notes/delete', { |  | ||||||
| 					noteId: this.appearNote.id |  | ||||||
| 				}); |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		delEdit() { |  | ||||||
| 			os.confirm({ |  | ||||||
| 				type: 'warning', |  | ||||||
| 				text: this.$ts.deleteAndEditConfirm, |  | ||||||
| 			}).then(({ canceled }) => { |  | ||||||
| 				if (canceled) return; |  | ||||||
| 
 |  | ||||||
| 				os.api('notes/delete', { |  | ||||||
| 					noteId: this.appearNote.id |  | ||||||
| 				}); |  | ||||||
| 
 |  | ||||||
| 				os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel }); |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		toggleFavorite(favorite: boolean) { |  | ||||||
| 			os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { |  | ||||||
| 				noteId: this.appearNote.id |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		toggleWatch(watch: boolean) { |  | ||||||
| 			os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', { |  | ||||||
| 				noteId: this.appearNote.id |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		toggleThreadMute(mute: boolean) { |  | ||||||
| 			os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', { |  | ||||||
| 				noteId: this.appearNote.id |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		getMenu() { |  | ||||||
| 			let menu; |  | ||||||
| 			if (this.$i) { |  | ||||||
| 				const statePromise = os.api('notes/state', { |  | ||||||
| 					noteId: this.appearNote.id |  | ||||||
| 				}); |  | ||||||
| 
 |  | ||||||
| 				menu = [{ |  | ||||||
| 					icon: 'fas fa-copy', |  | ||||||
| 					text: this.$ts.copyContent, |  | ||||||
| 					action: this.copyContent |  | ||||||
| 				}, { |  | ||||||
| 					icon: 'fas fa-link', |  | ||||||
| 					text: this.$ts.copyLink, |  | ||||||
| 					action: this.copyLink |  | ||||||
| 				}, (this.appearNote.url || this.appearNote.uri) ? { |  | ||||||
| 					icon: 'fas fa-external-link-square-alt', |  | ||||||
| 					text: this.$ts.showOnRemote, |  | ||||||
| 					action: () => { |  | ||||||
| 						window.open(this.appearNote.url || this.appearNote.uri, '_blank'); |  | ||||||
| 					} |  | ||||||
| 				} : undefined, |  | ||||||
| 				{ |  | ||||||
| 					icon: 'fas fa-share-alt', |  | ||||||
| 					text: this.$ts.share, |  | ||||||
| 					action: this.share |  | ||||||
| 				}, |  | ||||||
| 				this.$instance.translatorAvailable ? { |  | ||||||
| 					icon: 'fas fa-language', |  | ||||||
| 					text: this.$ts.translate, |  | ||||||
| 					action: this.translate |  | ||||||
| 				} : undefined, |  | ||||||
| 				null, |  | ||||||
| 				statePromise.then(state => state.isFavorited ? { |  | ||||||
| 					icon: 'fas fa-star', |  | ||||||
| 					text: this.$ts.unfavorite, |  | ||||||
| 					action: () => this.toggleFavorite(false) |  | ||||||
| 				} : { |  | ||||||
| 					icon: 'fas fa-star', |  | ||||||
| 					text: this.$ts.favorite, |  | ||||||
| 					action: () => this.toggleFavorite(true) |  | ||||||
| 				}), |  | ||||||
| 				{ |  | ||||||
| 					icon: 'fas fa-paperclip', |  | ||||||
| 					text: this.$ts.clip, |  | ||||||
| 					action: () => this.clip() |  | ||||||
| 				}, |  | ||||||
| 				(this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? { |  | ||||||
| 					icon: 'fas fa-eye-slash', |  | ||||||
| 					text: this.$ts.unwatch, |  | ||||||
| 					action: () => this.toggleWatch(false) |  | ||||||
| 				} : { |  | ||||||
| 					icon: 'fas fa-eye', |  | ||||||
| 					text: this.$ts.watch, |  | ||||||
| 					action: () => this.toggleWatch(true) |  | ||||||
| 				}) : undefined, |  | ||||||
| 				statePromise.then(state => state.isMutedThread ? { |  | ||||||
| 					icon: 'fas fa-comment-slash', |  | ||||||
| 					text: this.$ts.unmuteThread, |  | ||||||
| 					action: () => this.toggleThreadMute(false) |  | ||||||
| 				} : { |  | ||||||
| 					icon: 'fas fa-comment-slash', |  | ||||||
| 					text: this.$ts.muteThread, |  | ||||||
| 					action: () => this.toggleThreadMute(true) |  | ||||||
| 				}), |  | ||||||
| 				this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? { |  | ||||||
| 					icon: 'fas fa-thumbtack', |  | ||||||
| 					text: this.$ts.unpin, |  | ||||||
| 					action: () => this.togglePin(false) |  | ||||||
| 				} : { |  | ||||||
| 					icon: 'fas fa-thumbtack', |  | ||||||
| 					text: this.$ts.pin, |  | ||||||
| 					action: () => this.togglePin(true) |  | ||||||
| 				} : undefined, |  | ||||||
| 				/*...(this.$i.isModerator || this.$i.isAdmin ? [ |  | ||||||
| 					null, |  | ||||||
| 					{ |  | ||||||
| 						icon: 'fas fa-bullhorn', |  | ||||||
| 						text: this.$ts.promote, |  | ||||||
| 						action: this.promote |  | ||||||
| 					}] |  | ||||||
| 					: [] |  | ||||||
| 				),*/ |  | ||||||
| 				...(this.appearNote.userId != this.$i.id ? [ |  | ||||||
| 					null, |  | ||||||
| 					{ |  | ||||||
| 						icon: 'fas fa-exclamation-circle', |  | ||||||
| 						text: this.$ts.reportAbuse, |  | ||||||
| 						action: () => { |  | ||||||
| 							const u = `${url}/notes/${this.appearNote.id}`; |  | ||||||
| 							os.popup(import('@/components/abuse-report-window.vue'), { |  | ||||||
| 								user: this.appearNote.user, |  | ||||||
| 								initialComment: `Note: ${u}\n-----\n` |  | ||||||
| 							}, {}, 'closed'); |  | ||||||
| 						} |  | ||||||
| 					}] |  | ||||||
| 					: [] |  | ||||||
| 				), |  | ||||||
| 				...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [ |  | ||||||
| 					null, |  | ||||||
| 					this.appearNote.userId == this.$i.id ? { |  | ||||||
| 						icon: 'fas fa-edit', |  | ||||||
| 						text: this.$ts.deleteAndEdit, |  | ||||||
| 						action: this.delEdit |  | ||||||
| 					} : undefined, |  | ||||||
| 					{ |  | ||||||
| 						icon: 'fas fa-trash-alt', |  | ||||||
| 						text: this.$ts.delete, |  | ||||||
| 						danger: true, |  | ||||||
| 						action: this.del |  | ||||||
| 					}] |  | ||||||
| 					: [] |  | ||||||
| 				)] |  | ||||||
| 				.filter(x => x !== undefined); |  | ||||||
| 			} else { |  | ||||||
| 				menu = [{ |  | ||||||
| 					icon: 'fas fa-copy', |  | ||||||
| 					text: this.$ts.copyContent, |  | ||||||
| 					action: this.copyContent |  | ||||||
| 				}, { |  | ||||||
| 					icon: 'fas fa-link', |  | ||||||
| 					text: this.$ts.copyLink, |  | ||||||
| 					action: this.copyLink |  | ||||||
| 				}, (this.appearNote.url || this.appearNote.uri) ? { |  | ||||||
| 					icon: 'fas fa-external-link-square-alt', |  | ||||||
| 					text: this.$ts.showOnRemote, |  | ||||||
| 					action: () => { |  | ||||||
| 						window.open(this.appearNote.url || this.appearNote.uri, '_blank'); |  | ||||||
| 					} |  | ||||||
| 				} : undefined] |  | ||||||
| 				.filter(x => x !== undefined); |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			if (noteActions.length > 0) { |  | ||||||
| 				menu = menu.concat([null, ...noteActions.map(action => ({ |  | ||||||
| 					icon: 'fas fa-plug', |  | ||||||
| 					text: action.title, |  | ||||||
| 					action: () => { |  | ||||||
| 						action.handler(this.appearNote); |  | ||||||
| 					} |  | ||||||
| 				}))]); |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			return menu; |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		onContextmenu(e) { |  | ||||||
| 			const isLink = (el: HTMLElement) => { |  | ||||||
| 				if (el.tagName === 'A') return true; |  | ||||||
| 				if (el.parentElement) { |  | ||||||
| 					return isLink(el.parentElement); |  | ||||||
| 				} |  | ||||||
| 			}; |  | ||||||
| 			if (isLink(e.target)) return; |  | ||||||
| 			if (window.getSelection().toString() !== '') return; |  | ||||||
| 
 |  | ||||||
| 			if (this.$store.state.useReactionPickerForContextMenu) { |  | ||||||
| 				e.preventDefault(); |  | ||||||
| 				this.react(); |  | ||||||
| 			} else { |  | ||||||
| 				os.contextMenu(this.getMenu(), e).then(this.focus); |  | ||||||
| 			} |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		menu(viaKeyboard = false) { |  | ||||||
| 			os.popupMenu(this.getMenu(), this.$refs.menuButton, { |  | ||||||
| 				viaKeyboard |  | ||||||
| 			}).then(this.focus); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		showRenoteMenu(viaKeyboard = false) { |  | ||||||
| 			if (!this.isMyRenote) return; |  | ||||||
| 			os.popupMenu([{ |  | ||||||
| 				text: this.$ts.unrenote, |  | ||||||
| 				icon: 'fas fa-trash-alt', |  | ||||||
| 				danger: true, |  | ||||||
| 				action: () => { |  | ||||||
| 					os.api('notes/delete', { |  | ||||||
| 						noteId: this.note.id |  | ||||||
| 					}); |  | ||||||
| 					this.isDeleted = true; |  | ||||||
| 				} |  | ||||||
| 			}], this.$refs.renoteTime, { |  | ||||||
| 				viaKeyboard: viaKeyboard |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		toggleShowContent() { |  | ||||||
| 			this.showContent = !this.showContent; |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		copyContent() { |  | ||||||
| 			copyToClipboard(this.appearNote.text); |  | ||||||
| 			os.success(); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		copyLink() { |  | ||||||
| 			copyToClipboard(`${url}/notes/${this.appearNote.id}`); |  | ||||||
| 			os.success(); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		togglePin(pin: boolean) { |  | ||||||
| 			os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', { |  | ||||||
| 				noteId: this.appearNote.id |  | ||||||
| 			}, undefined, null, e => { |  | ||||||
| 				if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') { |  | ||||||
| 					os.alert({ |  | ||||||
| 						type: 'error', |  | ||||||
| 						text: this.$ts.pinLimitExceeded |  | ||||||
| 					}); |  | ||||||
| 				} |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		async clip() { |  | ||||||
| 			const clips = await os.api('clips/list'); |  | ||||||
| 			os.popupMenu([{ |  | ||||||
| 				icon: 'fas fa-plus', |  | ||||||
| 				text: this.$ts.createNew, |  | ||||||
| 				action: async () => { |  | ||||||
| 					const { canceled, result } = await os.form(this.$ts.createNewClip, { |  | ||||||
| 						name: { |  | ||||||
| 							type: 'string', |  | ||||||
| 							label: this.$ts.name |  | ||||||
| 						}, |  | ||||||
| 						description: { |  | ||||||
| 							type: 'string', |  | ||||||
| 							required: false, |  | ||||||
| 							multiline: true, |  | ||||||
| 							label: this.$ts.description |  | ||||||
| 						}, |  | ||||||
| 						isPublic: { |  | ||||||
| 							type: 'boolean', |  | ||||||
| 							label: this.$ts.public, |  | ||||||
| 							default: false |  | ||||||
| 						} |  | ||||||
| 					}); |  | ||||||
| 					if (canceled) return; |  | ||||||
| 
 |  | ||||||
| 					const clip = await os.apiWithDialog('clips/create', result); |  | ||||||
| 
 |  | ||||||
| 					os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id }); |  | ||||||
| 				} |  | ||||||
| 			}, null, ...clips.map(clip => ({ |  | ||||||
| 				text: clip.name, |  | ||||||
| 				action: () => { |  | ||||||
| 					os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id }); |  | ||||||
| 				} |  | ||||||
| 			}))], this.$refs.menuButton, { |  | ||||||
| 			}).then(this.focus); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		async promote() { |  | ||||||
| 			const { canceled, result: days } = await os.inputNumber({ |  | ||||||
| 				title: this.$ts.numberOfDays, |  | ||||||
| 			}); |  | ||||||
| 
 |  | ||||||
| 			if (canceled) return; |  | ||||||
| 
 |  | ||||||
| 			os.apiWithDialog('admin/promo/create', { |  | ||||||
| 				noteId: this.appearNote.id, |  | ||||||
| 				expiresAt: Date.now() + (86400000 * days) |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		share() { |  | ||||||
| 			navigator.share({ |  | ||||||
| 				title: this.$t('noteOf', { user: this.appearNote.user.name }), |  | ||||||
| 				text: this.appearNote.text, |  | ||||||
| 				url: `${url}/notes/${this.appearNote.id}` |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		async translate() { |  | ||||||
| 			if (this.translation != null) return; |  | ||||||
| 			this.translating = true; |  | ||||||
| 			const res = await os.api('notes/translate', { |  | ||||||
| 				noteId: this.appearNote.id, |  | ||||||
| 				targetLang: localStorage.getItem('lang') || navigator.language, |  | ||||||
| 			}); |  | ||||||
| 			this.translating = false; |  | ||||||
| 			this.translation = res; |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		focus() { |  | ||||||
| 			this.$el.focus(); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		blur() { |  | ||||||
| 			this.$el.blur(); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		focusBefore() { |  | ||||||
| 			focusPrev(this.$el); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		focusAfter() { |  | ||||||
| 			focusNext(this.$el); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		userPage |  | ||||||
| 	} |  | ||||||
| }); | }); | ||||||
|  | 
 | ||||||
|  | function reply(viaKeyboard = false): void { | ||||||
|  | 	pleaseLogin(); | ||||||
|  | 	os.post({ | ||||||
|  | 		reply: appearNote, | ||||||
|  | 		animation: !viaKeyboard, | ||||||
|  | 	}, () => { | ||||||
|  | 		focus(); | ||||||
|  | 	}); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function react(viaKeyboard = false): void { | ||||||
|  | 	pleaseLogin(); | ||||||
|  | 	blur(); | ||||||
|  | 	reactionPicker.show(reactButton.value, reaction => { | ||||||
|  | 		os.api('notes/reactions/create', { | ||||||
|  | 			noteId: appearNote.id, | ||||||
|  | 			reaction: reaction | ||||||
|  | 		}); | ||||||
|  | 	}, () => { | ||||||
|  | 		focus(); | ||||||
|  | 	}); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function undoReact(note): void { | ||||||
|  | 	const oldReaction = note.myReaction; | ||||||
|  | 	if (!oldReaction) return; | ||||||
|  | 	os.api('notes/reactions/delete', { | ||||||
|  | 		noteId: note.id | ||||||
|  | 	}); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function onContextmenu(e): void { | ||||||
|  | 	const isLink = (el: HTMLElement) => { | ||||||
|  | 		if (el.tagName === 'A') return true; | ||||||
|  | 		if (el.parentElement) { | ||||||
|  | 			return isLink(el.parentElement); | ||||||
|  | 		} | ||||||
|  | 	}; | ||||||
|  | 	if (isLink(e.target)) return; | ||||||
|  | 	if (window.getSelection().toString() !== '') return; | ||||||
|  | 
 | ||||||
|  | 	if (defaultStore.state.useReactionPickerForContextMenu) { | ||||||
|  | 		e.preventDefault(); | ||||||
|  | 		react(); | ||||||
|  | 	} else { | ||||||
|  | 		os.contextMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), e).then(focus); | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function menu(viaKeyboard = false): void { | ||||||
|  | 	os.popupMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), menuButton.value, { | ||||||
|  | 		viaKeyboard | ||||||
|  | 	}).then(focus); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function showRenoteMenu(viaKeyboard = false): void { | ||||||
|  | 	if (!isMyRenote) return; | ||||||
|  | 	os.popupMenu([{ | ||||||
|  | 		text: i18n.locale.unrenote, | ||||||
|  | 		icon: 'fas fa-trash-alt', | ||||||
|  | 		danger: true, | ||||||
|  | 		action: () => { | ||||||
|  | 			os.api('notes/delete', { | ||||||
|  | 				noteId: props.note.id | ||||||
|  | 			}); | ||||||
|  | 			isDeleted.value = true; | ||||||
|  | 		} | ||||||
|  | 	}], renoteTime.value, { | ||||||
|  | 		viaKeyboard: viaKeyboard | ||||||
|  | 	}); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function focus() { | ||||||
|  | 	el.value.focus(); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function blur() { | ||||||
|  | 	el.value.blur(); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | os.api('notes/children', { | ||||||
|  | 	noteId: appearNote.id, | ||||||
|  | 	limit: 30 | ||||||
|  | }).then(res => { | ||||||
|  | 	replies.value = res; | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | if (appearNote.replyId) { | ||||||
|  | 	os.api('notes/conversation', { | ||||||
|  | 		noteId: appearNote.replyId | ||||||
|  | 	}).then(res => { | ||||||
|  | 		conversation.value = res.reverse(); | ||||||
|  | 	}); | ||||||
|  | } | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <style lang="scss" scoped> | <style lang="scss" scoped> | ||||||
|  |  | ||||||
|  | @ -19,30 +19,16 @@ | ||||||
| </header> | </header> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script lang="ts"> | <script lang="ts" setup> | ||||||
| import { defineComponent } from 'vue'; | import { } from 'vue'; | ||||||
|  | import * as misskey from 'misskey-js'; | ||||||
| import { notePage } from '@/filters/note'; | import { notePage } from '@/filters/note'; | ||||||
| import { userPage } from '@/filters/user'; | import { userPage } from '@/filters/user'; | ||||||
| import * as os from '@/os'; |  | ||||||
| 
 | 
 | ||||||
| export default defineComponent({ | defineProps<{ | ||||||
| 	props: { | 	note: misskey.entities.Note; | ||||||
| 		note: { | 	pinned?: boolean; | ||||||
| 			type: Object, | }>(); | ||||||
| 			required: true |  | ||||||
| 		}, |  | ||||||
| 	}, |  | ||||||
| 
 |  | ||||||
| 	data() { |  | ||||||
| 		return { |  | ||||||
| 		}; |  | ||||||
| 	}, |  | ||||||
| 
 |  | ||||||
| 	methods: { |  | ||||||
| 		notePage, |  | ||||||
| 		userPage |  | ||||||
| 	} |  | ||||||
| }); |  | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <style lang="scss" scoped> | <style lang="scss" scoped> | ||||||
|  |  | ||||||
|  | @ -9,7 +9,7 @@ | ||||||
| 				<XCwButton v-model="showContent" :note="note"/> | 				<XCwButton v-model="showContent" :note="note"/> | ||||||
| 			</p> | 			</p> | ||||||
| 			<div v-show="note.cw == null || showContent" class="content"> | 			<div v-show="note.cw == null || showContent" class="content"> | ||||||
| 				<XSubNote-content class="text" :note="note"/> | 				<MkNoteSubNoteContent class="text" :note="note"/> | ||||||
| 			</div> | 			</div> | ||||||
| 		</div> | 		</div> | ||||||
| 	</div> | 	</div> | ||||||
|  | @ -19,14 +19,14 @@ | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| import { defineComponent } from 'vue'; | import { defineComponent } from 'vue'; | ||||||
| import XNoteHeader from './note-header.vue'; | import XNoteHeader from './note-header.vue'; | ||||||
| import XSubNoteContent from './sub-note-content.vue'; | import MkNoteSubNoteContent from './sub-note-content.vue'; | ||||||
| import XCwButton from './cw-button.vue'; | import XCwButton from './cw-button.vue'; | ||||||
| import * as os from '@/os'; | import * as os from '@/os'; | ||||||
| 
 | 
 | ||||||
| export default defineComponent({ | export default defineComponent({ | ||||||
| 	components: { | 	components: { | ||||||
| 		XNoteHeader, | 		XNoteHeader, | ||||||
| 		XSubNoteContent, | 		MkNoteSubNoteContent, | ||||||
| 		XCwButton, | 		XCwButton, | ||||||
| 	}, | 	}, | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -2,20 +2,21 @@ | ||||||
| <div | <div | ||||||
| 	v-if="!muted" | 	v-if="!muted" | ||||||
| 	v-show="!isDeleted" | 	v-show="!isDeleted" | ||||||
|  | 	ref="el" | ||||||
| 	v-hotkey="keymap" | 	v-hotkey="keymap" | ||||||
| 	v-size="{ max: [500, 450, 350, 300] }" | 	v-size="{ max: [500, 450, 350, 300] }" | ||||||
| 	class="tkcbzcuz" | 	class="tkcbzcuz" | ||||||
| 	:tabindex="!isDeleted ? '-1' : null" | 	:tabindex="!isDeleted ? '-1' : null" | ||||||
| 	:class="{ renote: isRenote }" | 	:class="{ renote: isRenote }" | ||||||
| > | > | ||||||
| 	<XSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/> | 	<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/> | ||||||
| 	<div v-if="pinned" class="info"><i class="fas fa-thumbtack"></i> {{ $ts.pinnedNote }}</div> | 	<div v-if="pinned" class="info"><i class="fas fa-thumbtack"></i> {{ i18n.locale.pinnedNote }}</div> | ||||||
| 	<div v-if="appearNote._prId_" class="info"><i class="fas fa-bullhorn"></i> {{ $ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ $ts.hideThisNote }} <i class="fas fa-times"></i></button></div> | 	<div v-if="appearNote._prId_" class="info"><i class="fas fa-bullhorn"></i> {{ i18n.locale.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.locale.hideThisNote }} <i class="fas fa-times"></i></button></div> | ||||||
| 	<div v-if="appearNote._featuredId_" class="info"><i class="fas fa-bolt"></i> {{ $ts.featured }}</div> | 	<div v-if="appearNote._featuredId_" class="info"><i class="fas fa-bolt"></i> {{ i18n.locale.featured }}</div> | ||||||
| 	<div v-if="isRenote" class="renote"> | 	<div v-if="isRenote" class="renote"> | ||||||
| 		<MkAvatar class="avatar" :user="note.user"/> | 		<MkAvatar class="avatar" :user="note.user"/> | ||||||
| 		<i class="fas fa-retweet"></i> | 		<i class="fas fa-retweet"></i> | ||||||
| 		<I18n :src="$ts.renotedBy" tag="span"> | 		<I18n :src="i18n.locale.renotedBy" tag="span"> | ||||||
| 			<template #user> | 			<template #user> | ||||||
| 				<MkA v-user-preview="note.userId" class="name" :to="userPage(note.user)"> | 				<MkA v-user-preview="note.userId" class="name" :to="userPage(note.user)"> | ||||||
| 					<MkUserName :user="note.user"/> | 					<MkUserName :user="note.user"/> | ||||||
|  | @ -47,7 +48,7 @@ | ||||||
| 				</p> | 				</p> | ||||||
| 				<div v-show="appearNote.cw == null || showContent" class="content" :class="{ collapsed }"> | 				<div v-show="appearNote.cw == null || showContent" class="content" :class="{ collapsed }"> | ||||||
| 					<div class="text"> | 					<div class="text"> | ||||||
| 						<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $ts.private }})</span> | 						<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.locale.private }})</span> | ||||||
| 						<MkA v-if="appearNote.replyId" class="reply" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA> | 						<MkA v-if="appearNote.replyId" class="reply" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA> | ||||||
| 						<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> | 						<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> | ||||||
| 						<a v-if="appearNote.renote != null" class="rp">RN:</a> | 						<a v-if="appearNote.renote != null" class="rp">RN:</a> | ||||||
|  | @ -66,7 +67,7 @@ | ||||||
| 					<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" class="url-preview"/> | 					<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" class="url-preview"/> | ||||||
| 					<div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote"/></div> | 					<div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote"/></div> | ||||||
| 					<button v-if="collapsed" class="fade _button" @click="collapsed = false"> | 					<button v-if="collapsed" class="fade _button" @click="collapsed = false"> | ||||||
| 						<span>{{ $ts.showMore }}</span> | 						<span>{{ i18n.locale.showMore }}</span> | ||||||
| 					</button> | 					</button> | ||||||
| 				</div> | 				</div> | ||||||
| 				<MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="fas fa-satellite-dish"></i> {{ appearNote.channel.name }}</MkA> | 				<MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="fas fa-satellite-dish"></i> {{ appearNote.channel.name }}</MkA> | ||||||
|  | @ -93,7 +94,7 @@ | ||||||
| 	</article> | 	</article> | ||||||
| </div> | </div> | ||||||
| <div v-else class="muted" @click="muted = false"> | <div v-else class="muted" @click="muted = false"> | ||||||
| 	<I18n :src="$ts.userSaysSomething" tag="small"> | 	<I18n :src="i18n.locale.userSaysSomething" tag="small"> | ||||||
| 		<template #name> | 		<template #name> | ||||||
| 			<MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)"> | 			<MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)"> | ||||||
| 				<MkUserName :user="appearNote.user"/> | 				<MkUserName :user="appearNote.user"/> | ||||||
|  | @ -103,11 +104,11 @@ | ||||||
| </div> | </div> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script lang="ts"> | <script lang="ts" setup> | ||||||
| import { defineAsyncComponent, defineComponent, markRaw } from 'vue'; | import { computed, inject, onMounted, onUnmounted, reactive, ref } from 'vue'; | ||||||
| import * as mfm from 'mfm-js'; | import * as mfm from 'mfm-js'; | ||||||
| import { sum } from '@/scripts/array'; | import * as misskey from 'misskey-js'; | ||||||
| import XSub from './note.sub.vue'; | import MkNoteSub from './MkNoteSub.vue'; | ||||||
| import XNoteHeader from './note-header.vue'; | import XNoteHeader from './note-header.vue'; | ||||||
| import XNoteSimple from './note-simple.vue'; | import XNoteSimple from './note-simple.vue'; | ||||||
| import XReactionsViewer from './reactions-viewer.vue'; | import XReactionsViewer from './reactions-viewer.vue'; | ||||||
|  | @ -115,745 +116,164 @@ import XMediaList from './media-list.vue'; | ||||||
| import XCwButton from './cw-button.vue'; | import XCwButton from './cw-button.vue'; | ||||||
| import XPoll from './poll.vue'; | import XPoll from './poll.vue'; | ||||||
| import XRenoteButton from './renote-button.vue'; | import XRenoteButton from './renote-button.vue'; | ||||||
|  | import MkUrlPreview from '@/components/url-preview.vue'; | ||||||
|  | import MkInstanceTicker from '@/components/instance-ticker.vue'; | ||||||
| import { pleaseLogin } from '@/scripts/please-login'; | import { pleaseLogin } from '@/scripts/please-login'; | ||||||
| import { focusPrev, focusNext } from '@/scripts/focus'; | import { focusPrev, focusNext } from '@/scripts/focus'; | ||||||
| import { url } from '@/config'; |  | ||||||
| import copyToClipboard from '@/scripts/copy-to-clipboard'; |  | ||||||
| import { checkWordMute } from '@/scripts/check-word-mute'; | import { checkWordMute } from '@/scripts/check-word-mute'; | ||||||
| import { userPage } from '@/filters/user'; | import { userPage } from '@/filters/user'; | ||||||
| import * as os from '@/os'; | import * as os from '@/os'; | ||||||
| import { stream } from '@/stream'; | import { defaultStore, noteViewInterruptors } from '@/store'; | ||||||
| import { noteActions, noteViewInterruptors } from '@/store'; |  | ||||||
| import { reactionPicker } from '@/scripts/reaction-picker'; | import { reactionPicker } from '@/scripts/reaction-picker'; | ||||||
| import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; | import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; | ||||||
| 
 | import { $i } from '@/account'; | ||||||
| export default defineComponent({ | import { i18n } from '@/i18n'; | ||||||
| 	components: { | import { getNoteMenu } from '@/scripts/get-note-menu'; | ||||||
| 		XSub, | import { useNoteCapture } from '@/scripts/use-note-capture'; | ||||||
| 		XNoteHeader, | 
 | ||||||
| 		XNoteSimple, | const props = defineProps<{ | ||||||
| 		XReactionsViewer, | 	note: misskey.entities.Note; | ||||||
| 		XMediaList, | 	pinned?: boolean; | ||||||
| 		XCwButton, | }>(); | ||||||
| 		XPoll, | 
 | ||||||
| 		XRenoteButton, | const inChannel = inject('inChannel', null); | ||||||
| 		MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')), | 
 | ||||||
| 		MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')), | const isRenote = ( | ||||||
| 	}, | 	props.note.renote != null && | ||||||
| 
 | 	props.note.text == null && | ||||||
| 	inject: { | 	props.note.fileIds.length === 0 && | ||||||
| 		inChannel: { | 	props.note.poll == null | ||||||
| 			default: null | ); | ||||||
| 		}, | 
 | ||||||
| 	}, | const el = ref<HTMLElement>(); | ||||||
| 
 | const menuButton = ref<HTMLElement>(); | ||||||
| 	props: { | const renoteButton = ref<InstanceType<typeof XRenoteButton>>(); | ||||||
| 		note: { | const renoteTime = ref<HTMLElement>(); | ||||||
| 			type: Object, | const reactButton = ref<HTMLElement>(); | ||||||
| 			required: true | let appearNote = $ref(isRenote ? props.note.renote as misskey.entities.Note : props.note); | ||||||
| 		}, | const isMyRenote = $i && ($i.id === props.note.userId); | ||||||
| 		pinned: { | const showContent = ref(false); | ||||||
| 			type: Boolean, | const collapsed = ref(appearNote.cw == null && appearNote.text != null && ( | ||||||
| 			required: false, | 	(appearNote.text.split('\n').length > 9) || | ||||||
| 			default: false | 	(appearNote.text.length > 500) | ||||||
| 		}, | )); | ||||||
| 	}, | const isDeleted = ref(false); | ||||||
| 
 | const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords)); | ||||||
| 	emits: ['update:note'], | const translation = ref(null); | ||||||
| 
 | const translating = ref(false); | ||||||
| 	data() { | const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null; | ||||||
| 		return { | const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); | ||||||
| 			connection: null, | 
 | ||||||
| 			replies: [], | const keymap = { | ||||||
| 			showContent: false, | 	'r': () => reply(true), | ||||||
| 			collapsed: false, | 	'e|a|plus': () => react(true), | ||||||
| 			isDeleted: false, | 	'q': () => renoteButton.value.renote(true), | ||||||
| 			muted: false, | 	'up|k|shift+tab': focusBefore, | ||||||
| 			translation: null, | 	'down|j|tab': focusAfter, | ||||||
| 			translating: false, | 	'esc': blur, | ||||||
| 		}; | 	'm|o': () => menu(true), | ||||||
| 	}, | 	's': () => showContent.value != showContent.value, | ||||||
| 
 | }; | ||||||
| 	computed: { | 
 | ||||||
| 		rs() { | useNoteCapture({ | ||||||
| 			return this.$store.state.reactions; | 	appearNote: $$(appearNote), | ||||||
| 		}, | 	rootEl: el, | ||||||
| 		keymap(): any { |  | ||||||
| 			return { |  | ||||||
| 				'r': () => this.reply(true), |  | ||||||
| 				'e|a|plus': () => this.react(true), |  | ||||||
| 				'q': () => this.$refs.renoteButton.renote(true), |  | ||||||
| 				'f|b': this.favorite, |  | ||||||
| 				'delete|ctrl+d': this.del, |  | ||||||
| 				'ctrl+q': this.renoteDirectly, |  | ||||||
| 				'up|k|shift+tab': this.focusBefore, |  | ||||||
| 				'down|j|tab': this.focusAfter, |  | ||||||
| 				'esc': this.blur, |  | ||||||
| 				'm|o': () => this.menu(true), |  | ||||||
| 				's': this.toggleShowContent, |  | ||||||
| 				'1': () => this.reactDirectly(this.rs[0]), |  | ||||||
| 				'2': () => this.reactDirectly(this.rs[1]), |  | ||||||
| 				'3': () => this.reactDirectly(this.rs[2]), |  | ||||||
| 				'4': () => this.reactDirectly(this.rs[3]), |  | ||||||
| 				'5': () => this.reactDirectly(this.rs[4]), |  | ||||||
| 				'6': () => this.reactDirectly(this.rs[5]), |  | ||||||
| 				'7': () => this.reactDirectly(this.rs[6]), |  | ||||||
| 				'8': () => this.reactDirectly(this.rs[7]), |  | ||||||
| 				'9': () => this.reactDirectly(this.rs[8]), |  | ||||||
| 				'0': () => this.reactDirectly(this.rs[9]), |  | ||||||
| 			}; |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		isRenote(): boolean { |  | ||||||
| 			return (this.note.renote && |  | ||||||
| 				this.note.text == null && |  | ||||||
| 				this.note.fileIds.length == 0 && |  | ||||||
| 				this.note.poll == null); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		appearNote(): any { |  | ||||||
| 			return this.isRenote ? this.note.renote : this.note; |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		isMyNote(): boolean { |  | ||||||
| 			return this.$i && (this.$i.id === this.appearNote.userId); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		isMyRenote(): boolean { |  | ||||||
| 			return this.$i && (this.$i.id === this.note.userId); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		reactionsCount(): number { |  | ||||||
| 			return this.appearNote.reactions |  | ||||||
| 				? sum(Object.values(this.appearNote.reactions)) |  | ||||||
| 				: 0; |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		urls(): string[] { |  | ||||||
| 			if (this.appearNote.text) { |  | ||||||
| 				return extractUrlFromMfm(mfm.parse(this.appearNote.text)); |  | ||||||
| 			} else { |  | ||||||
| 				return null; |  | ||||||
| 			} |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		showTicker() { |  | ||||||
| 			if (this.$store.state.instanceTicker === 'always') return true; |  | ||||||
| 			if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true; |  | ||||||
| 			return false; |  | ||||||
| 		} |  | ||||||
| 	}, |  | ||||||
| 
 |  | ||||||
| 	async created() { |  | ||||||
| 		if (this.$i) { |  | ||||||
| 			this.connection = stream; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		this.collapsed = this.appearNote.cw == null && this.appearNote.text && ( |  | ||||||
| 			(this.appearNote.text.split('\n').length > 9) || |  | ||||||
| 			(this.appearNote.text.length > 500) |  | ||||||
| 		); |  | ||||||
| 		this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords); |  | ||||||
| 
 |  | ||||||
| 		// plugin |  | ||||||
| 		if (noteViewInterruptors.length > 0) { |  | ||||||
| 			let result = this.note; |  | ||||||
| 			for (const interruptor of noteViewInterruptors) { |  | ||||||
| 				result = await interruptor.handler(JSON.parse(JSON.stringify(result))); |  | ||||||
| 			} |  | ||||||
| 			this.$emit('update:note', Object.freeze(result)); |  | ||||||
| 		} |  | ||||||
| 	}, |  | ||||||
| 
 |  | ||||||
| 	mounted() { |  | ||||||
| 		this.capture(true); |  | ||||||
| 
 |  | ||||||
| 		if (this.$i) { |  | ||||||
| 			this.connection.on('_connected_', this.onStreamConnected); |  | ||||||
| 		} |  | ||||||
| 	}, |  | ||||||
| 
 |  | ||||||
| 	beforeUnmount() { |  | ||||||
| 		this.decapture(true); |  | ||||||
| 
 |  | ||||||
| 		if (this.$i) { |  | ||||||
| 			this.connection.off('_connected_', this.onStreamConnected); |  | ||||||
| 		} |  | ||||||
| 	}, |  | ||||||
| 
 |  | ||||||
| 	methods: { |  | ||||||
| 		updateAppearNote(v) { |  | ||||||
| 			this.$emit('update:note', Object.freeze(this.isRenote ? { |  | ||||||
| 				...this.note, |  | ||||||
| 				renote: { |  | ||||||
| 					...this.note.renote, |  | ||||||
| 					...v |  | ||||||
| 				} |  | ||||||
| 			} : { |  | ||||||
| 				...this.note, |  | ||||||
| 				...v |  | ||||||
| 			})); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		readPromo() { |  | ||||||
| 			os.api('promo/read', { |  | ||||||
| 				noteId: this.appearNote.id |  | ||||||
| 			}); |  | ||||||
| 			this.isDeleted = true; |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		capture(withHandler = false) { |  | ||||||
| 			if (this.$i) { |  | ||||||
| 				// TODO: このノートがストリーミング経由で流れてきた場合のみ sr する |  | ||||||
| 				this.connection.send(document.body.contains(this.$el) ? 'sr' : 's', { id: this.appearNote.id }); |  | ||||||
| 				if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated); |  | ||||||
| 			} |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		decapture(withHandler = false) { |  | ||||||
| 			if (this.$i) { |  | ||||||
| 				this.connection.send('un', { |  | ||||||
| 					id: this.appearNote.id |  | ||||||
| 				}); |  | ||||||
| 				if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated); |  | ||||||
| 			} |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		onStreamConnected() { |  | ||||||
| 			this.capture(); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		onStreamNoteUpdated(data) { |  | ||||||
| 			const { type, id, body } = data; |  | ||||||
| 
 |  | ||||||
| 			if (id !== this.appearNote.id) return; |  | ||||||
| 
 |  | ||||||
| 			switch (type) { |  | ||||||
| 				case 'reacted': { |  | ||||||
| 					const reaction = body.reaction; |  | ||||||
| 
 |  | ||||||
| 					// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) |  | ||||||
| 					let n = { |  | ||||||
| 						...this.appearNote, |  | ||||||
| 					}; |  | ||||||
| 
 |  | ||||||
| 					if (body.emoji) { |  | ||||||
| 						const emojis = this.appearNote.emojis || []; |  | ||||||
| 						if (!emojis.includes(body.emoji)) { |  | ||||||
| 							n.emojis = [...emojis, body.emoji]; |  | ||||||
| 						} |  | ||||||
| 					} |  | ||||||
| 
 |  | ||||||
| 					// TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる |  | ||||||
| 					const currentCount = (this.appearNote.reactions || {})[reaction] || 0; |  | ||||||
| 
 |  | ||||||
| 					// Increment the count |  | ||||||
| 					n.reactions = { |  | ||||||
| 						...this.appearNote.reactions, |  | ||||||
| 						[reaction]: currentCount + 1 |  | ||||||
| 					}; |  | ||||||
| 
 |  | ||||||
| 					if (body.userId === this.$i.id) { |  | ||||||
| 						n.myReaction = reaction; |  | ||||||
| 					} |  | ||||||
| 
 |  | ||||||
| 					this.updateAppearNote(n); |  | ||||||
| 					break; |  | ||||||
| 				} |  | ||||||
| 
 |  | ||||||
| 				case 'unreacted': { |  | ||||||
| 					const reaction = body.reaction; |  | ||||||
| 
 |  | ||||||
| 					// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) |  | ||||||
| 					let n = { |  | ||||||
| 						...this.appearNote, |  | ||||||
| 					}; |  | ||||||
| 
 |  | ||||||
| 					// TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる |  | ||||||
| 					const currentCount = (this.appearNote.reactions || {})[reaction] || 0; |  | ||||||
| 
 |  | ||||||
| 					// Decrement the count |  | ||||||
| 					n.reactions = { |  | ||||||
| 						...this.appearNote.reactions, |  | ||||||
| 						[reaction]: Math.max(0, currentCount - 1) |  | ||||||
| 					}; |  | ||||||
| 
 |  | ||||||
| 					if (body.userId === this.$i.id) { |  | ||||||
| 						n.myReaction = null; |  | ||||||
| 					} |  | ||||||
| 
 |  | ||||||
| 					this.updateAppearNote(n); |  | ||||||
| 					break; |  | ||||||
| 				} |  | ||||||
| 
 |  | ||||||
| 				case 'pollVoted': { |  | ||||||
| 					const choice = body.choice; |  | ||||||
| 
 |  | ||||||
| 					// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) |  | ||||||
| 					let n = { |  | ||||||
| 						...this.appearNote, |  | ||||||
| 					}; |  | ||||||
| 
 |  | ||||||
| 					const choices = [...this.appearNote.poll.choices]; |  | ||||||
| 					choices[choice] = { |  | ||||||
| 						...choices[choice], |  | ||||||
| 						votes: choices[choice].votes + 1, |  | ||||||
| 						...(body.userId === this.$i.id ? { |  | ||||||
| 							isVoted: true |  | ||||||
| 						} : {}) |  | ||||||
| 					}; |  | ||||||
| 
 |  | ||||||
| 					n.poll = { |  | ||||||
| 						...this.appearNote.poll, |  | ||||||
| 						choices: choices |  | ||||||
| 					}; |  | ||||||
| 
 |  | ||||||
| 					this.updateAppearNote(n); |  | ||||||
| 					break; |  | ||||||
| 				} |  | ||||||
| 
 |  | ||||||
| 				case 'deleted': { |  | ||||||
| 					this.isDeleted = true; |  | ||||||
| 					break; |  | ||||||
| 				} |  | ||||||
| 			} |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		reply(viaKeyboard = false) { |  | ||||||
| 			pleaseLogin(); |  | ||||||
| 			os.post({ |  | ||||||
| 				reply: this.appearNote, |  | ||||||
| 				animation: !viaKeyboard, |  | ||||||
| 			}, () => { |  | ||||||
| 				this.focus(); |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		renoteDirectly() { |  | ||||||
| 			os.apiWithDialog('notes/create', { |  | ||||||
| 				renoteId: this.appearNote.id |  | ||||||
| 			}, undefined, (res: any) => { |  | ||||||
| 				os.alert({ |  | ||||||
| 					type: 'success', |  | ||||||
| 					text: this.$ts.renoted, |  | ||||||
| 				}); |  | ||||||
| 			}, (e: Error) => { |  | ||||||
| 				if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') { |  | ||||||
| 					os.alert({ |  | ||||||
| 						type: 'error', |  | ||||||
| 						text: this.$ts.cantRenote, |  | ||||||
| 					}); |  | ||||||
| 				} else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') { |  | ||||||
| 					os.alert({ |  | ||||||
| 						type: 'error', |  | ||||||
| 						text: this.$ts.cantReRenote, |  | ||||||
| 					}); |  | ||||||
| 				} |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		react(viaKeyboard = false) { |  | ||||||
| 			pleaseLogin(); |  | ||||||
| 			this.blur(); |  | ||||||
| 			reactionPicker.show(this.$refs.reactButton, reaction => { |  | ||||||
| 				os.api('notes/reactions/create', { |  | ||||||
| 					noteId: this.appearNote.id, |  | ||||||
| 					reaction: reaction |  | ||||||
| 				}); |  | ||||||
| 			}, () => { |  | ||||||
| 				this.focus(); |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		reactDirectly(reaction) { |  | ||||||
| 			os.api('notes/reactions/create', { |  | ||||||
| 				noteId: this.appearNote.id, |  | ||||||
| 				reaction: reaction |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		undoReact(note) { |  | ||||||
| 			const oldReaction = note.myReaction; |  | ||||||
| 			if (!oldReaction) return; |  | ||||||
| 			os.api('notes/reactions/delete', { |  | ||||||
| 				noteId: note.id |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		favorite() { |  | ||||||
| 			pleaseLogin(); |  | ||||||
| 			os.apiWithDialog('notes/favorites/create', { |  | ||||||
| 				noteId: this.appearNote.id |  | ||||||
| 			}, undefined, (res: any) => { |  | ||||||
| 				os.alert({ |  | ||||||
| 					type: 'success', |  | ||||||
| 					text: this.$ts.favorited, |  | ||||||
| 				}); |  | ||||||
| 			}, (e: Error) => { |  | ||||||
| 				if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') { |  | ||||||
| 					os.alert({ |  | ||||||
| 						type: 'error', |  | ||||||
| 						text: this.$ts.alreadyFavorited, |  | ||||||
| 					}); |  | ||||||
| 				} else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') { |  | ||||||
| 					os.alert({ |  | ||||||
| 						type: 'error', |  | ||||||
| 						text: this.$ts.cantFavorite, |  | ||||||
| 					}); |  | ||||||
| 				} |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		del() { |  | ||||||
| 			os.confirm({ |  | ||||||
| 				type: 'warning', |  | ||||||
| 				text: this.$ts.noteDeleteConfirm, |  | ||||||
| 			}).then(({ canceled }) => { |  | ||||||
| 				if (canceled) return; |  | ||||||
| 
 |  | ||||||
| 				os.api('notes/delete', { |  | ||||||
| 					noteId: this.appearNote.id |  | ||||||
| 				}); |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		delEdit() { |  | ||||||
| 			os.confirm({ |  | ||||||
| 				type: 'warning', |  | ||||||
| 				text: this.$ts.deleteAndEditConfirm, |  | ||||||
| 			}).then(({ canceled }) => { |  | ||||||
| 				if (canceled) return; |  | ||||||
| 
 |  | ||||||
| 				os.api('notes/delete', { |  | ||||||
| 					noteId: this.appearNote.id |  | ||||||
| 				}); |  | ||||||
| 
 |  | ||||||
| 				os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel }); |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		toggleFavorite(favorite: boolean) { |  | ||||||
| 			os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { |  | ||||||
| 				noteId: this.appearNote.id |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		toggleWatch(watch: boolean) { |  | ||||||
| 			os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', { |  | ||||||
| 				noteId: this.appearNote.id |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		toggleThreadMute(mute: boolean) { |  | ||||||
| 			os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', { |  | ||||||
| 				noteId: this.appearNote.id |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		getMenu() { |  | ||||||
| 			let menu; |  | ||||||
| 			if (this.$i) { |  | ||||||
| 				const statePromise = os.api('notes/state', { |  | ||||||
| 					noteId: this.appearNote.id |  | ||||||
| 				}); |  | ||||||
| 
 |  | ||||||
| 				menu = [{ |  | ||||||
| 					icon: 'fas fa-copy', |  | ||||||
| 					text: this.$ts.copyContent, |  | ||||||
| 					action: this.copyContent |  | ||||||
| 				}, { |  | ||||||
| 					icon: 'fas fa-link', |  | ||||||
| 					text: this.$ts.copyLink, |  | ||||||
| 					action: this.copyLink |  | ||||||
| 				}, (this.appearNote.url || this.appearNote.uri) ? { |  | ||||||
| 					icon: 'fas fa-external-link-square-alt', |  | ||||||
| 					text: this.$ts.showOnRemote, |  | ||||||
| 					action: () => { |  | ||||||
| 						window.open(this.appearNote.url || this.appearNote.uri, '_blank'); |  | ||||||
| 					} |  | ||||||
| 				} : undefined, |  | ||||||
| 				{ |  | ||||||
| 					icon: 'fas fa-share-alt', |  | ||||||
| 					text: this.$ts.share, |  | ||||||
| 					action: this.share |  | ||||||
| 				}, |  | ||||||
| 				this.$instance.translatorAvailable ? { |  | ||||||
| 					icon: 'fas fa-language', |  | ||||||
| 					text: this.$ts.translate, |  | ||||||
| 					action: this.translate |  | ||||||
| 				} : undefined, |  | ||||||
| 				null, |  | ||||||
| 				statePromise.then(state => state.isFavorited ? { |  | ||||||
| 					icon: 'fas fa-star', |  | ||||||
| 					text: this.$ts.unfavorite, |  | ||||||
| 					action: () => this.toggleFavorite(false) |  | ||||||
| 				} : { |  | ||||||
| 					icon: 'fas fa-star', |  | ||||||
| 					text: this.$ts.favorite, |  | ||||||
| 					action: () => this.toggleFavorite(true) |  | ||||||
| 				}), |  | ||||||
| 				{ |  | ||||||
| 					icon: 'fas fa-paperclip', |  | ||||||
| 					text: this.$ts.clip, |  | ||||||
| 					action: () => this.clip() |  | ||||||
| 				}, |  | ||||||
| 				(this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? { |  | ||||||
| 					icon: 'fas fa-eye-slash', |  | ||||||
| 					text: this.$ts.unwatch, |  | ||||||
| 					action: () => this.toggleWatch(false) |  | ||||||
| 				} : { |  | ||||||
| 					icon: 'fas fa-eye', |  | ||||||
| 					text: this.$ts.watch, |  | ||||||
| 					action: () => this.toggleWatch(true) |  | ||||||
| 				}) : undefined, |  | ||||||
| 				statePromise.then(state => state.isMutedThread ? { |  | ||||||
| 					icon: 'fas fa-comment-slash', |  | ||||||
| 					text: this.$ts.unmuteThread, |  | ||||||
| 					action: () => this.toggleThreadMute(false) |  | ||||||
| 				} : { |  | ||||||
| 					icon: 'fas fa-comment-slash', |  | ||||||
| 					text: this.$ts.muteThread, |  | ||||||
| 					action: () => this.toggleThreadMute(true) |  | ||||||
| 				}), |  | ||||||
| 				this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? { |  | ||||||
| 					icon: 'fas fa-thumbtack', |  | ||||||
| 					text: this.$ts.unpin, |  | ||||||
| 					action: () => this.togglePin(false) |  | ||||||
| 				} : { |  | ||||||
| 					icon: 'fas fa-thumbtack', |  | ||||||
| 					text: this.$ts.pin, |  | ||||||
| 					action: () => this.togglePin(true) |  | ||||||
| 				} : undefined, |  | ||||||
| 				/* |  | ||||||
| 				...(this.$i.isModerator || this.$i.isAdmin ? [ |  | ||||||
| 					null, |  | ||||||
| 					{ |  | ||||||
| 						icon: 'fas fa-bullhorn', |  | ||||||
| 						text: this.$ts.promote, |  | ||||||
| 						action: this.promote |  | ||||||
| 					}] |  | ||||||
| 					: [] |  | ||||||
| 				),*/ |  | ||||||
| 				...(this.appearNote.userId != this.$i.id ? [ |  | ||||||
| 					null, |  | ||||||
| 					{ |  | ||||||
| 						icon: 'fas fa-exclamation-circle', |  | ||||||
| 						text: this.$ts.reportAbuse, |  | ||||||
| 						action: () => { |  | ||||||
| 							const u = `${url}/notes/${this.appearNote.id}`; |  | ||||||
| 							os.popup(import('@/components/abuse-report-window.vue'), { |  | ||||||
| 								user: this.appearNote.user, |  | ||||||
| 								initialComment: `Note: ${u}\n-----\n` |  | ||||||
| 							}, {}, 'closed'); |  | ||||||
| 						} |  | ||||||
| 					}] |  | ||||||
| 					: [] |  | ||||||
| 				), |  | ||||||
| 				...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [ |  | ||||||
| 					null, |  | ||||||
| 					this.appearNote.userId == this.$i.id ? { |  | ||||||
| 						icon: 'fas fa-edit', |  | ||||||
| 						text: this.$ts.deleteAndEdit, |  | ||||||
| 						action: this.delEdit |  | ||||||
| 					} : undefined, |  | ||||||
| 					{ |  | ||||||
| 						icon: 'fas fa-trash-alt', |  | ||||||
| 						text: this.$ts.delete, |  | ||||||
| 						danger: true, |  | ||||||
| 						action: this.del |  | ||||||
| 					}] |  | ||||||
| 					: [] |  | ||||||
| 				)] |  | ||||||
| 				.filter(x => x !== undefined); |  | ||||||
| 			} else { |  | ||||||
| 				menu = [{ |  | ||||||
| 					icon: 'fas fa-copy', |  | ||||||
| 					text: this.$ts.copyContent, |  | ||||||
| 					action: this.copyContent |  | ||||||
| 				}, { |  | ||||||
| 					icon: 'fas fa-link', |  | ||||||
| 					text: this.$ts.copyLink, |  | ||||||
| 					action: this.copyLink |  | ||||||
| 				}, (this.appearNote.url || this.appearNote.uri) ? { |  | ||||||
| 					icon: 'fas fa-external-link-square-alt', |  | ||||||
| 					text: this.$ts.showOnRemote, |  | ||||||
| 					action: () => { |  | ||||||
| 						window.open(this.appearNote.url || this.appearNote.uri, '_blank'); |  | ||||||
| 					} |  | ||||||
| 				} : undefined] |  | ||||||
| 				.filter(x => x !== undefined); |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			if (noteActions.length > 0) { |  | ||||||
| 				menu = menu.concat([null, ...noteActions.map(action => ({ |  | ||||||
| 					icon: 'fas fa-plug', |  | ||||||
| 					text: action.title, |  | ||||||
| 					action: () => { |  | ||||||
| 						action.handler(this.appearNote); |  | ||||||
| 					} |  | ||||||
| 				}))]); |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			return menu; |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		onContextmenu(e) { |  | ||||||
| 			const isLink = (el: HTMLElement) => { |  | ||||||
| 				if (el.tagName === 'A') return true; |  | ||||||
| 				if (el.parentElement) { |  | ||||||
| 					return isLink(el.parentElement); |  | ||||||
| 				} |  | ||||||
| 			}; |  | ||||||
| 			if (isLink(e.target)) return; |  | ||||||
| 			if (window.getSelection().toString() !== '') return; |  | ||||||
| 
 |  | ||||||
| 			if (this.$store.state.useReactionPickerForContextMenu) { |  | ||||||
| 				e.preventDefault(); |  | ||||||
| 				this.react(); |  | ||||||
| 			} else { |  | ||||||
| 				os.contextMenu(this.getMenu(), e).then(this.focus); |  | ||||||
| 			} |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		menu(viaKeyboard = false) { |  | ||||||
| 			os.popupMenu(this.getMenu(), this.$refs.menuButton, { |  | ||||||
| 				viaKeyboard |  | ||||||
| 			}).then(this.focus); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		showRenoteMenu(viaKeyboard = false) { |  | ||||||
| 			if (!this.isMyRenote) return; |  | ||||||
| 			os.popupMenu([{ |  | ||||||
| 				text: this.$ts.unrenote, |  | ||||||
| 				icon: 'fas fa-trash-alt', |  | ||||||
| 				danger: true, |  | ||||||
| 				action: () => { |  | ||||||
| 					os.api('notes/delete', { |  | ||||||
| 						noteId: this.note.id |  | ||||||
| 					}); |  | ||||||
| 					this.isDeleted = true; |  | ||||||
| 				} |  | ||||||
| 			}], this.$refs.renoteTime, { |  | ||||||
| 				viaKeyboard: viaKeyboard |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		toggleShowContent() { |  | ||||||
| 			this.showContent = !this.showContent; |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		copyContent() { |  | ||||||
| 			copyToClipboard(this.appearNote.text); |  | ||||||
| 			os.success(); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		copyLink() { |  | ||||||
| 			copyToClipboard(`${url}/notes/${this.appearNote.id}`); |  | ||||||
| 			os.success(); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		togglePin(pin: boolean) { |  | ||||||
| 			os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', { |  | ||||||
| 				noteId: this.appearNote.id |  | ||||||
| 			}, undefined, null, e => { |  | ||||||
| 				if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') { |  | ||||||
| 					os.alert({ |  | ||||||
| 						type: 'error', |  | ||||||
| 						text: this.$ts.pinLimitExceeded |  | ||||||
| 					}); |  | ||||||
| 				} |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		async clip() { |  | ||||||
| 			const clips = await os.api('clips/list'); |  | ||||||
| 			os.popupMenu([{ |  | ||||||
| 				icon: 'fas fa-plus', |  | ||||||
| 				text: this.$ts.createNew, |  | ||||||
| 				action: async () => { |  | ||||||
| 					const { canceled, result } = await os.form(this.$ts.createNewClip, { |  | ||||||
| 						name: { |  | ||||||
| 							type: 'string', |  | ||||||
| 							label: this.$ts.name |  | ||||||
| 						}, |  | ||||||
| 						description: { |  | ||||||
| 							type: 'string', |  | ||||||
| 							required: false, |  | ||||||
| 							multiline: true, |  | ||||||
| 							label: this.$ts.description |  | ||||||
| 						}, |  | ||||||
| 						isPublic: { |  | ||||||
| 							type: 'boolean', |  | ||||||
| 							label: this.$ts.public, |  | ||||||
| 							default: false |  | ||||||
| 						} |  | ||||||
| 					}); |  | ||||||
| 					if (canceled) return; |  | ||||||
| 
 |  | ||||||
| 					const clip = await os.apiWithDialog('clips/create', result); |  | ||||||
| 
 |  | ||||||
| 					os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id }); |  | ||||||
| 				} |  | ||||||
| 			}, null, ...clips.map(clip => ({ |  | ||||||
| 				text: clip.name, |  | ||||||
| 				action: () => { |  | ||||||
| 					os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id }); |  | ||||||
| 				} |  | ||||||
| 			}))], this.$refs.menuButton, { |  | ||||||
| 			}).then(this.focus); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		async promote() { |  | ||||||
| 			const { canceled, result: days } = await os.inputNumber({ |  | ||||||
| 				title: this.$ts.numberOfDays, |  | ||||||
| 			}); |  | ||||||
| 
 |  | ||||||
| 			if (canceled) return; |  | ||||||
| 
 |  | ||||||
| 			os.apiWithDialog('admin/promo/create', { |  | ||||||
| 				noteId: this.appearNote.id, |  | ||||||
| 				expiresAt: Date.now() + (86400000 * days) |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		share() { |  | ||||||
| 			navigator.share({ |  | ||||||
| 				title: this.$t('noteOf', { user: this.appearNote.user.name }), |  | ||||||
| 				text: this.appearNote.text, |  | ||||||
| 				url: `${url}/notes/${this.appearNote.id}` |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		async translate() { |  | ||||||
| 			if (this.translation != null) return; |  | ||||||
| 			this.translating = true; |  | ||||||
| 			const res = await os.api('notes/translate', { |  | ||||||
| 				noteId: this.appearNote.id, |  | ||||||
| 				targetLang: localStorage.getItem('lang') || navigator.language, |  | ||||||
| 			}); |  | ||||||
| 			this.translating = false; |  | ||||||
| 			this.translation = res; |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		focus() { |  | ||||||
| 			this.$el.focus(); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		blur() { |  | ||||||
| 			this.$el.blur(); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		focusBefore() { |  | ||||||
| 			focusPrev(this.$el); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		focusAfter() { |  | ||||||
| 			focusNext(this.$el); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		userPage |  | ||||||
| 	} |  | ||||||
| }); | }); | ||||||
|  | 
 | ||||||
|  | function reply(viaKeyboard = false): void { | ||||||
|  | 	pleaseLogin(); | ||||||
|  | 	os.post({ | ||||||
|  | 		reply: appearNote, | ||||||
|  | 		animation: !viaKeyboard, | ||||||
|  | 	}, () => { | ||||||
|  | 		focus(); | ||||||
|  | 	}); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function react(viaKeyboard = false): void { | ||||||
|  | 	pleaseLogin(); | ||||||
|  | 	blur(); | ||||||
|  | 	reactionPicker.show(reactButton.value, reaction => { | ||||||
|  | 		os.api('notes/reactions/create', { | ||||||
|  | 			noteId: appearNote.id, | ||||||
|  | 			reaction: reaction | ||||||
|  | 		}); | ||||||
|  | 	}, () => { | ||||||
|  | 		focus(); | ||||||
|  | 	}); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function undoReact(note): void { | ||||||
|  | 	const oldReaction = note.myReaction; | ||||||
|  | 	if (!oldReaction) return; | ||||||
|  | 	os.api('notes/reactions/delete', { | ||||||
|  | 		noteId: note.id | ||||||
|  | 	}); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function onContextmenu(e): void { | ||||||
|  | 	const isLink = (el: HTMLElement) => { | ||||||
|  | 		if (el.tagName === 'A') return true; | ||||||
|  | 		if (el.parentElement) { | ||||||
|  | 			return isLink(el.parentElement); | ||||||
|  | 		} | ||||||
|  | 	}; | ||||||
|  | 	if (isLink(e.target)) return; | ||||||
|  | 	if (window.getSelection().toString() !== '') return; | ||||||
|  | 
 | ||||||
|  | 	if (defaultStore.state.useReactionPickerForContextMenu) { | ||||||
|  | 		e.preventDefault(); | ||||||
|  | 		react(); | ||||||
|  | 	} else { | ||||||
|  | 		os.contextMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), e).then(focus); | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function menu(viaKeyboard = false): void { | ||||||
|  | 	os.popupMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), menuButton.value, { | ||||||
|  | 		viaKeyboard | ||||||
|  | 	}).then(focus); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function showRenoteMenu(viaKeyboard = false): void { | ||||||
|  | 	if (!isMyRenote) return; | ||||||
|  | 	os.popupMenu([{ | ||||||
|  | 		text: i18n.locale.unrenote, | ||||||
|  | 		icon: 'fas fa-trash-alt', | ||||||
|  | 		danger: true, | ||||||
|  | 		action: () => { | ||||||
|  | 			os.api('notes/delete', { | ||||||
|  | 				noteId: props.note.id | ||||||
|  | 			}); | ||||||
|  | 			isDeleted.value = true; | ||||||
|  | 		} | ||||||
|  | 	}], renoteTime.value, { | ||||||
|  | 		viaKeyboard: viaKeyboard | ||||||
|  | 	}); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function focus() { | ||||||
|  | 	el.value.focus(); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function blur() { | ||||||
|  | 	el.value.blur(); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function focusBefore() { | ||||||
|  | 	focusPrev(el.value); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function focusAfter() { | ||||||
|  | 	focusNext(el.value); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function readPromo() { | ||||||
|  | 	os.api('promo/read', { | ||||||
|  | 		noteId: appearNote.id | ||||||
|  | 	}); | ||||||
|  | 	isDeleted.value = true; | ||||||
|  | } | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <style lang="scss" scoped> | <style lang="scss" scoped> | ||||||
|  |  | ||||||
|  | @ -10,7 +10,7 @@ | ||||||
| 	<template #default="{ items: notes }"> | 	<template #default="{ items: notes }"> | ||||||
| 		<div class="giivymft" :class="{ noGap }"> | 		<div class="giivymft" :class="{ noGap }"> | ||||||
| 			<XList ref="notes" v-slot="{ item: note }" :items="notes" :direction="pagination.reversed ? 'up' : 'down'" :reversed="pagination.reversed" :no-gap="noGap" :ad="true" class="notes"> | 			<XList ref="notes" v-slot="{ item: note }" :items="notes" :direction="pagination.reversed ? 'up' : 'down'" :reversed="pagination.reversed" :no-gap="noGap" :ad="true" class="notes"> | ||||||
| 				<XNote :key="note._featuredId_ || note._prId_ || note.id" class="qtqtichx" :note="note" @update:note="updated(note, $event)"/> | 				<XNote :key="note._featuredId_ || note._prId_ || note.id" class="qtqtichx" :note="note"/> | ||||||
| 			</XList> | 			</XList> | ||||||
| 		</div> | 		</div> | ||||||
| 	</template> | 	</template> | ||||||
|  | @ -31,10 +31,6 @@ const props = defineProps<{ | ||||||
| 
 | 
 | ||||||
| const pagingComponent = ref<InstanceType<typeof MkPagination>>(); | const pagingComponent = ref<InstanceType<typeof MkPagination>>(); | ||||||
| 
 | 
 | ||||||
| const updated = (oldValue, newValue) => { |  | ||||||
| 	pagingComponent.value?.updateItem(oldValue.id, () => newValue); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| defineExpose({ | defineExpose({ | ||||||
| 	prepend: (note) => { | 	prepend: (note) => { | ||||||
| 		pagingComponent.value?.prepend(note); | 		pagingComponent.value?.prepend(note); | ||||||
|  |  | ||||||
|  | @ -9,7 +9,7 @@ | ||||||
| 
 | 
 | ||||||
| 	<template #default="{ items: notifications }"> | 	<template #default="{ items: notifications }"> | ||||||
| 		<XList v-slot="{ item: notification }" class="elsfgstc" :items="notifications" :no-gap="true"> | 		<XList v-slot="{ item: notification }" class="elsfgstc" :items="notifications" :no-gap="true"> | ||||||
| 			<XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note" @update:note="noteUpdated(notification, $event)"/> | 			<XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/> | ||||||
| 			<XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/> | 			<XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/> | ||||||
| 		</XList> | 		</XList> | ||||||
| 	</template> | 	</template> | ||||||
|  | @ -62,13 +62,6 @@ const onNotification = (notification) => { | ||||||
| 	} | 	} | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const noteUpdated = (item, note) => { |  | ||||||
| 	pagingComponent.value?.updateItem(item.id, old => ({ |  | ||||||
| 		...old, |  | ||||||
| 		note: note, |  | ||||||
| 	})); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| onMounted(() => { | onMounted(() => { | ||||||
| 	const connection = stream.useChannel('main'); | 	const connection = stream.useChannel('main'); | ||||||
| 	connection.on('notification', onNotification); | 	connection.on('notification', onNotification); | ||||||
|  |  | ||||||
|  | @ -90,7 +90,6 @@ const init = async (): Promise<void> => { | ||||||
| 	}).then(res => { | 	}).then(res => { | ||||||
| 		for (let i = 0; i < res.length; i++) { | 		for (let i = 0; i < res.length; i++) { | ||||||
| 			const item = res[i]; | 			const item = res[i]; | ||||||
| 			markRaw(item); |  | ||||||
| 			if (props.pagination.reversed) { | 			if (props.pagination.reversed) { | ||||||
| 				if (i === res.length - 2) item._shouldInsertAd_ = true; | 				if (i === res.length - 2) item._shouldInsertAd_ = true; | ||||||
| 			} else { | 			} else { | ||||||
|  | @ -134,7 +133,6 @@ const fetchMore = async (): Promise<void> => { | ||||||
| 	}).then(res => { | 	}).then(res => { | ||||||
| 		for (let i = 0; i < res.length; i++) { | 		for (let i = 0; i < res.length; i++) { | ||||||
| 			const item = res[i]; | 			const item = res[i]; | ||||||
| 			markRaw(item); |  | ||||||
| 			if (props.pagination.reversed) { | 			if (props.pagination.reversed) { | ||||||
| 				if (i === res.length - 9) item._shouldInsertAd_ = true; | 				if (i === res.length - 9) item._shouldInsertAd_ = true; | ||||||
| 			} else { | 			} else { | ||||||
|  | @ -169,9 +167,6 @@ const fetchMoreAhead = async (): Promise<void> => { | ||||||
| 			sinceId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id, | 			sinceId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id, | ||||||
| 		}), | 		}), | ||||||
| 	}).then(res => { | 	}).then(res => { | ||||||
| 		for (const item of res) { |  | ||||||
| 			markRaw(item); |  | ||||||
| 		} |  | ||||||
| 		if (res.length > SECOND_FETCH_LIMIT) { | 		if (res.length > SECOND_FETCH_LIMIT) { | ||||||
| 			res.pop(); | 			res.pop(); | ||||||
| 			items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res); | 			items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res); | ||||||
|  |  | ||||||
|  | @ -10,7 +10,7 @@ | ||||||
| 
 | 
 | ||||||
| 		<template #default="{ items }"> | 		<template #default="{ items }"> | ||||||
| 			<XList v-slot="{ item }" :items="items" :direction="'down'" :no-gap="false" :ad="false"> | 			<XList v-slot="{ item }" :items="items" :direction="'down'" :no-gap="false" :ad="false"> | ||||||
| 				<XNote :key="item.id" :note="item.note" :class="$style.note" @update:note="noteUpdated(item, $event)"/> | 				<XNote :key="item.id" :note="item.note" :class="$style.note"/> | ||||||
| 			</XList> | 			</XList> | ||||||
| 		</template> | 		</template> | ||||||
| 	</MkPagination> | 	</MkPagination> | ||||||
|  | @ -32,13 +32,6 @@ const pagination = { | ||||||
| 
 | 
 | ||||||
| const pagingComponent = ref<InstanceType<typeof MkPagination>>(); | const pagingComponent = ref<InstanceType<typeof MkPagination>>(); | ||||||
| 
 | 
 | ||||||
| const noteUpdated = (item, note) => { |  | ||||||
| 	pagingComponent.value?.updateItem(item.id, old => ({ |  | ||||||
| 		...old, |  | ||||||
| 		note: note, |  | ||||||
| 	})); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| defineExpose({ | defineExpose({ | ||||||
| 	[symbols.PAGE_INFO]: { | 	[symbols.PAGE_INFO]: { | ||||||
| 		title: i18n.locale.favorites, | 		title: i18n.locale.favorites, | ||||||
|  | @ -53,4 +46,4 @@ defineExpose({ | ||||||
| 	background: var(--panel); | 	background: var(--panel); | ||||||
| 	border-radius: var(--radius); | 	border-radius: var(--radius); | ||||||
| } | } | ||||||
| </style> | </style> | ||||||
|  |  | ||||||
|  | @ -7,7 +7,7 @@ | ||||||
| 				<MkReactionIcon class="reaction" :reaction="item.type" :custom-emojis="item.note.emojis" :no-style="true"/> | 				<MkReactionIcon class="reaction" :reaction="item.type" :custom-emojis="item.note.emojis" :no-style="true"/> | ||||||
| 				<MkTime :time="item.createdAt" class="createdAt"/> | 				<MkTime :time="item.createdAt" class="createdAt"/> | ||||||
| 			</div> | 			</div> | ||||||
| 			<MkNote :key="item.id" :note="item.note" @update:note="updated(note, $event)"/> | 			<MkNote :key="item.id" :note="item.note"/> | ||||||
| 		</div> | 		</div> | ||||||
| 	</MkPagination> | 	</MkPagination> | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
|  | @ -1,4 +1,4 @@ | ||||||
| export async function checkWordMute(note: Record<string, any>, me: Record<string, any> | null | undefined, mutedWords: string[][]): Promise<boolean> { | export function checkWordMute(note: Record<string, any>, me: Record<string, any> | null | undefined, mutedWords: string[][]): boolean { | ||||||
| 	// 自分自身
 | 	// 自分自身
 | ||||||
| 	if (me && (note.userId === me.id)) return false; | 	if (me && (note.userId === me.id)) return false; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
							
								
								
									
										310
									
								
								packages/client/src/scripts/get-note-menu.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										310
									
								
								packages/client/src/scripts/get-note-menu.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,310 @@ | ||||||
|  | import { Ref } from 'vue'; | ||||||
|  | import * as misskey from 'misskey-js'; | ||||||
|  | import { $i } from '@/account'; | ||||||
|  | import { i18n } from '@/i18n'; | ||||||
|  | import { instance } from '@/instance'; | ||||||
|  | import * as os from '@/os'; | ||||||
|  | import copyToClipboard from '@/scripts/copy-to-clipboard'; | ||||||
|  | import { url } from '@/config'; | ||||||
|  | import { noteActions } from '@/store'; | ||||||
|  | import { pleaseLogin } from './please-login'; | ||||||
|  | 
 | ||||||
|  | export function getNoteMenu(props: { | ||||||
|  | 	note: misskey.entities.Note; | ||||||
|  | 	menuButton: Ref<HTMLElement>; | ||||||
|  | 	translation: Ref<any>; | ||||||
|  | 	translating: Ref<boolean>; | ||||||
|  | }) { | ||||||
|  | 	const isRenote = ( | ||||||
|  | 		props.note.renote != null && | ||||||
|  | 		props.note.text == null && | ||||||
|  | 		props.note.fileIds.length === 0 && | ||||||
|  | 		props.note.poll == null | ||||||
|  | 	); | ||||||
|  | 
 | ||||||
|  | 	let appearNote = isRenote ? props.note.renote as misskey.entities.Note : props.note; | ||||||
|  | 
 | ||||||
|  | 	function del(): void { | ||||||
|  | 		os.confirm({ | ||||||
|  | 			type: 'warning', | ||||||
|  | 			text: i18n.locale.noteDeleteConfirm, | ||||||
|  | 		}).then(({ canceled }) => { | ||||||
|  | 			if (canceled) return; | ||||||
|  | 
 | ||||||
|  | 			os.api('notes/delete', { | ||||||
|  | 				noteId: appearNote.id | ||||||
|  | 			}); | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	function delEdit(): void { | ||||||
|  | 		os.confirm({ | ||||||
|  | 			type: 'warning', | ||||||
|  | 			text: i18n.locale.deleteAndEditConfirm, | ||||||
|  | 		}).then(({ canceled }) => { | ||||||
|  | 			if (canceled) return; | ||||||
|  | 
 | ||||||
|  | 			os.api('notes/delete', { | ||||||
|  | 				noteId: appearNote.id | ||||||
|  | 			}); | ||||||
|  | 
 | ||||||
|  | 			os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel }); | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	function toggleFavorite(favorite: boolean): void { | ||||||
|  | 		os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { | ||||||
|  | 			noteId: appearNote.id | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	function toggleWatch(watch: boolean): void { | ||||||
|  | 		os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', { | ||||||
|  | 			noteId: appearNote.id | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	function toggleThreadMute(mute: boolean): void { | ||||||
|  | 		os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', { | ||||||
|  | 			noteId: appearNote.id | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	function copyContent(): void { | ||||||
|  | 		copyToClipboard(appearNote.text); | ||||||
|  | 		os.success(); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	function copyLink(): void { | ||||||
|  | 		copyToClipboard(`${url}/notes/${appearNote.id}`); | ||||||
|  | 		os.success(); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	function togglePin(pin: boolean): void { | ||||||
|  | 		os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', { | ||||||
|  | 			noteId: appearNote.id | ||||||
|  | 		}, undefined, null, e => { | ||||||
|  | 			if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') { | ||||||
|  | 				os.alert({ | ||||||
|  | 					type: 'error', | ||||||
|  | 					text: i18n.locale.pinLimitExceeded | ||||||
|  | 				}); | ||||||
|  | 			} | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	async function clip(): Promise<void> { | ||||||
|  | 		const clips = await os.api('clips/list'); | ||||||
|  | 		os.popupMenu([{ | ||||||
|  | 			icon: 'fas fa-plus', | ||||||
|  | 			text: i18n.locale.createNew, | ||||||
|  | 			action: async () => { | ||||||
|  | 				const { canceled, result } = await os.form(i18n.locale.createNewClip, { | ||||||
|  | 					name: { | ||||||
|  | 						type: 'string', | ||||||
|  | 						label: i18n.locale.name | ||||||
|  | 					}, | ||||||
|  | 					description: { | ||||||
|  | 						type: 'string', | ||||||
|  | 						required: false, | ||||||
|  | 						multiline: true, | ||||||
|  | 						label: i18n.locale.description | ||||||
|  | 					}, | ||||||
|  | 					isPublic: { | ||||||
|  | 						type: 'boolean', | ||||||
|  | 						label: i18n.locale.public, | ||||||
|  | 						default: false | ||||||
|  | 					} | ||||||
|  | 				}); | ||||||
|  | 				if (canceled) return; | ||||||
|  | 
 | ||||||
|  | 				const clip = await os.apiWithDialog('clips/create', result); | ||||||
|  | 
 | ||||||
|  | 				os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id }); | ||||||
|  | 			} | ||||||
|  | 		}, null, ...clips.map(clip => ({ | ||||||
|  | 			text: clip.name, | ||||||
|  | 			action: () => { | ||||||
|  | 				os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id }); | ||||||
|  | 			} | ||||||
|  | 		}))], props.menuButton.value, { | ||||||
|  | 		}).then(focus); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	async function promote(): Promise<void> { | ||||||
|  | 		const { canceled, result: days } = await os.inputNumber({ | ||||||
|  | 			title: i18n.locale.numberOfDays, | ||||||
|  | 		}); | ||||||
|  | 
 | ||||||
|  | 		if (canceled) return; | ||||||
|  | 
 | ||||||
|  | 		os.apiWithDialog('admin/promo/create', { | ||||||
|  | 			noteId: appearNote.id, | ||||||
|  | 			expiresAt: Date.now() + (86400000 * days), | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	function share(): void { | ||||||
|  | 		navigator.share({ | ||||||
|  | 			title: i18n.t('noteOf', { user: appearNote.user.name }), | ||||||
|  | 			text: appearNote.text, | ||||||
|  | 			url: `${url}/notes/${appearNote.id}`, | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	async function translate(): Promise<void> { | ||||||
|  | 		if (props.translation.value != null) return; | ||||||
|  | 		props.translating.value = true; | ||||||
|  | 		const res = await os.api('notes/translate', { | ||||||
|  | 			noteId: appearNote.id, | ||||||
|  | 			targetLang: localStorage.getItem('lang') || navigator.language, | ||||||
|  | 		}); | ||||||
|  | 		props.translating.value = false; | ||||||
|  | 		props.translation.value = res; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	let menu; | ||||||
|  | 	if ($i) { | ||||||
|  | 		const statePromise = os.api('notes/state', { | ||||||
|  | 			noteId: appearNote.id | ||||||
|  | 		}); | ||||||
|  | 
 | ||||||
|  | 		menu = [{ | ||||||
|  | 			icon: 'fas fa-copy', | ||||||
|  | 			text: i18n.locale.copyContent, | ||||||
|  | 			action: copyContent | ||||||
|  | 		}, { | ||||||
|  | 			icon: 'fas fa-link', | ||||||
|  | 			text: i18n.locale.copyLink, | ||||||
|  | 			action: copyLink | ||||||
|  | 		}, (appearNote.url || appearNote.uri) ? { | ||||||
|  | 			icon: 'fas fa-external-link-square-alt', | ||||||
|  | 			text: i18n.locale.showOnRemote, | ||||||
|  | 			action: () => { | ||||||
|  | 				window.open(appearNote.url || appearNote.uri, '_blank'); | ||||||
|  | 			} | ||||||
|  | 		} : undefined, | ||||||
|  | 		{ | ||||||
|  | 			icon: 'fas fa-share-alt', | ||||||
|  | 			text: i18n.locale.share, | ||||||
|  | 			action: share | ||||||
|  | 		}, | ||||||
|  | 		instance.translatorAvailable ? { | ||||||
|  | 			icon: 'fas fa-language', | ||||||
|  | 			text: i18n.locale.translate, | ||||||
|  | 			action: translate | ||||||
|  | 		} : undefined, | ||||||
|  | 		null, | ||||||
|  | 		statePromise.then(state => state.isFavorited ? { | ||||||
|  | 			icon: 'fas fa-star', | ||||||
|  | 			text: i18n.locale.unfavorite, | ||||||
|  | 			action: () => toggleFavorite(false) | ||||||
|  | 		} : { | ||||||
|  | 			icon: 'fas fa-star', | ||||||
|  | 			text: i18n.locale.favorite, | ||||||
|  | 			action: () => toggleFavorite(true) | ||||||
|  | 		}), | ||||||
|  | 		{ | ||||||
|  | 			icon: 'fas fa-paperclip', | ||||||
|  | 			text: i18n.locale.clip, | ||||||
|  | 			action: () => clip() | ||||||
|  | 		}, | ||||||
|  | 		(appearNote.userId != $i.id) ? statePromise.then(state => state.isWatching ? { | ||||||
|  | 			icon: 'fas fa-eye-slash', | ||||||
|  | 			text: i18n.locale.unwatch, | ||||||
|  | 			action: () => toggleWatch(false) | ||||||
|  | 		} : { | ||||||
|  | 			icon: 'fas fa-eye', | ||||||
|  | 			text: i18n.locale.watch, | ||||||
|  | 			action: () => toggleWatch(true) | ||||||
|  | 		}) : undefined, | ||||||
|  | 		statePromise.then(state => state.isMutedThread ? { | ||||||
|  | 			icon: 'fas fa-comment-slash', | ||||||
|  | 			text: i18n.locale.unmuteThread, | ||||||
|  | 			action: () => toggleThreadMute(false) | ||||||
|  | 		} : { | ||||||
|  | 			icon: 'fas fa-comment-slash', | ||||||
|  | 			text: i18n.locale.muteThread, | ||||||
|  | 			action: () => toggleThreadMute(true) | ||||||
|  | 		}), | ||||||
|  | 		appearNote.userId == $i.id ? ($i.pinnedNoteIds || []).includes(appearNote.id) ? { | ||||||
|  | 			icon: 'fas fa-thumbtack', | ||||||
|  | 			text: i18n.locale.unpin, | ||||||
|  | 			action: () => togglePin(false) | ||||||
|  | 		} : { | ||||||
|  | 			icon: 'fas fa-thumbtack', | ||||||
|  | 			text: i18n.locale.pin, | ||||||
|  | 			action: () => togglePin(true) | ||||||
|  | 		} : undefined, | ||||||
|  | 		/* | ||||||
|  | 		...($i.isModerator || $i.isAdmin ? [ | ||||||
|  | 			null, | ||||||
|  | 			{ | ||||||
|  | 				icon: 'fas fa-bullhorn', | ||||||
|  | 				text: i18n.locale.promote, | ||||||
|  | 				action: promote | ||||||
|  | 			}] | ||||||
|  | 			: [] | ||||||
|  | 		),*/ | ||||||
|  | 		...(appearNote.userId != $i.id ? [ | ||||||
|  | 			null, | ||||||
|  | 			{ | ||||||
|  | 				icon: 'fas fa-exclamation-circle', | ||||||
|  | 				text: i18n.locale.reportAbuse, | ||||||
|  | 				action: () => { | ||||||
|  | 					const u = `${url}/notes/${appearNote.id}`; | ||||||
|  | 					os.popup(import('@/components/abuse-report-window.vue'), { | ||||||
|  | 						user: appearNote.user, | ||||||
|  | 						initialComment: `Note: ${u}\n-----\n` | ||||||
|  | 					}, {}, 'closed'); | ||||||
|  | 				} | ||||||
|  | 			}] | ||||||
|  | 			: [] | ||||||
|  | 		), | ||||||
|  | 		...(appearNote.userId == $i.id || $i.isModerator || $i.isAdmin ? [ | ||||||
|  | 			null, | ||||||
|  | 			appearNote.userId == $i.id ? { | ||||||
|  | 				icon: 'fas fa-edit', | ||||||
|  | 				text: i18n.locale.deleteAndEdit, | ||||||
|  | 				action: delEdit | ||||||
|  | 			} : undefined, | ||||||
|  | 			{ | ||||||
|  | 				icon: 'fas fa-trash-alt', | ||||||
|  | 				text: i18n.locale.delete, | ||||||
|  | 				danger: true, | ||||||
|  | 				action: del | ||||||
|  | 			}] | ||||||
|  | 			: [] | ||||||
|  | 		)] | ||||||
|  | 		.filter(x => x !== undefined); | ||||||
|  | 	} else { | ||||||
|  | 		menu = [{ | ||||||
|  | 			icon: 'fas fa-copy', | ||||||
|  | 			text: i18n.locale.copyContent, | ||||||
|  | 			action: copyContent | ||||||
|  | 		}, { | ||||||
|  | 			icon: 'fas fa-link', | ||||||
|  | 			text: i18n.locale.copyLink, | ||||||
|  | 			action: copyLink | ||||||
|  | 		}, (appearNote.url || appearNote.uri) ? { | ||||||
|  | 			icon: 'fas fa-external-link-square-alt', | ||||||
|  | 			text: i18n.locale.showOnRemote, | ||||||
|  | 			action: () => { | ||||||
|  | 				window.open(appearNote.url || appearNote.uri, '_blank'); | ||||||
|  | 			} | ||||||
|  | 		} : undefined] | ||||||
|  | 		.filter(x => x !== undefined); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if (noteActions.length > 0) { | ||||||
|  | 		menu = menu.concat([null, ...noteActions.map(action => ({ | ||||||
|  | 			icon: 'fas fa-plug', | ||||||
|  | 			text: action.title, | ||||||
|  | 			action: () => { | ||||||
|  | 				action.handler(appearNote); | ||||||
|  | 			} | ||||||
|  | 		}))]); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return menu; | ||||||
|  | } | ||||||
							
								
								
									
										123
									
								
								packages/client/src/scripts/use-note-capture.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								packages/client/src/scripts/use-note-capture.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,123 @@ | ||||||
|  | import { onUnmounted, Ref } from 'vue'; | ||||||
|  | import * as misskey from 'misskey-js'; | ||||||
|  | import { stream } from '@/stream'; | ||||||
|  | import { $i } from '@/account'; | ||||||
|  | 
 | ||||||
|  | export function useNoteCapture(props: { | ||||||
|  | 	rootEl: Ref<HTMLElement>; | ||||||
|  | 	appearNote: Ref<misskey.entities.Note>; | ||||||
|  | }) { | ||||||
|  | 	const appearNote = props.appearNote; | ||||||
|  | 	const connection = $i ? stream : null; | ||||||
|  | 
 | ||||||
|  | 	function onStreamNoteUpdated(data): void { | ||||||
|  | 		const { type, id, body } = data; | ||||||
|  | 
 | ||||||
|  | 		if (id !== appearNote.value.id) return; | ||||||
|  | 
 | ||||||
|  | 		switch (type) { | ||||||
|  | 			case 'reacted': { | ||||||
|  | 				const reaction = body.reaction; | ||||||
|  | 
 | ||||||
|  | 				const updated = JSON.parse(JSON.stringify(appearNote.value)); | ||||||
|  | 
 | ||||||
|  | 				if (body.emoji) { | ||||||
|  | 					const emojis = appearNote.value.emojis || []; | ||||||
|  | 					if (!emojis.includes(body.emoji)) { | ||||||
|  | 						updated.emojis = [...emojis, body.emoji]; | ||||||
|  | 					} | ||||||
|  | 				} | ||||||
|  | 
 | ||||||
|  | 				// TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
 | ||||||
|  | 				const currentCount = (appearNote.value.reactions || {})[reaction] || 0; | ||||||
|  | 
 | ||||||
|  | 				updated.reactions[reaction] = currentCount + 1; | ||||||
|  | 
 | ||||||
|  | 				if ($i && (body.userId === $i.id)) { | ||||||
|  | 					updated.myReaction = reaction; | ||||||
|  | 				} | ||||||
|  | 
 | ||||||
|  | 				appearNote.value = updated; | ||||||
|  | 				break; | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			case 'unreacted': { | ||||||
|  | 				const reaction = body.reaction; | ||||||
|  | 
 | ||||||
|  | 				const updated = JSON.parse(JSON.stringify(appearNote.value)); | ||||||
|  | 
 | ||||||
|  | 				// TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
 | ||||||
|  | 				const currentCount = (appearNote.value.reactions || {})[reaction] || 0; | ||||||
|  | 
 | ||||||
|  | 				updated.reactions[reaction] = Math.max(0, currentCount - 1); | ||||||
|  | 
 | ||||||
|  | 				if ($i && (body.userId === $i.id)) { | ||||||
|  | 					updated.myReaction = null; | ||||||
|  | 				} | ||||||
|  | 
 | ||||||
|  | 				appearNote.value = updated; | ||||||
|  | 				break; | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			case 'pollVoted': { | ||||||
|  | 				const choice = body.choice; | ||||||
|  | 
 | ||||||
|  | 				const updated = JSON.parse(JSON.stringify(appearNote.value)); | ||||||
|  | 
 | ||||||
|  | 				const choices = [...appearNote.value.poll.choices]; | ||||||
|  | 				choices[choice] = { | ||||||
|  | 					...choices[choice], | ||||||
|  | 					votes: choices[choice].votes + 1, | ||||||
|  | 					...($i && (body.userId === $i.id) ? { | ||||||
|  | 						isVoted: true | ||||||
|  | 					} : {}) | ||||||
|  | 				}; | ||||||
|  | 
 | ||||||
|  | 				updated.poll.choices = choices; | ||||||
|  | 
 | ||||||
|  | 				appearNote.value = updated; | ||||||
|  | 				break; | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			case 'deleted': { | ||||||
|  | 				const updated = JSON.parse(JSON.stringify(appearNote.value)); | ||||||
|  | 				updated.value = true; | ||||||
|  | 				appearNote.value = updated; | ||||||
|  | 				break; | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	function capture(withHandler = false): void { | ||||||
|  | 		if (connection) { | ||||||
|  | 			// TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
 | ||||||
|  | 			connection.send(document.body.contains(props.rootEl.value) ? 'sr' : 's', { id: appearNote.value.id }); | ||||||
|  | 			if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	function decapture(withHandler = false): void { | ||||||
|  | 		if (connection) { | ||||||
|  | 			connection.send('un', { | ||||||
|  | 				id: appearNote.value.id, | ||||||
|  | 			}); | ||||||
|  | 			if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	function onStreamConnected() { | ||||||
|  | 		capture(false); | ||||||
|  | 	} | ||||||
|  | 	 | ||||||
|  | 	capture(true); | ||||||
|  | 	if (connection) { | ||||||
|  | 		connection.on('_connected_', onStreamConnected); | ||||||
|  | 	} | ||||||
|  | 	 | ||||||
|  | 	onUnmounted(() => { | ||||||
|  | 		decapture(true); | ||||||
|  | 		if (connection) { | ||||||
|  | 			connection.off('_connected_', onStreamConnected); | ||||||
|  | 		} | ||||||
|  | 	}); | ||||||
|  | } | ||||||
|  | @ -160,7 +160,7 @@ export const defaultStore = markRaw(new Storage('base', { | ||||||
| 	}, | 	}, | ||||||
| 	useReactionPickerForContextMenu: { | 	useReactionPickerForContextMenu: { | ||||||
| 		where: 'device', | 		where: 'device', | ||||||
| 		default: true | 		default: false | ||||||
| 	}, | 	}, | ||||||
| 	showGapBetweenNotesInTimeline: { | 	showGapBetweenNotesInTimeline: { | ||||||
| 		where: 'device', | 		where: 'device', | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue