feat: port base store to pinia
This commit is contained in:
@ -1,9 +1,12 @@
|
||||
import {defineStore, acceptHMRUpdate} from 'pinia'
|
||||
import {findIndexById} from '@/helpers/utils'
|
||||
|
||||
import type {AttachmentState} from '@/store/types'
|
||||
import type {IAttachment} from '@/modelTypes/IAttachment'
|
||||
|
||||
export interface AttachmentState {
|
||||
attachments: IAttachment[],
|
||||
}
|
||||
|
||||
export const useAttachmentStore = defineStore('attachment', {
|
||||
state: (): AttachmentState => ({
|
||||
attachments: [],
|
||||
|
@ -6,16 +6,27 @@ import {objectToSnakeCase} from '@/helpers/case'
|
||||
import UserModel, { getAvatarUrl } from '@/models/user'
|
||||
import UserSettingsService from '@/services/userSettings'
|
||||
import {getToken, refreshToken, removeToken, saveToken} from '@/helpers/auth'
|
||||
import {setLoadingPinia} from '@/store/helper'
|
||||
import {setLoadingPinia} from '@/stores/helper'
|
||||
import {success} from '@/message'
|
||||
import {redirectToProvider} from '@/helpers/redirectToProvider'
|
||||
import {AUTH_TYPES, type IUser} from '@/modelTypes/IUser'
|
||||
import type {AuthState} from '@/store/types'
|
||||
import type {IUserSettings} from '@/modelTypes/IUserSettings'
|
||||
import router from '@/router'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import UserSettingsModel from '@/models/userSettings'
|
||||
import {store} from '@/store'
|
||||
|
||||
export interface AuthState {
|
||||
authenticated: boolean,
|
||||
isLinkShareAuth: boolean,
|
||||
info: IUser | null,
|
||||
needsTotpPasscode: boolean,
|
||||
avatarUrl: string,
|
||||
lastUserInfoRefresh: Date | null,
|
||||
settings: IUserSettings,
|
||||
isLoading: boolean,
|
||||
isLoadingGeneralSettings: boolean
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () : AuthState => ({
|
||||
@ -93,7 +104,8 @@ export const useAuthStore = defineStore('auth', {
|
||||
// Logs a user in with a set of credentials.
|
||||
async login(credentials) {
|
||||
const HTTP = HTTPFactory()
|
||||
store.commit('loading', true)
|
||||
const baseStore = useBaseStore()
|
||||
baseStore.setLoading(true)
|
||||
this.setIsLoading(true)
|
||||
|
||||
// Delete an eventually preexisting old token
|
||||
@ -117,7 +129,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
|
||||
throw e
|
||||
} finally {
|
||||
store.commit('loading', false)
|
||||
baseStore.setLoading(false)
|
||||
this.setIsLoading(false)
|
||||
}
|
||||
},
|
||||
@ -126,7 +138,8 @@ export const useAuthStore = defineStore('auth', {
|
||||
// Not sure if this is the right place to put the logic in, maybe a seperate js component would be better suited.
|
||||
async register(credentials) {
|
||||
const HTTP = HTTPFactory()
|
||||
store.commit('loading', true)
|
||||
const baseStore = useBaseStore()
|
||||
baseStore.setLoading(true)
|
||||
this.setIsLoading(true)
|
||||
try {
|
||||
await HTTP.post('register', credentials)
|
||||
@ -138,14 +151,15 @@ export const useAuthStore = defineStore('auth', {
|
||||
|
||||
throw e
|
||||
} finally {
|
||||
store.commit('loading', false)
|
||||
baseStore.setLoading(false)
|
||||
this.setIsLoading(false)
|
||||
}
|
||||
},
|
||||
|
||||
async openIdAuth({provider, code}) {
|
||||
const HTTP = HTTPFactory()
|
||||
store.commit('loading', true)
|
||||
const baseStore = useBaseStore()
|
||||
baseStore.setLoading(true)
|
||||
this.setIsLoading(true)
|
||||
|
||||
const data = {
|
||||
@ -162,7 +176,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
// Tell others the user is autheticated
|
||||
this.checkAuth()
|
||||
} finally {
|
||||
store.commit('loading', false)
|
||||
baseStore.setLoading(false)
|
||||
this.setIsLoading(false)
|
||||
}
|
||||
},
|
||||
|
@ -1,40 +1,34 @@
|
||||
import type {InjectionKey} from 'vue'
|
||||
import {createStore, useStore as baseUseStore, Store} from 'vuex'
|
||||
import {defineStore, acceptHMRUpdate} from 'pinia'
|
||||
|
||||
import {getBlobFromBlurHash} from '../helpers/getBlobFromBlurHash'
|
||||
import {
|
||||
BACKGROUND,
|
||||
BLUR_HASH,
|
||||
CURRENT_LIST,
|
||||
HAS_TASKS,
|
||||
KEYBOARD_SHORTCUTS_ACTIVE,
|
||||
LOADING,
|
||||
LOADING_MODULE, LOGO_VISIBLE,
|
||||
MENU_ACTIVE,
|
||||
QUICK_ACTIONS_ACTIVE,
|
||||
} from '../store/mutation-types'
|
||||
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
|
||||
|
||||
import ListModel from '@/models/list'
|
||||
|
||||
import ListService from '../services/list'
|
||||
import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl'
|
||||
|
||||
import type { RootStoreState, StoreState } from '../store/types'
|
||||
import pinia from '@/pinia'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import type {IList} from '@/modelTypes/IList'
|
||||
|
||||
export const key: InjectionKey<Store<StoreState>> = Symbol()
|
||||
export interface RootStoreState {
|
||||
loading: boolean,
|
||||
loadingModule: null,
|
||||
|
||||
// define your own `useStore` composition function
|
||||
export function useStore () {
|
||||
return baseUseStore(key)
|
||||
currentList: IList,
|
||||
background: string,
|
||||
blurHash: string,
|
||||
|
||||
hasTasks: boolean,
|
||||
menuActive: boolean,
|
||||
keyboardShortcutsActive: boolean,
|
||||
quickActionsActive: boolean,
|
||||
logoVisible: boolean,
|
||||
}
|
||||
|
||||
export const store = createStore<RootStoreState>({
|
||||
strict: import.meta.env.DEV,
|
||||
state: () => ({
|
||||
export const useBaseStore = defineStore('base', {
|
||||
state: () : RootStoreState => ({
|
||||
loading: false,
|
||||
loadingModule: null,
|
||||
|
||||
// This is used to highlight the current list in menu for all list related views
|
||||
currentList: new ListModel({
|
||||
id: 0,
|
||||
@ -42,76 +36,94 @@ export const store = createStore<RootStoreState>({
|
||||
}),
|
||||
background: '',
|
||||
blurHash: '',
|
||||
|
||||
hasTasks: false,
|
||||
menuActive: true,
|
||||
keyboardShortcutsActive: false,
|
||||
quickActionsActive: false,
|
||||
logoVisible: true,
|
||||
}),
|
||||
mutations: {
|
||||
[LOADING](state, loading) {
|
||||
state.loading = loading
|
||||
|
||||
actions: {
|
||||
setLoading(loading: boolean) {
|
||||
this.loading = loading
|
||||
},
|
||||
[LOADING_MODULE](state, module) {
|
||||
state.loadingModule = module
|
||||
|
||||
setLoadingModule(module) {
|
||||
this.loadingModule = module
|
||||
},
|
||||
[CURRENT_LIST](state, currentList) {
|
||||
|
||||
// FIXME: same action as mutation name
|
||||
setCurrentList(currentList: IList) {
|
||||
// Server updates don't return the right. Therefore, the right is reset after updating the list which is
|
||||
// confusing because all the buttons will disappear in that case. To prevent this, we're keeping the right
|
||||
// when updating the list in global state.
|
||||
if (typeof state.currentList.maxRight !== 'undefined' && (typeof currentList.maxRight === 'undefined' || currentList.maxRight === null)) {
|
||||
currentList.maxRight = state.currentList.maxRight
|
||||
if (
|
||||
typeof this.currentList.maxRight !== 'undefined' &&
|
||||
(
|
||||
typeof currentList.maxRight === 'undefined' ||
|
||||
currentList.maxRight === null
|
||||
)
|
||||
) {
|
||||
currentList.maxRight = this.currentList.maxRight
|
||||
}
|
||||
state.currentList = currentList
|
||||
this.currentList = currentList
|
||||
},
|
||||
[HAS_TASKS](state, hasTasks) {
|
||||
state.hasTasks = hasTasks
|
||||
},
|
||||
[MENU_ACTIVE](state, menuActive) {
|
||||
state.menuActive = menuActive
|
||||
},
|
||||
toggleMenu(state) {
|
||||
state.menuActive = !state.menuActive
|
||||
},
|
||||
[KEYBOARD_SHORTCUTS_ACTIVE](state, active) {
|
||||
state.keyboardShortcutsActive = active
|
||||
},
|
||||
[QUICK_ACTIONS_ACTIVE](state, active) {
|
||||
state.quickActionsActive = active
|
||||
},
|
||||
[BACKGROUND](state, background) {
|
||||
state.background = background
|
||||
},
|
||||
[BLUR_HASH](state, blurHash) {
|
||||
state.blurHash = blurHash
|
||||
},
|
||||
[LOGO_VISIBLE](state, visible: boolean) {
|
||||
state.logoVisible = visible
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async [CURRENT_LIST]({state, commit}, {list, forceUpdate = false}) {
|
||||
|
||||
setHasTasks(hasTasks: boolean) {
|
||||
this.hasTasks = hasTasks
|
||||
},
|
||||
|
||||
setMenuActive(menuActive: boolean) {
|
||||
this.menuActive = menuActive
|
||||
},
|
||||
|
||||
toggleMenu() {
|
||||
this.menuActive = !this.menuActive
|
||||
},
|
||||
|
||||
setKeyboardShortcutsActive(active: boolean) {
|
||||
this.keyboardShortcutsActive = active
|
||||
},
|
||||
|
||||
setQuickActionsActive(active: boolean) {
|
||||
this.quickActionsActive = active
|
||||
},
|
||||
|
||||
setBackground(background: string) {
|
||||
this.background = background
|
||||
},
|
||||
|
||||
setBlurHash(blurHash: string) {
|
||||
this.blurHash = blurHash
|
||||
},
|
||||
|
||||
setLogoVisible(visible: boolean) {
|
||||
this.logoVisible = visible
|
||||
},
|
||||
|
||||
// FIXME: update all actions handleSetCurrentList
|
||||
async handleSetCurrentList({list, forceUpdate = false}) {
|
||||
if (list === null) {
|
||||
commit(CURRENT_LIST, {})
|
||||
commit(BACKGROUND, null)
|
||||
commit(BLUR_HASH, null)
|
||||
this.setCurrentList({})
|
||||
this.setBackground('')
|
||||
this.setBlurHash('')
|
||||
return
|
||||
}
|
||||
|
||||
// The forceUpdate parameter is used only when updating a list background directly because in that case
|
||||
// the current list stays the same, but we want to show the new background right away.
|
||||
if (list.id !== state.currentList.id || forceUpdate) {
|
||||
if (list.id !== this.currentList.id || forceUpdate) {
|
||||
if (list.backgroundInformation) {
|
||||
try {
|
||||
const blurHash = await getBlobFromBlurHash(list.backgroundBlurHash)
|
||||
if (blurHash) {
|
||||
commit(BLUR_HASH, window.URL.createObjectURL(blurHash))
|
||||
this.setBlurHash(window.URL.createObjectURL(blurHash))
|
||||
}
|
||||
|
||||
const listService = new ListService()
|
||||
const background = await listService.background(list)
|
||||
commit(BACKGROUND, background)
|
||||
this.setBackground(background)
|
||||
} catch (e) {
|
||||
console.error('Error getting background image for list', list.id, e)
|
||||
}
|
||||
@ -119,16 +131,21 @@ export const store = createStore<RootStoreState>({
|
||||
}
|
||||
|
||||
if (typeof list.backgroundInformation === 'undefined' || list.backgroundInformation === null) {
|
||||
commit(BACKGROUND, null)
|
||||
commit(BLUR_HASH, null)
|
||||
this.setBackground('')
|
||||
this.setBlurHash('')
|
||||
}
|
||||
|
||||
commit(CURRENT_LIST, list)
|
||||
this.setCurrentList(list)
|
||||
},
|
||||
|
||||
async loadApp() {
|
||||
await checkAndSetApiUrl(window.API_URL)
|
||||
const authStore = useAuthStore(pinia)
|
||||
await authStore.checkAuth()
|
||||
await useAuthStore().checkAuth()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// support hot reloading
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useBaseStore, import.meta.hot))
|
||||
}
|
@ -1,10 +1,38 @@
|
||||
import {defineStore, acceptHMRUpdate} from 'pinia'
|
||||
import {parseURL} from 'ufo'
|
||||
|
||||
import {CONFIG} from '../store/mutation-types'
|
||||
import {HTTPFactory} from '@/http-common'
|
||||
import {objectToCamelCase} from '@/helpers/case'
|
||||
import type {ConfigState} from '@/store/types'
|
||||
|
||||
export interface ConfigState {
|
||||
version: string,
|
||||
frontendUrl: string,
|
||||
motd: string,
|
||||
linkSharingEnabled: boolean,
|
||||
maxFileSize: '20MB',
|
||||
registrationEnabled: boolean,
|
||||
availableMigrators: [],
|
||||
taskAttachmentsEnabled: boolean,
|
||||
totpEnabled: boolean,
|
||||
enabledBackgroundProviders: [],
|
||||
legal: {
|
||||
imprintUrl: string,
|
||||
privacyPolicyUrl: string,
|
||||
},
|
||||
caldavEnabled: boolean,
|
||||
userDeletionEnabled: boolean,
|
||||
taskCommentsEnabled: boolean,
|
||||
auth: {
|
||||
local: {
|
||||
enabled: boolean,
|
||||
},
|
||||
openidConnect: {
|
||||
enabled: boolean,
|
||||
redirectUrl: string,
|
||||
providers: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const useConfigStore = defineStore('config', {
|
||||
state: (): ConfigState => ({
|
||||
@ -45,13 +73,13 @@ export const useConfigStore = defineStore('config', {
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
[CONFIG](config: ConfigState) {
|
||||
setConfig(config: ConfigState) {
|
||||
Object.assign(this, config)
|
||||
},
|
||||
async update() {
|
||||
const HTTP = HTTPFactory()
|
||||
const {data: config} = await HTTP.get('info')
|
||||
this[CONFIG](objectToCamelCase(config))
|
||||
this.setConfig(objectToCamelCase(config))
|
||||
return config
|
||||
},
|
||||
},
|
||||
|
19
src/stores/helper.ts
Normal file
19
src/stores/helper.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type { StoreDefinition } from 'pinia'
|
||||
|
||||
export const setLoadingPinia = (store: StoreDefinition, loadFunc : ((isLoading: boolean) => void) | null = null) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (loadFunc === null) {
|
||||
store.isLoading = true
|
||||
} else {
|
||||
loadFunc(true)
|
||||
}
|
||||
}, 100)
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
if (loadFunc === null) {
|
||||
store.isLoading = false
|
||||
} else {
|
||||
loadFunc(false)
|
||||
}
|
||||
}
|
||||
}
|
@ -5,10 +5,11 @@ import {findById, findIndexById} from '@/helpers/utils'
|
||||
import {i18n} from '@/i18n'
|
||||
import {success} from '@/message'
|
||||
|
||||
import BucketService from '../services/bucket'
|
||||
import {setLoadingPinia} from '@/store/helper'
|
||||
import BucketService from '@/services/bucket'
|
||||
import TaskCollectionService from '@/services/taskCollection'
|
||||
import type { KanbanState } from '@/store/types'
|
||||
|
||||
import {setLoadingPinia} from '@/stores/helper'
|
||||
|
||||
import type { ITask } from '@/modelTypes/ITask'
|
||||
import type { IList } from '@/modelTypes/IList'
|
||||
import type { IBucket } from '@/modelTypes/IBucket'
|
||||
@ -37,6 +38,21 @@ const addTaskToBucketAndSort = (state: KanbanState, task: ITask) => {
|
||||
state.buckets[bucketIndex].tasks.sort((a, b) => a.kanbanPosition > b.kanbanPosition ? 1 : -1)
|
||||
}
|
||||
|
||||
export interface KanbanState {
|
||||
buckets: IBucket[],
|
||||
listId: IList['id'],
|
||||
bucketLoading: {
|
||||
[id: IBucket['id']]: boolean
|
||||
},
|
||||
taskPagesPerBucket: {
|
||||
[id: IBucket['id']]: number
|
||||
},
|
||||
allTasksLoadedForBucket: {
|
||||
[id: IBucket['id']]: boolean
|
||||
},
|
||||
isLoading: boolean,
|
||||
}
|
||||
|
||||
/**
|
||||
* This store is intended to hold the currently active kanban view.
|
||||
* It should hold only the current buckets.
|
||||
|
@ -4,7 +4,7 @@ import LabelService from '@/services/label'
|
||||
import {success} from '@/message'
|
||||
import {i18n} from '@/i18n'
|
||||
import {createNewIndexer} from '@/indexes'
|
||||
import {setLoadingPinia} from '@/store/helper'
|
||||
import {setLoadingPinia} from '@/stores/helper'
|
||||
import type {ILabel} from '@/modelTypes/ILabel'
|
||||
|
||||
const {add, remove, update, search} = createNewIndexer('labels', ['title', 'description'])
|
||||
@ -20,7 +20,12 @@ async function getAllLabels(page = 1): Promise<ILabel[]> {
|
||||
}
|
||||
}
|
||||
|
||||
import type {LabelState} from '@/store/types'
|
||||
export interface LabelState {
|
||||
labels: {
|
||||
[id: ILabel['id']]: ILabel
|
||||
},
|
||||
isLoading: boolean,
|
||||
}
|
||||
|
||||
export const useLabelStore = defineStore('label', {
|
||||
state: () : LabelState => ({
|
||||
|
@ -3,12 +3,11 @@ import {acceptHMRUpdate, defineStore} from 'pinia'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import ListService from '@/services/list'
|
||||
import {setLoadingPinia} from '@/store/helper'
|
||||
import {setLoadingPinia} from '@/stores/helper'
|
||||
import {removeListFromHistory} from '@/modules/listHistory'
|
||||
import {createNewIndexer} from '@/indexes'
|
||||
import {useNamespaceStore} from './namespaces'
|
||||
|
||||
import type {ListState} from '@/store/types'
|
||||
import type {IList} from '@/modelTypes/IList'
|
||||
|
||||
import type {MaybeRef} from '@vueuse/core'
|
||||
@ -20,6 +19,11 @@ const {add, remove, search, update} = createNewIndexer('lists', ['title', 'descr
|
||||
|
||||
const FavoriteListsNamespace = -2
|
||||
|
||||
export interface ListState {
|
||||
lists: { [id: IList['id']]: IList },
|
||||
isLoading: boolean,
|
||||
}
|
||||
|
||||
export const useListStore = defineStore('list', {
|
||||
state: () : ListState => ({
|
||||
isLoading: false,
|
||||
@ -113,7 +117,7 @@ export const useListStore = defineStore('list', {
|
||||
namespaceStore.setListInNamespaceById(list)
|
||||
|
||||
// the returned list from listService.update is the same!
|
||||
// in order to not validate vuex mutations we have to create a new copy
|
||||
// in order to not create a manipulation in pinia store we have to create a new copy
|
||||
const newList = {
|
||||
...list,
|
||||
namespaceId: FavoriteListsNamespace,
|
||||
|
@ -1,15 +1,19 @@
|
||||
import {defineStore, acceptHMRUpdate} from 'pinia'
|
||||
|
||||
import NamespaceService from '../services/namespace'
|
||||
import {setLoadingPinia} from '@/store/helper'
|
||||
import {setLoadingPinia} from '@/stores/helper'
|
||||
import {createNewIndexer} from '@/indexes'
|
||||
import type {NamespaceState} from '@/store/types'
|
||||
import type {INamespace} from '@/modelTypes/INamespace'
|
||||
import type {IList} from '@/modelTypes/IList'
|
||||
import {useListStore} from '@/stores/lists'
|
||||
|
||||
const {add, remove, search, update} = createNewIndexer('namespaces', ['title', 'description'])
|
||||
|
||||
export interface NamespaceState {
|
||||
namespaces: INamespace[]
|
||||
isLoading: boolean,
|
||||
}
|
||||
|
||||
export const useNamespaceStore = defineStore('namespace', {
|
||||
state: (): NamespaceState => ({
|
||||
isLoading: false,
|
||||
|
@ -7,8 +7,7 @@ import TaskAssigneeService from '@/services/taskAssignee'
|
||||
import LabelTaskService from '@/services/labelTask'
|
||||
import UserService from '@/services/user'
|
||||
|
||||
import {HAS_TASKS} from '../store/mutation-types'
|
||||
import {setLoadingPinia} from '../store/helper'
|
||||
import {playPop} from '@/helpers/playPop'
|
||||
import {getQuickAddMagicMode} from '@/helpers/quickAddMagicMode'
|
||||
import {parseTaskText} from '@/modules/parseTaskText'
|
||||
|
||||
@ -24,13 +23,12 @@ import type {IUser} from '@/modelTypes/IUser'
|
||||
import type {IAttachment} from '@/modelTypes/IAttachment'
|
||||
import type {IList} from '@/modelTypes/IList'
|
||||
|
||||
import type {TaskState} from '@/store/types'
|
||||
import {setLoadingPinia} from '@/stores/helper'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useLabelStore} from '@/stores/labels'
|
||||
import {useListStore} from '@/stores/lists'
|
||||
import {useAttachmentStore} from '@/stores/attachments'
|
||||
import {useKanbanStore} from '@/stores/kanban'
|
||||
import {playPop} from '@/helpers/playPop'
|
||||
import {store} from '@/store'
|
||||
|
||||
// IDEA: maybe use a small fuzzy search here to prevent errors
|
||||
function findPropertyByValue(object, key, value) {
|
||||
@ -40,10 +38,13 @@ function findPropertyByValue(object, key, value) {
|
||||
}
|
||||
|
||||
// Check if the user exists in the search results
|
||||
function validateUser(users: IUser[], username: IUser['username']) {
|
||||
return findPropertyByValue(users, 'username', username) ||
|
||||
findPropertyByValue(users, 'name', username) ||
|
||||
findPropertyByValue(users, 'email', username)
|
||||
function validateUser(
|
||||
users: IUser[],
|
||||
query: IUser['username'] | IUser['name'] | IUser['email'],
|
||||
) {
|
||||
return findPropertyByValue(users, 'username', query) ||
|
||||
findPropertyByValue(users, 'name', query) ||
|
||||
findPropertyByValue(users, 'email', query)
|
||||
}
|
||||
|
||||
// Check if the label exists
|
||||
@ -77,6 +78,9 @@ async function findAssignees(parsedTaskAssignees: string[]) {
|
||||
return validatedUsers.filter((item) => Boolean(item))
|
||||
}
|
||||
|
||||
export interface TaskState {
|
||||
isLoading: boolean,
|
||||
}
|
||||
|
||||
export const useTaskStore = defineStore('task', {
|
||||
state: () : TaskState => ({
|
||||
@ -89,7 +93,7 @@ export const useTaskStore = defineStore('task', {
|
||||
const cancel = setLoadingPinia(this)
|
||||
try {
|
||||
const tasks = await taskService.getAll({}, params)
|
||||
store.commit(HAS_TASKS, tasks.length > 0)
|
||||
useBaseStore().setHasTasks(tasks.length > 0)
|
||||
return tasks
|
||||
} finally {
|
||||
cancel()
|
||||
|
Reference in New Issue
Block a user