Merge branch 'develop' into fix-msg-room
This commit is contained in:
		
						commit
						927317b5bb
					
				
					 36 changed files with 454 additions and 356 deletions
				
			
		|  | @ -10,13 +10,19 @@ | |||
| ## 12.x.x (unreleased) | ||||
| 
 | ||||
| ### Improvements | ||||
| - 連合インスタンスページからインスタンス情報再取得を行えるように | ||||
| 
 | ||||
| ### Bugfixes | ||||
| - 投稿のNSFW画像を表示したあとにリアクションが更新されると画像が非表示になる問題を修正 | ||||
| - 「クリップ」ページが開かない問題を修正 | ||||
| - トレンドウィジェットが動作しないのを修正 | ||||
| - フェデレーションウィジェットが動作しないのを修正 | ||||
| - リアクション設定で絵文字ピッカーが開かないのを修正 | ||||
| - DMページでメンションが含まれる問題を修正 | ||||
| - 投稿フォームのハッシュタグ保持フィールドが動作しない問題を修正 | ||||
| - Add `img-src` and `media-src` directives to `Content-Security-Policy` for | ||||
|   files and media proxy | ||||
| - ensure that specified users does not get duplicates | ||||
| 
 | ||||
| ## 12.102.1 (2022/01/27) | ||||
| ### Bugfixes | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ We're glad you're interested in contributing Misskey! In this document you will | |||
| 
 | ||||
| **ℹ️ Important:** This project uses Japanese as its major language, **but you do not need to translate and write the Issues/PRs in Japanese.** | ||||
| Also, you might receive comments on your Issue/PR in Japanese, but you do not need to reply to them in Japanese as well.\ | ||||
| The accuracy of translation into Japanese is not high, so it will be easier for us to understand if you write it in the original language. | ||||
| The accuracy of machine translation into Japanese is not high, so it will be easier for us to understand if you write it in the original language. | ||||
| It will also allow the reader to use the translation tool of their preference if necessary. | ||||
| 
 | ||||
