setting-sync (#175)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-authored-by: FloatingGhost <hannah@coffee-and-dreams.uk> Reviewed-on: AkkomaGang/pleroma-fe#175
This commit is contained in:
parent
eaf2bd05a0
commit
4f837f75ea
15 changed files with 314 additions and 16 deletions
|
@ -61,7 +61,6 @@
|
|||
<EditStatusModal v-if="editingAvailable" />
|
||||
<StatusHistoryModal v-if="editingAvailable" />
|
||||
<SettingsModal />
|
||||
<UpdateNotification />
|
||||
<GlobalNoticeList />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -398,7 +398,6 @@ const afterStoreSetup = async ({ store, i18n }) => {
|
|||
store.dispatch('startFetchingAnnouncements')
|
||||
getTOS({ store })
|
||||
getStickers({ store })
|
||||
store.dispatch('getSupportedTranslationlanguages')
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
|
|
|
@ -12,7 +12,8 @@ export default {
|
|||
'path',
|
||||
'disabled',
|
||||
'options',
|
||||
'expert'
|
||||
'expert',
|
||||
'hideDefaultLabel'
|
||||
],
|
||||
computed: {
|
||||
pathDefault () {
|
||||
|
|
|
@ -16,7 +16,11 @@
|
|||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
{{ option.value === defaultState ? $t('settings.instance_default_simple') : '' }}
|
||||
<template
|
||||
v-if="hideDefaultLabel !== true"
|
||||
>
|
||||
{{ option.value === defaultState ? $t('settings.instance_default_simple') : '' }}
|
||||
</template>
|
||||
</option>
|
||||
</Select>
|
||||
<ModifiedIndicator :changed="isChanged" />
|
||||
|
|
|
@ -19,7 +19,7 @@ const SharedComputedObject = () => ({
|
|||
.map(key => [key, {
|
||||
get () { return this.$store.getters.mergedConfig[key] },
|
||||
set (value) {
|
||||
this.$store.dispatch('setOption', { name: key, value })
|
||||
this.$store.dispatch('setOption', { name: key, value, manual: true })
|
||||
}
|
||||
}])
|
||||
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
|
||||
|
@ -27,7 +27,7 @@ const SharedComputedObject = () => ({
|
|||
.map(key => ['serverSide_' + key, {
|
||||
get () { return this.$store.state.serverSideConfig[key] },
|
||||
set (value) {
|
||||
this.$store.dispatch('setServerSideOption', { name: key, value })
|
||||
this.$store.dispatch('setServerSideOption', { name: key, value, manual: true })
|
||||
}
|
||||
}])
|
||||
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
|
||||
|
|
|
@ -8,11 +8,12 @@ import SharedComputedObject from '../helpers/shared_computed_object.js'
|
|||
import ServerSideIndicator from '../helpers/server_side_indicator.vue'
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import {
|
||||
faGlobe
|
||||
faGlobe, faSync
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
library.add(
|
||||
faGlobe
|
||||
faGlobe,
|
||||
faSync
|
||||
)
|
||||
|
||||
const GeneralTab = {
|
||||
|
@ -48,6 +49,8 @@ const GeneralTab = {
|
|||
value: tab,
|
||||
label: this.$t(`user_card.${tab}`)
|
||||
})),
|
||||
profilesExpanded: false,
|
||||
newProfileName: '',
|
||||
loopSilentAvailable:
|
||||
// Firefox
|
||||
Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') ||
|
||||
|
@ -88,8 +91,22 @@ const GeneralTab = {
|
|||
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
|
||||
}
|
||||
},
|
||||
settingsProfiles () {
|
||||
return (this.$store.state.instance.settingsProfiles || [])
|
||||
},
|
||||
settingsProfile: {
|
||||
get: function () { return this.$store.getters.mergedConfig.profile },
|
||||
set: function (val) {
|
||||
this.$store.dispatch('setOption', { name: 'profile', value: val })
|
||||
this.$store.dispatch('getSettingsProfile')
|
||||
}
|
||||
},
|
||||
settingsVersion () {
|
||||
return this.$store.getters.mergedConfig.profileVersion
|
||||
},
|
||||
translationLanguages () {
|
||||
return (this.$store.getters.mergedConfig.supportedTranslationLanguages.target || []).map(lang => ({ key: lang.code, value: lang.code, label: lang.name }))
|
||||
const langs = this.$store.state.instance.translationLanguages || []
|
||||
return (langs || []).map(lang => ({ key: lang.code, value: lang.code, label: lang.name }))
|
||||
},
|
||||
translationLanguage: {
|
||||
get: function () { return this.$store.getters.mergedConfig.translationLanguage },
|
||||
|
@ -105,6 +122,30 @@ const GeneralTab = {
|
|||
},
|
||||
setTranslationLanguage (value) {
|
||||
this.$store.dispatch('setOption', { name: 'translationLanguage', value })
|
||||
},
|
||||
toggleExpandedSettings () {
|
||||
this.profilesExpanded = !this.profilesExpanded
|
||||
},
|
||||
loadSettingsProfile (name) {
|
||||
this.$store.commit('setOption', { name: 'profile', value: name })
|
||||
this.$store.dispatch('getSettingsProfile', true)
|
||||
},
|
||||
createSettingsProfile () {
|
||||
this.$store.dispatch('setOption', { name: 'profile', value: this.newProfileName })
|
||||
this.$store.dispatch('setOption', { name: 'profileVersion', value: 1 })
|
||||
this.$store.dispatch('syncSettings')
|
||||
this.newProfileName = ''
|
||||
},
|
||||
forceSync () {
|
||||
this.$store.dispatch('getSettingsProfile')
|
||||
},
|
||||
refreshProfiles () {
|
||||
this.$store.dispatch('listSettingsProfiles')
|
||||
},
|
||||
deleteSettingsProfile (name) {
|
||||
if (confirm(this.$t('settings.settings_profile_delete_confirm'))) {
|
||||
this.$store.dispatch('deleteSettingsProfile', name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
<template>
|
||||
<div :label="$t('settings.general')">
|
||||
<div class="setting-item">
|
||||
<h2>{{ $t('settings.interface') }}</h2>
|
||||
<ul class="setting-list">
|
||||
<li>
|
||||
<interface-language-switcher
|
||||
|
@ -10,6 +9,94 @@
|
|||
:set-language="val => language = val"
|
||||
/>
|
||||
</li>
|
||||
<li
|
||||
v-if="user && (settingsProfiles.length > 0)"
|
||||
>
|
||||
<h2>{{ $t('settings.settings_profile') }}</h2>
|
||||
<p>
|
||||
{{ $t('settings.settings_profile_currently', { name: settingsProfile, version: settingsVersion }) }}
|
||||
<button
|
||||
class="btn button-default"
|
||||
@click="forceSync()"
|
||||
>
|
||||
{{ $t('settings.settings_profile_force_sync') }}
|
||||
</button>
|
||||
|
||||
</p>
|
||||
<div
|
||||
@click="toggleExpandedSettings"
|
||||
>
|
||||
<template
|
||||
v-if="profilesExpanded"
|
||||
>
|
||||
<button class="btn button-default">
|
||||
{{ $t('settings.settings_profiles_unshow') }}
|
||||
</button>
|
||||
</template>
|
||||
<template
|
||||
v-else
|
||||
>
|
||||
<button class="btn button-default">
|
||||
{{ $t('settings.settings_profiles_show') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<br>
|
||||
<template
|
||||
v-if="profilesExpanded"
|
||||
>
|
||||
|
||||
<div
|
||||
v-for="profile in settingsProfiles"
|
||||
:key="profile.id"
|
||||
class="settings-profile"
|
||||
>
|
||||
<h4>{{ profile.name }} ({{ profile.version }})</h4>
|
||||
<template
|
||||
v-if="settingsProfile === profile.name"
|
||||
>
|
||||
{{ $t('settings.settings_profile_in_use') }}
|
||||
</template>
|
||||
<template
|
||||
v-else
|
||||
>
|
||||
<button
|
||||
class="btn button-default"
|
||||
@click="loadSettingsProfile(profile.name)"
|
||||
>
|
||||
{{ $t('settings.settings_profile_use') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn button-default"
|
||||
@click="deleteSettingsProfile(profile.name)"
|
||||
>
|
||||
{{ $t('settings.settings_profile_delete') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<button class="btn button-default" @click="refreshProfiles()">
|
||||
{{ $t('settings.settings_profiles_refresh') }}
|
||||
<FAIcon icon="sync" @click="refreshProfiles()" />
|
||||
</button>
|
||||
<h3>{{ $t('settings.settings_profile_creation') }}</h3>
|
||||
<label for="settings-profile-new-name">
|
||||
{{ $t('settings.settings_profile_creation_new_name_label') }}
|
||||
</label>
|
||||
<input v-model="newProfileName" id="settings-profile-new-name">
|
||||
<button
|
||||
class="btn button-default"
|
||||
@click="createSettingsProfile"
|
||||
>
|
||||
{{ $t('settings.settings_profile_creation_submit') }}
|
||||
</button>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<h2>{{ $t('settings.interface') }}</h2>
|
||||
<ul class="setting-list">
|
||||
<li v-if="instanceSpecificPanelPresent">
|
||||
<BooleanSetting path="hideISP">
|
||||
{{ $t('settings.hide_isp') }}
|
||||
|
@ -463,7 +550,6 @@
|
|||
</BooleanSetting>
|
||||
</li>
|
||||
<li>
|
||||
<!-- <BooleanSetting path="serverSide_defaultNSFW"> -->
|
||||
<BooleanSetting path="sensitiveByDefault">
|
||||
{{ $t('settings.sensitive_by_default') }}
|
||||
</BooleanSetting>
|
||||
|
@ -546,3 +632,13 @@
|
|||
</template>
|
||||
|
||||
<script src="./general_tab.js"></script>
|
||||
<style lang="scss">
|
||||
.settings-profile {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
#settings-profile-new-name {
|
||||
margin-left: 1em;
|
||||
margin-right: 1em;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -83,7 +83,7 @@ const StatusContent = {
|
|||
return this.status.attachments.map(file => fileType.fileType(file.mimetype))
|
||||
},
|
||||
translationLanguages () {
|
||||
return (this.$store.getters.mergedConfig.supportedTranslationLanguages.source || []).map(lang => ({ key: lang.code, value: lang.code, label: lang.name }))
|
||||
return (this.$store.state.instance.supportedTranslationLanguages.source || []).map(lang => ({ key: lang.code, value: lang.code, label: lang.name }))
|
||||
},
|
||||
...mapGetters(['mergedConfig'])
|
||||
},
|
||||
|
|
|
@ -693,6 +693,19 @@
|
|||
"setting_changed": "Setting is different from default",
|
||||
"setting_server_side": "This setting is tied to your profile and affects all sessions and clients",
|
||||
"settings": "Settings",
|
||||
"settings_profile": "Settings Profiles",
|
||||
"settings_profile_currently": "Currently using {name} (version: {version})",
|
||||
"settings_profiles_show": "Show all settings profiles",
|
||||
"settings_profiles_unshow": "Hide all settings profiles",
|
||||
"settings_profile_in_use": "In use",
|
||||
"settings_profile_creation": "Create new profile",
|
||||
"settings_profile_creation_submit": "Create",
|
||||
"settings_profile_creation_new_name_label": "Name",
|
||||
"settings_profile_use": "Use",
|
||||
"settings_profile_delete": "Delete",
|
||||
"settings_profile_delete_confirm": "Do you really want to delete this profile?",
|
||||
"settings_profile_force_sync": "Synchronize",
|
||||
"settings_profiles_refresh": "Reload settings profiles",
|
||||
"show_admin_badge": "Show \"Admin\" badge in my profile",
|
||||
"show_moderator_badge": "Show \"Moderator\" badge in my profile",
|
||||
"show_nav_shortcuts": "Show extra navigation shortcuts in top panel",
|
||||
|
@ -892,6 +905,12 @@
|
|||
"word_filter": "Word filter",
|
||||
"wordfilter": "Wordfilter"
|
||||
},
|
||||
"settings_profile": {
|
||||
"synchronizing": "Synchronizing setting profile \"{profile}\"...",
|
||||
"synchronized": "Synchronized settings!",
|
||||
"synchronization_error": "Could not synchronize settings: {err}",
|
||||
"creating": "Creating new setting profile \"{profile}\"..."
|
||||
},
|
||||
"status": {
|
||||
"ancestor_follow": "See {numReplies} other reply under this post | See {numReplies} other replies under this post",
|
||||
"ancestor_follow_with_icon": "{icon} {text}",
|
||||
|
|
|
@ -273,10 +273,15 @@ const api = {
|
|||
getSupportedTranslationlanguages (store) {
|
||||
store.state.backendInteractor.getSupportedTranslationlanguages({ store })
|
||||
.then((data) => {
|
||||
store.dispatch('setOption', { name: 'supportedTranslationLanguages', value: data })
|
||||
store.dispatch('setInstanceOption', { name: 'supportedTranslationLanguages', value: data })
|
||||
})
|
||||
},
|
||||
listSettingsProfiles (store) {
|
||||
store.state.backendInteractor.listSettingsProfiles({ store })
|
||||
.then((data) => {
|
||||
store.commit('setInstanceOption', { name: 'settingsProfiles', value: data })
|
||||
})
|
||||
},
|
||||
|
||||
// Pleroma websocket
|
||||
setWsToken (store, token) {
|
||||
store.commit('setWsToken', token)
|
||||
|
|
|
@ -21,6 +21,8 @@ export const multiChoiceProperties = [
|
|||
]
|
||||
|
||||
export const defaultState = {
|
||||
profile: 'default',
|
||||
profileVersion: 0,
|
||||
expertLevel: 0, // used to track which settings to show and hide
|
||||
colors: {},
|
||||
theme: undefined,
|
||||
|
@ -160,6 +162,42 @@ const config = {
|
|||
}
|
||||
},
|
||||
actions: {
|
||||
syncSettings: (store) => {
|
||||
store.commit('setOption', { name: 'profileVersion', value: store.state.profileVersion + 1 })
|
||||
const notice = {
|
||||
level: 'info',
|
||||
messageKey: 'settings_profile.synchronizing',
|
||||
messageArgs: { profile: store.state.profile },
|
||||
timeout: 5000
|
||||
}
|
||||
store.dispatch('pushGlobalNotice', notice)
|
||||
store.rootState.api.backendInteractor.saveSettingsProfile({
|
||||
settings: store.state, profileName: store.state.profile, version: store.state.profileVersion
|
||||
}).then(() => {
|
||||
store.dispatch('removeGlobalNotice', notice)
|
||||
store.dispatch('pushGlobalNotice', {
|
||||
level: 'success',
|
||||
messageKey: 'settings_profile.synchronized',
|
||||
messageArgs: { profile: store.state.profile },
|
||||
timeout: 2000
|
||||
})
|
||||
store.dispatch('listSettingsProfiles')
|
||||
}).catch((err) => {
|
||||
store.dispatch('removeGlobalNotice', notice)
|
||||
store.dispatch('pushGlobalNotice', {
|
||||
level: 'error',
|
||||
messageKey: 'settings_profile.synchronization_error',
|
||||
messageArgs: { error: err.message },
|
||||
timeout: 5000
|
||||
})
|
||||
console.error(err)
|
||||
})
|
||||
},
|
||||
deleteSettingsProfile (store, name) {
|
||||
store.rootState.api.backendInteractor.deleteSettingsProfile({ profileName: name }).then(() => {
|
||||
store.dispatch('listSettingsProfiles')
|
||||
})
|
||||
},
|
||||
loadSettings ({ dispatch }, data) {
|
||||
const knownKeys = new Set(Object.keys(defaultState))
|
||||
const presentKeys = new Set(Object.keys(data))
|
||||
|
@ -177,8 +215,11 @@ const config = {
|
|||
setHighlight ({ commit, dispatch }, { user, color, type }) {
|
||||
commit('setHighlight', { user, color, type })
|
||||
},
|
||||
setOption ({ commit, dispatch }, { name, value }) {
|
||||
setOption ({ commit, dispatch }, { name, value, manual }) {
|
||||
commit('setOption', { name, value })
|
||||
if (manual === true) {
|
||||
dispatch('syncSettings')
|
||||
}
|
||||
switch (name) {
|
||||
case 'theme':
|
||||
setPreset(value)
|
||||
|
@ -196,6 +237,36 @@ const config = {
|
|||
dispatch('setLayoutWidth', undefined)
|
||||
break
|
||||
}
|
||||
},
|
||||
getSettingsProfile (store, forceUpdate = false) {
|
||||
const profile = store.state.profile
|
||||
store.rootState.api.backendInteractor.getSettingsProfile({ store, profileName: profile })
|
||||
.then(({ settings, version }) => {
|
||||
console.log('found settings version', version)
|
||||
if (forceUpdate || (version > store.state.profileVersion)) {
|
||||
store.commit('setOption', { name: 'profileVersion', value: version })
|
||||
Object.entries(settings).forEach(([name, value]) => {
|
||||
if (store.state[name] !== value) {
|
||||
store.dispatch('setOption', { name, value })
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.log('settings are up to date')
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`could not fetch profile ${profile}`, err)
|
||||
if (err.statusCode === 404) {
|
||||
// create profile
|
||||
store.dispatch('pushGlobalNotice', {
|
||||
level: 'warning',
|
||||
messageKey: 'settings_profile.creating',
|
||||
messageArgs: { profile },
|
||||
timeout: 5000
|
||||
})
|
||||
store.dispatch('syncSettings')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -562,6 +562,8 @@ const users = {
|
|||
|
||||
// Start fetching notifications
|
||||
store.dispatch('startFetchingNotifications')
|
||||
|
||||
store.dispatch('startFetchingConfig')
|
||||
}
|
||||
|
||||
if (store.getters.mergedConfig.useStreamingApi) {
|
||||
|
@ -581,6 +583,9 @@ const users = {
|
|||
|
||||
store.dispatch('setLayoutWidth', windowWidth())
|
||||
store.dispatch('setLayoutHeight', windowHeight())
|
||||
store.dispatch('getSupportedTranslationlanguages')
|
||||
store.dispatch('getSettingsProfile')
|
||||
store.dispatch('listSettingsProfiles')
|
||||
|
||||
// Fetch our friends
|
||||
store.rootState.api.backendInteractor.fetchFriends({ id: user.id })
|
||||
|
|
|
@ -102,6 +102,8 @@ const PLEROMA_ANNOUNCEMENTS_URL = '/api/v1/pleroma/admin/announcements'
|
|||
const PLEROMA_POST_ANNOUNCEMENT_URL = '/api/v1/pleroma/admin/announcements'
|
||||
const PLEROMA_EDIT_ANNOUNCEMENT_URL = id => `/api/v1/pleroma/admin/announcements/${id}`
|
||||
const PLEROMA_DELETE_ANNOUNCEMENT_URL = id => `/api/v1/pleroma/admin/announcements/${id}`
|
||||
const AKKOMA_SETTING_PROFILE_URL = (name) => `/api/v1/akkoma/frontend_settings/pleroma-fe/${name}`
|
||||
const AKKOMA_SETTING_PROFILE_LIST = `/api/v1/akkoma/frontend_settings/pleroma-fe`
|
||||
|
||||
const oldfetch = window.fetch
|
||||
|
||||
|
@ -1451,6 +1453,40 @@ const deleteAnnouncement = ({ id, credentials }) => {
|
|||
})
|
||||
}
|
||||
|
||||
const getSettingsProfile = ({ profileName, credentials }) => {
|
||||
return promisedRequest({
|
||||
url: AKKOMA_SETTING_PROFILE_URL(profileName),
|
||||
credentials
|
||||
})
|
||||
}
|
||||
|
||||
const saveSettingsProfile = ({ profileName, credentials, settings, version }) => {
|
||||
return promisedRequest({
|
||||
url: AKKOMA_SETTING_PROFILE_URL(profileName),
|
||||
method: 'PUT',
|
||||
credentials,
|
||||
payload: {
|
||||
settings,
|
||||
version
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const deleteSettingsProfile = ({ profileName, credentials }) => {
|
||||
return promisedRequest({
|
||||
url: AKKOMA_SETTING_PROFILE_URL(profileName),
|
||||
method: 'DELETE',
|
||||
credentials
|
||||
})
|
||||
}
|
||||
|
||||
const listSettingsProfiles = ({ credentials }) => {
|
||||
return promisedRequest({
|
||||
url: AKKOMA_SETTING_PROFILE_LIST,
|
||||
credentials
|
||||
})
|
||||
}
|
||||
|
||||
export const getMastodonSocketURI = ({ credentials, stream, args = {} }) => {
|
||||
return Object.entries({
|
||||
...(credentials
|
||||
|
@ -1677,7 +1713,11 @@ const apiService = {
|
|||
deleteAnnouncement,
|
||||
adminFetchAnnouncements,
|
||||
translateStatus,
|
||||
getSupportedTranslationlanguages
|
||||
getSupportedTranslationlanguages,
|
||||
getSettingsProfile,
|
||||
saveSettingsProfile,
|
||||
listSettingsProfiles,
|
||||
deleteSettingsProfile
|
||||
}
|
||||
|
||||
export default apiService
|
||||
|
|
|
@ -4,6 +4,7 @@ import notificationsFetcher from '../notifications_fetcher/notifications_fetcher
|
|||
import followRequestFetcher from '../../services/follow_request_fetcher/follow_request_fetcher.service'
|
||||
import listsFetcher from '../../services/lists_fetcher/lists_fetcher.service.js'
|
||||
import announcementsFetcher from '../../services/announcements_fetcher/announcements_fetcher.service.js'
|
||||
import configFetcher from '../config_fetcher/config_fetcher.service.js'
|
||||
|
||||
const backendInteractorService = credentials => ({
|
||||
startFetchingTimeline ({ timeline, store, userId = false, listId = false, tag }) {
|
||||
|
@ -18,6 +19,10 @@ const backendInteractorService = credentials => ({
|
|||
return notificationsFetcher.startFetching({ store, credentials })
|
||||
},
|
||||
|
||||
startFetchingConfig ({ store }) {
|
||||
return configFetcher.startFetching({ store, credentials })
|
||||
},
|
||||
|
||||
fetchNotifications (args) {
|
||||
return notificationsFetcher.fetchAndUpdate({ ...args, credentials })
|
||||
},
|
||||
|
|
13
src/services/config_fetcher/config_fetcher.service.js
Normal file
13
src/services/config_fetcher/config_fetcher.service.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { promiseInterval } from '../promise_interval/promise_interval.js'
|
||||
|
||||
const startFetching = ({ credentials, store }) => {
|
||||
const boundFetchAndUpdate = () => store.dispatch('getSettingsProfile')
|
||||
boundFetchAndUpdate()
|
||||
return promiseInterval(boundFetchAndUpdate, 5 * 60000)
|
||||
}
|
||||
|
||||
const configFetcher = {
|
||||
startFetching
|
||||
}
|
||||
|
||||
export default configFetcher
|
Loading…
Reference in a new issue