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: { | ||||
| 	immediate: boolean; | ||||
| 	afterMounted: boolean; | ||||
| }): void { | ||||
| }): (() => void) | undefined { | ||||
| 	if (Number.isNaN(interval)) return; | ||||
| 
 | ||||
| 	let intervalId: number | null = null; | ||||
|  | @ -18,7 +18,14 @@ export function useInterval(fn: () => void, interval: number, options: { | |||
| 		intervalId = window.setInterval(fn, interval); | ||||
| 	} | ||||
| 
 | ||||
| 	onUnmounted(() => { | ||||
| 	const clear = () => { | ||||
| 		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 #func><button class="_button" @click="configure"><i class="ti ti-settings"></i></button></template> | ||||
| 
 | ||||
| 	<div class="ekmkgxbk"> | ||||
| 		<MkLoading v-if="fetching"/> | ||||
| 		<div v-else class="feed"> | ||||
| 			<Transition name="change" mode="default"> | ||||
| 	<div :class="$style.feed"> | ||||
| 		<div v-if="fetching" :class="$style.loading"> | ||||
| 			<MkEllipsis/> | ||||
| 		</div> | ||||
| 		<div v-else> | ||||
| 			<Transition :name="$style.change" mode="default" appear> | ||||
| 				<MarqueeText :key="key" :duration="widgetProps.duration" :reverse="widgetProps.reverse"> | ||||
| 					<span v-for="item in items" class="item"> | ||||
| 						<a class="link" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span class="divider"></span> | ||||
| 					<span v-for="item in items" :class="$style.item" :key="item.link"> | ||||
| 						<a :class="$style.link" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span :class="$style.divider"></span> | ||||
| 					</span> | ||||
| 				</MarqueeText> | ||||
| 			</Transition> | ||||
|  | @ -19,14 +21,14 @@ | |||
| </template> | ||||
| 
 | ||||
| <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 MarqueeText from '@/components/MkMarquee.vue'; | ||||
| import { GetFormResultType } from '@/scripts/form'; | ||||
| import * as os from '@/os'; | ||||
| import MkContainer from '@/components/MkContainer.vue'; | ||||
| import { useInterval } from '@/scripts/use-interval'; | ||||
| import { shuffle } from '@/scripts/shuffle'; | ||||
| import { url as base } from '@/config'; | ||||
| import { useInterval } from '@/scripts/use-interval'; | ||||
| 
 | ||||
| const name = 'rssTicker'; | ||||
| 
 | ||||
|  | @ -43,6 +45,10 @@ const widgetPropsDef = { | |||
| 		type: 'number' as const, | ||||
| 		default: 60, | ||||
| 	}, | ||||
| 	maxEntries: { | ||||
| 		type: 'number' as const, | ||||
| 		default: 15, | ||||
| 	}, | ||||
| 	duration: { | ||||
| 		type: 'range' as const, | ||||
| 		default: 70, | ||||
|  | @ -78,29 +84,49 @@ const { widgetProps, configure } = useWidgetPropsManager(name, | |||
| 	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 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); | ||||
| 
 | ||||
| const tick = () => { | ||||
| 	window.fetch(`/api/fetch-rss?url=${widgetProps.url}`, {}).then(res => { | ||||
| 		res.json().then(feed => { | ||||
| 	if (document.visibilityState === 'hidden' && rawItems.value.length !== 0) return; | ||||
| 
 | ||||
| 	window.fetch(fetchEndpoint.value, {}) | ||||
| 	.then(res => res.json()) | ||||
| 	.then(feed => { | ||||
| 		if (widgetProps.shuffle) { | ||||
| 			shuffle(feed.items); | ||||
| 		} | ||||
| 			items.value = feed.items; | ||||
| 		rawItems.value = feed.items; | ||||
| 		fetching.value = false; | ||||
| 		key++; | ||||
| 	}); | ||||
| 	}); | ||||
| }; | ||||
| 
 | ||||
| watch(() => widgetProps.url, tick); | ||||
| 
 | ||||
| useInterval(tick, Math.max(10000, widgetProps.refreshIntervalSec * 1000), { | ||||
| watch(() => fetchEndpoint, tick); | ||||
| watch(() => widgetProps.refreshIntervalSec, () => { | ||||
| 	if (intervalClear) { | ||||
| 		intervalClear(); | ||||
| 	} | ||||
| 	intervalClear = useInterval(tick, Math.max(10000, widgetProps.refreshIntervalSec * 1000), { | ||||
| 		immediate: true, | ||||
| 		afterMounted: true, | ||||
| }); | ||||
| 	}); | ||||
| }, { immediate: true }); | ||||
| 
 | ||||
| defineExpose<WidgetComponentExpose>({ | ||||
| 	name, | ||||
|  | @ -109,44 +135,49 @@ defineExpose<WidgetComponentExpose>({ | |||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| .change-enter-active, .change-leave-active { | ||||
| <style lang="scss" module> | ||||
| .change { | ||||
| 	&:global(-enter-active), | ||||
| 	&:global(-leave-active) { | ||||
| 		position: absolute; | ||||
| 		top: 0; | ||||
| 		transition: all 1s ease; | ||||
| } | ||||
| .change-enter-from { | ||||
| 	} | ||||
| 	&:global(-enter-from) { | ||||
| 		opacity: 0; | ||||
| 		transform: translateY(-100%); | ||||
| } | ||||
| .change-leave-to { | ||||
| 	} | ||||
| 	&:global(-leave-to) { | ||||
| 		opacity: 0; | ||||
| 		transform: translateY(100%); | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| .ekmkgxbk { | ||||
| 	> .feed { | ||||
| .feed { | ||||
| 	--height: 42px; | ||||
| 	padding: 0; | ||||
| 	font-size: 0.9em; | ||||
| 	line-height: var(--height); | ||||
| 	height: var(--height); | ||||
| 	contain: strict; | ||||
| } | ||||
| 
 | ||||
| 		::v-deep(.item) { | ||||
| .loading { | ||||
| 	text-align: center; | ||||
| } | ||||
| 
 | ||||
| .item { | ||||
| 	display: inline-flex; | ||||
| 	align-items: center; | ||||
| 	vertical-align: bottom; | ||||
| 	color: var(--fg); | ||||
| } | ||||
| 
 | ||||
| 			> .divider { | ||||
| .divider { | ||||
| 	display: inline-block; | ||||
| 	width: 0.5px; | ||||
| 	height: 16px; | ||||
| 	margin: 0 1em; | ||||
| 	background: var(--divider); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
|  |  | |||
|  | @ -5,19 +5,24 @@ | |||
| 
 | ||||
| 	<div class="ekmkgxbj"> | ||||
| 		<MkLoading v-if="fetching"/> | ||||
| 		<div v-else class="feed"> | ||||
| 			<a v-for="item in items" class="item" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a> | ||||
| 		<div class="_fullinfo" v-else-if="(!items || items.length === 0) && widgetProps.showHeader"> | ||||
| 			<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> | ||||
| </MkContainer> | ||||
| </template> | ||||
| 
 | ||||
| <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 { GetFormResultType } from '@/scripts/form'; | ||||
| import * as os from '@/os'; | ||||
| import MkContainer from '@/components/MkContainer.vue'; | ||||
| import { url as base } from '@/config'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import { useInterval } from '@/scripts/use-interval'; | ||||
| 
 | ||||
| const name = 'rss'; | ||||
|  | @ -27,6 +32,14 @@ const widgetPropsDef = { | |||
| 		type: 'string' as const, | ||||
| 		default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews', | ||||
| 	}, | ||||
| 	refreshIntervalSec: { | ||||
| 		type: 'number' as const, | ||||
| 		default: 60, | ||||
| 	}, | ||||
| 	maxEntries: { | ||||
| 		type: 'number' as const, | ||||
| 		default: 15, | ||||
| 	}, | ||||
| 	showHeader: { | ||||
| 		type: 'boolean' as const, | ||||
| 		default: true, | ||||
|  | @ -47,24 +60,37 @@ const { widgetProps, configure } = useWidgetPropsManager(name, | |||
| 	emit, | ||||
| ); | ||||
| 
 | ||||
| const items = ref([]); | ||||
| const rawItems = ref([]); | ||||
| const items = computed(() => rawItems.value.slice(0, widgetProps.maxEntries)); | ||||
| 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 = () => { | ||||
| 	window.fetch(`/api/fetch-rss?url=${widgetProps.url}`, {}).then(res => { | ||||
| 		res.json().then(feed => { | ||||
| 			items.value = feed.items; | ||||
| 	if (document.visibilityState === 'hidden' && rawItems.value.length !== 0) return; | ||||
| 
 | ||||
| 	window.fetch(fetchEndpoint.value, {}) | ||||
| 	.then(res => res.json()) | ||||
| 	.then(feed => { | ||||
| 		rawItems.value = feed.items ?? []; | ||||
| 		fetching.value = false; | ||||
| 	}); | ||||
| 	}); | ||||
| }; | ||||
| 
 | ||||
| watch(() => widgetProps.url, tick); | ||||
| 
 | ||||
| useInterval(tick, 60000, { | ||||
| watch(() => fetchEndpoint, tick); | ||||
| watch(() => widgetProps.refreshIntervalSec, () => { | ||||
| 	if (intervalClear) { | ||||
| 		intervalClear(); | ||||
| 	} | ||||
| 	intervalClear = useInterval(tick, Math.max(10000, widgetProps.refreshIntervalSec * 1000), { | ||||
| 		immediate: true, | ||||
| 		afterMounted: true, | ||||
| }); | ||||
| 	}); | ||||
| }, { immediate: true }); | ||||
| 
 | ||||
| defineExpose<WidgetComponentExpose>({ | ||||
| 	name, | ||||
|  | @ -73,13 +99,13 @@ defineExpose<WidgetComponentExpose>({ | |||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| .ekmkgxbj { | ||||
| 	> .feed { | ||||
| <style lang="scss" module> | ||||
| .feed { | ||||
| 	padding: 0; | ||||
| 	font-size: 0.9em; | ||||
| } | ||||
| 
 | ||||
| 		> .item { | ||||
| .item { | ||||
| 	display: block; | ||||
| 	padding: 8px 16px; | ||||
| 	color: var(--fg); | ||||
|  | @ -90,7 +116,5 @@ defineExpose<WidgetComponentExpose>({ | |||
| 	&:nth-child(even) { | ||||
| 		background: rgba(#000, 0.05); | ||||
| 	} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue