wip
This commit is contained in:
parent
ae753905e5
commit
775aa9137b
|
@ -96,6 +96,6 @@ export const VIDEO_CONF = createRequestTypes('VIDEO_CONF', [
|
|||
'ACCEPT_CALL',
|
||||
'SET_CALLING'
|
||||
]);
|
||||
export const TROUBLESHOOTING_NOTIFICATION = createRequestTypes('TROUBLESHOOTING_NOTIFICATION', ['REQUEST', 'SET']);
|
||||
export const TROUBLESHOOTING_NOTIFICATION = createRequestTypes('TROUBLESHOOTING_NOTIFICATION', ['INIT', 'SET']);
|
||||
export const SUPPORTED_VERSIONS = createRequestTypes('SUPPORTED_VERSIONS', ['SET']);
|
||||
export const IN_APP_FEEDBACK = createRequestTypes('IN_APP_FEEDBACK', ['SET', 'REMOVE', 'CLEAR']);
|
||||
|
|
|
@ -7,9 +7,9 @@ type TSetTroubleshootingNotification = Action & { payload: Partial<ITroubleshoot
|
|||
|
||||
export type TActionTroubleshootingNotification = Action & TSetTroubleshootingNotification;
|
||||
|
||||
export function requestTroubleshootingNotification(): Action {
|
||||
export function initTroubleshootingNotification(): Action {
|
||||
return {
|
||||
type: TROUBLESHOOTING_NOTIFICATION.REQUEST
|
||||
type: TROUBLESHOOTING_NOTIFICATION.INIT
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -5,12 +5,18 @@ import I18n from '../../../i18n';
|
|||
export const showErrorAlert = (message: string, title?: string, onPress = () => {}): void =>
|
||||
Alert.alert(title || '', message, [{ text: 'OK', onPress }], { cancelable: true });
|
||||
|
||||
export const showErrorAlertWithEMessage = (e: any): void => {
|
||||
const messageError =
|
||||
e.data && e.data.error.includes('[error-too-many-requests]')
|
||||
? I18n.t('error-too-many-requests', { seconds: e.data.error.replace(/\D/g, '') })
|
||||
: e.data.errorType;
|
||||
showErrorAlert(messageError);
|
||||
export const showErrorAlertWithEMessage = (e: any, title?: string): void => {
|
||||
let errorMessage: string = e?.data?.error;
|
||||
|
||||
if (errorMessage.includes('[error-too-many-requests]')) {
|
||||
const seconds = errorMessage.replace(/\D/g, '');
|
||||
errorMessage = I18n.t('error-too-many-requests', { seconds });
|
||||
} else {
|
||||
const errorKey = errorMessage;
|
||||
errorMessage = I18n.isTranslated(errorKey) ? I18n.t(errorKey) : errorMessage;
|
||||
}
|
||||
|
||||
showErrorAlert(errorMessage, title);
|
||||
};
|
||||
|
||||
interface IShowConfirmationAlert {
|
||||
|
|
|
@ -13,7 +13,7 @@ interface IGenericAction extends Action {
|
|||
type: string;
|
||||
}
|
||||
|
||||
function* request() {
|
||||
function* init() {
|
||||
const serverVersion = yield* appSelector(state => state.server.version);
|
||||
let deviceNotificationEnabled = false;
|
||||
let defaultPushGateway = false;
|
||||
|
@ -21,14 +21,22 @@ function* request() {
|
|||
try {
|
||||
const { authorizationStatus } = yield* call(notifee.getNotificationSettings);
|
||||
deviceNotificationEnabled = authorizationStatus > AuthorizationStatus.DENIED;
|
||||
} catch (e) {
|
||||
log(e);
|
||||
}
|
||||
|
||||
try {
|
||||
if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '6.5.0')) {
|
||||
const pushInfoResult = yield* call(pushInfo);
|
||||
if (pushInfoResult.success) {
|
||||
pushGatewayEnabled = pushInfoResult.pushGatewayEnabled;
|
||||
defaultPushGateway = pushInfoResult.defaultPushGateway;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log(e);
|
||||
}
|
||||
|
||||
const issuesWithNotifications =
|
||||
!deviceNotificationEnabled || (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '6.6.0') && !pushGatewayEnabled);
|
||||
yield put(
|
||||
|
@ -42,5 +50,5 @@ function* request() {
|
|||
}
|
||||
|
||||
export default function* root(): Generator {
|
||||
yield takeLatest<IGenericAction>(TROUBLESHOOTING_NOTIFICATION.REQUEST, request);
|
||||
yield takeLatest<IGenericAction>(TROUBLESHOOTING_NOTIFICATION.INIT, init);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
import React from 'react';
|
||||
import { Alert, StyleSheet, Text } from 'react-native';
|
||||
|
||||
import * as List from '../../../containers/List';
|
||||
import i18n from '../../../i18n';
|
||||
import { useAppSelector } from '../../../lib/hooks';
|
||||
import { useTheme } from '../../../theme';
|
||||
import sharedStyles from '../../Styles';
|
||||
|
||||
const WARNING_MINIMUM_VALUE = 70;
|
||||
const WARNING_MAXIMUM_VALUE = 90;
|
||||
|
||||
export default function CommunityEditionPushQuota(): React.ReactElement | null {
|
||||
const { colors } = useTheme();
|
||||
const { consumptionPercentage, isCommunityEdition } = useAppSelector(state => ({
|
||||
isCommunityEdition: state.troubleshootingNotification.isCommunityEdition,
|
||||
consumptionPercentage: state.troubleshootingNotification.consumptionPercentage
|
||||
}));
|
||||
|
||||
if (!isCommunityEdition) return null;
|
||||
|
||||
const percentage = `${Math.floor(consumptionPercentage)}%`;
|
||||
|
||||
let percentageColor = colors.statusFontOnSuccess;
|
||||
if (consumptionPercentage > WARNING_MINIMUM_VALUE && consumptionPercentage < WARNING_MAXIMUM_VALUE) {
|
||||
percentageColor = colors.statusFontOnWarning;
|
||||
}
|
||||
if (consumptionPercentage >= WARNING_MAXIMUM_VALUE) {
|
||||
percentageColor = colors.statusFontOnDanger;
|
||||
}
|
||||
|
||||
const alertWorkspaceConsumption = () => {
|
||||
Alert.alert(i18n.t('Push_consumption_alert_title'), i18n.t('Push_consumption_alert_description'));
|
||||
};
|
||||
|
||||
return (
|
||||
<List.Section title='Community_edition_push_quota'>
|
||||
<List.Separator />
|
||||
<List.Item
|
||||
title='Workspace_consumption'
|
||||
testID='push-troubleshoot-view-workspace-consumption'
|
||||
onPress={alertWorkspaceConsumption}
|
||||
right={() => <Text style={[styles.pickerText, { color: percentageColor }]}>{percentage}</Text>}
|
||||
/>
|
||||
<List.Separator />
|
||||
<List.Info info='Workspace_consumption_description' />
|
||||
</List.Section>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pickerText: {
|
||||
...sharedStyles.textRegular,
|
||||
fontSize: 16
|
||||
}
|
||||
});
|
|
@ -0,0 +1,50 @@
|
|||
import notifee from '@notifee/react-native';
|
||||
import React from 'react';
|
||||
import { Linking } from 'react-native';
|
||||
|
||||
import * as List from '../../../containers/List';
|
||||
import i18n from '../../../i18n';
|
||||
import { useAppSelector } from '../../../lib/hooks';
|
||||
import { isIOS, showErrorAlert } from '../../../lib/methods/helpers';
|
||||
import { useTheme } from '../../../theme';
|
||||
import CustomListSection from './CustomListSection';
|
||||
|
||||
export default function DeviceNotificationSettings(): React.ReactElement {
|
||||
const { colors } = useTheme();
|
||||
const { deviceNotificationEnabled } = useAppSelector(state => ({
|
||||
deviceNotificationEnabled: state.troubleshootingNotification.deviceNotificationEnabled
|
||||
}));
|
||||
|
||||
const goToNotificationSettings = () => {
|
||||
if (isIOS) {
|
||||
Linking.openURL('app-settings:');
|
||||
} else {
|
||||
notifee.openNotificationSettings();
|
||||
}
|
||||
};
|
||||
|
||||
const alertDeviceNotificationSettings = () => {
|
||||
if (deviceNotificationEnabled) return;
|
||||
showErrorAlert(
|
||||
i18n.t('Device_notifications_alert_description'),
|
||||
i18n.t('Device_notifications_alert_title'),
|
||||
goToNotificationSettings
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomListSection
|
||||
title='Device_notification_settings'
|
||||
statusColor={!deviceNotificationEnabled ? colors.userPresenceBusy : colors.userPresenceOnline}
|
||||
>
|
||||
<List.Separator />
|
||||
<List.Item
|
||||
title={!deviceNotificationEnabled ? 'Allow_push_notifications_for_rocket_chat' : 'Go_to_device_settings'}
|
||||
onPress={alertDeviceNotificationSettings}
|
||||
testID='push-troubleshoot-view-allow-push-notifications'
|
||||
disabled={deviceNotificationEnabled}
|
||||
/>
|
||||
<List.Separator />
|
||||
</CustomListSection>
|
||||
);
|
||||
}
|
|
@ -1,64 +0,0 @@
|
|||
import React from 'react';
|
||||
import { StyleSheet, Text } from 'react-native';
|
||||
|
||||
import * as List from '../../../containers/List';
|
||||
import { useTheme } from '../../../theme';
|
||||
import sharedStyles from '../../Styles';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pickerText: {
|
||||
...sharedStyles.textRegular,
|
||||
fontSize: 16
|
||||
}
|
||||
});
|
||||
|
||||
type TPercentageState = 'success' | 'warning' | 'danger';
|
||||
|
||||
const DANGER_VALUE = 90;
|
||||
const WARNING_MINIMUM_VALUE = 70;
|
||||
const WARNING_MAXIMUM_VALUE = 90;
|
||||
|
||||
const getPercentageState = (value: number): TPercentageState => {
|
||||
if (value > WARNING_MINIMUM_VALUE && value < WARNING_MAXIMUM_VALUE) {
|
||||
return 'warning';
|
||||
}
|
||||
if (value >= DANGER_VALUE) {
|
||||
return 'danger';
|
||||
}
|
||||
return 'success';
|
||||
};
|
||||
|
||||
const ListPercentage = ({
|
||||
value = 0,
|
||||
title,
|
||||
testID,
|
||||
onPress
|
||||
}: {
|
||||
title: string;
|
||||
testID: string;
|
||||
value: number;
|
||||
onPress: () => void;
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
const percentage = `${Math.floor(value)}%`;
|
||||
const percentageState = getPercentageState(value);
|
||||
|
||||
let percentageTextColor = colors.statusFontOnSuccess;
|
||||
if (percentageState === 'warning') {
|
||||
percentageTextColor = colors.statusFontOnWarning;
|
||||
}
|
||||
if (percentageState === 'danger') {
|
||||
percentageTextColor = colors.statusFontOnDanger;
|
||||
}
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
title={title}
|
||||
testID={testID}
|
||||
onPress={onPress}
|
||||
right={() => <Text style={[styles.pickerText, { color: percentageTextColor }]}>{percentage}</Text>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListPercentage;
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
import { Linking } from 'react-native';
|
||||
|
||||
import * as List from '../../../containers/List';
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
export default function NotificationDelay(): React.ReactElement {
|
||||
const { colors } = useTheme();
|
||||
|
||||
const openNotificationDocumentation = () => Linking.openURL('https://go.rocket.chat/i/push-notifications');
|
||||
|
||||
return (
|
||||
<List.Section title='Notification_delay'>
|
||||
<List.Separator />
|
||||
<List.Item
|
||||
title='Documentation'
|
||||
onPress={openNotificationDocumentation}
|
||||
right={() => <List.Icon size={32} name='new-window' color={colors.fontAnnotation} />}
|
||||
testID='push-troubleshoot-view-notification-delay'
|
||||
/>
|
||||
<List.Separator />
|
||||
<List.Info info='Notification_delay_description' />
|
||||
</List.Section>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Alert } from 'react-native';
|
||||
|
||||
import * as List from '../../../containers/List';
|
||||
import i18n from '../../../i18n';
|
||||
import { useAppSelector, usePermissions } from '../../../lib/hooks';
|
||||
import { compareServerVersion, showErrorAlertWithEMessage } from '../../../lib/methods/helpers';
|
||||
import { Services } from '../../../lib/services';
|
||||
import { useTheme } from '../../../theme';
|
||||
import CustomListSection from './CustomListSection';
|
||||
|
||||
export default function PushGatewayConnection(): React.ReactElement | null {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { colors } = useTheme();
|
||||
const [testPushNotificationsPermission] = usePermissions(['test-push-notifications']);
|
||||
const { defaultPushGateway, pushGatewayEnabled, serverVersion } = useAppSelector(state => ({
|
||||
pushGatewayEnabled: state.troubleshootingNotification.pushGatewayEnabled,
|
||||
defaultPushGateway: state.troubleshootingNotification.defaultPushGateway,
|
||||
foreground: state.app.foreground,
|
||||
serverVersion: state.server.version
|
||||
}));
|
||||
|
||||
if (!compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '6.6.0')) return null;
|
||||
|
||||
const handleTestPushNotification = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await Services.pushTest();
|
||||
if (result.success) {
|
||||
Alert.alert(i18n.t('Test_push_notification'), i18n.t('Your_push_was_sent_to_s_devices', { s: result.tokensCount }));
|
||||
}
|
||||
} catch (error: any) {
|
||||
showErrorAlertWithEMessage(error, i18n.t('Test_push_notification'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
let infoColor = 'Push_gateway_not_connected_description';
|
||||
let statusColor = colors.userPresenceBusy;
|
||||
if (pushGatewayEnabled) {
|
||||
statusColor = colors.userPresenceOnline;
|
||||
infoColor = 'Push_gateway_connected_description';
|
||||
}
|
||||
if (pushGatewayEnabled && !defaultPushGateway) {
|
||||
statusColor = colors.badgeBackgroundLevel3;
|
||||
infoColor = 'Custom_push_gateway_connected_description';
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomListSection
|
||||
title={!defaultPushGateway ? 'Custom_push_gateway_connection' : 'Push_gateway_connection'}
|
||||
statusColor={statusColor}
|
||||
>
|
||||
<List.Separator />
|
||||
<List.Item
|
||||
title='Test_push_notification'
|
||||
disabled={!pushGatewayEnabled || !testPushNotificationsPermission || loading}
|
||||
onPress={handleTestPushNotification}
|
||||
testID='push-troubleshoot-view-push-gateway-connection'
|
||||
/>
|
||||
<List.Separator />
|
||||
<List.Info info={infoColor} />
|
||||
</CustomListSection>
|
||||
);
|
||||
}
|
|
@ -1,51 +1,30 @@
|
|||
import { StackNavigationProp } from '@react-navigation/stack';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Alert, Linking } from 'react-native';
|
||||
import notifee from '@notifee/react-native';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { initTroubleshootingNotification } from '../../actions/troubleshootingNotification';
|
||||
import * as List from '../../containers/List';
|
||||
import SafeAreaView from '../../containers/SafeAreaView';
|
||||
import StatusBar from '../../containers/StatusBar';
|
||||
import I18n from '../../i18n';
|
||||
import { useAppSelector } from '../../lib/hooks';
|
||||
import { SettingsStackParamList } from '../../stacks/types';
|
||||
import { useTheme } from '../../theme';
|
||||
import CustomListSection from './components/CustomListSection';
|
||||
import { compareServerVersion, isIOS, showErrorAlert } from '../../lib/methods/helpers';
|
||||
import { requestTroubleshootingNotification } from '../../actions/troubleshootingNotification';
|
||||
import { useAppSelector, usePermissions } from '../../lib/hooks';
|
||||
import { Services } from '../../lib/services';
|
||||
import ListPercentage from './components/ListPercentage';
|
||||
import CommunityEditionPushQuota from './components/CommunityEditionPushQuota';
|
||||
import DeviceNotificationSettings from './components/DeviceNotificationSettings';
|
||||
import NotificationDelay from './components/NotificationDelay';
|
||||
import PushGatewayConnection from './components/PushGatewayConnection';
|
||||
|
||||
interface IPushTroubleshootViewProps {
|
||||
navigation: StackNavigationProp<SettingsStackParamList, 'PushTroubleshootView'>;
|
||||
}
|
||||
|
||||
const PushTroubleshootView = ({ navigation }: IPushTroubleshootViewProps): JSX.Element => {
|
||||
const { colors } = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const [testPushNotificationsPermission] = usePermissions(['test-push-notifications']);
|
||||
const {
|
||||
deviceNotificationEnabled,
|
||||
defaultPushGateway,
|
||||
pushGatewayEnabled,
|
||||
consumptionPercentage,
|
||||
isCommunityEdition,
|
||||
foreground,
|
||||
serverVersion
|
||||
} = useAppSelector(state => ({
|
||||
deviceNotificationEnabled: state.troubleshootingNotification.deviceNotificationEnabled,
|
||||
pushGatewayEnabled: state.troubleshootingNotification.pushGatewayEnabled,
|
||||
defaultPushGateway: state.troubleshootingNotification.defaultPushGateway,
|
||||
foreground: state.app.foreground,
|
||||
serverVersion: state.server.version,
|
||||
isCommunityEdition: state.troubleshootingNotification.isCommunityEdition,
|
||||
consumptionPercentage: state.troubleshootingNotification.consumptionPercentage
|
||||
}));
|
||||
const foreground = useAppSelector(state => state.app.foreground);
|
||||
|
||||
useEffect(() => {
|
||||
if (foreground) {
|
||||
dispatch(requestTroubleshootingNotification());
|
||||
dispatch(initTroubleshootingNotification());
|
||||
}
|
||||
}, [dispatch, foreground]);
|
||||
|
||||
|
@ -55,114 +34,14 @@ const PushTroubleshootView = ({ navigation }: IPushTroubleshootViewProps): JSX.E
|
|||
});
|
||||
}, [navigation]);
|
||||
|
||||
const openNotificationDocumentation = async () => {
|
||||
await Linking.openURL('https://go.rocket.chat/i/push-notifications');
|
||||
};
|
||||
|
||||
const alertDeviceNotificationSettings = () => {
|
||||
showErrorAlert(
|
||||
I18n.t('Device_notifications_alert_description'),
|
||||
I18n.t('Device_notifications_alert_title'),
|
||||
goToNotificationSettings
|
||||
);
|
||||
};
|
||||
|
||||
const alertWorkspaceConsumption = () => {
|
||||
Alert.alert(I18n.t('Push_consumption_alert_title'), I18n.t('Push_consumption_alert_description'));
|
||||
};
|
||||
|
||||
const goToNotificationSettings = () => {
|
||||
if (isIOS) {
|
||||
Linking.openURL('app-settings:');
|
||||
} else {
|
||||
notifee.openNotificationSettings();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestPushNotification = async () => {
|
||||
let message = '';
|
||||
try {
|
||||
const result = await Services.pushTest();
|
||||
if (result.success) {
|
||||
message = I18n.t('Your_push_was_sent_to_s_devices', { s: result.tokensCount });
|
||||
}
|
||||
} catch (error: any) {
|
||||
message = I18n.isTranslated(error?.data?.errorType) ? I18n.t(error?.data?.errorType) : error?.data?.error;
|
||||
} finally {
|
||||
Alert.alert(I18n.t('Test_push_notification'), message);
|
||||
}
|
||||
};
|
||||
|
||||
let pushGatewayInfoDescription = 'Push_gateway_not_connected_description';
|
||||
let pushGatewayStatusColor = colors.userPresenceBusy;
|
||||
if (pushGatewayEnabled) {
|
||||
pushGatewayStatusColor = colors.userPresenceOnline;
|
||||
pushGatewayInfoDescription = 'Push_gateway_connected_description';
|
||||
}
|
||||
if (pushGatewayEnabled && !defaultPushGateway) {
|
||||
pushGatewayStatusColor = colors.badgeBackgroundLevel3;
|
||||
pushGatewayInfoDescription = 'Custom_push_gateway_connected_description';
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView testID='push-troubleshoot-view'>
|
||||
<StatusBar />
|
||||
<List.Container testID='push-troubleshoot-view-list'>
|
||||
<CustomListSection
|
||||
title='Device_notification_settings'
|
||||
statusColor={!deviceNotificationEnabled ? colors.userPresenceBusy : colors.userPresenceOnline}
|
||||
>
|
||||
<List.Separator />
|
||||
<List.Item
|
||||
title={!deviceNotificationEnabled ? 'Allow_push_notifications_for_rocket_chat' : 'Go_to_device_settings'}
|
||||
onPress={!deviceNotificationEnabled ? alertDeviceNotificationSettings : undefined}
|
||||
testID='push-troubleshoot-view-allow-push-notifications'
|
||||
/>
|
||||
<List.Separator />
|
||||
</CustomListSection>
|
||||
|
||||
{isCommunityEdition ? (
|
||||
<List.Section title='Community_edition_push_quota'>
|
||||
<List.Separator />
|
||||
<ListPercentage
|
||||
title='Workspace_consumption'
|
||||
onPress={alertWorkspaceConsumption}
|
||||
testID='push-troubleshoot-view-workspace-consumption'
|
||||
value={consumptionPercentage}
|
||||
/>
|
||||
<List.Separator />
|
||||
<List.Info info='Workspace_consumption_description' />
|
||||
</List.Section>
|
||||
) : null}
|
||||
|
||||
{compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '6.6.0') ? (
|
||||
<CustomListSection
|
||||
title={!defaultPushGateway ? 'Custom_push_gateway_connection' : 'Push_gateway_connection'}
|
||||
statusColor={pushGatewayStatusColor}
|
||||
>
|
||||
<List.Separator />
|
||||
<List.Item
|
||||
title='Test_push_notification'
|
||||
disabled={!pushGatewayEnabled || !testPushNotificationsPermission}
|
||||
onPress={handleTestPushNotification}
|
||||
testID='push-troubleshoot-view-push-gateway-connection'
|
||||
/>
|
||||
<List.Separator />
|
||||
<List.Info info={pushGatewayInfoDescription} />
|
||||
</CustomListSection>
|
||||
) : null}
|
||||
|
||||
<List.Section title='Notification_delay'>
|
||||
<List.Separator />
|
||||
<List.Item
|
||||
title='Documentation'
|
||||
onPress={openNotificationDocumentation}
|
||||
right={() => <List.Icon size={32} name='new-window' color={colors.fontAnnotation} />}
|
||||
testID='push-troubleshoot-view-notification-delay'
|
||||
/>
|
||||
<List.Separator />
|
||||
<List.Info info='Notification_delay_description' />
|
||||
</List.Section>
|
||||
<DeviceNotificationSettings />
|
||||
<CommunityEditionPushQuota />
|
||||
<PushGatewayConnection />
|
||||
<NotificationDelay />
|
||||
</List.Container>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
|
Loading…
Reference in New Issue