enhance: RSSウィジェット / RSSティッカーウィジェットをいい感じにする (#9469)
* ✌️ * use useInterval * ✌️ * rawItems.value.length !== 0 * fix * https://github.com/misskey-dev/misskey/pull/9469#discussion_r1061763613
This commit is contained in:
		
							parent
							
								
									f51220a5bf
								
							
						
					
					
						commit
						b1a75177a0
					
				
					 3 changed files with 159 additions and 97 deletions
				
			
		|  | @ -3,7 +3,7 @@ import { onMounted, onUnmounted } from 'vue'; | ||||||
| export function useInterval(fn: () => void, interval: number, options: { | export function useInterval(fn: () => void, interval: number, options: { | ||||||
| 	immediate: boolean; | 	immediate: boolean; | ||||||
| 	afterMounted: boolean; | 	afterMounted: boolean; | ||||||
| }): void { | }): (() => void) | undefined { | ||||||
| 	if (Number.isNaN(interval)) return; | 	if (Number.isNaN(interval)) return; | ||||||
| 
 | 
 | ||||||
| 	let intervalId: number | null = null; | 	let intervalId: number | null = null; | ||||||
|  | @ -18,7 +18,14 @@ export function useInterval(fn: () => void, interval: number, options: { | ||||||
| 		intervalId = window.setInterval(fn, interval); | 		intervalId = window.setInterval(fn, interval); | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	onUnmounted(() => { | 	const clear = () => { | ||||||
| 		if (intervalId) window.clearInterval(intervalId); | 		if (intervalId) window.clearInterval(intervalId); | ||||||
|  | 		intervalId = null; | ||||||
|  | 	}; | ||||||
|  | 
 | ||||||
|  | 	onUnmounted(() => { | ||||||
|  | 		clear(); | ||||||
| 	}); | 	}); | ||||||
|  | 
 | ||||||
|  | 	return clear; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -3,13 +3,15 @@ | ||||||
| 	<template #header><i class="ti ti-rss"></i>RSS</template> | 	<template #header><i class="ti ti-rss"></i>RSS</template> | ||||||
| 	<template #func><button class="_button" @click="configure"><i class="ti ti-settings"></i></button></template> | 	<template #func><button class="_button" @click="configure"><i class="ti ti-settings"></i></button></template> | ||||||
| 
 | 
 | ||||||
| 	<div class="ekmkgxbk"> | 	<div :class="$style.feed"> | ||||||
| 		<MkLoading v-if="fetching"/> | 		<div v-if="fetching" :class="$style.loading"> | ||||||
| 		<div v-else class="feed"> | 			<MkEllipsis/> | ||||||
| 			<Transition name="change" mode="default"> | 		</div> | ||||||
|  | 		<div v-else> | ||||||
|  | 			<Transition :name="$style.change" mode="default" appear> | ||||||
| 				<MarqueeText :key="key" :duration="widgetProps.duration" :reverse="widgetProps.reverse"> | 				<MarqueeText :key="key" :duration="widgetProps.duration" :reverse="widgetProps.reverse"> | ||||||
| 					<span v-for="item in items" class="item"> | 					<span v-for="item in items" :class="$style.item" :key="item.link"> | ||||||
| 						<a class="link" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span class="divider"></span> | 						<a :class="$style.link" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span :class="$style.divider"></span> | ||||||
| 					</span> | 					</span> | ||||||
| 				</MarqueeText> | 				</MarqueeText> | ||||||
| 			</Transition> | 			</Transition> | ||||||
|  | @ -19,14 +21,14 @@ | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script lang="ts" setup> | <script lang="ts" setup> | ||||||
| import { onMounted, onUnmounted, ref, watch } from 'vue'; | import { ref, watch, computed } from 'vue'; | ||||||
| import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; | import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; | ||||||
| import MarqueeText from '@/components/MkMarquee.vue'; | import MarqueeText from '@/components/MkMarquee.vue'; | ||||||
| import { GetFormResultType } from '@/scripts/form'; | import { GetFormResultType } from '@/scripts/form'; | ||||||
| import * as os from '@/os'; |  | ||||||
| import MkContainer from '@/components/MkContainer.vue'; | import MkContainer from '@/components/MkContainer.vue'; | ||||||
| import { useInterval } from '@/scripts/use-interval'; |  | ||||||
| import { shuffle } from '@/scripts/shuffle'; | import { shuffle } from '@/scripts/shuffle'; | ||||||
|  | import { url as base } from '@/config'; | ||||||
|  | import { useInterval } from '@/scripts/use-interval'; | ||||||
| 
 | 
 | ||||||
| const name = 'rssTicker'; | const name = 'rssTicker'; | ||||||
| 
 | 
 | ||||||
|  | @ -43,6 +45,10 @@ const widgetPropsDef = { | ||||||
| 		type: 'number' as const, | 		type: 'number' as const, | ||||||
| 		default: 60, | 		default: 60, | ||||||
| 	}, | 	}, | ||||||
|  | 	maxEntries: { | ||||||
|  | 		type: 'number' as const, | ||||||
|  | 		default: 15, | ||||||
|  | 	}, | ||||||
| 	duration: { | 	duration: { | ||||||
| 		type: 'range' as const, | 		type: 'range' as const, | ||||||
| 		default: 70, | 		default: 70, | ||||||
|  | @ -78,29 +84,49 @@ const { widgetProps, configure } = useWidgetPropsManager(name, | ||||||
| 	emit, | 	emit, | ||||||
| ); | ); | ||||||
| 
 | 
 | ||||||
| const items = ref([]); | const rawItems = ref([]); | ||||||
|  | const items = computed(() => { | ||||||
|  | 	const newItems = rawItems.value.slice(0, widgetProps.maxEntries) | ||||||
|  | 	if (widgetProps.shuffle) { | ||||||
|  | 		shuffle(newItems); | ||||||
|  | 	} | ||||||
|  | 	return newItems; | ||||||
|  | }); | ||||||
| const fetching = ref(true); | const fetching = ref(true); | ||||||
|  | const fetchEndpoint = computed(() => { | ||||||
|  | 	const url = new URL('/api/fetch-rss', base); | ||||||
|  | 	url.searchParams.set('url', widgetProps.url); | ||||||
|  | 	return url; | ||||||
|  | }); | ||||||
|  | let intervalClear = $ref<(() => void) | undefined>(); | ||||||
|  | 
 | ||||||
| let key = $ref(0); | let key = $ref(0); | ||||||
| 
 | 
 | ||||||
| const tick = () => { | const tick = () => { | ||||||
| 	window.fetch(`/api/fetch-rss?url=${widgetProps.url}`, {}).then(res => { | 	if (document.visibilityState === 'hidden' && rawItems.value.length !== 0) return; | ||||||
| 		res.json().then(feed => { | 
 | ||||||
| 			if (widgetProps.shuffle) { | 	window.fetch(fetchEndpoint.value, {}) | ||||||
| 				shuffle(feed.items); | 	.then(res => res.json()) | ||||||
| 			} | 	.then(feed => { | ||||||
| 			items.value = feed.items; | 		if (widgetProps.shuffle) { | ||||||
| 			fetching.value = false; | 			shuffle(feed.items); | ||||||
| 			key++; | 		} | ||||||
| 		}); | 		rawItems.value = feed.items; | ||||||
|  | 		fetching.value = false; | ||||||
|  | 		key++; | ||||||
| 	}); | 	}); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| watch(() => widgetProps.url, tick); | watch(() => fetchEndpoint, tick); | ||||||
| 
 | watch(() => widgetProps.refreshIntervalSec, () => { | ||||||
| useInterval(tick, Math.max(10000, widgetProps.refreshIntervalSec * 1000), { | 	if (intervalClear) { | ||||||
| 	immediate: true, | 		intervalClear(); | ||||||
| 	afterMounted: true, | 	} | ||||||
| }); | 	intervalClear = useInterval(tick, Math.max(10000, widgetProps.refreshIntervalSec * 1000), { | ||||||
|  | 		immediate: true, | ||||||
|  | 		afterMounted: true, | ||||||
|  | 	}); | ||||||
|  | }, { immediate: true }); | ||||||
| 
 | 
 | ||||||
| defineExpose<WidgetComponentExpose>({ | defineExpose<WidgetComponentExpose>({ | ||||||
| 	name, | 	name, | ||||||
|  | @ -109,44 +135,49 @@ defineExpose<WidgetComponentExpose>({ | ||||||
| }); | }); | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <style lang="scss" scoped> | <style lang="scss" module> | ||||||
| .change-enter-active, .change-leave-active { | .change { | ||||||
| 	position: absolute; | 	&:global(-enter-active), | ||||||
| 	top: 0; | 	&:global(-leave-active) { | ||||||
|   transition: all 1s ease; | 		position: absolute; | ||||||
| } | 		top: 0; | ||||||
| .change-enter-from { | 		transition: all 1s ease; | ||||||
|   opacity: 0; | 	} | ||||||
| 	transform: translateY(-100%); | 	&:global(-enter-from) { | ||||||
| } | 		opacity: 0; | ||||||
| .change-leave-to { | 		transform: translateY(-100%); | ||||||
|   opacity: 0; | 	} | ||||||
| 	transform: translateY(100%); | 	&:global(-leave-to) { | ||||||
| } | 		opacity: 0; | ||||||
| 
 | 		transform: translateY(100%); | ||||||
| .ekmkgxbk { |  | ||||||
| 	> .feed { |  | ||||||
| 		--height: 42px; |  | ||||||
| 		padding: 0; |  | ||||||
| 		font-size: 0.9em; |  | ||||||
| 		line-height: var(--height); |  | ||||||
| 		height: var(--height); |  | ||||||
| 		contain: strict; |  | ||||||
| 
 |  | ||||||
| 		::v-deep(.item) { |  | ||||||
| 			display: inline-flex; |  | ||||||
| 			align-items: center; |  | ||||||
| 			vertical-align: bottom; |  | ||||||
| 			color: var(--fg); |  | ||||||
| 
 |  | ||||||
| 			> .divider { |  | ||||||
| 				display: inline-block; |  | ||||||
| 				width: 0.5px; |  | ||||||
| 				height: 16px; |  | ||||||
| 				margin: 0 1em; |  | ||||||
| 				background: var(--divider); |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | .feed { | ||||||
|  | 	--height: 42px; | ||||||
|  | 	padding: 0; | ||||||
|  | 	font-size: 0.9em; | ||||||
|  | 	line-height: var(--height); | ||||||
|  | 	height: var(--height); | ||||||
|  | 	contain: strict; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .loading { | ||||||
|  | 	text-align: center; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .item { | ||||||
|  | 	display: inline-flex; | ||||||
|  | 	align-items: center; | ||||||
|  | 	vertical-align: bottom; | ||||||
|  | 	color: var(--fg); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .divider { | ||||||
|  | 	display: inline-block; | ||||||
|  | 	width: 0.5px; | ||||||
|  | 	height: 16px; | ||||||
|  | 	margin: 0 1em; | ||||||
|  | 	background: var(--divider); | ||||||
|  | } | ||||||
| </style> | </style> | ||||||
|  |  | ||||||
|  | @ -5,19 +5,24 @@ | ||||||
| 
 | 
 | ||||||
| 	<div class="ekmkgxbj"> | 	<div class="ekmkgxbj"> | ||||||
| 		<MkLoading v-if="fetching"/> | 		<MkLoading v-if="fetching"/> | ||||||
| 		<div v-else class="feed"> | 		<div class="_fullinfo" v-else-if="(!items || items.length === 0) && widgetProps.showHeader"> | ||||||
| 			<a v-for="item in items" class="item" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a> | 			<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> | ||||||
|  | 			<div>{{ i18n.ts.nothing }}</div> | ||||||
|  | 		</div> | ||||||
|  | 		<div v-else :class="$style.feed"> | ||||||
|  | 			<a v-for="item in items" :class="$style.item" :href="item.link" :key="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a> | ||||||
| 		</div> | 		</div> | ||||||
| 	</div> | 	</div> | ||||||
| </MkContainer> | </MkContainer> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script lang="ts" setup> | <script lang="ts" setup> | ||||||
| import { onMounted, onUnmounted, ref, watch } from 'vue'; | import { ref, watch, computed } from 'vue'; | ||||||
| import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; | import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; | ||||||
| import { GetFormResultType } from '@/scripts/form'; | import { GetFormResultType } from '@/scripts/form'; | ||||||
| import * as os from '@/os'; |  | ||||||
| import MkContainer from '@/components/MkContainer.vue'; | import MkContainer from '@/components/MkContainer.vue'; | ||||||
|  | import { url as base } from '@/config'; | ||||||
|  | import { i18n } from '@/i18n'; | ||||||
| import { useInterval } from '@/scripts/use-interval'; | import { useInterval } from '@/scripts/use-interval'; | ||||||
| 
 | 
 | ||||||
| const name = 'rss'; | const name = 'rss'; | ||||||
|  | @ -27,6 +32,14 @@ const widgetPropsDef = { | ||||||
| 		type: 'string' as const, | 		type: 'string' as const, | ||||||
| 		default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews', | 		default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews', | ||||||
| 	}, | 	}, | ||||||
|  | 	refreshIntervalSec: { | ||||||
|  | 		type: 'number' as const, | ||||||
|  | 		default: 60, | ||||||
|  | 	}, | ||||||
|  | 	maxEntries: { | ||||||
|  | 		type: 'number' as const, | ||||||
|  | 		default: 15, | ||||||
|  | 	}, | ||||||
| 	showHeader: { | 	showHeader: { | ||||||
| 		type: 'boolean' as const, | 		type: 'boolean' as const, | ||||||
| 		default: true, | 		default: true, | ||||||
|  | @ -47,24 +60,37 @@ const { widgetProps, configure } = useWidgetPropsManager(name, | ||||||
| 	emit, | 	emit, | ||||||
| ); | ); | ||||||
| 
 | 
 | ||||||
| const items = ref([]); | const rawItems = ref([]); | ||||||
|  | const items = computed(() => rawItems.value.slice(0, widgetProps.maxEntries)); | ||||||
| const fetching = ref(true); | const fetching = ref(true); | ||||||
|  | const fetchEndpoint = computed(() => { | ||||||
|  | 	const url = new URL('/api/fetch-rss', base); | ||||||
|  | 	url.searchParams.set('url', widgetProps.url); | ||||||
|  | 	return url; | ||||||
|  | }); | ||||||
|  | let intervalClear = $ref<(() => void) | undefined>(); | ||||||
| 
 | 
 | ||||||
| const tick = () => { | const tick = () => { | ||||||
| 	window.fetch(`/api/fetch-rss?url=${widgetProps.url}`, {}).then(res => { | 	if (document.visibilityState === 'hidden' && rawItems.value.length !== 0) return; | ||||||
| 		res.json().then(feed => { | 
 | ||||||
| 			items.value = feed.items; | 	window.fetch(fetchEndpoint.value, {}) | ||||||
| 			fetching.value = false; | 	.then(res => res.json()) | ||||||
| 		}); | 	.then(feed => { | ||||||
|  | 		rawItems.value = feed.items ?? []; | ||||||
|  | 		fetching.value = false; | ||||||
| 	}); | 	}); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| watch(() => widgetProps.url, tick); | watch(() => fetchEndpoint, tick); | ||||||
| 
 | watch(() => widgetProps.refreshIntervalSec, () => { | ||||||
| useInterval(tick, 60000, { | 	if (intervalClear) { | ||||||
| 	immediate: true, | 		intervalClear(); | ||||||
| 	afterMounted: true, | 	} | ||||||
| }); | 	intervalClear = useInterval(tick, Math.max(10000, widgetProps.refreshIntervalSec * 1000), { | ||||||
|  | 		immediate: true, | ||||||
|  | 		afterMounted: true, | ||||||
|  | 	}); | ||||||
|  | }, { immediate: true }); | ||||||
| 
 | 
 | ||||||
| defineExpose<WidgetComponentExpose>({ | defineExpose<WidgetComponentExpose>({ | ||||||
| 	name, | 	name, | ||||||
|  | @ -73,24 +99,22 @@ defineExpose<WidgetComponentExpose>({ | ||||||
| }); | }); | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <style lang="scss" scoped> | <style lang="scss" module> | ||||||
| .ekmkgxbj { | .feed { | ||||||
| 	> .feed { | 	padding: 0; | ||||||
| 		padding: 0; | 	font-size: 0.9em; | ||||||
| 		font-size: 0.9em; | } | ||||||
| 
 | 
 | ||||||
| 		> .item { | .item { | ||||||
| 			display: block; | 	display: block; | ||||||
| 			padding: 8px 16px; | 	padding: 8px 16px; | ||||||
| 			color: var(--fg); | 	color: var(--fg); | ||||||
| 			white-space: nowrap; | 	white-space: nowrap; | ||||||
| 			text-overflow: ellipsis; | 	text-overflow: ellipsis; | ||||||
| 			overflow: hidden; | 	overflow: hidden; | ||||||
| 
 | 
 | ||||||
| 			&:nth-child(even) { | 	&:nth-child(even) { | ||||||
| 				background: rgba(#000, 0.05); | 		background: rgba(#000, 0.05); | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| </style> | </style> | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue