Show when Subscriptions / Trending / Most Popular were last updated (#4380)

* Implement first draft of last subscription refresh timestamp

* Update styling to be a top bar

* Update styling to be banner-compatible, & increase banner X button size on mobile

* Update subscription refresh timestamp to be relative

* Implement refresh timestamps for Shorts, Live, and Community tabs

* Extract refresh widget to its own component

* Add Trending and Popular refresh widgets with timestamps

* Fix justifying when no timestamp exists

* Move timestamps to utils store

* Remove unneeded ref classes and currentLocale computed property

* Add page-specific titles for each feed type

* Implement showing least recent cache date per profile

* Update styling property placement & match top nav box shadow on ft-refresh-widget

* Implement showing timestamp for profile only if all channel subscriptions can be found in cache

* Disable refresh button instead of removing it or the widget from the DOM

* Increase top banner's top margin

* Update channel caching calls to provide timestamps

* Modify updateCacheByChannel functions to have default timestamp of new Date()

* Fix 30-day month relative date calculation scenarios through new optional parameter

* Rectify Case 3 (see https://github.com/FreeTubeApp/FreeTube/pull/3668)

* Add back missing line in Popular.js
This commit is contained in:
Jason 2024-04-17 21:54:46 +00:00 committed by GitHub
parent 2bc44cd66b
commit ab3c1b9b29
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 412 additions and 135 deletions

View File

@ -18,10 +18,14 @@
.banner {
inline-size: 85%;
margin-block: 20px;
margin-block: 40px 0;
margin-inline: auto;
}
.banner + .banner {
margin-block: 20px;
}
.banner-wrapper {
margin-block: 0;
margin-inline: 10px;
@ -53,8 +57,8 @@
}
.banner {
inline-size: 80%;
margin-block-start: 20px;
inline-size: 90%;
margin-block: 60px 0;
}
.flexBox {

View File

@ -16,6 +16,10 @@ export default defineComponent({
type: Array,
default: () => ['fas', 'ellipsis-v']
},
disabled: {
type: Boolean,
default: false
},
theme: {
type: String,
default: 'base'
@ -88,6 +92,7 @@ export default defineComponent({
},
handleIconClick: function () {
if (this.disabled) { return }
if (this.forceDropdown || (this.dropdownOptions.length > 0)) {
this.dropdownShown = !this.dropdownShown
@ -104,6 +109,7 @@ export default defineComponent({
},
handleIconMouseDown: function () {
if (this.disabled) { return }
if (this.dropdownShown) {
this.mouseDownOnIcon = true
}

View File

@ -79,6 +79,12 @@
}
}
.disabled {
opacity: 0.5;
pointer-events: none;
user-select: none;
}
.iconDropdown {
background-color: var(--side-nav-color);
box-shadow: 0 1px 2px rgb(0 0 0 / 50%);

View File

@ -7,7 +7,8 @@
:icon="icon"
:class="{
[theme]: true,
shadow: useShadow
shadow: useShadow,
disabled
}"
:style="{
padding: padding + 'px',

View File

@ -5,6 +5,7 @@ import {
copyToClipboard,
formatDurationAsTimestamp,
formatNumber,
getRelativeTimeFromDate,
openExternalLink,
showToast,
toDistractionFreeTitle,
@ -345,6 +346,10 @@ export default defineComponent({
return this.historyEntryExists && !this.inHistory
},
currentLocale: function () {
return this.$i18n.locale.replace('_', '-')
},
externalPlayer: function () {
return this.$store.getters.getExternalPlayer
},
@ -462,14 +467,6 @@ export default defineComponent({
return query
},
currentLocale: function () {
return this.$i18n.locale.replace('_', '-')
},
showAddToPlaylistPrompt: function () {
return this.$store.getters.getShowAddToPlaylistPrompt
},
useDeArrowTitles: function () {
return this.$store.getters.getUseDeArrowTitles
},
@ -668,48 +665,8 @@ export default defineComponent({
if (this.inHistory) {
this.uploadedTime = new Date(this.data.published).toLocaleDateString([this.currentLocale, 'en'])
} else {
const now = new Date().getTime()
// Convert from ms to second
// For easier code interpretation the value is made to be positive
let timeDiffFromNow = ((now - this.data.published) / 1000)
let timeUnit = 'second'
if (timeDiffFromNow >= 60) {
timeDiffFromNow /= 60
timeUnit = 'minute'
}
if (timeUnit === 'minute' && timeDiffFromNow >= 60) {
timeDiffFromNow /= 60
timeUnit = 'hour'
}
if (timeUnit === 'hour' && timeDiffFromNow >= 24) {
timeDiffFromNow /= 24
timeUnit = 'day'
}
const timeDiffFromNowDays = timeDiffFromNow
if (timeUnit === 'day' && timeDiffFromNow >= 7) {
timeDiffFromNow /= 7
timeUnit = 'week'
}
// Use 30 days per month, just like calculatePublishedDate
if (timeUnit === 'week' && timeDiffFromNowDays >= 30) {
timeDiffFromNow = timeDiffFromNowDays / 30
timeUnit = 'month'
}
if (timeUnit === 'month' && timeDiffFromNow >= 12) {
timeDiffFromNow /= 12
timeUnit = 'year'
}
// Using `Math.ceil` so that -1.x days ago displayed as 1 day ago
// Notice that the value is turned to negative to be displayed as "ago"
this.uploadedTime = new Intl.RelativeTimeFormat([this.currentLocale, 'en']).format(Math.ceil(-timeDiffFromNow), timeUnit)
this.uploadedTime = getRelativeTimeFromDate(new Date(this.data.published).toDateString(), false)
}
}

View File

@ -30,3 +30,11 @@
inset-inline-end: 10px;
cursor: pointer;
}
@media only screen and (width <= 680px) {
.bannerIcon {
inset-block-start: 27%;
block-size: 25px;
inline-size: 25px;
}
}

View File

@ -24,7 +24,7 @@
tabindex="0"
:title="$t('Close Banner')"
@click.stop="handleClose"
@keydown.enter.stop.prevent="handleClose"
@keydown.enter.space.stop.prevent="handleClose"
/>
</div>
</template>

View File

@ -0,0 +1,36 @@
.floatingRefreshSection {
position: fixed;
inset-block-start: 60px;
inset-inline-end: 0;
box-sizing: border-box;
inline-size: calc(100% - 80px);
padding-block: 5px;
padding-inline: 10px;
box-shadow: 0 2px 1px 0 var(--primary-shadow-color);
background-color: var(--card-bg-color);
border-inline-start: 2px solid var(--primary-color);
display: flex;
align-items: center;
gap: 5px;
justify-content: flex-end;
}
.floatingRefreshSection:has(.lastRefreshTimestamp + .refreshButton) {
justify-content: space-between;
}
.floatingRefreshSection.sideNavOpen {
inline-size: calc(100% - 200px);
}
.lastRefreshTimestamp {
margin-block: 0;
text-align: center;
font-size: 16px;
}
@media only screen and (width <= 680px) {
.floatingRefreshSection, .floatingRefreshSection.sideNavOpen {
inline-size: 100%;
}
}

View File

@ -0,0 +1,29 @@
import { defineComponent } from 'vue'
import FtIconButton from '../ft-icon-button/ft-icon-button.vue'
export default defineComponent({
name: 'FtRefreshWidget',
components: {
'ft-icon-button': FtIconButton,
},
props: {
disableRefresh: {
type: Boolean,
default: false
},
lastRefreshTimestamp: {
type: String,
default: ''
},
title: {
type: String,
required: true
}
},
computed: {
isSideNavOpen: function () {
return this.$store.getters.getIsSideNavOpen
}
}
})

View File

@ -0,0 +1,27 @@
<template>
<div
class="floatingRefreshSection"
:class="{
sideNavOpen: isSideNavOpen
}"
>
<p
v-if="lastRefreshTimestamp"
class="lastRefreshTimestamp"
>
{{ $t('Feed.Feed Last Updated', { feedName: title, date: lastRefreshTimestamp }) }}
</p>
<ft-icon-button
:disabled="disableRefresh"
:icon="['fas', 'sync']"
class="refreshButton"
:title="$t('Feed.Refresh Feed', { subscriptionName: title })"
:size="12"
theme="primary"
@click="$emit('click')"
/>
</div>
</template>
<script src="./ft-refresh-widget.js" />
<style scoped src="./ft-refresh-widget.css" />

View File

@ -2,7 +2,7 @@ import { defineComponent } from 'vue'
import { mapActions, mapMutations } from 'vuex'
import SubscriptionsTabUI from '../subscriptions-tab-ui/subscriptions-tab-ui.vue'
import { calculatePublishedDate, copyToClipboard, showToast } from '../../helpers/utils'
import { calculatePublishedDate, copyToClipboard, getRelativeTimeFromDate, showToast } from '../../helpers/utils'
import { getLocalChannelCommunity } from '../../helpers/api/local'
import { invidiousGetCommunityPosts } from '../../helpers/api/invidious'
@ -49,6 +49,11 @@ export default defineComponent({
})
return entries
},
lastCommunityRefreshTimestamp: function () {
return getRelativeTimeFromDate(this.$store.getters.getLastCommunityRefreshTimestampByProfile(this.activeProfileId), true)
},
postCacheForAllActiveProfileChannelsPresent() {
if (this.cacheEntriesForAllActiveProfileChannels.length === 0) { return false }
if (this.cacheEntriesForAllActiveProfileChannels.length < this.activeSubscriptionList.length) { return false }
@ -69,22 +74,33 @@ export default defineComponent({
watch: {
activeProfile: async function (_) {
this.isLoading = true
this.loadpostsFromCacheSometimes()
this.loadPostsFromCacheSometimes()
},
},
mounted: async function () {
this.isLoading = true
this.loadpostsFromCacheSometimes()
this.loadPostsFromCacheSometimes()
},
methods: {
loadpostsFromCacheSometimes() {
loadPostsFromCacheSometimes() {
// This method is called on view visible
if (this.postCacheForAllActiveProfileChannelsPresent) {
this.loadPostsFromCacheForAllActiveProfileChannels()
if (this.cacheEntriesForAllActiveProfileChannels.length > 0) {
let minTimestamp = null
this.cacheEntriesForAllActiveProfileChannels.forEach((cacheEntry) => {
if (!minTimestamp || cacheEntry.timestamp.getTime() < minTimestamp.getTime()) {
minTimestamp = cacheEntry.timestamp
}
})
this.updateLastCommunityRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: minTimestamp })
}
return
}
// clear timestamp if not all entries are present in the cache
this.updateLastCommunityRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: '' })
this.maybeLoadPostsForSubscriptionsFromRemote()
},
@ -137,7 +153,7 @@ export default defineComponent({
this.updateSubscriptionPostsCacheByChannel({
channelId: channel.id,
posts: posts,
posts: posts
})
if (posts.length > 0) {
@ -168,6 +184,7 @@ export default defineComponent({
return posts
}))).flatMap((o) => o)
postList.push(...postListFromRemote)
this.updateLastCommunityRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: new Date() })
postList.sort((a, b) => {
return calculatePublishedDate(b.publishedText) - calculatePublishedDate(a.publishedText)
})
@ -243,6 +260,7 @@ export default defineComponent({
'updateShowProgressBar',
'batchUpdateSubscriptionDetails',
'updateSubscriptionPostsCacheByChannel',
'updateLastCommunityRefreshTimestampByProfile'
]),
...mapMutations([

View File

@ -6,6 +6,8 @@
:attempted-fetch="attemptedFetch"
:is-community="true"
:initial-data-limit="20"
:last-refresh-timestamp="lastCommunityRefreshTimestamp"
:title="$t('Global.Community')"
@refresh="loadPostsForSubscriptionsFromRemote"
/>
</template>

View File

@ -2,7 +2,7 @@ import { defineComponent } from 'vue'
import { mapActions, mapMutations } from 'vuex'
import SubscriptionsTabUI from '../subscriptions-tab-ui/subscriptions-tab-ui.vue'
import { setPublishedTimestampsInvidious, copyToClipboard, showToast } from '../../helpers/utils'
import { setPublishedTimestampsInvidious, copyToClipboard, getRelativeTimeFromDate, showToast } from '../../helpers/utils'
import { invidiousAPICall } from '../../helpers/api/invidious'
import { getLocalChannelLiveStreams } from '../../helpers/api/local'
import { parseYouTubeRSSFeed, updateVideoListAfterProcessing } from '../../helpers/subscriptions'
@ -70,6 +70,10 @@ export default defineComponent({
fetchSubscriptionsAutomatically: function() {
return this.$store.getters.getFetchSubscriptionsAutomatically
},
lastLiveRefreshTimestamp: function () {
return getRelativeTimeFromDate(this.$store.getters.getLastLiveRefreshTimestampByProfile(this.activeProfileId), true)
}
},
watch: {
activeProfile: async function (_) {
@ -87,9 +91,20 @@ export default defineComponent({
// This method is called on view visible
if (this.videoCacheForAllActiveProfileChannelsPresent) {
this.loadVideosFromCacheForAllActiveProfileChannels()
if (this.cacheEntriesForAllActiveProfileChannels.length > 0) {
let minTimestamp = null
this.cacheEntriesForAllActiveProfileChannels.forEach((cacheEntry) => {
if (!minTimestamp || cacheEntry.timestamp.getTime() < minTimestamp.getTime()) {
minTimestamp = cacheEntry.timestamp
}
})
this.updateLastLiveRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: minTimestamp })
}
return
}
// clear timestamp if not all entries are present in the cache
this.updateLastLiveRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: '' })
this.maybeLoadVideosForSubscriptionsFromRemote()
},
@ -154,7 +169,7 @@ export default defineComponent({
this.setProgressBarPercentage(percentageComplete)
this.updateSubscriptionLiveCacheByChannel({
channelId: channel.id,
videos: videos,
videos: videos
})
if (name || thumbnailUrl) {
@ -168,6 +183,7 @@ export default defineComponent({
return videos
}))).flatMap((o) => o)
videoList.push(...videoListFromRemote)
this.updateLastLiveRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: new Date() })
this.videoList = updateVideoListAfterProcessing(videoList)
this.isLoading = false
@ -382,6 +398,7 @@ export default defineComponent({
'batchUpdateSubscriptionDetails',
'updateShowProgressBar',
'updateSubscriptionLiveCacheByChannel',
'updateLastLiveRefreshTimestampByProfile'
]),
...mapMutations([

View File

@ -4,6 +4,8 @@
:video-list="videoList"
:error-channels="errorChannels"
:attempted-fetch="attemptedFetch"
:last-refresh-timestamp="lastLiveRefreshTimestamp"
:title="$t('Global.Live')"
@refresh="loadVideosForSubscriptionsFromRemote"
/>
</template>

View File

@ -3,7 +3,7 @@ import { mapActions, mapMutations } from 'vuex'
import SubscriptionsTabUI from '../subscriptions-tab-ui/subscriptions-tab-ui.vue'
import { parseYouTubeRSSFeed, updateVideoListAfterProcessing } from '../../helpers/subscriptions'
import { copyToClipboard, showToast } from '../../helpers/utils'
import { copyToClipboard, getRelativeTimeFromDate, showToast } from '../../helpers/utils'
export default defineComponent({
name: 'SubscriptionsShorts',
@ -31,6 +31,10 @@ export default defineComponent({
return this.$store.getters.getCurrentInvidiousInstance
},
lastShortRefreshTimestamp: function () {
return getRelativeTimeFromDate(this.$store.getters.getLastShortRefreshTimestampByProfile(this.activeProfileId), true)
},
activeProfile: function () {
return this.$store.getters.getActiveProfile
},
@ -79,11 +83,23 @@ export default defineComponent({
methods: {
loadVideosFromCacheSometimes() {
// This method is called on view visible
if (this.videoCacheForAllActiveProfileChannelsPresent) {
this.loadVideosFromCacheForAllActiveProfileChannels()
if (this.cacheEntriesForAllActiveProfileChannels.length > 0) {
let minTimestamp = null
this.cacheEntriesForAllActiveProfileChannels.forEach((cacheEntry) => {
if (!minTimestamp || cacheEntry.timestamp.getTime() < minTimestamp.getTime()) {
minTimestamp = cacheEntry.timestamp
}
})
this.updateLastShortRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: minTimestamp })
}
return
}
// clear timestamp if not all entries are present in the cache
this.updateLastShortRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: '' })
this.maybeLoadVideosForSubscriptionsFromRemote()
},
@ -131,7 +147,7 @@ export default defineComponent({
this.setProgressBarPercentage(percentageComplete)
this.updateSubscriptionShortsCacheByChannel({
channelId: channel.id,
videos: videos,
videos: videos
})
if (name) {
@ -144,6 +160,7 @@ export default defineComponent({
return videos
}))).flatMap((o) => o)
videoList.push(...videoListFromRemote)
this.updateLastShortRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: new Date() })
this.videoList = updateVideoListAfterProcessing(videoList)
this.isLoading = false
@ -254,6 +271,7 @@ export default defineComponent({
'batchUpdateSubscriptionDetails',
'updateShowProgressBar',
'updateSubscriptionShortsCacheByChannel',
'updateLastShortRefreshTimestampByProfile'
]),
...mapMutations([

View File

@ -4,6 +4,8 @@
:video-list="videoList"
:error-channels="errorChannels"
:attempted-fetch="attemptedFetch"
:last-refresh-timestamp="lastShortRefreshTimestamp"
:title="$t('Global.Shorts')"
@refresh="loadVideosForSubscriptionsFromRemote"
/>
</template>

View File

@ -8,18 +8,6 @@
color: var(--tertiary-text-color);
}
.floatingTopButton {
position: fixed;
inset-block-start: 70px;
inset-inline-end: 10px;
}
@media only screen and (width <= 350px) {
.floatingTopButton {
position: absolute
}
}
@media only screen and (width <= 680px) {
.card {
inline-size: 90%;

View File

@ -3,7 +3,7 @@ import { defineComponent } from 'vue'
import FtLoader from '../ft-loader/ft-loader.vue'
import FtCard from '../ft-card/ft-card.vue'
import FtButton from '../ft-button/ft-button.vue'
import FtIconButton from '../ft-icon-button/ft-icon-button.vue'
import FtRefreshWidget from '../ft-refresh-widget/ft-refresh-widget.vue'
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
import FtElementList from '../ft-element-list/ft-element-list.vue'
import FtChannelBubble from '../ft-channel-bubble/ft-channel-bubble.vue'
@ -15,7 +15,7 @@ export default defineComponent({
'ft-loader': FtLoader,
'ft-card': FtCard,
'ft-button': FtButton,
'ft-icon-button': FtIconButton,
'ft-refresh-widget': FtRefreshWidget,
'ft-flex-box': FtFlexBox,
'ft-element-list': FtElementList,
'ft-channel-bubble': FtChannelBubble,
@ -45,6 +45,14 @@ export default defineComponent({
initialDataLimit: {
type: Number,
default: 100
},
lastRefreshTimestamp: {
type: String,
required: true
},
title: {
type: String,
required: true
}
},
emits: ['refresh'],
@ -72,7 +80,7 @@ export default defineComponent({
fetchSubscriptionsAutomatically: function() {
return this.$store.getters.getFetchSubscriptionsAutomatically
},
}
},
created: function () {
const dataLimit = sessionStorage.getItem('subscriptionLimit')

View File

@ -58,13 +58,10 @@
/>
</ft-flex-box>
</ft-auto-load-next-page-wrapper>
<ft-icon-button
v-if="!isLoading && activeSubscriptionList.length > 0"
:icon="['fas', 'sync']"
class="floatingTopButton"
:title="$t('Subscriptions.Refresh Subscriptions')"
:size="12"
theme="primary"
<ft-refresh-widget
:disable-refresh="isLoading || activeSubscriptionList.length === 0"
:last-refresh-timestamp="lastRefreshTimestamp"
:title="title"
@click="$emit('refresh')"
/>
</div>

View File

@ -2,7 +2,7 @@ import { defineComponent } from 'vue'
import { mapActions, mapMutations } from 'vuex'
import SubscriptionsTabUI from '../subscriptions-tab-ui/subscriptions-tab-ui.vue'
import { setPublishedTimestampsInvidious, copyToClipboard, showToast } from '../../helpers/utils'
import { setPublishedTimestampsInvidious, copyToClipboard, getRelativeTimeFromDate, showToast } from '../../helpers/utils'
import { invidiousAPICall } from '../../helpers/api/invidious'
import { getLocalChannelVideos } from '../../helpers/api/local'
import { parseYouTubeRSSFeed, updateVideoListAfterProcessing } from '../../helpers/subscriptions'
@ -33,6 +33,14 @@ export default defineComponent({
return this.$store.getters.getCurrentInvidiousInstance
},
currentLocale: function () {
return this.$i18n.locale.replace('_', '-')
},
lastVideoRefreshTimestamp: function () {
return getRelativeTimeFromDate(this.$store.getters.getLastVideoRefreshTimestampByProfile(this.activeProfileId), true)
},
useRssFeeds: function () {
return this.$store.getters.getUseRssFeeds
},
@ -87,9 +95,20 @@ export default defineComponent({
// This method is called on view visible
if (this.videoCacheForAllActiveProfileChannelsPresent) {
this.loadVideosFromCacheForAllActiveProfileChannels()
if (this.cacheEntriesForAllActiveProfileChannels.length > 0) {
let minTimestamp = null
this.cacheEntriesForAllActiveProfileChannels.forEach((cacheEntry) => {
if (!minTimestamp || cacheEntry.timestamp.getTime() < minTimestamp.getTime()) {
minTimestamp = cacheEntry.timestamp
}
})
this.updateLastVideoRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: minTimestamp })
}
return
}
// clear timestamp if not all entries are present in the cache
this.updateLastVideoRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: '' })
this.maybeLoadVideosForSubscriptionsFromRemote()
},
@ -154,7 +173,7 @@ export default defineComponent({
this.setProgressBarPercentage(percentageComplete)
this.updateSubscriptionVideosCacheByChannel({
channelId: channel.id,
videos: videos,
videos: videos
})
if (name || thumbnailUrl) {
@ -168,6 +187,7 @@ export default defineComponent({
return videos
}))).flatMap((o) => o)
videoList.push(...videoListFromRemote)
this.updateLastVideoRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: new Date() })
this.videoList = updateVideoListAfterProcessing(videoList)
this.isLoading = false
@ -380,6 +400,7 @@ export default defineComponent({
'batchUpdateSubscriptionDetails',
'updateShowProgressBar',
'updateSubscriptionVideosCacheByChannel',
'updateLastVideoRefreshTimestampByProfile'
]),
...mapMutations([

View File

@ -3,7 +3,9 @@
:is-loading="isLoading"
:video-list="videoList"
:error-channels="errorChannels"
:last-refresh-timestamp="lastVideoRefreshTimestamp"
:attempted-fetch="attemptedFetch"
:title="$t('Global.Videos')"
@refresh="loadVideosForSubscriptionsFromRemote"
/>
</template>

View File

@ -10,6 +10,11 @@ import router from '../router/index'
export const CHANNEL_HANDLE_REGEX = /^@[\w.-]{3,30}$/
const PUBLISHED_TEXT_REGEX = /(\d+)\s?([a-z]+)/i
function currentLocale () {
return i18n.locale.replace('_', '-')
}
/**
* @param {string} publishedText
* @param {boolean} isLive
@ -52,6 +57,7 @@ export function calculatePublishedDate(publishedText, isLive = false, isUpcoming
} else if (timeFrame.startsWith('week') || timeFrame === 'w') {
timeSpan = timeAmount * 604800000
} else if (timeFrame.startsWith('month') || timeFrame === 'mo') {
// 30 day month being used
timeSpan = timeAmount * 2592000000
} else if (timeFrame.startsWith('year') || timeFrame === 'y') {
timeSpan = timeAmount * 31556952000
@ -715,6 +721,57 @@ export function getTodayDateStrLocalTimezone() {
return timeNowStr.split('T')[0]
}
export function getRelativeTimeFromDate(date, hideSeconds = false, useThirtyDayMonths = true) {
if (!date) {
return ''
}
const now = new Date().getTime()
// Convert from ms to second
// For easier code interpretation the value is made to be positive
// `comparisonDate` is sometimes a string
const comparisonDate = Date.parse(date)
let timeDiffFromNow = ((now - comparisonDate) / 1000)
let timeUnit = 'second'
if (timeDiffFromNow < 60 && hideSeconds) {
return i18n.t('Moments Ago')
}
if (timeDiffFromNow >= 60) {
timeDiffFromNow /= 60
timeUnit = 'minute'
}
if (timeUnit === 'minute' && timeDiffFromNow >= 60) {
timeDiffFromNow /= 60
timeUnit = 'hour'
}
if (timeUnit === 'hour' && timeDiffFromNow >= 24) {
timeDiffFromNow /= 24
timeUnit = 'day'
}
/* Different months might have a different number of days.
In some contexts, to ensure the display is fine, we use 31.
In other contexts, like when working with calculatePublishedDate, we use 30. */
const daysInMonth = useThirtyDayMonths ? 30 : 31
if (timeUnit === 'day' && timeDiffFromNow >= daysInMonth) {
timeDiffFromNow /= daysInMonth
timeUnit = 'month'
}
if (timeUnit === 'month' && timeDiffFromNow >= 12) {
timeDiffFromNow /= 12
timeUnit = 'year'
}
// Using `Math.ceil` so that -1.x days ago displayed as 1 day ago
// Notice that the value is turned to negative to be displayed as "ago"
return new Intl.RelativeTimeFormat([currentLocale(), 'en']).format(Math.ceil(-timeDiffFromNow), timeUnit)
}
/**
* Escapes HTML tags to avoid XSS
* @param {string} untrusted

View File

@ -69,19 +69,21 @@ const actions = {
}
const mutations = {
updateVideoCacheByChannel(state, { channelId, videos }) {
updateVideoCacheByChannel(state, { channelId, videos, timestamp = new Date() }) {
const existingObject = state.videoCache[channelId]
const newObject = existingObject ?? { videos: null }
if (videos != null) { newObject.videos = videos }
newObject.timestamp = timestamp
state.videoCache[channelId] = newObject
},
clearVideoCache(state) {
state.videoCache = {}
},
updateShortsCacheByChannel(state, { channelId, videos }) {
updateShortsCacheByChannel(state, { channelId, videos, timestamp = new Date() }) {
const existingObject = state.shortsCache[channelId]
const newObject = existingObject ?? { videos: null }
if (videos != null) { newObject.videos = videos }
newObject.timestamp = timestamp
state.shortsCache[channelId] = newObject
},
updateShortsCacheWithChannelPageShorts(state, { channelId, videos }) {
@ -112,19 +114,21 @@ const mutations = {
clearShortsCache(state) {
state.shortsCache = {}
},
updateLiveCacheByChannel(state, { channelId, videos }) {
updateLiveCacheByChannel(state, { channelId, videos, timestamp = new Date() }) {
const existingObject = state.liveCache[channelId]
const newObject = existingObject ?? { videos: null }
if (videos != null) { newObject.videos = videos }
newObject.timestamp = timestamp
state.liveCache[channelId] = newObject
},
clearLiveCache(state) {
state.liveCache = {}
},
updatePostsCacheByChannel(state, { channelId, posts }) {
updatePostsCacheByChannel(state, { channelId, posts, timestamp = new Date() }) {
const existingObject = state.postsCache[channelId]
const newObject = existingObject ?? { posts: null }
if (posts != null) { newObject.posts = posts }
newObject.timestamp = timestamp
state.postsCache[channelId] = newObject
},
clearPostsCache(state) {

View File

@ -48,7 +48,13 @@ const state = {
},
externalPlayerNames: [],
externalPlayerValues: [],
externalPlayerCmdArguments: {}
externalPlayerCmdArguments: {},
lastVideoRefreshTimestampByProfile: {},
lastShortRefreshTimestampByProfile: {},
lastLiveRefreshTimestampByProfile: {},
lastCommunityRefreshTimestampByProfile: {},
lastPopularRefreshTimestamp: '',
lastTrendingRefreshTimestamp: '',
}
const getters = {
@ -138,6 +144,30 @@ const getters = {
getExternalPlayerCmdArguments () {
return state.externalPlayerCmdArguments
},
getLastTrendingRefreshTimestamp() {
return state.lastTrendingRefreshTimestamp
},
getLastPopularRefreshTimestamp() {
return state.lastPopularRefreshTimestamp
},
getLastCommunityRefreshTimestampByProfile: (state) => (profileId) => {
return state.lastCommunityRefreshTimestampByProfile[profileId]
},
getLastShortRefreshTimestampByProfile: (state) => (profileId) => {
return state.lastShortRefreshTimestampByProfile[profileId]
},
getLastLiveRefreshTimestampByProfile: (state) => (profileId) => {
return state.lastLiveRefreshTimestampByProfile[profileId]
},
getLastVideoRefreshTimestampByProfile: (state) => (profileId) => {
return state.lastVideoRefreshTimestampByProfile[profileId]
}
}
@ -732,6 +762,22 @@ const actions = {
const { ipcRenderer } = require('electron')
ipcRenderer.send(IpcChannels.OPEN_IN_EXTERNAL_PLAYER, { executable, args })
}
},
updateLastCommunityRefreshTimestampByProfile ({ commit }, payload) {
commit('updateLastCommunityRefreshTimestampByProfile', payload)
},
updateLastShortRefreshTimestampByProfile ({ commit }, payload) {
commit('updateLastShortRefreshTimestampByProfile', payload)
},
updateLastLiveRefreshTimestampByProfile ({ commit }, payload) {
commit('updateLastLiveRefreshTimestampByProfile', payload)
},
updateLastVideoRefreshTimestampByProfile ({ commit }, payload) {
commit('updateLastVideoRefreshTimestampByProfile', payload)
}
}
@ -824,6 +870,30 @@ const mutations = {
state.trendingCache[page] = value
},
setLastTrendingRefreshTimestamp (state, timestamp) {
state.lastTrendingRefreshTimestamp = timestamp
},
setLastPopularRefreshTimestamp (state, timestamp) {
state.lastPopularRefreshTimestamp = timestamp
},
updateLastCommunityRefreshTimestampByProfile (state, { profileId, timestamp }) {
vueSet(state.lastCommunityRefreshTimestampByProfile, profileId, timestamp)
},
updateLastShortRefreshTimestampByProfile (state, { profileId, timestamp }) {
vueSet(state.lastShortRefreshTimestampByProfile, profileId, timestamp)
},
updateLastLiveRefreshTimestampByProfile (state, { profileId, timestamp }) {
vueSet(state.lastLiveRefreshTimestampByProfile, profileId, timestamp)
},
updateLastVideoRefreshTimestampByProfile (state, { profileId, timestamp }) {
vueSet(state.lastVideoRefreshTimestampByProfile, profileId, timestamp)
},
clearTrendingCache(state) {
state.trendingCache = {
default: null,

View File

@ -4,18 +4,6 @@
margin-inline: auto;
}
.floatingTopButton {
position: fixed;
inset-block-start: 70px;
inset-inline-end: 10px;
}
@media only screen and (width <= 350px) {
.floatingTopButton {
position: absolute
}
}
@media only screen and (width <= 680px) {
.card {
inline-size: 90%;

View File

@ -1,11 +1,13 @@
import { defineComponent } from 'vue'
import { mapMutations } from 'vuex'
import FtLoader from '../../components/ft-loader/ft-loader.vue'
import FtCard from '../../components/ft-card/ft-card.vue'
import FtElementList from '../../components/ft-element-list/ft-element-list.vue'
import FtIconButton from '../../components/ft-icon-button/ft-icon-button.vue'
import FtRefreshWidget from '../../components/ft-refresh-widget/ft-refresh-widget.vue'
import { invidiousAPICall } from '../../helpers/api/invidious'
import { copyToClipboard, setPublishedTimestampsInvidious, showToast } from '../../helpers/utils'
import { copyToClipboard, getRelativeTimeFromDate, setPublishedTimestampsInvidious, showToast } from '../../helpers/utils'
export default defineComponent({
name: 'Popular',
@ -13,7 +15,8 @@ export default defineComponent({
'ft-loader': FtLoader,
'ft-card': FtCard,
'ft-element-list': FtElementList,
'ft-icon-button': FtIconButton
'ft-icon-button': FtIconButton,
'ft-refresh-widget': FtRefreshWidget,
},
data: function () {
return {
@ -22,6 +25,9 @@ export default defineComponent({
}
},
computed: {
lastPopularRefreshTimestamp: function () {
return getRelativeTimeFromDate(this.$store.getters.getLastPopularRefreshTimestamp, true)
},
popularCache: function () {
return this.$store.getters.getPopularCache
}
@ -64,6 +70,7 @@ export default defineComponent({
return item.type === 'video' || item.type === 'shortVideo' || item.type === 'channel' || item.type === 'playlist'
})
setPublishedTimestampsInvidious(items.filter(item => item.type === 'video' || item.type === 'shortVideo'))
this.setLastPopularRefreshTimestamp(new Date())
this.shownResults = items
@ -92,6 +99,10 @@ export default defineComponent({
}
break
}
}
},
...mapMutations([
'setLastPopularRefreshTimestamp'
])
}
})

View File

@ -13,12 +13,10 @@
:data="shownResults"
/>
</ft-card>
<ft-icon-button
v-if="!isLoading"
:icon="['fas', 'sync']"
class="floatingTopButton"
:size="12"
theme="primary"
<ft-refresh-widget
:disable-refresh="isLoading"
:last-refresh-timestamp="lastPopularRefreshTimestamp"
:title="$t('Most Popular')"
@click="fetchPopularInfo"
/>
</div>

View File

@ -4,12 +4,6 @@
margin-inline: auto;
}
.floatingTopButton {
position: fixed;
inset-block-start: 70px;
inset-inline-end: 10px;
}
.trendingInfoTabs {
inline-size: 100%;
display: grid;
@ -38,12 +32,6 @@
font-weight: bold;
}
@media only screen and (width <= 350px) {
.floatingTopButton {
position: absolute
}
}
@media only screen and (width <= 680px) {
.card {
inline-size: 90%;

View File

@ -1,12 +1,13 @@
import { defineComponent } from 'vue'
import { mapActions } from 'vuex'
import { mapActions, mapMutations } from 'vuex'
import FtCard from '../../components/ft-card/ft-card.vue'
import FtLoader from '../../components/ft-loader/ft-loader.vue'
import FtElementList from '../../components/ft-element-list/ft-element-list.vue'
import FtIconButton from '../../components/ft-icon-button/ft-icon-button.vue'
import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue'
import FtRefreshWidget from '../../components/ft-refresh-widget/ft-refresh-widget.vue'
import { copyToClipboard, setPublishedTimestampsInvidious, showToast } from '../../helpers/utils'
import { copyToClipboard, getRelativeTimeFromDate, setPublishedTimestampsInvidious, showToast } from '../../helpers/utils'
import { getLocalTrending } from '../../helpers/api/local'
import { invidiousAPICall } from '../../helpers/api/invidious'
@ -17,7 +18,8 @@ export default defineComponent({
'ft-loader': FtLoader,
'ft-element-list': FtElementList,
'ft-icon-button': FtIconButton,
'ft-flex-box': FtFlexBox
'ft-flex-box': FtFlexBox,
'ft-refresh-widget': FtRefreshWidget,
},
data: function () {
return {
@ -34,6 +36,9 @@ export default defineComponent({
backendFallback: function () {
return this.$store.getters.getBackendFallback
},
lastTrendingRefreshTimestamp: function () {
return getRelativeTimeFromDate(this.$store.getters.getLastTrendingRefreshTimestamp, true)
},
region: function () {
return this.$store.getters.getRegion.toUpperCase()
},
@ -90,6 +95,8 @@ export default defineComponent({
} else {
this.getTrendingInfoLocal()
}
this.setLastTrendingRefreshTimestamp(new Date())
},
getTrendingInfoLocal: async function () {
@ -195,6 +202,10 @@ export default defineComponent({
...mapActions([
'showOutlines'
]),
...mapMutations([
'setLastTrendingRefreshTimestamp'
])
}
})

View File

@ -85,12 +85,10 @@
:data="shownResults"
/>
</ft-card>
<ft-icon-button
v-if="!isLoading"
:icon="['fas', 'sync']"
class="floatingTopButton"
:size="12"
theme="primary"
<ft-refresh-widget
:disable-refresh="isLoading"
:last-refresh-timestamp="lastTrendingRefreshTimestamp"
:title="$t('Trending.Trending')"
@click="getTrendingInfo(true)"
/>
</div>

View File

@ -109,7 +109,6 @@ Subscriptions:
Empty Channels: Your subscribed channels currently does not have any videos.
'Getting Subscriptions. Please wait.': Getting Subscriptions. Please wait.
Empty Posts: Your subscribed channels currently do not have any posts.
Refresh Subscriptions: Refresh Subscriptions
Load More Videos: Load More Videos
Load More Posts: Load More Posts
Subscriptions Tabs: Subscriptions Tabs
@ -132,6 +131,9 @@ Trending:
Movies: Movies
Trending Tabs: Trending Tabs
Most Popular: Most Popular
Feed:
Feed Last Updated: '{feedName} feed last updated: {date}'
Refresh Feed: Refresh {subscriptionName}
Playlists: Playlists
User Playlists:
Your Playlists: Your Playlists
@ -1061,6 +1063,7 @@ Hashtag:
Hashtag: Hashtag
This hashtag does not currently have any videos: This hashtag does not currently
have any videos
Moments Ago: moments ago
Yes: Yes
No: No
Ok: Ok