| ## Issues | ||||
|  |  | |||
|  | @ -176,3 +176,7 @@ describe('After user singed in', () => { | |||
| 		cy.contains('Hello, Misskey!'); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| // TODO: 投稿フォームの公開範囲指定のテスト
 | ||||
| // TODO: 投稿フォームのファイル添付のテスト
 | ||||
| // TODO: 投稿フォームのハッシュタグ保持フィールドのテスト
 | ||||
|  |  | |||
|  | @ -235,6 +235,8 @@ resetAreYouSure: "リセットしますか?" | |||
| saved: "保存しました" | ||||
| messaging: "チャット" | ||||
| upload: "アップロード" | ||||
| keepOriginalUploading: "オリジナル画像を保持" | ||||
| keepOriginalUploadingDescription: "画像をアップロードする時にオリジナル版を保持します。オフにするとアップロード時にブラウザでWeb公開用画像を生成します。" | ||||
| fromDrive: "ドライブから" | ||||
| fromUrl: "URLから" | ||||
| uploadFromUrl: "URLアップロード" | ||||
|  |  | |||
|  | @ -122,7 +122,7 @@ | |||
| 		"langmap": "0.0.16", | ||||
| 		"mfm-js": "0.21.0", | ||||
| 		"mime-types": "2.1.34", | ||||
| 		"misskey-js": "0.0.13", | ||||
| 		"misskey-js": "0.0.14", | ||||
| 		"mocha": "8.4.0", | ||||
| 		"ms": "3.0.0-canary.1", | ||||
| 		"multer": "1.4.4", | ||||
|  |  | |||
|  | @ -32,7 +32,7 @@ export default (endpoint: IEndpoint, ctx: Koa.Context) => new Promise((res) => { | |||
| 	// Authentication
 | ||||
| 	authenticate(body['i']).then(([user, app]) => { | ||||
| 		// API invoking
 | ||||
| 		call(endpoint.name, user, app, body, (ctx as any).file).then((res: any) => { | ||||
| 		call(endpoint.name, user, app, body, ctx).then((res: any) => { | ||||
| 			reply(res); | ||||
| 		}).catch((e: ApiError) => { | ||||
| 			reply(e.httpStatusCode ? e.httpStatusCode : e.kind === 'client' ? 400 : 500, e); | ||||
|  |  | |||
|  | @ -1,3 +1,4 @@ | |||
| import * as Koa from 'koa'; | ||||
| import { performance } from 'perf_hooks'; | ||||
| import { limiter } from './limiter'; | ||||
| import { User } from '@/models/entities/user'; | ||||
|  | @ -12,7 +13,7 @@ const accessDenied = { | |||
| 	id: '56f35758-7dd5-468b-8439-5d6fb8ec9b8e', | ||||
| }; | ||||
| 
 | ||||
| export default async (endpoint: string, user: User | null | undefined, token: AccessToken | null | undefined, data: any, file?: any) => { | ||||
| export default async (endpoint: string, user: User | null | undefined, token: AccessToken | null | undefined, data: any, ctx?: Koa.Context) => { | ||||
| 	const isSecure = user != null && token == null; | ||||
| 
 | ||||
| 	const ep = endpoints.find(e => e.name === endpoint); | ||||
|  | @ -76,9 +77,20 @@ export default async (endpoint: string, user: User | null | undefined, token: Ac | |||
| 		}); | ||||
| 	} | ||||
| 
 | ||||
| 	// Cast non JSON input
 | ||||
| 	if (ep.meta.requireFile && ep.meta.params) { | ||||
| 		const body = (ctx!.request as any).body; | ||||
| 		for (const k of Object.keys(ep.meta.params)) { | ||||
| 			const param = ep.meta.params[k]; | ||||
| 			if (['Boolean', 'Number'].includes(param.validator.name) && typeof body[k] === 'string') { | ||||
| 				body[k] = JSON.parse(body[k]); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// API invoking
 | ||||
| 	const before = performance.now(); | ||||
| 	return await ep.exec(data, user, token, file).catch((e: Error) => { | ||||
| 	return await ep.exec(data, user, token, ctx!.file).catch((e: Error) => { | ||||
| 		if (e instanceof ApiError) { | ||||
| 			throw e; | ||||
| 		} else { | ||||
|  |  | |||
|  | @ -39,15 +39,13 @@ export const meta = { | |||
| 		}, | ||||
| 
 | ||||
| 		isSensitive: { | ||||
| 			validator: $.optional.either($.bool, $.str), | ||||
| 			validator: $.optional.bool, | ||||
| 			default: false, | ||||
| 			transform: (v: any): boolean => v === true || v === 'true', | ||||
| 		}, | ||||
| 
 | ||||
| 		force: { | ||||
| 			validator: $.optional.either($.bool, $.str), | ||||
| 			validator: $.optional.bool, | ||||
| 			default: false, | ||||
| 			transform: (v: any): boolean => v === true || v === 'true', | ||||
| 		}, | ||||
| 	}, | ||||
| 
 | ||||
|  |  | |||
|  | @ -18,7 +18,7 @@ const _dirname = dirname(_filename); | |||
| const app = new Koa(); | ||||
| app.use(cors()); | ||||
| app.use(async (ctx, next) => { | ||||
| 	ctx.set('Content-Security-Policy', `default-src 'none'; style-src 'unsafe-inline'`); | ||||
| 	ctx.set('Content-Security-Policy', `default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'`); | ||||
| 	await next(); | ||||
| }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -11,7 +11,7 @@ import { proxyMedia } from './proxy-media'; | |||
| const app = new Koa(); | ||||
| app.use(cors()); | ||||
| app.use(async (ctx, next) => { | ||||
| 	ctx.set('Content-Security-Policy', `default-src 'none'; style-src 'unsafe-inline'`); | ||||
| 	ctx.set('Content-Security-Policy', `default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'`); | ||||
| 	await next(); | ||||
| }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -4967,10 +4967,10 @@ minizlib@^2.0.0, minizlib@^2.1.1: | |||
|     minipass "^3.0.0" | ||||
|     yallist "^4.0.0" | ||||
| 
 | ||||
| misskey-js@0.0.13: | ||||
|   version "0.0.13" | ||||
|   resolved "https://registry.yarnpkg.com/misskey-js/-/misskey-js-0.0.13.tgz#03a4e469186e28752d599dc4093519eb64647970" | ||||
|   integrity sha512-kBdJdfe281gtykzzsrN3IAxWUQIimzPiJGyKWf863ggWJlWYVPmP9hTFlX2z8oPOaypgVBPEPHyw/jNUdc2DbQ== | ||||
| misskey-js@0.0.14: | ||||
|   version "0.0.14" | ||||
|   resolved "https://registry.yarnpkg.com/misskey-js/-/misskey-js-0.0.14.tgz#1a616bdfbe81c6ee6900219eaf425bb5c714dd4d" | ||||
|   integrity sha512-bvLx6U3OwQwqHfp/WKwIVwdvNYAAPk0+YblXyxmSG3dwlzCgBRRLcB8o6bNruUDyJgh3t73pLDcOz3myxcUmww== | ||||
|   dependencies: | ||||
|     autobind-decorator "^2.4.0" | ||||
|     eventemitter3 "^4.0.7" | ||||
|  |  | |||
|  | @ -18,6 +18,7 @@ module.exports = { | |||
| 		// data の禁止理由: 抽象的すぎるため
 | ||||
| 		// e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため
 | ||||
| 		"id-denylist": ["error", "window", "data", "e"], | ||||
| 		'eqeqeq': ['error', 'always', { 'null': 'ignore' }], | ||||
| 		"vue/attributes-order": ["error", { | ||||
| 			"alphabetical": false | ||||
| 		}], | ||||
|  |  | |||
|  | @ -69,7 +69,7 @@ | |||
| 		"langmap": "0.0.16", | ||||
| 		"matter-js": "0.18.0", | ||||
| 		"mfm-js": "0.21.0", | ||||
| 		"misskey-js": "0.0.13", | ||||
| 		"misskey-js": "0.0.14", | ||||
| 		"mocha": "8.4.0", | ||||
| 		"ms": "2.1.3", | ||||
| 		"nested-property": "4.0.0", | ||||
|  |  | |||
							
								
								
									
										51
									
								
								packages/client/src/components/chart-tooltip.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								packages/client/src/components/chart-tooltip.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,51 @@ | |||
| <template> | ||||
| <MkTooltip ref="tooltip" :showing="showing" :x="x" :y="y" :max-width="340" @closed="emit('closed')"> | ||||
| 	<div v-if="title" class="qpcyisrl"> | ||||
| 		<div class="title">{{ title }}</div> | ||||
| 		<div v-for="x in series" class="series"> | ||||
| 			<span class="color" :style="{ background: x.backgroundColor, borderColor: x.borderColor }"></span> | ||||
| 			<span>{{ x.text }}</span> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </MkTooltip> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import {  } from 'vue'; | ||||
| import MkTooltip from './ui/tooltip.vue'; | ||||
| 
 | ||||
| const props = defineProps<{ | ||||
| 	showing: boolean; | ||||
| 	x: number; | ||||
| 	y: number; | ||||
| 	title: string; | ||||
| 	series: { | ||||
| 		backgroundColor: string; | ||||
| 		borderColor: string; | ||||
| 		text: string; | ||||
| 	}[]; | ||||
| }>(); | ||||
| 
 | ||||
| const emit = defineEmits<{ | ||||
| 	(ev: 'closed'): void; | ||||
| }>(); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| .qpcyisrl { | ||||
| 	> .title { | ||||
| 		margin-bottom: 4px; | ||||
| 	} | ||||
| 
 | ||||
| 	> .series { | ||||
| 		> .color { | ||||
| 			display: inline-block; | ||||
| 			width: 8px; | ||||
| 			height: 8px; | ||||
| 			border-width: 1px; | ||||
| 			border-style: solid; | ||||
| 			margin-right: 8px; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
|  | @ -8,7 +8,7 @@ | |||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { defineComponent, onMounted, ref, watch, PropType } from 'vue'; | ||||
| import { defineComponent, onMounted, ref, watch, PropType, onUnmounted, shallowRef } from 'vue'; | ||||
| import { | ||||
| 	Chart, | ||||
| 	ArcElement, | ||||
|  | @ -31,6 +31,7 @@ import { enUS } from 'date-fns/locale'; | |||
| import zoomPlugin from 'chartjs-plugin-zoom'; | ||||
| import * as os from '@/os'; | ||||
| import { defaultStore } from '@/store'; | ||||
| import MkChartTooltip from '@/components/chart-tooltip.vue'; | ||||
| 
 | ||||
| Chart.register( | ||||
| 	ArcElement, | ||||
|  | @ -137,6 +138,43 @@ export default defineComponent({ | |||
| 			})); | ||||
| 		}; | ||||
| 
 | ||||
| 		const tooltipShowing = ref(false); | ||||
| 		const tooltipX = ref(0); | ||||
| 		const tooltipY = ref(0); | ||||
| 		const tooltipTitle = ref(null); | ||||
| 		const tooltipSeries = ref(null); | ||||
| 		let disposeTooltipComponent; | ||||
| 
 | ||||
| 		os.popup(MkChartTooltip, { | ||||
| 			showing: tooltipShowing, | ||||
| 			x: tooltipX, | ||||
| 			y: tooltipY, | ||||
| 			title: tooltipTitle, | ||||
| 			series: tooltipSeries, | ||||
| 		}, {}).then(({ dispose }) => { | ||||
| 			disposeTooltipComponent = dispose; | ||||
| 		}); | ||||
| 
 | ||||
| 		function externalTooltipHandler(context) { | ||||
| 			if (context.tooltip.opacity === 0) { | ||||
| 				tooltipShowing.value = false; | ||||
| 				return; | ||||
| 			} | ||||
| 
 | ||||
| 			tooltipTitle.value = context.tooltip.title[0]; | ||||
| 			tooltipSeries.value = context.tooltip.body.map((b, i) => ({ | ||||
| 				backgroundColor: context.tooltip.labelColors[i].backgroundColor, | ||||
| 				borderColor: context.tooltip.labelColors[i].borderColor, | ||||
| 				text: b.lines[0], | ||||
| 			})); | ||||
| 
 | ||||
| 			const rect = context.chart.canvas.getBoundingClientRect(); | ||||
| 
 | ||||
| 			tooltipShowing.value = true; | ||||
| 			tooltipX.value = rect.left + window.pageXOffset + context.tooltip.caretX; | ||||
| 			tooltipY.value = rect.top + window.pageYOffset + context.tooltip.caretY; | ||||
| 		} | ||||
| 
 | ||||
| 		const render = () => { | ||||
| 			if (chartInstance) { | ||||
| 				chartInstance.destroy(); | ||||
|  | @ -222,10 +260,12 @@ export default defineComponent({ | |||
| 							}, | ||||
| 						}, | ||||
| 						tooltip: { | ||||
| 							enabled: false, | ||||
| 							mode: 'index', | ||||
| 							animation: { | ||||
| 								duration: 0, | ||||
| 							}, | ||||
| 							external: externalTooltipHandler, | ||||
| 						}, | ||||
| 						zoom: { | ||||
| 							pan: { | ||||
|  | @ -684,6 +724,10 @@ export default defineComponent({ | |||
| 			fetchAndRender(); | ||||
| 		}); | ||||
| 
 | ||||
| 		onUnmounted(() => { | ||||
| 			if (disposeTooltipComponent) disposeTooltipComponent(); | ||||
| 		}); | ||||
| 
 | ||||
| 		return { | ||||
| 			chartEl, | ||||
| 			fetching, | ||||
|  |  | |||
|  | @ -117,7 +117,7 @@ export default defineComponent({ | |||
| 				text: computed(() => { | ||||
| 					return props.textConverter(finalValue.value); | ||||
| 				}), | ||||
| 				source: thumbEl, | ||||
| 				targetElement: thumbEl, | ||||
| 			}, {}, 'closed'); | ||||
| 
 | ||||
| 			const style = document.createElement('style'); | ||||
|  |  | |||
|  | @ -20,45 +20,33 @@ | |||
| </div> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { defineComponent, ref, toRefs } from 'vue'; | ||||
| <script lang="ts" setup> | ||||
| import { toRefs, Ref } from 'vue'; | ||||
| import * as os from '@/os'; | ||||
| import Ripple from '@/components/ripple.vue'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
| 	props: { | ||||
| 		modelValue: { | ||||
| 			type: Boolean, | ||||
| 			default: false | ||||
| 		}, | ||||
| 		disabled: { | ||||
| 			type: Boolean, | ||||
| 			default: false | ||||
| 		} | ||||
| 	}, | ||||
| const props = defineProps<{ | ||||
| 	modelValue: boolean | Ref<boolean>; | ||||
| 	disabled?: boolean; | ||||
| }>(); | ||||
| 
 | ||||
| 	setup(props, context) { | ||||
| 		const button = ref<HTMLElement>(); | ||||
| 		const checked = toRefs(props).modelValue; | ||||
| 		const toggle = () => { | ||||
| 			if (props.disabled) return; | ||||
| 			context.emit('update:modelValue', !checked.value); | ||||
| const emit = defineEmits<{ | ||||
| 	(e: 'update:modelValue', v: boolean): void; | ||||
| }>(); | ||||
| 
 | ||||
| 			if (!checked.value) { | ||||
| 				const rect = button.value.getBoundingClientRect(); | ||||
| 				const x = rect.left + (button.value.offsetWidth / 2); | ||||
| 				const y = rect.top + (button.value.offsetHeight / 2); | ||||
| 				os.popup(Ripple, { x, y, particle: false }, {}, 'end'); | ||||
| 			} | ||||
| 		}; | ||||
| let button = $ref<HTMLElement>(); | ||||
| const checked = toRefs(props).modelValue; | ||||
| const toggle = () => { | ||||
| 	if (props.disabled) return; | ||||
| 	emit('update:modelValue', !checked.value); | ||||
| 
 | ||||
| 		return { | ||||
| 			button, | ||||
| 			checked, | ||||
| 			toggle, | ||||
| 		}; | ||||
| 	}, | ||||
| }); | ||||
| 	if (!checked.value) { | ||||
| 		const rect = button.getBoundingClientRect(); | ||||
| 		const x = rect.left + (button.offsetWidth / 2); | ||||
| 		const y = rect.top + (button.offsetHeight / 2); | ||||
| 		os.popup(Ripple, { x, y, particle: false }, {}, 'end'); | ||||
| 	} | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
|  |  | |||
|  | @ -153,7 +153,7 @@ export default defineComponent({ | |||
| 				showing, | ||||
| 				reaction: props.notification.reaction ? props.notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : props.notification.reaction, | ||||
| 				emojis: props.notification.note.emojis, | ||||
| 				source: reactionRef.value.$el, | ||||
| 				targetElement: reactionRef.value.$el, | ||||
| 			}, {}, 'closed'); | ||||
| 		}); | ||||
| 
 | ||||
|  |  | |||
|  | @ -135,7 +135,10 @@ let showPreview = $ref(false); | |||
| let cw = $ref<string | null>(null); | ||||
| let localOnly = $ref<boolean>(props.initialLocalOnly ?? defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly); | ||||
| let visibility = $ref(props.initialVisibility ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility) as typeof misskey.noteVisibilities[number]); | ||||
| let visibleUsers = $ref(props.initialVisibleUsers ?? []); | ||||
| let visibleUsers = $ref([]); | ||||
| if (props.initialVisibleUsers) { | ||||
| 	props.initialVisibleUsers.forEach(pushVisibleUser); | ||||
| } | ||||
| let autocomplete = $ref(null); | ||||
| let draghover = $ref(false); | ||||
| let quoteId = $ref(null); | ||||
|  | @ -262,12 +265,12 @@ if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visib | |||
| 		os.api('users/show', { | ||||
| 			userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply.userId) | ||||
| 		}).then(users => { | ||||
| 			visibleUsers.push(...users); | ||||
| 			users.forEach(pushVisibleUser); | ||||
| 		}); | ||||
| 
 | ||||
| 		if (props.reply.userId !== $i.id) { | ||||
| 			os.api('users/show', { userId: props.reply.userId }).then(user => { | ||||
| 				visibleUsers.push(user); | ||||
| 				pushVisibleUser(user); | ||||
| 			}); | ||||
| 		} | ||||
| 	} | ||||
|  | @ -275,7 +278,7 @@ if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visib | |||
| 
 | ||||
| if (props.specified) { | ||||
| 	visibility = 'specified'; | ||||
| 	visibleUsers.push(props.specified); | ||||
| 	pushVisibleUser(props.specified); | ||||
| } | ||||
| 
 | ||||
| // keep cw when reply | ||||
|  | @ -397,9 +400,15 @@ function setVisibility() { | |||
| 	}, 'closed'); | ||||
| } | ||||
| 
 | ||||
| function pushVisibleUser(user) { | ||||
| 	if (!visibleUsers.some(u => u.username === user.username && u.host === user.host)) { | ||||
| 		visibleUsers.push(user); | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| function addVisibleUser() { | ||||
| 	os.selectUser().then(user => { | ||||
| 		visibleUsers.push(user); | ||||
| 		pushVisibleUser(user); | ||||
| 	}); | ||||
| } | ||||
| 
 | ||||
|  | @ -540,8 +549,8 @@ async function post() { | |||
| 	}; | ||||
| 
 | ||||
| 	if (withHashtags && hashtags && hashtags.trim() !== '') { | ||||
| 		const hashtags = hashtags.trim().split(' ').map(x => x.startsWith('#') ? x : '#' + x).join(' '); | ||||
| 		data.text = data.text ? `${data.text} ${hashtags}` : hashtags; | ||||
| 		const hashtags_ = hashtags.trim().split(' ').map(x => x.startsWith('#') ? x : '#' + x).join(' '); | ||||
| 		data.text = data.text ? `${data.text} ${hashtags_}` : hashtags_; | ||||
| 	} | ||||
| 
 | ||||
| 	// plugin | ||||
|  | @ -565,9 +574,9 @@ async function post() { | |||
| 			deleteDraft(); | ||||
| 			emit('posted'); | ||||
| 			if (data.text && data.text != '') { | ||||
| 				const hashtags = mfm.parse(data.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag); | ||||
| 				const hashtags_ = mfm.parse(data.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag); | ||||
| 				const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[]; | ||||
| 				localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history)))); | ||||
| 				localStorage.setItem('hashtags', JSON.stringify(unique(hashtags_.concat(history)))); | ||||
| 			} | ||||
| 			posting = false; | ||||
| 			postAccount = null; | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| <template> | ||||
| <MkTooltip ref="tooltip" :source="source" :max-width="340" @closed="emit('closed')"> | ||||
| <MkTooltip ref="tooltip" :target-element="targetElement" :max-width="340" @closed="emit('closed')"> | ||||
| 	<div class="beeadbfb"> | ||||
| 		<XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/> | ||||
| 		<div class="name">{{ reaction.replace('@.', '') }}</div> | ||||
|  | @ -15,11 +15,11 @@ import XReactionIcon from './reaction-icon.vue'; | |||
| const props = defineProps<{ | ||||
| 	reaction: string; | ||||
| 	emojis: any[]; // TODO | ||||
| 	source: any; // TODO | ||||
| 	targetElement: HTMLElement; | ||||
| }>(); | ||||
| 
 | ||||
| const emit = defineEmits<{ | ||||
| 	(e: 'closed'): void; | ||||
| 	(ev: 'closed'): void; | ||||
| }>(); | ||||
| </script> | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| <template> | ||||
| <MkTooltip ref="tooltip" :source="source" :max-width="340" @closed="emit('closed')"> | ||||
| <MkTooltip ref="tooltip" :target-element="targetElement" :max-width="340" @closed="emit('closed')"> | ||||
| 	<div class="bqxuuuey"> | ||||
| 		<div class="reaction"> | ||||
| 			<XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/> | ||||
|  | @ -26,11 +26,11 @@ const props = defineProps<{ | |||
| 	users: any[]; // TODO | ||||
| 	count: number; | ||||
| 	emojis: any[]; // TODO | ||||
| 	source: any; // TODO | ||||
| 	targetElement: HTMLElement; | ||||
| }>(); | ||||
| 
 | ||||
| const emit = defineEmits<{ | ||||
| 	(e: 'closed'): void; | ||||
| 	(ev: 'closed'): void; | ||||
| }>(); | ||||
| </script> | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| <template> | ||||
| <MkTooltip ref="tooltip" :source="source" :max-width="250" @closed="emit('closed')"> | ||||
| <MkTooltip ref="tooltip" :target-element="targetElement" :max-width="250" @closed="emit('closed')"> | ||||
| 	<div class="beaffaef"> | ||||
| 		<div v-for="u in users" :key="u.id" class="user"> | ||||
| 			<MkAvatar class="avatar" :user="u"/> | ||||
|  | @ -17,11 +17,11 @@ import MkTooltip from './ui/tooltip.vue'; | |||
| const props = defineProps<{ | ||||
| 	users: any[]; // TODO | ||||
| 	count: number; | ||||
| 	source: any; // TODO | ||||
| 	targetElement: HTMLElement; | ||||
| }>(); | ||||
| 
 | ||||
| const emit = defineEmits<{ | ||||
| 	(e: 'closed'): void; | ||||
| 	(ev: 'closed'): void; | ||||
| }>(); | ||||
| </script> | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,88 +1,71 @@ | |||
| <template> | ||||
| <transition :name="$store.state.animation ? 'fade' : ''" appear> | ||||
| 	<div class="nvlagfpb" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}"> | ||||
| 	<div ref="rootEl" class="nvlagfpb" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}"> | ||||
| 		<MkMenu :items="items" class="_popup _shadow" :align="'left'" @close="$emit('closed')"/> | ||||
| 	</div> | ||||
| </transition> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { defineComponent } from 'vue'; | ||||
| <script lang="ts" setup> | ||||
| import { onMounted, onBeforeUnmount } from 'vue'; | ||||
| import contains from '@/scripts/contains'; | ||||
| import MkMenu from './menu.vue'; | ||||
| import { MenuItem } from './types/menu.vue'; | ||||
| import * as os from '@/os'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
| 	components: { | ||||
| 		MkMenu, | ||||
| 	}, | ||||
| 	props: { | ||||
| 		items: { | ||||
| 			type: Array, | ||||
| 			required: true | ||||
| 		}, | ||||
| 		ev: { | ||||
| 			required: true | ||||
| 		}, | ||||
| 		viaKeyboard: { | ||||
| 			type: Boolean, | ||||
| 			required: false | ||||
| 		}, | ||||
| 	}, | ||||
| 	emits: ['closed'], | ||||
| 	data() { | ||||
| 		return { | ||||
| 			zIndex: os.claimZIndex('high'), | ||||
| 		}; | ||||
| 	}, | ||||
| 	computed: { | ||||
| 		keymap(): any { | ||||
| 			return { | ||||
| 				'esc': () => this.$emit('closed'), | ||||
| 			}; | ||||
| 		}, | ||||
| 	}, | ||||
| 	mounted() { | ||||
| 		let left = this.ev.pageX + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1 | ||||
| 		let top = this.ev.pageY + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1 | ||||
| const props = defineProps<{ | ||||
| 	items: MenuItem[]; | ||||
| 	ev: MouseEvent; | ||||
| }>(); | ||||
| 
 | ||||
| 		const width = this.$el.offsetWidth; | ||||
| 		const height = this.$el.offsetHeight; | ||||
| const emit = defineEmits<{ | ||||
| 	(e: 'closed'): void; | ||||
| }>(); | ||||
| 
 | ||||
| 		if (left + width - window.pageXOffset > window.innerWidth) { | ||||
| 			left = window.innerWidth - width + window.pageXOffset; | ||||
| 		} | ||||
| let rootEl = $ref<HTMLDivElement>(); | ||||
| 
 | ||||
| 		if (top + height - window.pageYOffset > window.innerHeight) { | ||||
| 			top = window.innerHeight - height + window.pageYOffset; | ||||
| 		} | ||||
| let zIndex = $ref<number>(os.claimZIndex('high')); | ||||
| 
 | ||||
| 		if (top < 0) { | ||||
| 			top = 0; | ||||
| 		} | ||||
| onMounted(() => { | ||||
| 	let left = props.ev.pageX + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1 | ||||
| 	let top = props.ev.pageY + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1 | ||||
| 
 | ||||
| 		if (left < 0) { | ||||
| 			left = 0; | ||||
| 		} | ||||
| 	const width = rootEl.offsetWidth; | ||||
| 	const height = rootEl.offsetHeight; | ||||
| 
 | ||||
| 		this.$el.style.top = top + 'px'; | ||||
| 		this.$el.style.left = left + 'px'; | ||||
| 	if (left + width - window.pageXOffset > window.innerWidth) { | ||||
| 		left = window.innerWidth - width + window.pageXOffset; | ||||
| 	} | ||||
| 
 | ||||
| 		for (const el of Array.from(document.querySelectorAll('body *'))) { | ||||
| 			el.addEventListener('mousedown', this.onMousedown); | ||||
| 		} | ||||
| 	}, | ||||
| 	beforeUnmount() { | ||||
| 		for (const el of Array.from(document.querySelectorAll('body *'))) { | ||||
| 			el.removeEventListener('mousedown', this.onMousedown); | ||||
| 		} | ||||
| 	}, | ||||
| 	methods: { | ||||
| 		onMousedown(e) { | ||||
| 			if (!contains(this.$el, e.target) && (this.$el != e.target)) this.$emit('closed'); | ||||
| 		}, | ||||
| 	if (top + height - window.pageYOffset > window.innerHeight) { | ||||
| 		top = window.innerHeight - height + window.pageYOffset; | ||||
| 	} | ||||
| 
 | ||||
| 	if (top < 0) { | ||||
| 		top = 0; | ||||
| 	} | ||||
| 
 | ||||
| 	if (left < 0) { | ||||
| 		left = 0; | ||||
| 	} | ||||
| 
 | ||||
| 	rootEl.style.top = `${top}px`; | ||||
| 	rootEl.style.left = `${left}px`; | ||||
| 
 | ||||
| 	for (const el of Array.from(document.querySelectorAll('body *'))) { | ||||
| 		el.addEventListener('mousedown', onMousedown); | ||||
| 	} | ||||
| }); | ||||
| 
 | ||||
| onBeforeUnmount(() => { | ||||
| 	for (const el of Array.from(document.querySelectorAll('body *'))) { | ||||
| 		el.removeEventListener('mousedown', onMousedown); | ||||
| 	} | ||||
| }); | ||||
| 
 | ||||
| function onMousedown(e: Event) { | ||||
| 	if (!contains(rootEl, e.target) && (rootEl != e.target)) emit('closed'); | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
|  |  | |||
|  | @ -1,8 +1,8 @@ | |||
| <template> | ||||
| <div ref="items" v-hotkey="keymap" | ||||
| <div ref="itemsEl" v-hotkey="keymap" | ||||
| 	class="rrevdjwt" | ||||
| 	:class="{ center: align === 'center', asDrawer }" | ||||
| 	:style="{ width: (width && !asDrawer) ? width + 'px' : null, maxHeight: maxHeight ? maxHeight + 'px' : null }" | ||||
| 	:style="{ width: (width && !asDrawer) ? width + 'px' : '', maxHeight: maxHeight ? maxHeight + 'px' : '' }" | ||||
| 	@contextmenu.self="e => e.preventDefault()" | ||||
| > | ||||
| 	<template v-for="(item, i) in items2"> | ||||
|  | @ -28,6 +28,9 @@ | |||
| 			<MkAvatar :user="item.user" class="avatar"/><MkUserName :user="item.user"/> | ||||
| 			<span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> | ||||
| 		</button> | ||||
| 		<span v-else-if="item.type === 'switch'" :tabindex="i" class="item"> | ||||
| 			<FormSwitch v-model="item.ref" :disabled="item.disabled" class="form-switch">{{ item.text }}</FormSwitch> | ||||
| 		</span> | ||||
| 		<button v-else :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)"> | ||||
| 			<i v-if="item.icon" class="fa-fw" :class="item.icon"></i> | ||||
| 			<MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/> | ||||
|  | @ -41,114 +44,78 @@ | |||
| </div> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { defineComponent, ref, unref } from 'vue'; | ||||
| <script lang="ts" setup> | ||||
| import { nextTick, onMounted, watch } from 'vue'; | ||||
| import { focusPrev, focusNext } from '@/scripts/focus'; | ||||
| import contains from '@/scripts/contains'; | ||||
| import FormSwitch from '@/components/form/switch.vue'; | ||||
| import { MenuItem, InnerMenuItem, MenuPending, MenuAction } from '@/types/menu'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
| 	props: { | ||||
| 		items: { | ||||
| 			type: Array, | ||||
| 			required: true | ||||
| 		}, | ||||
| 		viaKeyboard: { | ||||
| 			type: Boolean, | ||||
| 			required: false | ||||
| 		}, | ||||
| 		asDrawer: { | ||||
| 			type: Boolean, | ||||
| 			required: false | ||||
| 		}, | ||||
| 		align: { | ||||
| 			type: String, | ||||
| 			requried: false | ||||
| 		}, | ||||
| 		width: { | ||||
| 			type: Number, | ||||
| 			required: false | ||||
| 		}, | ||||
| 		maxHeight: { | ||||
| 			type: Number, | ||||
| 			required: false | ||||
| 		}, | ||||
| 	}, | ||||
| 	emits: ['close'], | ||||
| 	data() { | ||||
| 		return { | ||||
| 			items2: [], | ||||
| 		}; | ||||
| 	}, | ||||
| 	computed: { | ||||
| 		keymap(): any { | ||||
| 			return { | ||||
| 				'up|k|shift+tab': this.focusUp, | ||||
| 				'down|j|tab': this.focusDown, | ||||
| 				'esc': this.close, | ||||
| 			}; | ||||
| 		}, | ||||
| 	}, | ||||
| 	watch: { | ||||
| 		items: { | ||||
| 			handler() { | ||||
| 				const items = ref(unref(this.items).filter(item => item !== undefined)); | ||||
| const props = defineProps<{ | ||||
| 	items: MenuItem[]; | ||||
| 	viaKeyboard?: boolean; | ||||
| 	asDrawer?: boolean; | ||||
| 	align?: 'center' | string; | ||||
| 	width?: number; | ||||
| 	maxHeight?: number; | ||||
| }>(); | ||||
| 
 | ||||
| 				for (let i = 0; i < items.value.length; i++) { | ||||
| 					const item = items.value[i]; | ||||
| 					 | ||||
| 					if (item && item.then) { // if item is Promise | ||||
| 						items.value[i] = { type: 'pending' }; | ||||
| 						item.then(actualItem => { | ||||
| 							items.value[i] = actualItem; | ||||
| 						}); | ||||
| 					} | ||||
| 				} | ||||
| const emit = defineEmits<{ | ||||
| 	(e: 'close'): void; | ||||
| }>(); | ||||
| 
 | ||||
| 				this.items2 = items; | ||||
| 			}, | ||||
| 			immediate: true | ||||
| 		} | ||||
| 	}, | ||||
| 	mounted() { | ||||
| 		if (this.viaKeyboard) { | ||||
| 			this.$nextTick(() => { | ||||
| 				focusNext(this.$refs.items.children[0], true, false); | ||||
| let itemsEl = $ref<HTMLDivElement>(); | ||||
| 
 | ||||
| let items2: InnerMenuItem[] = $ref([]); | ||||
| 
 | ||||
| let keymap = $computed(() => ({ | ||||
| 	'up|k|shift+tab': focusUp, | ||||
| 	'down|j|tab': focusDown, | ||||
| 	'esc': close, | ||||
| })); | ||||
| 
 | ||||
| watch(() => props.items, () => { | ||||
| 	const items: (MenuItem | MenuPending)[] = [...props.items].filter(item => item !== undefined); | ||||
| 
 | ||||
| 	for (let i = 0; i < items.length; i++) { | ||||
| 		const item = items[i]; | ||||
| 
 | ||||
| 		if (item && 'then' in item) { // if item is Promise | ||||
| 			items[i] = { type: 'pending' }; | ||||
| 			item.then(actualItem => { | ||||
| 				items2[i] = actualItem; | ||||
| 			}); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 		if (this.contextmenuEvent) { | ||||
| 			this.$el.style.top = this.contextmenuEvent.pageY + 'px'; | ||||
| 			this.$el.style.left = this.contextmenuEvent.pageX + 'px'; | ||||
| 	items2 = items as InnerMenuItem[]; | ||||
| }, { | ||||
| 	immediate: true, | ||||
| }); | ||||
| 
 | ||||
| 			for (const el of Array.from(document.querySelectorAll('body *'))) { | ||||
| 				el.addEventListener('mousedown', this.onMousedown); | ||||
| 			} | ||||
| 		} | ||||
| 	}, | ||||
| 	beforeUnmount() { | ||||
| 		for (const el of Array.from(document.querySelectorAll('body *'))) { | ||||
| 			el.removeEventListener('mousedown', this.onMousedown); | ||||
| 		} | ||||
| 	}, | ||||
| 	methods: { | ||||
| 		clicked(fn, ev) { | ||||
| 			fn(ev); | ||||
| 			this.close(); | ||||
| 		}, | ||||
| 		close() { | ||||
| 			this.$emit('close'); | ||||
| 		}, | ||||
| 		focusUp() { | ||||
| 			focusPrev(document.activeElement); | ||||
| 		}, | ||||
| 		focusDown() { | ||||
| 			focusNext(document.activeElement); | ||||
| 		}, | ||||
| 		onMousedown(e) { | ||||
| 			if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close(); | ||||
| 		}, | ||||
| onMounted(() => { | ||||
| 	if (props.viaKeyboard) { | ||||
| 		nextTick(() => { | ||||
| 			focusNext(itemsEl.children[0], true, false); | ||||
| 		}); | ||||
| 	} | ||||
| }); | ||||
| 
 | ||||
| function clicked(fn: MenuAction, ev: MouseEvent) { | ||||
| 	fn(ev); | ||||
| 	close(); | ||||
| } | ||||
| 
 | ||||
| function close() { | ||||
| 	emit('close'); | ||||
| } | ||||
| 
 | ||||
| function focusUp() { | ||||
| 	focusPrev(document.activeElement); | ||||
| } | ||||
| 
 | ||||
| function focusDown() { | ||||
| 	focusNext(document.activeElement); | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
|  |  | |||
|  | @ -1,44 +1,28 @@ | |||
| <template> | ||||
| <MkModal ref="modal" v-slot="{ type, maxHeight }" :z-priority="'high'" :src="src" :transparent-bg="true" @click="$refs.modal.close()" @closed="$emit('closed')"> | ||||
| 	<MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :as-drawer="type === 'drawer'" class="sfhdhdhq _popup _shadow" :class="{ drawer: type === 'drawer' }" @close="$refs.modal.close()"/> | ||||
| <MkModal ref="modal" v-slot="{ type, maxHeight }" :z-priority="'high'" :src="src" :transparent-bg="true" @click="modal.close()" @closed="emit('closed')"> | ||||
| 	<MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :as-drawer="type === 'drawer'" class="sfhdhdhq _popup _shadow" :class="{ drawer: type === 'drawer' }" @close="modal.close()"/> | ||||
| </MkModal> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { defineComponent } from 'vue'; | ||||
| <script lang="ts" setup> | ||||
| import { } from 'vue'; | ||||
| import MkModal from './modal.vue'; | ||||
| import MkMenu from './menu.vue'; | ||||
| import { MenuItem } from '@/types/menu'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
| 	components: { | ||||
| 		MkModal, | ||||
| 		MkMenu, | ||||
| 	}, | ||||
| defineProps<{ | ||||
| 	items: MenuItem[]; | ||||
| 	align?: 'center' | string; | ||||
| 	width?: number; | ||||
| 	viaKeyboard?: boolean; | ||||
| 	src?: any; | ||||
| }>(); | ||||
| 
 | ||||
| 	props: { | ||||
| 		items: { | ||||
| 			type: Array, | ||||
| 			required: true | ||||
| 		}, | ||||
| 		align: { | ||||
| 			type: String, | ||||
| 			required: false | ||||
| 		}, | ||||
| 		width: { | ||||
| 			type: Number, | ||||
| 			required: false | ||||
| 		}, | ||||
| 		viaKeyboard: { | ||||
| 			type: Boolean, | ||||
| 			required: false | ||||
| 		}, | ||||
| 		src: { | ||||
| 			required: false | ||||
| 		}, | ||||
| 	}, | ||||
| const emit = defineEmits<{ | ||||
| 	(e: 'closed'): void; | ||||
| }>(); | ||||
| 
 | ||||
| 	emits: ['close', 'closed'], | ||||
| }); | ||||
| let modal = $ref<InstanceType<typeof MkModal>>(); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
|  |  | |||
|  | @ -1,99 +1,96 @@ | |||
| <template> | ||||
| <transition :name="$store.state.animation ? 'tooltip' : ''" appear @after-leave="$emit('closed')"> | ||||
| <transition :name="$store.state.animation ? 'tooltip' : ''" appear @after-leave="emit('closed')"> | ||||
| 	<div v-show="showing" ref="el" class="buebdbiu _acrylic _shadow" :style="{ zIndex, maxWidth: maxWidth + 'px' }"> | ||||
| 		<slot>{{ text }}</slot> | ||||
| 	</div> | ||||
| </transition> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { defineComponent, nextTick, onMounted, onUnmounted, ref } from 'vue'; | ||||
| <script lang="ts" setup> | ||||
| import { nextTick, onMounted, onUnmounted, ref } from 'vue'; | ||||
| import * as os from '@/os'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
| 	props: { | ||||
| 		showing: { | ||||
| 			type: Boolean, | ||||
| 			required: true, | ||||
| 		}, | ||||
| 		source: { | ||||
| 			required: true, | ||||
| 		}, | ||||
| 		text: { | ||||
| 			type: String, | ||||
| 			required: false | ||||
| 		}, | ||||
| 		maxWidth: { | ||||
| 			type: Number, | ||||
| 			required: false, | ||||
| 			default: 250, | ||||
| 		}, | ||||
| 	}, | ||||
| const props = withDefaults(defineProps<{ | ||||
| 	showing: boolean; | ||||
| 	targetElement?: HTMLElement; | ||||
| 	x?: number; | ||||
| 	y?: number; | ||||
| 	text?: string; | ||||
| 	maxWidth?: number; | ||||
| }>(), { | ||||
| 	maxWidth: 250, | ||||
| }); | ||||
| 
 | ||||
| 	emits: ['closed'], | ||||
| const emit = defineEmits<{ | ||||
| 	(ev: 'closed'): void; | ||||
| }>(); | ||||
| 
 | ||||
| 	setup(props, context) { | ||||
| 		const el = ref<HTMLElement>(); | ||||
| 		const zIndex = os.claimZIndex('high'); | ||||
| const el = ref<HTMLElement>(); | ||||
| const zIndex = os.claimZIndex('high'); | ||||
| 
 | ||||
| 		const setPosition = () => { | ||||
| 			if (el.value == null) return; | ||||
| const setPosition = () => { | ||||
| 	if (el.value == null) return; | ||||
| 
 | ||||
| 			const rect = props.source.getBoundingClientRect(); | ||||
| 	const contentWidth = el.value.offsetWidth; | ||||
| 	const contentHeight = el.value.offsetHeight; | ||||
| 
 | ||||
| 			const contentWidth = el.value.offsetWidth; | ||||
| 			const contentHeight = el.value.offsetHeight; | ||||
| 	let left: number; | ||||
| 	let top: number; | ||||
| 
 | ||||
| 			let left = rect.left + window.pageXOffset + (props.source.offsetWidth / 2); | ||||
| 			let top = rect.top + window.pageYOffset - contentHeight; | ||||
| 	let rect: DOMRect; | ||||
| 
 | ||||
| 			left -= (el.value.offsetWidth / 2); | ||||
| 	if (props.targetElement) { | ||||
| 		rect = props.targetElement.getBoundingClientRect(); | ||||
| 
 | ||||
| 			if (left + contentWidth - window.pageXOffset > window.innerWidth) { | ||||
| 				left = window.innerWidth - contentWidth + window.pageXOffset - 1; | ||||
| 			} | ||||
| 		left = rect.left + window.pageXOffset + (props.targetElement.offsetWidth / 2); | ||||
| 		top = rect.top + window.pageYOffset - contentHeight; | ||||
| 
 | ||||
| 			if (top - window.pageYOffset < 0) { | ||||
| 				top = rect.top + window.pageYOffset + props.source.offsetHeight; | ||||
| 				el.value.style.transformOrigin = 'center top'; | ||||
| 			} | ||||
| 		el.value.style.transformOrigin = 'center bottom'; | ||||
| 	} else { | ||||
| 		left = props.x; | ||||
| 		top = props.y - contentHeight; | ||||
| 	} | ||||
| 
 | ||||
| 			el.value.style.left = left + 'px'; | ||||
| 			el.value.style.top = top + 'px'; | ||||
| 		}; | ||||
| 	left -= (el.value.offsetWidth / 2); | ||||
| 
 | ||||
| 		onMounted(() => { | ||||
| 			nextTick(() => { | ||||
| 				if (props.source == null) { | ||||
| 					context.emit('closed'); | ||||
| 					return; | ||||
| 				} | ||||
| 	if (left + contentWidth - window.pageXOffset > window.innerWidth) { | ||||
| 		left = window.innerWidth - contentWidth + window.pageXOffset - 1; | ||||
| 	} | ||||
| 
 | ||||
| 	// ツールチップを上に向かって表示するスペースがなければ下に向かって出す | ||||
| 	if (top - window.pageYOffset < 0) { | ||||
| 		if (props.targetElement) { | ||||
| 			top = rect.top + window.pageYOffset + props.targetElement.offsetHeight; | ||||
| 			el.value.style.transformOrigin = 'center top'; | ||||
| 		} else { | ||||
| 			top = props.y; | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	el.value.style.left = left + 'px'; | ||||
| 	el.value.style.top = top + 'px'; | ||||
| }; | ||||
| 
 | ||||
| onMounted(() => { | ||||
| 	nextTick(() => { | ||||
| 		setPosition(); | ||||
| 
 | ||||
| 		let loopHandler; | ||||
| 
 | ||||
| 		const loop = () => { | ||||
| 			loopHandler = window.requestAnimationFrame(() => { | ||||
| 				setPosition(); | ||||
| 
 | ||||
| 				let loopHandler; | ||||
| 
 | ||||
| 				const loop = () => { | ||||
| 					loopHandler = window.requestAnimationFrame(() => { | ||||
| 						setPosition(); | ||||
| 						loop(); | ||||
| 					}); | ||||
| 				}; | ||||
| 
 | ||||
| 				loop(); | ||||
| 
 | ||||
| 				onUnmounted(() => { | ||||
| 					window.cancelAnimationFrame(loopHandler); | ||||
| 				}); | ||||
| 			}); | ||||
| 		}); | ||||
| 
 | ||||
| 		return { | ||||
| 			el, | ||||
| 			zIndex, | ||||
| 		}; | ||||
| 	}, | ||||
| }) | ||||
| 
 | ||||
| 		loop(); | ||||
| 
 | ||||
| 		onUnmounted(() => { | ||||
| 			window.cancelAnimationFrame(loopHandler); | ||||
| 		}); | ||||
| 	}); | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
|  | @ -118,6 +115,6 @@ export default defineComponent({ | |||
| 	border-radius: 4px; | ||||
| 	border: solid 0.5px var(--divider); | ||||
| 	pointer-events: none; | ||||
| 	transform-origin: center bottom; | ||||
| 	transform-origin: center center; | ||||
| } | ||||
| </style> | ||||
|  |  | |||
|  | @ -48,7 +48,7 @@ export default { | |||
| 			popup(import('@/components/ui/tooltip.vue'), { | ||||
| 				showing, | ||||
| 				text: self.text, | ||||
| 				source: el | ||||
| 				targetElement: el, | ||||
| 			}, {}, 'closed'); | ||||
| 
 | ||||
| 			self._close = () => { | ||||
|  | @ -56,8 +56,8 @@ export default { | |||
| 			}; | ||||
| 		}; | ||||
| 
 | ||||
| 		el.addEventListener('selectstart', e => { | ||||
| 			e.preventDefault(); | ||||
| 		el.addEventListener('selectstart', ev => { | ||||
| 			ev.preventDefault(); | ||||
| 		}); | ||||
| 
 | ||||
| 		el.addEventListener(start, () => { | ||||
|  |  | |||
|  | @ -7,8 +7,10 @@ import * as Misskey from 'misskey-js'; | |||
| import { apiUrl, url } from '@/config'; | ||||
| import MkPostFormDialog from '@/components/post-form-dialog.vue'; | ||||
| import MkWaitingDialog from '@/components/waiting-dialog.vue'; | ||||
| import { MenuItem } from '@/types/menu'; | ||||
| import { resolve } from '@/router'; | ||||
| import { $i } from '@/account'; | ||||
| import { defaultStore } from '@/store'; | ||||
| 
 | ||||
| export const pendingApiRequestsCount = ref(0); | ||||
| 
 | ||||
|  | @ -470,7 +472,7 @@ export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea: | |||
| 	}); | ||||
| } | ||||
| 
 | ||||
| export function popupMenu(items: any[] | Ref<any[]>, src?: HTMLElement, options?: { | ||||
| export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement, options?: { | ||||
| 	align?: string; | ||||
| 	width?: number; | ||||
| 	viaKeyboard?: boolean; | ||||
|  | @ -494,7 +496,7 @@ export function popupMenu(items: any[] | Ref<any[]>, src?: HTMLElement, options? | |||
| 	}); | ||||
| } | ||||
| 
 | ||||
| export function contextMenu(items: any[], ev: MouseEvent) { | ||||
| export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent) { | ||||
| 	ev.preventDefault(); | ||||
| 	return new Promise((resolve, reject) => { | ||||
| 		let dispose; | ||||
|  | @ -541,7 +543,7 @@ export const uploads = ref<{ | |||
| 	img: string; | ||||
| }[]>([]); | ||||
| 
 | ||||
| export function upload(file: File, folder?: any, name?: string): Promise<Misskey.entities.DriveFile> { | ||||
| export function upload(file: File, folder?: any, name?: string, keepOriginal: boolean = defaultStore.state.keepOriginalUploading): Promise<Misskey.entities.DriveFile> { | ||||
| 	if (folder && typeof folder == 'object') folder = folder.id; | ||||
| 
 | ||||
| 	return new Promise((resolve, reject) => { | ||||
|  | @ -559,6 +561,8 @@ export function upload(file: File, folder?: any, name?: string): Promise<Misskey | |||
| 
 | ||||
| 			uploads.value.push(ctx); | ||||
| 
 | ||||
| 			console.log(keepOriginal); | ||||
| 
 | ||||
| 			const data = new FormData(); | ||||
| 			data.append('i', $i.token); | ||||
| 			data.append('force', 'true'); | ||||
|  |  | |||
|  | @ -29,6 +29,7 @@ | |||
| 			<template #label>Moderation</template> | ||||
| 			<FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ $ts.stopActivityDelivery }}</FormSwitch> | ||||
| 			<FormSwitch v-model="isBlocked" class="_formBlock" @update:modelValue="toggleBlock">{{ $ts.blockThisInstance }}</FormSwitch> | ||||
| 			<MkButton @click="refreshMetadata">Refresh metadata</MkButton> | ||||
| 		</FormSection> | ||||
| 
 | ||||
| 		<FormSection> | ||||
|  | @ -111,6 +112,7 @@ import MkChart from '@/components/chart.vue'; | |||
| import MkObjectView from '@/components/object-view.vue'; | ||||
| import FormLink from '@/components/form/link.vue'; | ||||
| import MkLink from '@/components/link.vue'; | ||||
| import MkButton from '@/components/ui/button.vue'; | ||||
| import FormSection from '@/components/form/section.vue'; | ||||
| import MkKeyValue from '@/components/key-value.vue'; | ||||
| import MkSelect from '@/components/form/select.vue'; | ||||
|  | @ -155,6 +157,15 @@ async function toggleSuspend(v) { | |||
| 	}); | ||||
| } | ||||
| 
 | ||||
| function refreshMetadata() { | ||||
| 	os.api('admin/federation/refresh-remote-instance-metadata', { | ||||
| 		host: instance.host, | ||||
| 	}); | ||||
| 	os.alert({ | ||||
| 		text: 'Refresh requested', | ||||
| 	}); | ||||
| } | ||||
| 
 | ||||
| fetch(); | ||||
| 
 | ||||
| defineExpose({ | ||||
|  |  | |||
|  | @ -28,6 +28,7 @@ | |||
| 			<template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template> | ||||
| 			<template #suffixIcon><i class="fas fa-folder-open"></i></template> | ||||
| 		</FormLink> | ||||
| 		<FormSwitch v-model="keepOriginalUploading" class="_formBlock">{{ $ts.keepOriginalUploading }}<template #caption>{{ $ts.keepOriginalUploadingDescription }}</template></FormSwitch> | ||||
| 	</FormSection> | ||||
| </div> | ||||
| </template> | ||||
|  | @ -36,18 +37,21 @@ | |||
| import { defineComponent } from 'vue'; | ||||
| import * as tinycolor from 'tinycolor2'; | ||||
| import FormLink from '@/components/form/link.vue'; | ||||
| import FormSwitch from '@/components/form/switch.vue'; | ||||
| import FormSection from '@/components/form/section.vue'; | ||||
| import MkKeyValue from '@/components/key-value.vue'; | ||||
| import FormSplit from '@/components/form/split.vue'; | ||||
| import * as os from '@/os'; | ||||
| import bytes from '@/filters/bytes'; | ||||
| import * as symbols from '@/symbols'; | ||||
| import { defaultStore } from '@/store'; | ||||
| 
 | ||||
| // TODO: render chart | ||||
| 
 | ||||
| export default defineComponent({ | ||||
| 	components: { | ||||
| 		FormLink, | ||||
| 		FormSwitch, | ||||
| 		FormSection, | ||||
| 		MkKeyValue, | ||||
| 		FormSplit, | ||||
|  | @ -79,7 +83,8 @@ export default defineComponent({ | |||
| 					l: 0.5 | ||||
| 				}) | ||||
| 			}; | ||||
| 		} | ||||
| 		}, | ||||
| 		keepOriginalUploading: defaultStore.makeGetterSetter('keepOriginalUploading'), | ||||
| 	}, | ||||
| 
 | ||||
| 	async created() { | ||||
|  |  | |||
|  | @ -1,3 +1,4 @@ | |||
| import { ref } from 'vue'; | ||||
| import * as os from '@/os'; | ||||
| import { stream } from '@/stream'; | ||||
| import { i18n } from '@/i18n'; | ||||
|  | @ -6,12 +7,14 @@ import { DriveFile } from 'misskey-js/built/entities'; | |||
| 
 | ||||
| function select(src: any, label: string | null, multiple: boolean): Promise<DriveFile | DriveFile[]> { | ||||
| 	return new Promise((res, rej) => { | ||||
| 		const keepOriginal = ref(defaultStore.state.keepOriginalUploading); | ||||
| 
 | ||||
| 		const chooseFileFromPc = () => { | ||||
| 			const input = document.createElement('input'); | ||||
| 			input.type = 'file'; | ||||
| 			input.multiple = multiple; | ||||
| 			input.onchange = () => { | ||||
| 				const promises = Array.from(input.files).map(file => os.upload(file, defaultStore.state.uploadFolder)); | ||||
| 				const promises = Array.from(input.files).map(file => os.upload(file, defaultStore.state.uploadFolder, undefined, keepOriginal.value)); | ||||
| 
 | ||||
| 				Promise.all(promises).then(driveFiles => { | ||||
| 					res(multiple ? driveFiles : driveFiles[0]); | ||||
|  | @ -74,6 +77,10 @@ function select(src: any, label: string | null, multiple: boolean): Promise<Driv | |||
| 			text: label, | ||||
| 			type: 'label' | ||||
| 		} : undefined, { | ||||
| 			type: 'switch', | ||||
| 			text: i18n.ts.keepOriginalUploading, | ||||
| 			ref: keepOriginal | ||||
| 		}, { | ||||
| 			text: i18n.ts.upload, | ||||
| 			icon: 'fas fa-upload', | ||||
| 			action: chooseFileFromPc | ||||
|  |  | |||
|  | @ -43,6 +43,10 @@ export const defaultStore = markRaw(new Storage('base', { | |||
| 		where: 'account', | ||||
| 		default: 'yyyy-MM-dd HH-mm-ss [{{number}}]' | ||||
| 	}, | ||||
| 	keepOriginalUploading: { | ||||
| 		where: 'account', | ||||
| 		default: false | ||||
| 	}, | ||||
| 	memo: { | ||||
| 		where: 'account', | ||||
| 		default: null | ||||
|  |  | |||
							
								
								
									
										20
									
								
								packages/client/src/types/menu.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								packages/client/src/types/menu.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,20 @@ | |||
| import * as Misskey from 'misskey-js'; | ||||
| import { Ref } from 'vue'; | ||||
| 
 | ||||
| export type MenuAction = (ev: MouseEvent) => void; | ||||
| 
 | ||||
| export type MenuDivider = null; | ||||
| export type MenuNull = undefined; | ||||
| export type MenuLabel = { type: 'label', text: string }; | ||||
| export type MenuLink = { type: 'link', to: string, text: string, icon?: string, indicate?: boolean, avatar?: Misskey.entities.User }; | ||||
| export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, icon?: string, indicate?: boolean }; | ||||
| export type MenuUser = { type: 'user', user: Misskey.entities.User, active?: boolean, indicate?: boolean, action: MenuAction }; | ||||
| export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, disabled?: boolean }; | ||||
| export type MenuButton = { type?: 'button', text: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean, avatar?: Misskey.entities.User; action: MenuAction }; | ||||
| 
 | ||||
| export type MenuPending = { type: 'pending' }; | ||||
| 
 | ||||
| type OuterMenuItem = MenuDivider | MenuNull | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton; | ||||
| type OuterPromiseMenuItem = Promise<MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton>; | ||||
| export type MenuItem = OuterMenuItem | OuterPromiseMenuItem; | ||||
| export type InnerMenuItem = MenuDivider | MenuPending | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton; | ||||
|  | @ -54,13 +54,13 @@ const charts = ref([]); | |||
| const fetching = ref(true); | ||||
| 
 | ||||
| const fetch = async () => { | ||||
| 	const instances = await os.api('federation/instances', { | ||||
| 	const fetchedInstances = await os.api('federation/instances', { | ||||
| 		sort: '+lastCommunicatedAt', | ||||
| 		limit: 5 | ||||
| 	}); | ||||
| 	const charts = await Promise.all(instances.map(i => os.api('charts/instance', { host: i.host, limit: 16, span: 'hour' }))); | ||||
| 	instances.value = instances; | ||||
| 	charts.value = charts; | ||||
| 	const fetchedCharts = await Promise.all(fetchedInstances.map(i => os.api('charts/instance', { host: i.host, limit: 16, span: 'hour' }))); | ||||
| 	instances.value = fetchedInstances; | ||||
| 	charts.value = fetchedCharts; | ||||
| 	fetching.value = false; | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -4139,10 +4139,10 @@ minimist@^1.2.0, minimist@^1.2.5: | |||
|   resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" | ||||
|   integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== | ||||
| 
 | ||||
| misskey-js@0.0.13: | ||||
|   version "0.0.13" | ||||
|   resolved "https://registry.yarnpkg.com/misskey-js/-/misskey-js-0.0.13.tgz#03a4e469186e28752d599dc4093519eb64647970" | ||||
|   integrity sha512-kBdJdfe281gtykzzsrN3IAxWUQIimzPiJGyKWf863ggWJlWYVPmP9hTFlX2z8oPOaypgVBPEPHyw/jNUdc2DbQ== | ||||
| misskey-js@0.0.14: | ||||
|   version "0.0.14" | ||||
|   resolved "https://registry.yarnpkg.com/misskey-js/-/misskey-js-0.0.14.tgz#1a616bdfbe81c6ee6900219eaf425bb5c714dd4d" | ||||
|   integrity sha512-bvLx6U3OwQwqHfp/WKwIVwdvNYAAPk0+YblXyxmSG3dwlzCgBRRLcB8o6bNruUDyJgh3t73pLDcOz3myxcUmww== | ||||
|   dependencies: | ||||
|     autobind-decorator "^2.4.0" | ||||
|     eventemitter3 "^4.0.7" | ||||
|  |  | |||
|  | @ -37,6 +37,7 @@ module.exports = { | |||
| 			] | ||||
| 		}], | ||||
| 		*/ | ||||
| 		'eqeqeq': ['error', 'always', { 'null': 'ignore' }], | ||||
| 		'no-multi-spaces': ['error'], | ||||
| 		'no-var': ['error'], | ||||
| 		'prefer-arrow-callback': ['error'], | ||||
|  | @ -56,7 +57,7 @@ module.exports = { | |||
| 		'object-curly-spacing': ['error', 'always'], | ||||
| 		'space-infix-ops': ['error'], | ||||
| 		'space-before-blocks': ['error', 'always'], | ||||
| 		'@typescript-eslint/no-unnecessary-condition': ['error'], | ||||
| 		'@typescript-eslint/no-unnecessary-condition': ['warn'], | ||||
| 		'@typescript-eslint/no-var-requires': ['warn'], | ||||
| 		'@typescript-eslint/no-inferrable-types': ['warn'], | ||||
| 		'@typescript-eslint/no-empty-function': ['off'], | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue