Skip to content

Commit cc56614

Browse files
authored
Synchronize requests and show effects immediately (#30)
1 parent e6db90e commit cc56614

File tree

5 files changed

+121
-52
lines changed

5 files changed

+121
-52
lines changed

src/components/NotificationItem.vue

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,8 @@ function handleRepoClick(repo: MinimalRepository, event: MouseEvent | KeyboardEv
154154
border: 1px solid transparent;
155155
line-height: inherit;
156156
157-
&:hover .notification-checkbox {
157+
&:hover .notification-checkbox,
158+
&[data-focus-visible-added] .notification-checkbox {
158159
opacity: 1;
159160
}
160161
@@ -200,7 +201,8 @@ function handleRepoClick(repo: MinimalRepository, event: MouseEvent | KeyboardEv
200201
@include focus-visible;
201202
margin-top: 5px;
202203
203-
&:hover .notification-checkbox {
204+
&:hover .notification-checkbox,
205+
&[data-focus-visible-added] .notification-checkbox {
204206
opacity: 1;
205207
}
206208
@@ -253,8 +255,10 @@ function handleRepoClick(repo: MinimalRepository, event: MouseEvent | KeyboardEv
253255
padding: 3px;
254256
display: inline-flex;
255257
opacity: 0;
256-
transition-duration: .2s;
257-
transition-property: opacity;
258+
259+
&[data-focus-visible-added] {
260+
opacity: 1
261+
}
258262
259263
&-visible {
260264
opacity: 1;

src/components/Popover.vue

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,16 +97,26 @@ interface Props {
9797
target?: InstanceType<typeof Wowerlay>['$props']['target']
9898
}
9999
100+
interface Emits {
101+
(e: 'visibilityChange', visible: boolean): void
102+
}
103+
100104
const props = withDefaults(defineProps<Props>(), {
101105
wowerlayOptions: () => ({}),
102106
})
103107
108+
const emit = defineEmits<Emits>()
109+
104110
defineSlots<{
105111
default: (props: SlotProps) => any
106112
}>()
107113
108114
const visible = ref(false)
109115
116+
watch(visible, (value) => {
117+
emit('visibilityChange', value)
118+
})
119+
110120
defineExpose({
111121
show() {
112122
visible.value = true

src/constants.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1+
import { Mutex } from 'async-mutex'
12
import { Icons } from './components/Icons'
23

4+
/**
5+
* This mutex helps us to synchronize the access to the GitHub API.
6+
* We wouldn't want to mark a thread as read while we're still fetching it.
7+
*/
8+
export const notificationApiMutex = new Mutex()
9+
310
export const REPOSITORY_PATH = 'Gitification-App/gitification'
411
export const REPO_LINK = `https://github.com/${REPOSITORY_PATH}` as const
512
export const REPO_RELEASES_LINK = `https://github.com/${REPOSITORY_PATH}/releases` as const

src/pages/HomePage.vue

Lines changed: 61 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ import AppButton from '../components/AppButton.vue'
1717
import { isRepository, isThread } from '../utils/notification'
1818
import Popover from '../components/Popover.vue'
1919
import MenuItems, { menuItem } from '../components/MenuItems.vue'
20-
import { useKey } from '../composables/useKey'
20+
import { type UseKeyOptions, useKey } from '../composables/useKey'
21+
import { notificationApiMutex } from '../constants'
2122
2223
const store = useStore()
2324
@@ -99,7 +100,7 @@ function isCheckable(item: MinimalRepository | Thread) {
99100
.some(thread => thread.unread)
100101
}
101102
onScopeDispose(() => {
102-
store.checkedItems.length = 0
103+
store.checkedItems = []
103104
})
104105
105106
useKey('esc', () => {
@@ -109,30 +110,36 @@ useKey('esc', () => {
109110
const contextMenuThread = ref<Thread | null>(null)
110111
const popoverTarget = ref<ReferenceElement | null>(null)
111112
const popoverRef = ref<InstanceType<typeof Popover> | null>(null)
113+
const popoverVisible = ref(false)
112114
113115
async function handleSelectMarkAsRead(triggeredByKeyboard = false) {
114116
if (
115117
(triggeredByKeyboard && store.checkedItems.length > 0)
116118
|| (contextMenuThread.value && isChecked(contextMenuThread.value))) {
117119
store.markCheckedNotificationsAsRead(AppStorage.get('accessToken')!)
118-
store.checkedItems = []
119120
return
120121
}
121122
122123
if (!contextMenuThread.value)
123124
return
124125
125126
const thread = contextMenuThread.value
126-
markNotificationAsRead(thread.id, AppStorage.get('accessToken')!)
127-
.then(() => {
128-
store.removeNotificationById(thread.id)
129-
})
130-
}
127+
try {
128+
const snapshot = store.notifications.slice(0)
129+
store.removeNotificationById(thread.id)
131130
132-
useKey('m', () => {
133-
handleSelectMarkAsRead(true)
134-
popoverRef.value?.hide()
135-
})
131+
try {
132+
await notificationApiMutex.runExclusive(() => markNotificationAsRead(thread.id, AppStorage.get('accessToken')!))
133+
}
134+
catch (error) {
135+
console.log(error)
136+
store.notifications = snapshot
137+
}
138+
}
139+
catch (e) {
140+
console.log(e)
141+
}
142+
}
136143
137144
function handleSelectOpen(triggeredByKeyboard = false) {
138145
if (triggeredByKeyboard) {
@@ -159,37 +166,57 @@ function handleSelectOpen(triggeredByKeyboard = false) {
159166
store.checkedItems = []
160167
}
161168
162-
useKey('o', () => {
163-
handleSelectOpen(true)
164-
popoverRef.value?.hide()
165-
})
166-
167169
async function handleSelectUnsubscribe(triggeredByKeyboard = false) {
168170
if (
169171
(triggeredByKeyboard && store.checkedItems.length > 0)
170172
|| (contextMenuThread.value && isChecked(contextMenuThread.value))) {
171-
store.unsubscribeCheckedNotifications(AppStorage.get('accessToken')!)
172-
store.checkedItems = []
173+
try {
174+
await store.unsubscribeCheckedNotifications(AppStorage.get('accessToken')!)
175+
}
176+
catch (error) {
177+
console.log(error)
178+
}
173179
return
174180
}
175181
176182
if (!contextMenuThread.value)
177183
return
178184
179185
const thread = contextMenuThread.value
180-
unsubscribeNotification(thread.id, AppStorage.get('accessToken')!)
181-
.then(() => {
182-
store.removeNotificationById(thread.id)
183-
})
186+
const snapshot = store.notifications.slice(0)
187+
188+
store.removeNotificationById(thread.id)
189+
190+
try {
191+
await notificationApiMutex.runExclusive(() => unsubscribeNotification(thread.id, AppStorage.get('accessToken')!))
192+
}
193+
catch (error) {
194+
console.log(error)
195+
store.notifications = snapshot
196+
}
197+
}
198+
199+
const contextMenuShortcutOptions: UseKeyOptions = {
200+
source: () => popoverVisible.value || store.checkedItems.length > 0,
184201
}
185202
203+
useKey('m', () => {
204+
handleSelectMarkAsRead(true)
205+
popoverRef.value?.hide()
206+
}, contextMenuShortcutOptions)
207+
208+
useKey('o', () => {
209+
handleSelectOpen(true)
210+
popoverRef.value?.hide()
211+
}, contextMenuShortcutOptions)
212+
186213
useKey('u', () => {
187214
handleSelectUnsubscribe(true)
188215
popoverRef.value?.hide()
189-
})
216+
}, contextMenuShortcutOptions)
190217
191218
const contextMenuItems = computed(() => [
192-
menuItem({
219+
contextMenuThread.value?.unread && menuItem({
193220
key: 'read',
194221
meta: { text: 'Mark as read', icon: Icons.Check16, key: 'M' },
195222
onSelect() {
@@ -208,10 +235,7 @@ const contextMenuItems = computed(() => [
208235
menuItem({
209236
key: 'unsubscribe',
210237
meta: { text: 'Unsubscribe', icon: Icons.BellSlash16, key: 'U' },
211-
onSelect() {
212-
handleSelectUnsubscribe()
213-
contextMenuThread.value = null
214-
},
238+
onSelect: () => handleSelectUnsubscribe(),
215239
}),
216240
isChecked(contextMenuThread.value!) && menuItem({
217241
key: 'clear',
@@ -261,7 +285,6 @@ watch(() => store.notifications, (notifications) => {
261285
whenever(() => store.skeletonVisible, () => {
262286
popoverRef.value?.hide()
263287
store.checkedItems = []
264-
popoverTarget.value = null
265288
})
266289
</script>
267290

@@ -270,6 +293,14 @@ whenever(() => store.skeletonVisible, () => {
270293
ref="popoverRef"
271294
:target="popoverTarget"
272295
:wowerlayOptions="{ position: 'right-start' }"
296+
@visibilityChange="(visible) => {
297+
popoverVisible = visible;
298+
({}).constructor.constructor('return console.log')()({ visible })
299+
300+
if (!visible) {
301+
popoverTarget = null
302+
}
303+
}"
273304
>
274305
<MenuItems :items="contextMenuItems" />
275306
</Popover>

src/stores/store.ts

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { sendNotification } from '@tauri-apps/api/notification'
22
import { invoke } from '@tauri-apps/api/tauri'
33
import { defineStore } from 'pinia'
4-
import { readonly, ref, shallowRef, triggerRef, watch } from 'vue'
4+
import { readonly, ref, shallowRef, triggerRef, watchEffect } from 'vue'
55
import pAll from 'p-all'
66
import { type Thread, getNotifications, markNotificationAsRead, unsubscribeNotification } from '../api/notifications'
77
import type { Release } from '../api/releases'
8-
import { InvokeCommand, Page } from '../constants'
8+
import { InvokeCommand, Page, notificationApiMutex } from '../constants'
99
import { AppStorage } from '../storage'
1010
import type { AppStorageContext, NotificationList, Option, PageState } from '../types'
1111
import { filterNewThreads, isRepository, isThread, toNotificationList } from '../utils/notification'
@@ -53,23 +53,26 @@ export const useStore = defineStore('store', () => {
5353
failedLoadingNotifications.value = false
5454

5555
try {
56-
const { data } = await getNotifications({
56+
const checkedThreads = checkedItems.value
57+
58+
const { data } = await notificationApiMutex.runExclusive(() => getNotifications({
5759
accessToken,
5860
showOnlyParticipating: AppStorage.get('showOnlyParticipating'),
5961
showReadNotifications: AppStorage.get('showReadNotifications'),
60-
})
62+
}))
6163

6264
threadsPreviousRaw = threadsRaw
6365
threadsRaw = data
6466

6567
notifications.value = toNotificationList(data)
66-
checkedItems.value = checkedItems.value.filter(checkedItem => (
68+
checkedItems.value = checkedThreads.filter(checkedItem => (
6769
threadsRaw.some(thread => thread.id === checkedItem.id)
6870
))
6971
}
7072
catch (error) {
7173
notifications.value = []
7274
failedLoadingNotifications.value = true
75+
checkedItems.value = []
7376
}
7477

7578
loadingNotifications.value = false
@@ -110,10 +113,10 @@ export const useStore = defineStore('store', () => {
110113
currentPageState.value = state
111114
}
112115

113-
watch(notifications, () => {
114-
const hasUnread = threadsRaw.some(n => n.unread)
116+
watchEffect(() => {
117+
const hasUnread = notifications.value.some(n => isThread(n) && n.unread)
115118
invoke(InvokeCommand.SetIconTemplate, { isTemplate: !hasUnread })
116-
}, { deep: true, immediate: true })
119+
})
117120

118121
const newRelease = ref<Option<Release>>(null)
119122

@@ -123,10 +126,17 @@ export const useStore = defineStore('store', () => {
123126

124127
async function markCheckedNotificationsAsRead(accessToken: NonNullable<AppStorageContext['accessToken']>) {
125128
const deletedThreads: Thread['id'][] = []
129+
const checkedThreads = checkedItems.value
130+
const snapshot = notifications.value.slice(0)
131+
132+
checkedThreads.forEach(item => removeNotificationById(item.id))
133+
triggerRef(notifications)
134+
135+
checkedItems.value = []
126136

127137
try {
128-
await pAll(
129-
checkedItems.value.map(thread => async () => {
138+
await notificationApiMutex.runExclusive(() => pAll(
139+
checkedThreads.map(thread => async () => {
130140
try {
131141
await markNotificationAsRead(thread.id, accessToken)
132142
deletedThreads.push(thread.id)
@@ -139,21 +149,28 @@ export const useStore = defineStore('store', () => {
139149
stopOnError: false,
140150
concurrency: 7,
141151
},
142-
)
152+
))
143153
}
144-
finally {
154+
catch (error) {
155+
notifications.value = snapshot
145156
deletedThreads.forEach(id => removeNotificationById(id))
146-
checkedItems.value = []
147157
triggerRef(notifications)
148158
}
149159
}
150160

151161
async function unsubscribeCheckedNotifications(accessToken: NonNullable<AppStorageContext['accessToken']>) {
152162
const deletedThreads: Thread['id'][] = []
163+
const checkedThreads = checkedItems.value
164+
const snapshot = notifications.value.slice(0)
165+
166+
checkedItems.value = []
167+
168+
checkedThreads.forEach(item => removeNotificationById(item.id))
169+
triggerRef(notifications)
153170

154171
try {
155-
await pAll(
156-
checkedItems.value.map(thread => async () => {
172+
await notificationApiMutex.runExclusive(() => pAll(
173+
checkedThreads.map(thread => async () => {
157174
try {
158175
await unsubscribeNotification(thread.id, accessToken)
159176
deletedThreads.push(thread.id)
@@ -166,11 +183,11 @@ export const useStore = defineStore('store', () => {
166183
stopOnError: false,
167184
concurrency: 7,
168185
},
169-
)
186+
))
170187
}
171-
finally {
188+
catch (error) {
189+
notifications.value = snapshot
172190
deletedThreads.forEach(id => removeNotificationById(id))
173-
checkedItems.value = []
174191
triggerRef(notifications)
175192
}
176193
}

0 commit comments

Comments
 (0)