One more attempt to fix DND on macOS.
This commit is contained in:
parent
1eff68813d
commit
32e650548f
|
@ -794,16 +794,16 @@ void NotificationData::notificationReplied(
|
|||
|
||||
} // namespace
|
||||
|
||||
bool SkipAudioForCustom() {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool SkipToastForCustom() {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool SkipFlashBounceForCustom() {
|
||||
return false;
|
||||
void MaybePlaySoundForCustom(Fn<void()> playSound) {
|
||||
playSound();
|
||||
}
|
||||
|
||||
void MaybeFlashBounceForCustom(Fn<void()> flashBounce) {
|
||||
flashBounce();
|
||||
}
|
||||
|
||||
bool WaitForInputForCustom() {
|
||||
|
@ -917,10 +917,7 @@ public:
|
|||
void clearFromHistory(not_null<History*> history);
|
||||
void clearFromSession(not_null<Main::Session*> session);
|
||||
void clearNotification(NotificationId id);
|
||||
|
||||
[[nodiscard]] bool inhibited() const {
|
||||
return _inhibited;
|
||||
}
|
||||
void invokeIfNotInhibited(Fn<void()> callback);
|
||||
|
||||
~Private();
|
||||
|
||||
|
@ -1154,6 +1151,12 @@ void Manager::Private::clearNotification(NotificationId id) {
|
|||
}
|
||||
}
|
||||
|
||||
void Manager::Private::invokeIfNotInhibited(Fn<void()> callback) {
|
||||
if (!_inhibited) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
Manager::Private::~Private() {
|
||||
clearAll();
|
||||
|
||||
|
@ -1215,16 +1218,16 @@ void Manager::doClearFromSession(not_null<Main::Session*> session) {
|
|||
_private->clearFromSession(session);
|
||||
}
|
||||
|
||||
bool Manager::doSkipAudio() const {
|
||||
return _private->inhibited();
|
||||
}
|
||||
|
||||
bool Manager::doSkipToast() const {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool Manager::doSkipFlashBounce() const {
|
||||
return _private->inhibited();
|
||||
void Manager::doMaybePlaySound(Fn<void()> playSound) {
|
||||
_private->invokeIfNotInhibited(std::move(playSound));
|
||||
}
|
||||
|
||||
void Manager::doMaybeFlashBounce(Fn<void()> flashBounce) {
|
||||
_private->invokeIfNotInhibited(std::move(flashBounce));
|
||||
}
|
||||
|
||||
} // namespace Notifications
|
||||
|
|
|
@ -33,9 +33,9 @@ protected:
|
|||
void doClearFromTopic(not_null<Data::ForumTopic*> topic) override;
|
||||
void doClearFromHistory(not_null<History*> history) override;
|
||||
void doClearFromSession(not_null<Main::Session*> session) override;
|
||||
bool doSkipAudio() const override;
|
||||
bool doSkipToast() const override;
|
||||
bool doSkipFlashBounce() const override;
|
||||
void doMaybePlaySound(Fn<void()> playSound) override;
|
||||
void doMaybeFlashBounce(Fn<void()> flashBounce) override;
|
||||
|
||||
private:
|
||||
class Private;
|
||||
|
|
|
@ -13,16 +13,16 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
|||
namespace Platform {
|
||||
namespace Notifications {
|
||||
|
||||
bool SkipAudioForCustom() {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool SkipToastForCustom() {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool SkipFlashBounceForCustom() {
|
||||
return false;
|
||||
void MaybePlaySoundForCustom(Fn<void()> playSound) {
|
||||
playSound();
|
||||
}
|
||||
|
||||
void MaybeFlashBounceForCustom(Fn<void()> flashBounce) {
|
||||
flashBounce();
|
||||
}
|
||||
|
||||
bool WaitForInputForCustom() {
|
||||
|
|
|
@ -34,9 +34,9 @@ protected:
|
|||
void doClearFromHistory(not_null<History*> history) override;
|
||||
void doClearFromSession(not_null<Main::Session*> session) override;
|
||||
QString accountNameSeparator() override;
|
||||
bool doSkipAudio() const override;
|
||||
bool doSkipToast() const override;
|
||||
bool doSkipFlashBounce() const override;
|
||||
void doMaybePlaySound(Fn<void()> playSound) override;
|
||||
void doMaybeFlashBounce(Fn<void()> flashBounce) override;
|
||||
|
||||
private:
|
||||
class Private;
|
||||
|
|
|
@ -27,17 +27,34 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
|||
|
||||
namespace {
|
||||
|
||||
static constexpr auto kQuerySettingsEachMs = 1000;
|
||||
auto DoNotDisturbEnabled = false;
|
||||
auto LastSettingsQueryMs = 0;
|
||||
constexpr auto kQuerySettingsEachMs = crl::time(1000);
|
||||
|
||||
crl::time LastSettingsQueryMs/* = 0*/;
|
||||
bool DoNotDisturbEnabled/* = false*/;
|
||||
|
||||
[[nodiscard]] bool ShouldQuerySettings() {
|
||||
const auto now = crl::now();
|
||||
if (LastSettingsQueryMs > 0 && now <= LastSettingsQueryMs + kQuerySettingsEachMs) {
|
||||
return false;
|
||||
}
|
||||
LastSettingsQueryMs = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
[[nodiscard]] QString LibraryPath() {
|
||||
static const auto result = [] {
|
||||
NSURL *url = [[NSFileManager defaultManager] URLForDirectory:NSLibraryDirectory inDomain:NSUserDomainMask appropriateForURL:nil create:NO error:nil];
|
||||
return url
|
||||
? QString::fromUtf8([[url path] fileSystemRepresentation])
|
||||
: QString();
|
||||
}();
|
||||
return result;
|
||||
}
|
||||
|
||||
void queryDoNotDisturbState() {
|
||||
auto ms = crl::now();
|
||||
if (LastSettingsQueryMs > 0 && ms <= LastSettingsQueryMs + kQuerySettingsEachMs) {
|
||||
if (!ShouldQuerySettings()) {
|
||||
return;
|
||||
}
|
||||
LastSettingsQueryMs = ms;
|
||||
|
||||
Boolean isKeyValid;
|
||||
const auto doNotDisturb = CFPreferencesGetAppBooleanValue(
|
||||
CFSTR("doNotDisturb"),
|
||||
|
@ -151,16 +168,16 @@ using Manager = Platform::Notifications::Manager;
|
|||
namespace Platform {
|
||||
namespace Notifications {
|
||||
|
||||
bool SkipAudioForCustom() {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool SkipToastForCustom() {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool SkipFlashBounceForCustom() {
|
||||
return false;
|
||||
void MaybePlaySoundForCustom(Fn<void()> playSound) {
|
||||
playSound();
|
||||
}
|
||||
|
||||
void MaybeFlashBounceForCustom(Fn<void()> flashBounce) {
|
||||
flashBounce();
|
||||
}
|
||||
|
||||
bool WaitForInputForCustom() {
|
||||
|
@ -207,6 +224,8 @@ public:
|
|||
void clearFromSession(not_null<Main::Session*> session);
|
||||
void updateDelegate();
|
||||
|
||||
void invokeIfNotFocused(Fn<void()> callback);
|
||||
|
||||
~Private();
|
||||
|
||||
private:
|
||||
|
@ -214,6 +233,7 @@ private:
|
|||
void putClearTask(Task task);
|
||||
|
||||
void clearingThreadLoop();
|
||||
void checkFocusState();
|
||||
|
||||
const uint64 _managerId = 0;
|
||||
QString _managerIdString;
|
||||
|
@ -249,6 +269,14 @@ private:
|
|||
ClearFinish>;
|
||||
std::vector<ClearTask> _clearingTasks;
|
||||
|
||||
QProcess _dnd;
|
||||
QProcess _focus;
|
||||
std::vector<Fn<void()>> _focusedCallbacks;
|
||||
bool _waitingDnd = false;
|
||||
bool _waitingFocus = false;
|
||||
bool _focused = false;
|
||||
bool _processesInited = false;
|
||||
|
||||
rpl::lifetime _lifetime;
|
||||
|
||||
};
|
||||
|
@ -457,6 +485,70 @@ void Manager::Private::updateDelegate() {
|
|||
[center setDelegate:_delegate];
|
||||
}
|
||||
|
||||
void Manager::Private::invokeIfNotFocused(Fn<void()> callback) {
|
||||
if (!Platform::IsMac11_0OrGreater()) {
|
||||
queryDoNotDisturbState();
|
||||
if (!DoNotDisturbEnabled) {
|
||||
callback();
|
||||
}
|
||||
} else if (Platform::IsMacStoreBuild() || LibraryPath().isEmpty()) {
|
||||
callback();
|
||||
} else if (!_focusedCallbacks.empty()) {
|
||||
_focusedCallbacks.push_back(std::move(callback));
|
||||
} else if (!ShouldQuerySettings()) {
|
||||
if (!_focused) {
|
||||
callback();
|
||||
}
|
||||
} else {
|
||||
if (!_processesInited) {
|
||||
_processesInited = true;
|
||||
QObject::connect(&_dnd, &QProcess::finished, [=] {
|
||||
_waitingDnd = false;
|
||||
checkFocusState();
|
||||
});
|
||||
QObject::connect(&_focus, &QProcess::finished, [=] {
|
||||
_waitingFocus = false;
|
||||
checkFocusState();
|
||||
});
|
||||
}
|
||||
const auto start = [](QProcess &process, QString keys) {
|
||||
auto arguments = QStringList()
|
||||
<< "-extract"
|
||||
<< keys
|
||||
<< "raw"
|
||||
<< "-o"
|
||||
<< "-"
|
||||
<< "--"
|
||||
<< (LibraryPath() + "/Preferences/com.apple.controlcenter.plist");
|
||||
DEBUG_LOG(("Focus Check: Started %1.").arg(u"plutil"_q + arguments.join(' ')));
|
||||
process.start(u"plutil"_q, arguments);
|
||||
};
|
||||
_focusedCallbacks.push_back(std::move(callback));
|
||||
_waitingFocus = _waitingDnd = true;
|
||||
start(_focus, u"NSStatusItem Visible FocusModes"_q);
|
||||
start(_dnd, u"NSStatusItem Visible DoNotDisturb"_q);
|
||||
}
|
||||
}
|
||||
|
||||
void Manager::Private::checkFocusState() {
|
||||
if (_waitingFocus || _waitingDnd) {
|
||||
return;
|
||||
}
|
||||
const auto istrue = [](QProcess &process) {
|
||||
const auto output = process.readAllStandardOutput();
|
||||
DEBUG_LOG(("Focus Check: %1").arg(output));
|
||||
const auto result = (output.trimmed() == u"true"_q);
|
||||
return result;
|
||||
};
|
||||
_focused = istrue(_focus) || istrue(_dnd);
|
||||
auto callbacks = base::take(_focusedCallbacks);
|
||||
if (!_focused) {
|
||||
for (const auto &callback : callbacks) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Manager::Private::~Private() {
|
||||
if (_clearingThread.joinable()) {
|
||||
putClearTask(ClearFinish());
|
||||
|
@ -517,17 +609,16 @@ QString Manager::accountNameSeparator() {
|
|||
return QString::fromUtf8(" \xE2\x86\x92 ");
|
||||
}
|
||||
|
||||
bool Manager::doSkipAudio() const {
|
||||
queryDoNotDisturbState();
|
||||
return DoNotDisturbEnabled;
|
||||
}
|
||||
|
||||
bool Manager::doSkipToast() const {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool Manager::doSkipFlashBounce() const {
|
||||
return doSkipAudio();
|
||||
void Manager::doMaybePlaySound(Fn<void()> playSound) {
|
||||
_private->invokeIfNotFocused(std::move(playSound));
|
||||
}
|
||||
|
||||
void Manager::doMaybeFlashBounce(Fn<void()> flashBounce) {
|
||||
_private->invokeIfNotFocused(std::move(flashBounce));
|
||||
}
|
||||
|
||||
} // namespace Notifications
|
||||
|
|
|
@ -12,9 +12,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
|||
namespace Platform {
|
||||
namespace Notifications {
|
||||
|
||||
[[nodiscard]] bool SkipAudioForCustom();
|
||||
[[nodiscard]] bool SkipToastForCustom();
|
||||
[[nodiscard]] bool SkipFlashBounceForCustom();
|
||||
void MaybePlaySoundForCustom(Fn<void()> playSound);
|
||||
void MaybeFlashBounceForCustom(Fn<void()> flashBounce);
|
||||
[[nodiscard]] bool WaitForInputForCustom();
|
||||
|
||||
[[nodiscard]] bool Supported();
|
||||
|
|
|
@ -53,6 +53,19 @@ namespace Notifications {
|
|||
#ifndef __MINGW32__
|
||||
namespace {
|
||||
|
||||
constexpr auto kQuerySettingsEachMs = 1000;
|
||||
|
||||
crl::time LastSettingsQueryMs/* = 0*/;
|
||||
|
||||
[[nodiscard]] bool ShouldQuerySettings() {
|
||||
const auto now = crl::now();
|
||||
if (LastSettingsQueryMs > 0 && now <= LastSettingsQueryMs + kQuerySettingsEachMs) {
|
||||
return false;
|
||||
}
|
||||
LastSettingsQueryMs = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::wstring NotificationTemplate(
|
||||
QString id,
|
||||
Window::Notifications::Manager::DisplayOptions options) {
|
||||
|
@ -333,24 +346,16 @@ void QueryUserNotificationState() {
|
|||
}
|
||||
}
|
||||
|
||||
static constexpr auto kQuerySettingsEachMs = 1000;
|
||||
crl::time LastSettingsQueryMs = 0;
|
||||
|
||||
void QuerySystemNotificationSettings() {
|
||||
auto ms = crl::now();
|
||||
if (LastSettingsQueryMs > 0 && ms <= LastSettingsQueryMs + kQuerySettingsEachMs) {
|
||||
if (!ShouldQuerySettings()) {
|
||||
return;
|
||||
}
|
||||
LastSettingsQueryMs = ms;
|
||||
QueryQuietHours();
|
||||
QueryFocusAssist();
|
||||
QueryUserNotificationState();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
#endif // !__MINGW32__
|
||||
|
||||
bool SkipAudioForCustom() {
|
||||
bool SkipSoundForCustom() {
|
||||
QuerySystemNotificationSettings();
|
||||
|
||||
return (UserNotificationState == QUNS_NOT_PRESENT)
|
||||
|
@ -358,6 +363,19 @@ bool SkipAudioForCustom() {
|
|||
|| Core::App().screenIsLocked();
|
||||
}
|
||||
|
||||
bool SkipFlashBounceForCustom() {
|
||||
return SkipToastForCustom();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
#endif // !__MINGW32__
|
||||
|
||||
void MaybePlaySoundForCustom(Fn<void()> playSound) {
|
||||
if (!SkipSoundForCustom()) {
|
||||
playSound();
|
||||
}
|
||||
}
|
||||
|
||||
bool SkipToastForCustom() {
|
||||
QuerySystemNotificationSettings();
|
||||
|
||||
|
@ -365,8 +383,10 @@ bool SkipToastForCustom() {
|
|||
|| (UserNotificationState == QUNS_RUNNING_D3D_FULL_SCREEN);
|
||||
}
|
||||
|
||||
bool SkipFlashBounceForCustom() {
|
||||
return SkipToastForCustom();
|
||||
void MaybeFlashBounceForCustom(Fn<void()> flashBounce) {
|
||||
if (!SkipFlashBounceForCustom()) {
|
||||
flashBounce();
|
||||
}
|
||||
}
|
||||
|
||||
bool WaitForInputForCustom() {
|
||||
|
@ -942,20 +962,26 @@ void Manager::onAfterNotificationActivated(
|
|||
_private->afterNotificationActivated(id, window);
|
||||
}
|
||||
|
||||
bool Manager::doSkipAudio() const {
|
||||
return SkipAudioForCustom()
|
||||
|| QuietHoursEnabled
|
||||
|| FocusAssistBlocks;
|
||||
}
|
||||
|
||||
bool Manager::doSkipToast() const {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool Manager::doSkipFlashBounce() const {
|
||||
return SkipFlashBounceForCustom()
|
||||
void Manager::doMaybePlaySound(Fn<void()> playSound) {
|
||||
const auto skip = SkipSoundForCustom()
|
||||
|| QuietHoursEnabled
|
||||
|| FocusAssistBlocks;
|
||||
if (!skip) {
|
||||
playSound();
|
||||
}
|
||||
}
|
||||
|
||||
void Manager::doMaybeFlashBounce(Fn<void()> flashBounce) {
|
||||
const auto skip = SkipFlashBounceForCustom()
|
||||
|| QuietHoursEnabled
|
||||
|| FocusAssistBlocks;
|
||||
if (!skip) {
|
||||
flashBounce();
|
||||
}
|
||||
}
|
||||
#endif // !__MINGW32__
|
||||
|
||||
|
|
|
@ -45,9 +45,9 @@ protected:
|
|||
void onAfterNotificationActivated(
|
||||
NotificationId id,
|
||||
not_null<Window::SessionController*> window) override;
|
||||
bool doSkipAudio() const override;
|
||||
bool doSkipToast() const override;
|
||||
bool doSkipFlashBounce() const override;
|
||||
void doMaybePlaySound(Fn<void()> playSound) override;
|
||||
void doMaybeFlashBounce(Fn<void()> flashBounce) override;
|
||||
|
||||
private:
|
||||
class Private;
|
||||
|
|
|
@ -574,22 +574,28 @@ void System::showNext() {
|
|||
}
|
||||
const auto &settings = Core::App().settings();
|
||||
if (alertThread) {
|
||||
if (settings.flashBounceNotify() && !_manager->skipFlashBounce()) {
|
||||
if (settings.flashBounceNotify()) {
|
||||
const auto peer = alertThread->peer();
|
||||
if (const auto window = Core::App().windowFor(peer)) {
|
||||
if (const auto handle = window->widget()->windowHandle()) {
|
||||
handle->alert(kSystemAlertDuration);
|
||||
// (handle, SLOT(_q_clearAlert())); in the future.
|
||||
if (const auto controller = window->sessionController()) {
|
||||
_manager->maybeFlashBounce(crl::guard(controller, [=] {
|
||||
if (const auto handle = window->widget()->windowHandle()) {
|
||||
handle->alert(kSystemAlertDuration);
|
||||
// (handle, SLOT(_q_clearAlert())); in the future.
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (settings.soundNotify() && !_manager->skipAudio()) {
|
||||
const auto track = lookupSound(
|
||||
&alertThread->owner(),
|
||||
alertThread->owner().notifySettings().sound(alertThread).id);
|
||||
track->playOnce();
|
||||
Media::Player::mixer()->suppressAll(track->getLengthMs());
|
||||
Media::Player::mixer()->scheduleFaderCallback();
|
||||
if (settings.soundNotify()) {
|
||||
const auto owner = &alertThread->owner();
|
||||
const auto id = owner->notifySettings().sound(alertThread).id;
|
||||
_manager->maybePlaySound(crl::guard(&owner->session(), [=] {
|
||||
const auto track = lookupSound(owner, id);
|
||||
track->playOnce();
|
||||
Media::Player::mixer()->suppressAll(track->getLengthMs());
|
||||
Media::Player::mixer()->scheduleFaderCallback();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -337,14 +337,14 @@ public:
|
|||
|
||||
[[nodiscard]] virtual ManagerType type() const = 0;
|
||||
|
||||
[[nodiscard]] bool skipAudio() const {
|
||||
return doSkipAudio();
|
||||
}
|
||||
[[nodiscard]] bool skipToast() const {
|
||||
return doSkipToast();
|
||||
}
|
||||
[[nodiscard]] bool skipFlashBounce() const {
|
||||
return doSkipFlashBounce();
|
||||
void maybePlaySound(Fn<void()> playSound) {
|
||||
doMaybePlaySound(std::move(playSound));
|
||||
}
|
||||
void maybeFlashBounce(Fn<void()> flashBounce) {
|
||||
doMaybeFlashBounce(std::move(flashBounce));
|
||||
}
|
||||
|
||||
virtual ~Manager() = default;
|
||||
|
@ -362,9 +362,9 @@ protected:
|
|||
virtual void doClearFromTopic(not_null<Data::ForumTopic*> topic) = 0;
|
||||
virtual void doClearFromHistory(not_null<History*> history) = 0;
|
||||
virtual void doClearFromSession(not_null<Main::Session*> session) = 0;
|
||||
virtual bool doSkipAudio() const = 0;
|
||||
virtual bool doSkipToast() const = 0;
|
||||
virtual bool doSkipFlashBounce() const = 0;
|
||||
[[nodiscard]] virtual bool doSkipToast() const = 0;
|
||||
virtual void doMaybePlaySound(Fn<void()> playSound) = 0;
|
||||
virtual void doMaybeFlashBounce(Fn<void()> flashBounce) = 0;
|
||||
[[nodiscard]] virtual bool forceHideDetails() const {
|
||||
return false;
|
||||
}
|
||||
|
@ -445,14 +445,14 @@ protected:
|
|||
}
|
||||
void doClearFromSession(not_null<Main::Session*> session) override {
|
||||
}
|
||||
bool doSkipAudio() const override {
|
||||
return false;
|
||||
}
|
||||
bool doSkipToast() const override {
|
||||
return false;
|
||||
}
|
||||
bool doSkipFlashBounce() const override {
|
||||
return false;
|
||||
void doMaybePlaySound(Fn<void()> playSound) override {
|
||||
playSound();
|
||||
}
|
||||
void doMaybeFlashBounce(Fn<void()> flashBounce) override {
|
||||
flashBounce();
|
||||
}
|
||||
|
||||
};
|
||||
|
|
|
@ -445,16 +445,16 @@ void Manager::doClearFromItem(not_null<HistoryItem*> item) {
|
|||
}
|
||||
}
|
||||
|
||||
bool Manager::doSkipAudio() const {
|
||||
return Platform::Notifications::SkipAudioForCustom();
|
||||
}
|
||||
|
||||
bool Manager::doSkipToast() const {
|
||||
return Platform::Notifications::SkipToastForCustom();
|
||||
}
|
||||
|
||||
bool Manager::doSkipFlashBounce() const {
|
||||
return Platform::Notifications::SkipFlashBounceForCustom();
|
||||
void Manager::doMaybePlaySound(Fn<void()> playSound) {
|
||||
Platform::Notifications::MaybePlaySoundForCustom(std::move(playSound));
|
||||
}
|
||||
|
||||
void Manager::doMaybeFlashBounce(Fn<void()> flashBounce) {
|
||||
Platform::Notifications::MaybeFlashBounceForCustom(std::move(flashBounce));
|
||||
}
|
||||
|
||||
void Manager::doUpdateAll() {
|
||||
|
|
|
@ -73,9 +73,9 @@ private:
|
|||
void doClearFromHistory(not_null<History*> history) override;
|
||||
void doClearFromSession(not_null<Main::Session*> session) override;
|
||||
void doClearFromItem(not_null<HistoryItem*> item) override;
|
||||
bool doSkipAudio() const override;
|
||||
bool doSkipToast() const override;
|
||||
bool doSkipFlashBounce() const override;
|
||||
void doMaybePlaySound(Fn<void()> playSound) override;
|
||||
void doMaybeFlashBounce(Fn<void()> flashBounce) override;
|
||||
|
||||
void showNextFromQueue();
|
||||
void unlinkFromShown(Notification *remove);
|
||||
|
|
Loading…
Reference in New Issue