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

This commit is contained in:
AkiraFukushima 2021-12-26 19:58:24 +09:00
parent f4f7f9a3ab
commit baf69b813b
No known key found for this signature in database
GPG Key ID: B6E51BAC4DE1A957
4 changed files with 222 additions and 90 deletions

View File

@ -25,7 +25,7 @@ import path from 'path'
import ContextMenu from 'electron-context-menu' import ContextMenu from 'electron-context-menu'
import { initSplashScreen, Config } from '@trodi/electron-splashscreen' import { initSplashScreen, Config } from '@trodi/electron-splashscreen'
import openAboutWindow from 'about-window' import openAboutWindow from 'about-window'
import generator, { Entity, detector, NotificationType } from 'megalodon' import { Entity, detector, NotificationType } from 'megalodon'
import sanitizeHtml from 'sanitize-html' import sanitizeHtml from 'sanitize-html'
import AutoLaunch from 'auto-launch' import AutoLaunch from 'auto-launch'
import minimist from 'minimist' import minimist from 'minimist'
@ -1169,9 +1169,9 @@ ipcMain.handle('get-notifications-marker', async (_: IpcMainInvokeEvent, ownerID
return marker return marker
}) })
ipcMain.handle( ipcMain.on(
'save-marker', 'save-marker',
async (_: IpcMainInvokeEvent, marker: LocalMarker): Promise<LocalMarker | null> => { async (_: IpcMainEvent, 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 null return null
} }
@ -1180,43 +1180,6 @@ ipcMain.handle(
} }
) )
setTimeout(async () => {
try {
const accounts = await accountRepo.listAccounts()
accounts.map(async acct => {
const proxy = await proxyConfiguration.forMastodon()
const sns = await detector(acct.baseURL, proxy)
if (sns === 'misskey') {
return
}
const client = generator(sns, acct.baseURL, acct.accessToken, 'Whalebird', proxy)
const home = await markerRepo.get(acct._id!, 'home')
const notifications = await markerRepo.get(acct._id!, 'notifications')
let params = {}
if (home !== null && home !== undefined) {
params = Object.assign({}, params, {
home: {
last_read_id: home.last_read_id
}
})
}
if (notifications !== null && notifications !== undefined) {
params = Object.assign({}, params, {
notifications: {
last_read_id: notifications.last_read_id
}
})
}
if (isEmpty(params)) {
return
}
await client.saveMarkers(params)
})
} catch (err) {
console.error(err)
}
}, 120000)
// hashtag // hashtag
ipcMain.handle('save-hashtag', async (_: IpcMainInvokeEvent, tag: string) => { ipcMain.handle('save-hashtag', async (_: IpcMainInvokeEvent, tag: string) => {
const hashtags = new Hashtags(hashtagsDB) const hashtags = new Hashtags(hashtagsDB)

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="handledNotifications" :min-item-size="20" id="scroller" class="scroller" ref="scroller"> <DynamicScroller :items="handledNotifications" :min-item-size="20" 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" :loading="loadingMore" @load_since="fetchNotificationsSince" />
</DynamicScrollerItem>
</template>
<template v-else>
<DynamicScrollerItem :item="item" :active="active" :size-dependencies="[item.url]" :data-index="index" :watchData="true"> <DynamicScrollerItem :item="item" :active="active" :size-dependencies="[item.url]" :data-index="index" :watchData="true">
<notification <notification
:message="item" :message="item"
@ -18,6 +24,7 @@
</notification> </notification>
</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">
<el-button type="primary" icon="el-icon-arrow-up" @click="upper" circle> </el-button> <el-button type="primary" icon="el-icon-arrow-up" @click="upper" circle> </el-button>
@ -29,13 +36,14 @@
import { mapState, mapGetters } from 'vuex' import { mapState, mapGetters } from 'vuex'
import moment from 'moment' import moment from 'moment'
import Notification from '~/src/renderer/components/organisms/Notification' import Notification from '~/src/renderer/components/organisms/Notification'
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: 'notifications', name: 'notifications',
components: { Notification }, components: { Notification, StatusLoading },
mixins: [reloadable], mixins: [reloadable],
data() { data() {
return { return {
@ -43,7 +51,8 @@ export default {
scrollPosition: null, scrollPosition: null,
observer: null, observer: null,
scrollTime: null, scrollTime: null,
resizeTime: null resizeTime: null,
loadingMore: false
} }
}, },
computed: { computed: {
@ -53,6 +62,7 @@ export default {
backgroundColor: state => state.App.theme.background_color backgroundColor: state => state.App.theme.background_color
}), }),
...mapState('TimelineSpace/Contents/Notifications', { ...mapState('TimelineSpace/Contents/Notifications', {
notifications: state => state.notifications,
lazyLoading: state => state.lazyLoading, lazyLoading: state => state.lazyLoading,
heading: state => state.heading, heading: state => state.heading,
scrolling: state => state.scrolling scrolling: state => state.scrolling
@ -86,14 +96,14 @@ export default {
}) })
if (this.heading && this.handledNotifications.length > 0) { if (this.heading && this.handledNotifications.length > 0) {
this.$store.dispatch('TimelineSpace/Contents/Notifications/saveMarker', this.handledNotifications[0].id) this.$store.dispatch('TimelineSpace/Contents/Notifications/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()
} }
@ -138,9 +148,9 @@ export default {
this.$store.dispatch('TimelineSpace/Contents/Notifications/resetBadge') this.$store.dispatch('TimelineSpace/Contents/Notifications/resetBadge')
} }
}, },
handledNotifications: function (newState, _oldState) { notifications: function (newState, _oldState) {
if (this.heading && newState.length > 0) { if (this.heading && newState.length > 0) {
this.$store.dispatch('TimelineSpace/Contents/Notifications/saveMarker', newState[0].id) this.$store.dispatch('TimelineSpace/Contents/Notifications/saveMarker')
} }
} }
}, },
@ -187,6 +197,7 @@ export default {
} else if (event.target.scrollTop <= 10 && !this.heading) { } else if (event.target.scrollTop <= 10 && !this.heading) {
this.$store.commit('TimelineSpace/Contents/Notifications/changeHeading', true) this.$store.commit('TimelineSpace/Contents/Notifications/changeHeading', true)
this.$store.dispatch('TimelineSpace/Contents/Notifications/resetBadge') this.$store.dispatch('TimelineSpace/Contents/Notifications/resetBadge')
this.$store.dispatch('TimelineSpace/Contents/Notifications/saveMarker')
} }
setTimeout(() => { setTimeout(() => {
@ -197,6 +208,14 @@ export default {
} }
}, 150) }, 150)
}, },
fetchNotificationsSince(since_id) {
this.loadingMore = true
this.$store.dispatch('TimelineSpace/Contents/Notifications/fetchNotificationsSince', 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

@ -133,19 +133,15 @@ const actions: ActionTree<HomeState, RootState> = {
const localMarker: LocalMarker | null = await dispatch('getMarker').catch(err => { const localMarker: LocalMarker | null = await dispatch('getMarker').catch(err => {
console.error(err) 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) if (rootState.App.useMarker && localMarker !== null) {
let timeline: Array<Entity.Status | LoadingCard> = [] const last = await client.getStatus(localMarker.last_read_id)
if (res.data.length > 0 && rootState.App.useMarker) { const lastReadStatus = last.data
let timeline: Array<Entity.Status | LoadingCard> = [lastReadStatus]
const card: LoadingCard = { const card: LoadingCard = {
type: 'middle-load', type: 'middle-load',
since_id: res.data[0].id, since_id: lastReadStatus.id,
// We don't need to fill this field in the first fetcing. // 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. // 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. // After new statuses are received, if the number of unread statuses is more than 40, max_id is not necessary.
@ -155,11 +151,22 @@ const actions: ActionTree<HomeState, RootState> = {
max_id: null, max_id: null,
id: 'loading-card' id: 'loading-card'
} }
timeline = timeline.concat([card])
} const res = await client.getHomeTimeline({ limit: 40, max_id: lastReadStatus.id })
// Make sure whether new statuses exist or not.
const nextResponse = await client.getHomeTimeline({ limit: 1, min_id: lastReadStatus.id })
if (nextResponse.data.length > 0) {
timeline = ([card] as Array<Entity.Status | LoadingCard>).concat(timeline).concat(res.data)
} else {
timeline = timeline.concat(res.data) timeline = timeline.concat(res.data)
}
commit(MUTATION_TYPES.UPDATE_TIMELINE, timeline) commit(MUTATION_TYPES.UPDATE_TIMELINE, timeline)
return res.data return res.data
} else {
const res = await client.getHomeTimeline({ limit: 40 })
commit(MUTATION_TYPES.UPDATE_TIMELINE, 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> => {
if (state.lazyLoading) { if (state.lazyLoading) {
@ -243,15 +250,27 @@ const actions: ActionTree<HomeState, RootState> = {
return localMarker return localMarker
}, },
saveMarker: async ({ state, rootState }) => { saveMarker: async ({ state, rootState }) => {
const timeline = state.timeline.filter(status => status.id !== 'loading-card') const timeline = state.timeline
if (timeline.length === 0) { if (timeline.length === 0 || timeline[0].id === 'loading-card') {
return return
} }
await win.ipcRenderer.invoke('save-marker', { win.ipcRenderer.send('save-marker', {
owner_id: rootState.TimelineSpace.account._id, owner_id: rootState.TimelineSpace.account._id,
timeline: 'home', timeline: 'home',
last_read_id: timeline[0].id last_read_id: timeline[0].id
} as LocalMarker) } as LocalMarker)
if (rootState.TimelineSpace.sns === 'misskey') {
return
}
const client = generator(
rootState.TimelineSpace.sns,
rootState.TimelineSpace.account.baseURL,
rootState.TimelineSpace.account.accessToken,
rootState.App.userAgent
)
const res = await client.saveMarkers({ home: { last_read_id: timeline[0].id } })
return res.data
} }
} }

View File

@ -3,13 +3,14 @@ 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
export type NotificationsState = { export type NotificationsState = {
lazyLoading: boolean lazyLoading: boolean
heading: boolean heading: boolean
notifications: Array<Entity.Notification> notifications: Array<Entity.Notification | LoadingCard>
scrolling: boolean scrolling: boolean
} }
@ -30,7 +31,8 @@ export const MUTATION_TYPES = {
DELETE_TOOT: 'deleteToot', DELETE_TOOT: 'deleteToot',
CLEAR_NOTIFICATIONS: 'clearNotifications', CLEAR_NOTIFICATIONS: 'clearNotifications',
ARCHIVE_NOTIFICATIONS: 'archiveNotifications', ARCHIVE_NOTIFICATIONS: 'archiveNotifications',
CHANGE_SCROLLING: 'changeScrolling' CHANGE_SCROLLING: 'changeScrolling',
APPEND_NOTIFICATIONS_AFTER_LOADING_CARD: 'appendNotificationsAfterLoadingCard'
} }
const mutations: MutationTree<NotificationsState> = { const mutations: MutationTree<NotificationsState> = {
@ -43,13 +45,13 @@ const mutations: MutationTree<NotificationsState> = {
[MUTATION_TYPES.APPEND_NOTIFICATIONS]: (state, notification: Entity.Notification) => { [MUTATION_TYPES.APPEND_NOTIFICATIONS]: (state, notification: Entity.Notification) => {
// Reject duplicated status in timeline // Reject duplicated status in timeline
if (!state.notifications.find(item => item.id === notification.id)) { if (!state.notifications.find(item => item.id === notification.id)) {
state.notifications = [notification].concat(state.notifications) state.notifications = ([notification] as Array<Entity.Notification | LoadingCard>).concat(state.notifications)
} }
}, },
[MUTATION_TYPES.UPDATE_NOTIFICATIONS]: (state, notifications: Array<Entity.Notification>) => { [MUTATION_TYPES.UPDATE_NOTIFICATIONS]: (state, notifications: Array<Entity.Notification | LoadingCard>) => {
state.notifications = notifications state.notifications = notifications
}, },
[MUTATION_TYPES.INSERT_NOTIFICATIONS]: (state, notifications: Array<Entity.Notification>) => { [MUTATION_TYPES.INSERT_NOTIFICATIONS]: (state, notifications: Array<Entity.Notification | LoadingCard>) => {
state.notifications = state.notifications.concat(notifications) state.notifications = state.notifications.concat(notifications)
}, },
[MUTATION_TYPES.UPDATE_TOOT]: (state, message: Entity.Status) => { [MUTATION_TYPES.UPDATE_TOOT]: (state, message: Entity.Status) => {
@ -67,7 +69,11 @@ const mutations: MutationTree<NotificationsState> = {
}) })
}, },
[MUTATION_TYPES.DELETE_TOOT]: (state, id: string) => { [MUTATION_TYPES.DELETE_TOOT]: (state, id: string) => {
state.notifications = state.notifications.filter(notification => { state.notifications = state.notifications.filter(notify => {
if (notify.id === 'loading-card') {
return true
}
const notification = notify as Entity.Notification
if (notification.status) { if (notification.status) {
if (notification.status.reblog && notification.status.reblog.id === id) { if (notification.status.reblog && notification.status.reblog.id === id) {
return false return false
@ -87,20 +93,68 @@ const mutations: MutationTree<NotificationsState> = {
}, },
[MUTATION_TYPES.CHANGE_SCROLLING]: (state, value: boolean) => { [MUTATION_TYPES.CHANGE_SCROLLING]: (state, value: boolean) => {
state.scrolling = value state.scrolling = value
},
[MUTATION_TYPES.APPEND_NOTIFICATIONS_AFTER_LOADING_CARD]: (state, notifications: Array<Entity.Notification | LoadingCard>) => {
const n = state.notifications.flatMap(notify => {
if (notify.id !== 'loading-card') {
return notify
} else {
return notifications
}
})
// Reject duplicated status in timeline
state.notifications = Array.from(new Set(n))
} }
} }
const actions: ActionTree<NotificationsState, RootState> = { const actions: ActionTree<NotificationsState, RootState> = {
fetchNotifications: async ({ commit, rootState }): Promise<Array<Entity.Notification>> => { fetchNotifications: async ({ dispatch, commit, rootState }): Promise<Array<Entity.Notification>> => {
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 localMarker: LocalMarker | null = await dispatch('getMarker').catch(err => {
console.error(err)
})
if (rootState.App.useMarker && localMarker !== null) {
// The result does not contain max_id's notification, when we specify max_id parameter in get notifications.
// So we need to get max_id's notification.
const last = await client.getNotification(localMarker.last_read_id)
const lastReadNotification = last.data
let notifications: Array<Entity.Notification | LoadingCard> = [lastReadNotification]
const card: LoadingCard = {
type: 'middle-load',
since_id: lastReadNotification.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 30, max_id is not necessary.
// We can fill max_id when calling fetchTimelineSince.
// If the number of unread statuses is less than 30, max_id is necessary, but it is enough to reject duplicated statuses.
// So we do it in mutation.
max_id: null,
id: 'loading-card'
}
const res = await client.getNotifications({ limit: 30, max_id: localMarker.last_read_id })
// Make sure whether new notifications exist or not
const nextResponse = await client.getNotifications({ limit: 1, min_id: lastReadNotification.id })
if (nextResponse.data.length > 0) {
notifications = ([card] as Array<Entity.Notification | LoadingCard>).concat(notifications).concat(res.data)
} else {
notifications = notifications.concat(res.data)
}
commit(MUTATION_TYPES.UPDATE_NOTIFICATIONS, notifications)
return res.data
} else {
const res = await client.getNotifications({ limit: 30 }) const res = await client.getNotifications({ limit: 30 })
commit(MUTATION_TYPES.UPDATE_NOTIFICATIONS, res.data) commit(MUTATION_TYPES.UPDATE_NOTIFICATIONS, res.data)
return res.data return res.data
}
}, },
lazyFetchNotifications: ( lazyFetchNotifications: (
{ state, commit, rootState }, { state, commit, rootState },
@ -126,15 +180,91 @@ const actions: ActionTree<NotificationsState, RootState> = {
commit(MUTATION_TYPES.CHANGE_LAZY_LOADING, false) commit(MUTATION_TYPES.CHANGE_LAZY_LOADING, false)
}) })
}, },
fetchNotificationsSince: async ({ state, rootState, commit }, since_id: string): Promise<Array<Entity.Notification> | null> => {
const client = generator(
rootState.TimelineSpace.sns,
rootState.TimelineSpace.account.baseURL,
rootState.TimelineSpace.account.accessToken,
rootState.App.userAgent
)
const cardIndex = state.notifications.findIndex(s => {
if (s.id === 'loading-card') {
return true
}
return false
})
let maxID: string | null = null
if (cardIndex > 0) {
maxID = state.notifications[cardIndex - 1].id
}
const res = await client.getNotifications({ min_id: since_id, limit: 30 })
if (res.data.length >= 30) {
const card: LoadingCard = {
type: 'middle-load',
since_id: res.data[0].id,
max_id: maxID,
id: 'loading-card'
}
let notifications: Array<Entity.Notification | LoadingCard> = [card]
notifications = notifications.concat(res.data)
commit(MUTATION_TYPES.APPEND_NOTIFICATIONS_AFTER_LOADING_CARD, notifications)
} else {
commit(MUTATION_TYPES.APPEND_NOTIFICATIONS_AFTER_LOADING_CARD, res.data)
}
return res.data
},
resetBadge: () => { resetBadge: () => {
win.ipcRenderer.send('reset-badge') win.ipcRenderer.send('reset-badge')
}, },
saveMarker: async ({ rootState }, id: string) => { getMarker: async ({ rootState }): Promise<LocalMarker | null> => {
await win.ipcRenderer.invoke('save-marker', { 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(['notifications'])
serverMarker = res.data
} catch (err) {
console.warn(err)
}
if ((serverMarker as Entity.Marker).notifications !== undefined) {
return {
timeline: 'notifications',
last_read_id: (serverMarker as Entity.Marker).notifications.last_read_id
} as LocalMarker
}
const localMarker: LocalMarker | null = await win.ipcRenderer.invoke('get-notifications-marker', rootState.TimelineSpace.account._id)
return localMarker
},
saveMarker: async ({ state, rootState }) => {
const notifications = state.notifications
if (notifications.length === 0 || notifications[0].id === 'loading-card') {
return
}
win.ipcRenderer.send('save-marker', {
owner_id: rootState.TimelineSpace.account._id, owner_id: rootState.TimelineSpace.account._id,
timeline: 'notifications', timeline: 'notifications',
last_read_id: id last_read_id: notifications[0].id
} as LocalMarker) } as LocalMarker)
if (rootState.TimelineSpace.sns === 'misskey') {
return
}
const client = generator(
rootState.TimelineSpace.sns,
rootState.TimelineSpace.account.baseURL,
rootState.TimelineSpace.account.accessToken,
rootState.App.userAgent
)
const res = await client.saveMarkers({ notifications: { last_read_id: notifications[0].id } })
return res.data
} }
} }
@ -142,6 +272,7 @@ const getters: GetterTree<NotificationsState, RootState> = {
handledNotifications: state => { handledNotifications: state => {
return state.notifications.filter(n => { return state.notifications.filter(n => {
switch (n.type) { switch (n.type) {
case 'middle-load':
case NotificationType.Follow: case NotificationType.Follow:
case NotificationType.Favourite: case NotificationType.Favourite:
case NotificationType.Reblog: case NotificationType.Reblog: