mirror of https://github.com/vector-im/riot-web
Implement the Roles & Permissions tab of new room settings
This is largely copy/paste from the current room settingspull/21833/head
parent
0cce912cf6
commit
0af419633f
|
@ -143,6 +143,7 @@
|
|||
@import "./views/settings/tabs/_GeneralUserSettingsTab.scss";
|
||||
@import "./views/settings/tabs/_HelpSettingsTab.scss";
|
||||
@import "./views/settings/tabs/_PreferencesSettingsTab.scss";
|
||||
@import "./views/settings/tabs/_RolesRoomSettingsTab.scss";
|
||||
@import "./views/settings/tabs/_SecuritySettingsTab.scss";
|
||||
@import "./views/settings/tabs/_SettingsTab.scss";
|
||||
@import "./views/settings/tabs/_VoiceSettingsTab.scss";
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
Copyright 2019 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
.mx_RolesRoomSettingsTab ul {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.mx_RolesRoomSettingsTab_unbanBtn {
|
||||
margin-right: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
|
@ -20,6 +20,7 @@ import {Tab, TabbedView} from "../../structures/TabbedView";
|
|||
import {_t, _td} from "../../../languageHandler";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import dis from '../../../dispatcher';
|
||||
import RolesRoomSettingsTab from "../settings/tabs/RolesRoomSettingsTab";
|
||||
import GeneralRoomSettingsTab from "../settings/tabs/GeneralRoomSettingsTab";
|
||||
|
||||
// TODO: Ditch this whole component
|
||||
|
@ -73,7 +74,7 @@ export default class RoomSettingsDialog extends React.Component {
|
|||
tabs.push(new Tab(
|
||||
_td("Roles & Permissions"),
|
||||
"mx_RoomSettingsDialog_rolesIcon",
|
||||
<div>Roles Test</div>,
|
||||
<RolesRoomSettingsTab roomId={this.props.roomId} />,
|
||||
));
|
||||
tabs.push(new Tab(
|
||||
_td("Advanced"),
|
||||
|
|
|
@ -0,0 +1,328 @@
|
|||
/*
|
||||
Copyright 2019 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {_t, _td} from "../../../../languageHandler";
|
||||
import MatrixClientPeg from "../../../../MatrixClientPeg";
|
||||
import sdk from "../../../../index";
|
||||
import AccessibleButton from "../../elements/AccessibleButton";
|
||||
import Modal from "../../../../Modal";
|
||||
|
||||
const plEventsToLabels = {
|
||||
// These will be translated for us later.
|
||||
"m.room.avatar": _td("To change the room's avatar, you must be a"),
|
||||
"m.room.name": _td("To change the room's name, you must be a"),
|
||||
"m.room.canonical_alias": _td("To change the room's main address, you must be a"),
|
||||
"m.room.history_visibility": _td("To change the room's history visibility, you must be a"),
|
||||
"m.room.power_levels": _td("To change the permissions in the room, you must be a"),
|
||||
"m.room.topic": _td("To change the topic, you must be a"),
|
||||
|
||||
"im.vector.modular.widgets": _td("To modify widgets in the room, you must be a"),
|
||||
};
|
||||
|
||||
const plEventsToShow = {
|
||||
// If an event is listed here, it will be shown in the PL settings. Defaults will be calculated.
|
||||
"m.room.avatar": {isState: true},
|
||||
"m.room.name": {isState: true},
|
||||
"m.room.canonical_alias": {isState: true},
|
||||
"m.room.history_visibility": {isState: true},
|
||||
"m.room.power_levels": {isState: true},
|
||||
"m.room.topic": {isState: true},
|
||||
|
||||
"im.vector.modular.widgets": {isState: true},
|
||||
};
|
||||
|
||||
// parse a string as an integer; if the input is undefined, or cannot be parsed
|
||||
// as an integer, return a default.
|
||||
function parseIntWithDefault(val, def) {
|
||||
const res = parseInt(val);
|
||||
return isNaN(res) ? def : res;
|
||||
}
|
||||
|
||||
export class BannedUser extends React.Component {
|
||||
static propTypes = {
|
||||
canUnban: PropTypes.bool,
|
||||
member: PropTypes.object.isRequired, // js-sdk RoomMember
|
||||
by: PropTypes.string.isRequired,
|
||||
reason: PropTypes.string,
|
||||
onUnbanned: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
_onUnbanClick = (e) => {
|
||||
MatrixClientPeg.get().unban(this.props.member.roomId, this.props.member.userId).then(() => {
|
||||
this.props.onUnbanned();
|
||||
}).catch((err) => {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
console.error("Failed to unban: " + err);
|
||||
Modal.createTrackedDialog('Failed to unban', '', ErrorDialog, {
|
||||
title: _t('Error'),
|
||||
description: _t('Failed to unban'),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
let unbanButton;
|
||||
|
||||
if (this.props.canUnban) {
|
||||
unbanButton = (
|
||||
<AccessibleButton kind='danger_sm' onClick={this._onUnbanClick}
|
||||
className='mx_RolesRoomSettingsTab_unbanBtn'>
|
||||
{ _t('Unban') }
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
const userId = this.props.member.name === this.props.member.userId ? null : this.props.member.userId;
|
||||
return (
|
||||
<li>
|
||||
{unbanButton}
|
||||
<span title={_t("Banned by %(displayName)s", {displayName: this.props.by})}>
|
||||
<strong>{ this.props.member.name }</strong> {userId}
|
||||
{this.props.reason ? " " + _t('Reason') + ": " + this.props.reason : ""}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default class RolesRoomSettingsTab extends React.Component {
|
||||
static propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
_populateDefaultPlEvents(eventsSection, stateLevel, eventsLevel) {
|
||||
for (const desiredEvent of Object.keys(plEventsToShow)) {
|
||||
if (!(desiredEvent in eventsSection)) {
|
||||
eventsSection[desiredEvent] = (plEventsToShow[desiredEvent].isState ? stateLevel : eventsLevel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const PowerSelector = sdk.getComponent('elements.PowerSelector');
|
||||
|
||||
const client = MatrixClientPeg.get();
|
||||
const room = client.getRoom(this.props.roomId);
|
||||
const plContent = room.currentState.getStateEvents('m.room.power_levels', '').getContent() || {};
|
||||
const canChangeLevels = room.currentState.mayClientSendStateEvent('m.room.power_levels', client);
|
||||
|
||||
const powerLevelDescriptors = {
|
||||
"users_default": {
|
||||
desc: _t('The default role for new room members is'),
|
||||
defaultValue: 0,
|
||||
},
|
||||
"events_default": {
|
||||
desc: _t('To send messages, you must be a'),
|
||||
defaultValue: 0,
|
||||
},
|
||||
"invite": {
|
||||
desc: _t('To invite users into the room, you must be a'),
|
||||
defaultValue: 50,
|
||||
},
|
||||
"state_default": {
|
||||
desc: _t('To configure the room, you must be a'),
|
||||
defaultValue: 50,
|
||||
},
|
||||
"kick": {
|
||||
desc: _t('To kick users, you must be a'),
|
||||
defaultValue: 50,
|
||||
},
|
||||
"ban": {
|
||||
desc: _t('To ban users, you must be a'),
|
||||
defaultValue: 50,
|
||||
},
|
||||
"redact": {
|
||||
desc: _t('To remove other users\' messages, you must be a'),
|
||||
defaultValue: 50,
|
||||
},
|
||||
"notifications.room": {
|
||||
desc: _t('To notify everyone in the room, you must be a'),
|
||||
defaultValue: 50,
|
||||
},
|
||||
};
|
||||
|
||||
const eventsLevels = plContent.events || {};
|
||||
const userLevels = plContent.users || {};
|
||||
const banLevel = parseIntWithDefault(plContent.ban, powerLevelDescriptors.ban.defaultValue);
|
||||
const defaultUserLevel = parseIntWithDefault(
|
||||
plContent.users_default,
|
||||
powerLevelDescriptors.users_default.defaultValue,
|
||||
);
|
||||
|
||||
let currentUserLevel = userLevels[client.getUserId()];
|
||||
if (currentUserLevel === undefined) {
|
||||
currentUserLevel = defaultUserLevel;
|
||||
}
|
||||
|
||||
this._populateDefaultPlEvents(
|
||||
eventsLevels,
|
||||
parseIntWithDefault(plContent.state_default, powerLevelDescriptors.state_default.defaultValue),
|
||||
parseIntWithDefault(plContent.events_default, powerLevelDescriptors.events_default.defaultValue),
|
||||
);
|
||||
|
||||
let privilegedUsersSection = <div>{_t('No users have specific privileges in this room')}</div>;
|
||||
let mutedUsersSection;
|
||||
if (Object.keys(userLevels).length) {
|
||||
const privilegedUsers = [];
|
||||
const mutedUsers = [];
|
||||
|
||||
Object.keys(userLevels).forEach(function(user) {
|
||||
if (userLevels[user] > defaultUserLevel) { // privileged
|
||||
privilegedUsers.push(<li key={user}>
|
||||
{ _t("%(user)s is a %(userRole)s", {
|
||||
user: user,
|
||||
userRole: <PowerSelector value={userLevels[user]} disabled={true} />,
|
||||
}) }
|
||||
</li>);
|
||||
} else if (userLevels[user] < defaultUserLevel) { // muted
|
||||
mutedUsers.push(<li key={user}>
|
||||
{ _t("%(user)s is a %(userRole)s", {
|
||||
user: user,
|
||||
userRole: <PowerSelector value={userLevels[user]} disabled={true} />,
|
||||
}) }
|
||||
</li>);
|
||||
}
|
||||
});
|
||||
|
||||
// comparator for sorting PL users lexicographically on PL descending, MXID ascending. (case-insensitive)
|
||||
const comparator = (a, b) => {
|
||||
const plDiff = userLevels[b.key] - userLevels[a.key];
|
||||
return plDiff !== 0 ? plDiff : a.key.toLocaleLowerCase().localeCompare(b.key.toLocaleLowerCase());
|
||||
};
|
||||
|
||||
privilegedUsers.sort(comparator);
|
||||
mutedUsers.sort(comparator);
|
||||
|
||||
if (privilegedUsers.length) {
|
||||
privilegedUsersSection =
|
||||
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
|
||||
<div className='mx_SettingsTab_subheading'>{ _t('Privileged Users') }</div>
|
||||
<ul>
|
||||
{privilegedUsers}
|
||||
</ul>
|
||||
</div>;
|
||||
}
|
||||
if (mutedUsers.length) {
|
||||
mutedUsersSection =
|
||||
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
|
||||
<div className='mx_SettingsTab_subheading'>{ _t('Muted Users') }</div>
|
||||
<ul>
|
||||
{mutedUsers}
|
||||
</ul>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
const banned = room.getMembersWithMembership("ban");
|
||||
let bannedUsersSection;
|
||||
if (banned.length) {
|
||||
const canBanUsers = currentUserLevel >= banLevel;
|
||||
bannedUsersSection =
|
||||
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
|
||||
<div className='mx_SettingsTab_subheading'>{ _t('Banned users') }</div>
|
||||
<ul>
|
||||
{banned.map((member) => {
|
||||
const banEvent = member.events.member.getContent();
|
||||
const sender = room.getMember(member.events.member.getSender());
|
||||
let bannedBy = member.events.member.getSender(); // start by falling back to mxid
|
||||
if (sender) bannedBy = sender.name;
|
||||
return (
|
||||
<BannedUser key={member.userId} canUnban={canBanUsers}
|
||||
member={member} reason={banEvent.reason}
|
||||
by={bannedBy} onUnbanned={this.forceUpdate} />
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
const powerSelectors = Object.keys(powerLevelDescriptors).map((key, index) => {
|
||||
const descriptor = powerLevelDescriptors[key];
|
||||
|
||||
const keyPath = key.split('.');
|
||||
let currentObj = plContent;
|
||||
for (const prop of keyPath) {
|
||||
if (currentObj === undefined) {
|
||||
break;
|
||||
}
|
||||
currentObj = currentObj[prop];
|
||||
}
|
||||
|
||||
const value = parseIntWithDefault(currentObj, descriptor.defaultValue);
|
||||
return <div key={index} className="">
|
||||
<span>{descriptor.desc} </span>
|
||||
<PowerSelector
|
||||
value={value}
|
||||
usersDefault={defaultUserLevel}
|
||||
controlled={false}
|
||||
disabled={!canChangeLevels || currentUserLevel < value}
|
||||
powerLevelKey={key} // Will be sent as the second parameter to `onChange`
|
||||
onChange={this.onPowerLevelsChanged}
|
||||
/>
|
||||
</div>;
|
||||
});
|
||||
|
||||
const eventPowerSelectors = Object.keys(eventsLevels).map(function(eventType, i) {
|
||||
let label = plEventsToLabels[eventType];
|
||||
if (label) {
|
||||
label = _t(label);
|
||||
} else {
|
||||
label = _t(
|
||||
"To send events of type <eventType/>, you must be a", {},
|
||||
{ 'eventType': <code>{ eventType }</code> },
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="" key={eventType}>
|
||||
<span>{label} </span>
|
||||
<PowerSelector
|
||||
value={eventsLevels[eventType]}
|
||||
usersDefault={defaultUserLevel}
|
||||
controlled={false}
|
||||
disabled={!canChangeLevels || currentUserLevel < eventsLevels[eventType]}
|
||||
powerLevelKey={"event_levels_" + eventType}
|
||||
onChange={self.onPowerLevelsChanged}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
let unfederatableSection;
|
||||
const createEvent = room.currentState.getStateEvents('m.room.create', '');
|
||||
if (createEvent && createEvent.getContent()['m.federate'] === false) {
|
||||
unfederatableSection = <div>{_t('This room is not accessible by remote Matrix servers')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_SettingsTab mx_RolesRoomSettingsTab">
|
||||
<div className="mx_SettingsTab_heading">{_t("Roles & Permissions")}</div>
|
||||
{privilegedUsersSection}
|
||||
{mutedUsersSection}
|
||||
{bannedUsersSection}
|
||||
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
|
||||
<span className='mx_SettingsTab_subheading'>{_t("Permissions")}</span>
|
||||
{powerSelectors}
|
||||
{eventPowerSelectors}
|
||||
{unfederatableSection}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -481,6 +481,33 @@
|
|||
"Room list": "Room list",
|
||||
"Timeline": "Timeline",
|
||||
"Autocomplete delay (ms)": "Autocomplete delay (ms)",
|
||||
"To change the room's avatar, you must be a": "To change the room's avatar, you must be a",
|
||||
"To change the room's name, you must be a": "To change the room's name, you must be a",
|
||||
"To change the room's main address, you must be a": "To change the room's main address, you must be a",
|
||||
"To change the room's history visibility, you must be a": "To change the room's history visibility, you must be a",
|
||||
"To change the permissions in the room, you must be a": "To change the permissions in the room, you must be a",
|
||||
"To change the topic, you must be a": "To change the topic, you must be a",
|
||||
"To modify widgets in the room, you must be a": "To modify widgets in the room, you must be a",
|
||||
"Failed to unban": "Failed to unban",
|
||||
"Unban": "Unban",
|
||||
"Banned by %(displayName)s": "Banned by %(displayName)s",
|
||||
"The default role for new room members is": "The default role for new room members is",
|
||||
"To send messages, you must be a": "To send messages, you must be a",
|
||||
"To invite users into the room, you must be a": "To invite users into the room, you must be a",
|
||||
"To configure the room, you must be a": "To configure the room, you must be a",
|
||||
"To kick users, you must be a": "To kick users, you must be a",
|
||||
"To ban users, you must be a": "To ban users, you must be a",
|
||||
"To remove other users' messages, you must be a": "To remove other users' messages, you must be a",
|
||||
"To notify everyone in the room, you must be a": "To notify everyone in the room, you must be a",
|
||||
"No users have specific privileges in this room": "No users have specific privileges in this room",
|
||||
"%(user)s is a %(userRole)s": "%(user)s is a %(userRole)s",
|
||||
"Privileged Users": "Privileged Users",
|
||||
"Muted Users": "Muted Users",
|
||||
"Banned users": "Banned users",
|
||||
"To send events of type <eventType/>, you must be a": "To send events of type <eventType/>, you must be a",
|
||||
"This room is not accessible by remote Matrix servers": "This room is not accessible by remote Matrix servers",
|
||||
"Roles & Permissions": "Roles & Permissions",
|
||||
"Permissions": "Permissions",
|
||||
"Unignore": "Unignore",
|
||||
"<not supported>": "<not supported>",
|
||||
"Import E2E room keys": "Import E2E room keys",
|
||||
|
@ -543,7 +570,6 @@
|
|||
"Disinvite this user?": "Disinvite this user?",
|
||||
"Kick this user?": "Kick this user?",
|
||||
"Failed to kick": "Failed to kick",
|
||||
"Unban": "Unban",
|
||||
"Ban": "Ban",
|
||||
"Unban this user?": "Unban this user?",
|
||||
"Ban this user?": "Ban this user?",
|
||||
|
@ -686,15 +712,6 @@
|
|||
"If you log out or use another device, you'll lose your secure message history. To prevent this, set up Secure Message Recovery.": "If you log out or use another device, you'll lose your secure message history. To prevent this, set up Secure Message Recovery.",
|
||||
"Secure Message Recovery": "Secure Message Recovery",
|
||||
"Don't ask again": "Don't ask again",
|
||||
"To change the room's avatar, you must be a": "To change the room's avatar, you must be a",
|
||||
"To change the room's name, you must be a": "To change the room's name, you must be a",
|
||||
"To change the room's main address, you must be a": "To change the room's main address, you must be a",
|
||||
"To change the room's history visibility, you must be a": "To change the room's history visibility, you must be a",
|
||||
"To change the permissions in the room, you must be a": "To change the permissions in the room, you must be a",
|
||||
"To change the topic, you must be a": "To change the topic, you must be a",
|
||||
"To modify widgets in the room, you must be a": "To modify widgets in the room, you must be a",
|
||||
"Failed to unban": "Failed to unban",
|
||||
"Banned by %(displayName)s": "Banned by %(displayName)s",
|
||||
"Privacy warning": "Privacy warning",
|
||||
"Changes to who can read history will only apply to future messages in this room": "Changes to who can read history will only apply to future messages in this room",
|
||||
"The visibility of existing history will be unchanged": "The visibility of existing history will be unchanged",
|
||||
|
@ -709,26 +726,11 @@
|
|||
"(warning: cannot be disabled again!)": "(warning: cannot be disabled again!)",
|
||||
"Encryption is enabled in this room": "Encryption is enabled in this room",
|
||||
"Encryption is not enabled in this room": "Encryption is not enabled in this room",
|
||||
"The default role for new room members is": "The default role for new room members is",
|
||||
"To send messages, you must be a": "To send messages, you must be a",
|
||||
"To invite users into the room, you must be a": "To invite users into the room, you must be a",
|
||||
"To configure the room, you must be a": "To configure the room, you must be a",
|
||||
"To kick users, you must be a": "To kick users, you must be a",
|
||||
"To ban users, you must be a": "To ban users, you must be a",
|
||||
"To remove other users' messages, you must be a": "To remove other users' messages, you must be a",
|
||||
"To notify everyone in the room, you must be a": "To notify everyone in the room, you must be a",
|
||||
"No users have specific privileges in this room": "No users have specific privileges in this room",
|
||||
"%(user)s is a %(userRole)s": "%(user)s is a %(userRole)s",
|
||||
"Privileged Users": "Privileged Users",
|
||||
"Muted Users": "Muted Users",
|
||||
"Banned users": "Banned users",
|
||||
"This room is not accessible by remote Matrix servers": "This room is not accessible by remote Matrix servers",
|
||||
"Favourite": "Favourite",
|
||||
"Tagged as: ": "Tagged as: ",
|
||||
"To link to a room it must have <a>an address</a>.": "To link to a room it must have <a>an address</a>.",
|
||||
"Guests cannot join this room even if explicitly invited.": "Guests cannot join this room even if explicitly invited.",
|
||||
"Click here to fix": "Click here to fix",
|
||||
"To send events of type <eventType/>, you must be a": "To send events of type <eventType/>, you must be a",
|
||||
"Upgrade room to version %(ver)s": "Upgrade room to version %(ver)s",
|
||||
"Open Devtools": "Open Devtools",
|
||||
"Who can access this room?": "Who can access this room?",
|
||||
|
@ -741,7 +743,6 @@
|
|||
"Members only (since the point in time of selecting this option)": "Members only (since the point in time of selecting this option)",
|
||||
"Members only (since they were invited)": "Members only (since they were invited)",
|
||||
"Members only (since they joined)": "Members only (since they joined)",
|
||||
"Permissions": "Permissions",
|
||||
"Internal room ID: ": "Internal room ID: ",
|
||||
"Room version number: ": "Room version number: ",
|
||||
"Add a topic": "Add a topic",
|
||||
|
@ -1063,7 +1064,6 @@
|
|||
"To help avoid duplicate issues, please <existingIssuesLink>view existing issues</existingIssuesLink> first (and add a +1) or <newIssueLink>create a new issue</newIssueLink> if you can't find it.": "To help avoid duplicate issues, please <existingIssuesLink>view existing issues</existingIssuesLink> first (and add a +1) or <newIssueLink>create a new issue</newIssueLink> if you can't find it.",
|
||||
"Report bugs & give feedback": "Report bugs & give feedback",
|
||||
"Go back": "Go back",
|
||||
"Roles & Permissions": "Roles & Permissions",
|
||||
"Visit old settings": "Visit old settings",
|
||||
"Failed to upgrade room": "Failed to upgrade room",
|
||||
"The room upgrade could not be completed": "The room upgrade could not be completed",
|
||||
|
|
Loading…
Reference in New Issue