refs #574 Show loading card and load unread statuses in Home

This commit is contained in:
AkiraFukushima 2021-12-13 23:22:34 +09:00
parent 7199252b1d
commit 64bcfef151
No known key found for this signature in database
GPG Key ID: B6E51BAC4DE1A957
9 changed files with 236 additions and 43 deletions

View File

@ -282,8 +282,8 @@ describe('TimelineSpace/Contents/Home', () => {
}) })
it('should be updated', () => { it('should be updated', () => {
Home.mutations![MUTATION_TYPES.UPDATE_TOOT](state, favouritedStatus) Home.mutations![MUTATION_TYPES.UPDATE_TOOT](state, favouritedStatus)
expect(state.timeline[0].reblog).not.toBeNull() expect((state.timeline[0] as Entity.Status).reblog).not.toBeNull()
expect(state.timeline[0].reblog!.favourited).toEqual(true) expect((state.timeline[0] as Entity.Status).reblog!.favourited).toEqual(true)
}) })
}) })
}) })

View File

@ -378,6 +378,9 @@
"left": "{{datetime}} left", "left": "{{datetime}} left",
"refresh": "Refresh" "refresh": "Refresh"
} }
},
"status_loading": {
"message": "Load more status"
} }
}, },
"side_bar": { "side_bar": {

View File

@ -1169,12 +1169,16 @@ ipcMain.handle('get-notifications-marker', async (_: IpcMainInvokeEvent, ownerID
return marker return marker
}) })
ipcMain.handle('save-marker', async (_: IpcMainInvokeEvent, marker: LocalMarker) => { ipcMain.handle(
'save-marker',
async (_: IpcMainInvokeEvent, marker: LocalMarker): Promise<LocalMarker | null> => {
if (marker.owner_id === null || marker.owner_id === undefined || marker.owner_id === '') { if (marker.owner_id === null || marker.owner_id === undefined || marker.owner_id === '') {
return return null
} }
await markerRepo.save(marker) const res = await markerRepo.save(marker)
}) return res
}
)
setTimeout(async () => { setTimeout(async () => {
try { try {

View File

@ -24,7 +24,7 @@
<el-switch id="hideAllAttachments" v-model="timeline_hide_attachments" active-color="#13ce66"> </el-switch> <el-switch id="hideAllAttachments" v-model="timeline_hide_attachments" active-color="#13ce66"> </el-switch>
</el-form-item> </el-form-item>
<el-form-item for="useMarker" :label="$t('preferences.general.timeline.useMarker')"> <el-form-item for="useMarker" :label="$t('preferences.general.timeline.useMarker')">
<el-switch id="useMarker" v-model="timeline_use_marker" active-color="#13ce66" disabled> </el-switch> <el-switch id="useMarker" v-model="timeline_use_marker" active-color="#13ce66"> </el-switch>
</el-form-item> </el-form-item>
<p class="notice"> <p class="notice">
{{ $t('preferences.general.timeline.useMarkerNotice') }} {{ $t('preferences.general.timeline.useMarkerNotice') }}

View File

@ -3,6 +3,12 @@
<div v-shortkey="{ linux: ['ctrl', 'r'], mac: ['meta', 'r'] }" @shortkey="reload()"></div> <div v-shortkey="{ linux: ['ctrl', 'r'], mac: ['meta', 'r'] }" @shortkey="reload()"></div>
<DynamicScroller :items="filteredTimeline" :min-item-size="86" id="scroller" class="scroller" ref="scroller"> <DynamicScroller :items="filteredTimeline" :min-item-size="86" id="scroller" class="scroller" ref="scroller">
<template v-slot="{ item, index, active }"> <template v-slot="{ item, index, active }">
<template v-if="item.id === 'loading-card'">
<DynamicScrollerItem :item="item" :active="active" :size-dependencies="[item.id]" :data-index="index" :watchData="true">
<StatusLoading :since_id="item.since_id" :max_id="item.max_id" @load_since="fetchTimelineSince" />
</DynamicScrollerItem>
</template>
<template v-else>
<DynamicScrollerItem :item="item" :active="active" :size-dependencies="[item.uri]" :data-index="index" :watchData="true"> <DynamicScrollerItem :item="item" :active="active" :size-dependencies="[item.uri]" :data-index="index" :watchData="true">
<toot <toot
:message="item" :message="item"
@ -20,6 +26,7 @@
</toot> </toot>
</DynamicScrollerItem> </DynamicScrollerItem>
</template> </template>
</template>
</DynamicScroller> </DynamicScroller>
<div :class="openSideBar ? 'upper-with-side-bar' : 'upper'" v-show="!heading"> <div :class="openSideBar ? 'upper-with-side-bar' : 'upper'" v-show="!heading">
@ -32,13 +39,14 @@
import { mapState, mapGetters } from 'vuex' import { mapState, mapGetters } from 'vuex'
import moment from 'moment' import moment from 'moment'
import Toot from '~/src/renderer/components/organisms/Toot' import Toot from '~/src/renderer/components/organisms/Toot'
import StatusLoading from '~/src/renderer/components/organisms/StatusLoading'
import reloadable from '~/src/renderer/components/mixins/reloadable' import reloadable from '~/src/renderer/components/mixins/reloadable'
import { Event } from '~/src/renderer/components/event' import { Event } from '~/src/renderer/components/event'
import { ScrollPosition } from '~/src/renderer/components/utils/scroll' import { ScrollPosition } from '~/src/renderer/components/utils/scroll'
export default { export default {
name: 'home', name: 'home',
components: { Toot }, components: { Toot, StatusLoading },
mixins: [reloadable], mixins: [reloadable],
data() { data() {
return { return {
@ -46,7 +54,8 @@ export default {
scrollPosition: null, scrollPosition: null,
observer: null, observer: null,
scrollTime: null, scrollTime: null,
resizeTime: null resizeTime: null,
loadingMore: false
} }
}, },
computed: { computed: {
@ -101,14 +110,14 @@ export default {
}) })
if (this.heading && this.timeline.length > 0) { if (this.heading && this.timeline.length > 0) {
this.$store.dispatch('TimelineSpace/Contents/Home/saveMarker', this.timeline[0].id) this.$store.dispatch('TimelineSpace/Contents/Home/saveMarker')
} }
const el = document.getElementById('scroller') const el = document.getElementById('scroller')
this.scrollPosition = new ScrollPosition(el) this.scrollPosition = new ScrollPosition(el)
this.scrollPosition.prepare() this.scrollPosition.prepare()
this.observer = new ResizeObserver(() => { this.observer = new ResizeObserver(() => {
if (this.scrollPosition && !this.heading && !this.lazyLoading && !this.scrolling) { if (this.loadingMore || (this.scrollPosition && !this.heading && !this.lazyLoading && !this.scrolling)) {
this.resizeTime = moment() this.resizeTime = moment()
this.scrollPosition.restore() this.scrollPosition.restore()
} }
@ -154,7 +163,7 @@ export default {
}, },
timeline: function (newState, _oldState) { timeline: function (newState, _oldState) {
if (this.heading && newState.length > 0) { if (this.heading && newState.length > 0) {
this.$store.dispatch('TimelineSpace/Contents/Home/saveMarker', newState[0].id) this.$store.dispatch('TimelineSpace/Contents/Home/saveMarker')
} }
} }
}, },
@ -214,6 +223,14 @@ export default {
deleteToot(message) { deleteToot(message) {
this.$store.commit('TimelineSpace/Contents/Home/deleteToot', message.id) this.$store.commit('TimelineSpace/Contents/Home/deleteToot', message.id)
}, },
fetchTimelineSince(since_id) {
this.loadingMore = true
this.$store.dispatch('TimelineSpace/Contents/Home/fetchTimelineSince', since_id).finally(() => {
setTimeout(() => {
this.loadingMore = false
}, 500)
})
},
async reload() { async reload() {
this.$store.commit('TimelineSpace/changeLoading', true) this.$store.commit('TimelineSpace/changeLoading', true)
try { try {

View File

@ -0,0 +1,44 @@
<template>
<div class="status-loading" tabIndex="0" @click="onClick">
<p class="load-text">{{ $t('cards.status_loading.message') }}</p>
<div class="fill-line"></div>
</div>
</template>
<script>
export default {
name: 'status-loading',
props: {
max_id: {
type: String,
default: ''
},
since_id: {
type: String,
default: ''
}
},
methods: {
onClick() {
if (this.since_id !== '') {
this.$emit('load_since', this.since_id)
} else if (this.max_id !== '') {
this.$emit('load_max', this.max_id)
}
}
}
}
</script>
<style lang="scss" scoped>
.status-loading {
background-color: var(--theme-background-color);
text-align: center;
padding: 12px 0;
height: 36px;
border-bottom: 1px solid var(--theme-border-color);
.load-text {
cursor: pointer;
}
}
</style>

View File

@ -27,6 +27,7 @@ export type AppState = {
hideAllAttachments: boolean hideAllAttachments: boolean
tootPadding: number tootPadding: number
userAgent: string userAgent: string
useMarker: boolean
} }
const state = (): AppState => ({ const state = (): AppState => ({
@ -51,7 +52,8 @@ const state = (): AppState => ({
ignoreCW: false, ignoreCW: false,
ignoreNSFW: false, ignoreNSFW: false,
hideAllAttachments: false, hideAllAttachments: false,
userAgent: 'Whalebird' userAgent: 'Whalebird',
useMarker: false
}) })
const MUTATION_TYPES = { const MUTATION_TYPES = {
@ -65,7 +67,8 @@ const MUTATION_TYPES = {
ADD_FONT: 'addFont', ADD_FONT: 'addFont',
UPDATE_IGNORE_CW: 'updateIgnoreCW', UPDATE_IGNORE_CW: 'updateIgnoreCW',
UPDATE_IGNORE_NSFW: 'updateIgnoreNSFW', UPDATE_IGNORE_NSFW: 'updateIgnoreNSFW',
UPDATE_HIDE_ALL_ATTACHMENTS: 'updateHideAllAttachments' UPDATE_HIDE_ALL_ATTACHMENTS: 'updateHideAllAttachments',
UPDATE_USE_MARKER: 'updateUseMarker'
} }
const mutations: MutationTree<AppState> = { const mutations: MutationTree<AppState> = {
@ -102,6 +105,9 @@ const mutations: MutationTree<AppState> = {
}, },
[MUTATION_TYPES.UPDATE_HIDE_ALL_ATTACHMENTS]: (state: AppState, hideAllAttachments: boolean) => { [MUTATION_TYPES.UPDATE_HIDE_ALL_ATTACHMENTS]: (state: AppState, hideAllAttachments: boolean) => {
state.hideAllAttachments = hideAllAttachments state.hideAllAttachments = hideAllAttachments
},
[MUTATION_TYPES.UPDATE_USE_MARKER]: (state: AppState, useMarker: boolean) => {
state.useMarker = useMarker
} }
} }
@ -127,6 +133,7 @@ const actions: ActionTree<AppState, RootState> = {
commit(MUTATION_TYPES.UPDATE_IGNORE_CW, conf.general.timeline.cw) commit(MUTATION_TYPES.UPDATE_IGNORE_CW, conf.general.timeline.cw)
commit(MUTATION_TYPES.UPDATE_IGNORE_NSFW, conf.general.timeline.nsfw) commit(MUTATION_TYPES.UPDATE_IGNORE_NSFW, conf.general.timeline.nsfw)
commit(MUTATION_TYPES.UPDATE_HIDE_ALL_ATTACHMENTS, conf.general.timeline.hideAllAttachments) commit(MUTATION_TYPES.UPDATE_HIDE_ALL_ATTACHMENTS, conf.general.timeline.hideAllAttachments)
commit(MUTATION_TYPES.UPDATE_USE_MARKER, conf.general.timeline.useMarker)
return conf return conf
}, },
updateTheme: async ({ commit }, appearance: Appearance) => { updateTheme: async ({ commit }, appearance: Appearance) => {

View File

@ -3,6 +3,7 @@ import { Module, MutationTree, ActionTree, GetterTree } from 'vuex'
import { RootState } from '@/store' import { RootState } from '@/store'
import { LocalMarker } from '~/src/types/localMarker' import { LocalMarker } from '~/src/types/localMarker'
import { MyWindow } from '~/src/types/global' import { MyWindow } from '~/src/types/global'
import { LoadingCard } from '@/types/loading-card'
const win = (window as any) as MyWindow const win = (window as any) as MyWindow
@ -11,7 +12,7 @@ export type HomeState = {
heading: boolean heading: boolean
showReblogs: boolean showReblogs: boolean
showReplies: boolean showReplies: boolean
timeline: Array<Entity.Status> timeline: Array<Entity.Status | LoadingCard>
scrolling: boolean scrolling: boolean
} }
@ -36,7 +37,8 @@ export const MUTATION_TYPES = {
DELETE_TOOT: 'deleteToot', DELETE_TOOT: 'deleteToot',
SHOW_REBLOGS: 'showReblogs', SHOW_REBLOGS: 'showReblogs',
SHOW_REPLIES: 'showReplies', SHOW_REPLIES: 'showReplies',
CHANGE_SCROLLING: 'changeScrolling' CHANGE_SCROLLING: 'changeScrolling',
APPEND_TIMELINE_AFTER_LOADING_CARD: 'appendTimelineAfterLoadingCard'
} }
const mutations: MutationTree<HomeState> = { const mutations: MutationTree<HomeState> = {
@ -49,13 +51,13 @@ const mutations: MutationTree<HomeState> = {
[MUTATION_TYPES.APPEND_TIMELINE]: (state, update: Entity.Status) => { [MUTATION_TYPES.APPEND_TIMELINE]: (state, update: Entity.Status) => {
// Reject duplicated status in timeline // Reject duplicated status in timeline
if (!state.timeline.find(item => item.id === update.id)) { if (!state.timeline.find(item => item.id === update.id)) {
state.timeline = [update].concat(state.timeline) state.timeline = ([update] as Array<Entity.Status | LoadingCard>).concat(state.timeline)
} }
}, },
[MUTATION_TYPES.UPDATE_TIMELINE]: (state, messages: Array<Entity.Status>) => { [MUTATION_TYPES.UPDATE_TIMELINE]: (state, messages: Array<Entity.Status | LoadingCard>) => {
state.timeline = messages state.timeline = messages
}, },
[MUTATION_TYPES.INSERT_TIMELINE]: (state, messages: Array<Entity.Status>) => { [MUTATION_TYPES.INSERT_TIMELINE]: (state, messages: Array<Entity.Status | LoadingCard>) => {
state.timeline = state.timeline.concat(messages) state.timeline = state.timeline.concat(messages)
}, },
[MUTATION_TYPES.ARCHIVE_TIMELINE]: state => { [MUTATION_TYPES.ARCHIVE_TIMELINE]: state => {
@ -66,7 +68,11 @@ const mutations: MutationTree<HomeState> = {
}, },
[MUTATION_TYPES.UPDATE_TOOT]: (state, message: Entity.Status) => { [MUTATION_TYPES.UPDATE_TOOT]: (state, message: Entity.Status) => {
// Replace target message in homeTimeline and notifications // Replace target message in homeTimeline and notifications
state.timeline = state.timeline.map(toot => { state.timeline = state.timeline.map(status => {
if (status.id === 'loading-card') {
return status
}
const toot = status as Entity.Status
if (toot.id === message.id) { if (toot.id === message.id) {
return message return message
} else if (toot.reblog !== null && toot.reblog.id === message.id) { } else if (toot.reblog !== null && toot.reblog.id === message.id) {
@ -82,7 +88,11 @@ const mutations: MutationTree<HomeState> = {
}) })
}, },
[MUTATION_TYPES.DELETE_TOOT]: (state, messageId: string) => { [MUTATION_TYPES.DELETE_TOOT]: (state, messageId: string) => {
state.timeline = state.timeline.filter(toot => { state.timeline = state.timeline.filter(status => {
if (status.id === 'loading-card') {
return true
}
const toot = status as Entity.Status
if (toot.reblog !== null && toot.reblog.id === messageId) { if (toot.reblog !== null && toot.reblog.id === messageId) {
return false return false
} else { } else {
@ -98,19 +108,57 @@ const mutations: MutationTree<HomeState> = {
}, },
[MUTATION_TYPES.CHANGE_SCROLLING]: (state, value: boolean) => { [MUTATION_TYPES.CHANGE_SCROLLING]: (state, value: boolean) => {
state.scrolling = value state.scrolling = value
},
[MUTATION_TYPES.APPEND_TIMELINE_AFTER_LOADING_CARD]: (state, timeline: Array<Entity.Status | LoadingCard>) => {
const tl = state.timeline.flatMap(status => {
if (status.id !== 'loading-card') {
return status
} else {
return timeline
}
})
// Reject duplicated status in timeline
state.timeline = Array.from(new Set(tl))
} }
} }
const actions: ActionTree<HomeState, RootState> = { const actions: ActionTree<HomeState, RootState> = {
fetchTimeline: async ({ commit, rootState }) => { fetchTimeline: async ({ dispatch, commit, rootState }) => {
const client = generator( const client = generator(
rootState.TimelineSpace.sns, rootState.TimelineSpace.sns,
rootState.TimelineSpace.account.baseURL, rootState.TimelineSpace.account.baseURL,
rootState.TimelineSpace.account.accessToken, rootState.TimelineSpace.account.accessToken,
rootState.App.userAgent rootState.App.userAgent
) )
const res = await client.getHomeTimeline({ limit: 40 }) const localMarker: LocalMarker | null = await dispatch('getMarker').catch(err => {
commit(MUTATION_TYPES.UPDATE_TIMELINE, res.data) console.error(err)
})
let params = { limit: 40 }
if (localMarker !== null) {
params = Object.assign({}, params, {
max_id: localMarker.last_read_id
})
}
const res = await client.getHomeTimeline(params)
let timeline: Array<Entity.Status | LoadingCard> = []
if (res.data.length > 0 && rootState.App.useMarker) {
const card: LoadingCard = {
type: 'middle-load',
since_id: res.data[0].id,
// We don't need to fill this field in the first fetcing.
// Because in most cases there is no new statuses at the first fetching.
// After new statuses are received, if the number of unread statuses is more than 40, max_id is not necessary.
// We can fill max_id when calling fetchTimelineSince.
// If the number of unread statuses is less than 40, max_id is necessary, but it is enough to reject duplicated statuses.
// So we do it in mutation.
max_id: null,
id: 'loading-card'
}
timeline = timeline.concat([card])
}
timeline = timeline.concat(res.data)
commit(MUTATION_TYPES.UPDATE_TIMELINE, timeline)
return res.data return res.data
}, },
lazyFetchTimeline: async ({ state, commit, rootState }, lastStatus: Entity.Status): Promise<Array<Entity.Status> | null> => { lazyFetchTimeline: async ({ state, commit, rootState }, lastStatus: Entity.Status): Promise<Array<Entity.Status> | null> => {
@ -134,11 +182,75 @@ const actions: ActionTree<HomeState, RootState> = {
commit(MUTATION_TYPES.CHANGE_LAZY_LOADING, false) commit(MUTATION_TYPES.CHANGE_LAZY_LOADING, false)
}) })
}, },
saveMarker: async ({ rootState }, id: string) => { fetchTimelineSince: async ({ state, rootState, commit }, since_id: string): Promise<Array<Entity.Status> | null> => {
const client = generator(
rootState.TimelineSpace.sns,
rootState.TimelineSpace.account.baseURL,
rootState.TimelineSpace.account.accessToken,
rootState.App.userAgent
)
const cardIndex = state.timeline.findIndex(s => {
if (s.id === 'loading-card') {
return true
}
return false
})
let maxID: string | null = null
if (cardIndex > 0) {
maxID = state.timeline[cardIndex - 1].id
}
const res = await client.getHomeTimeline({ min_id: since_id, limit: 40 })
if (res.data.length >= 40) {
const card: LoadingCard = {
type: 'middle-load',
since_id: res.data[0].id,
max_id: maxID,
id: 'loading-card'
}
let timeline: Array<Entity.Status | LoadingCard> = [card]
timeline = timeline.concat(res.data)
commit(MUTATION_TYPES.APPEND_TIMELINE_AFTER_LOADING_CARD, timeline)
} else {
commit(MUTATION_TYPES.APPEND_TIMELINE_AFTER_LOADING_CARD, res.data)
}
return res.data
},
getMarker: async ({ rootState }): Promise<LocalMarker | null> => {
if (!rootState.App.useMarker) {
return null
}
const client = generator(
rootState.TimelineSpace.sns,
rootState.TimelineSpace.account.baseURL,
rootState.TimelineSpace.account.accessToken,
rootState.App.userAgent
)
let serverMarker: Entity.Marker | {} = {}
try {
const res = await client.getMarkers(['home'])
serverMarker = res.data
} catch (err) {
console.warn(err)
}
if ((serverMarker as Entity.Marker).home !== undefined) {
return {
timeline: 'home',
last_read_id: (serverMarker as Entity.Marker).home.last_read_id
} as LocalMarker
}
const localMarker: LocalMarker | null = await win.ipcRenderer.invoke('get-home-marker', rootState.TimelineSpace.account._id)
return localMarker
},
saveMarker: async ({ state, rootState }) => {
const timeline = state.timeline.filter(status => status.id !== 'loading-card')
if (timeline.length === 0) {
return
}
await win.ipcRenderer.invoke('save-marker', { await win.ipcRenderer.invoke('save-marker', {
owner_id: rootState.TimelineSpace.account._id, owner_id: rootState.TimelineSpace.account._id,
timeline: 'home', timeline: 'home',
last_read_id: id last_read_id: timeline[0].id
} as LocalMarker) } as LocalMarker)
} }
} }

View File

@ -0,0 +1,6 @@
export type LoadingCard = {
type: 'middle-load'
max_id: string | null
since_id: string | null
id: 'loading-card'
}