// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- /* exported UnlockDialog */ const { AccountsService, Atk, Clutter, Gdm, Gio, GnomeDesktop, GLib, GObject, Meta, Shell, St } = imports.gi; const Background = imports.ui.background; const Layout = imports.ui.layout; const Main = imports.ui.main; const MessageTray = imports.ui.messageTray; const SwipeTracker = imports.ui.swipeTracker; const AuthPrompt = imports.gdm.authPrompt; // The timeout before going back automatically to the lock screen (in seconds) const IDLE_TIMEOUT = 2 * 60; // The timeout before showing the unlock hint (in seconds) const HINT_TIMEOUT = 4; const CROSSFADE_TIME = 300; const FADE_OUT_TRANSLATION = 200; const FADE_OUT_SCALE = 0.3; const BLUR_BRIGHTNESS = 0.55; const BLUR_SIGMA = 60; const SUMMARY_ICON_SIZE = 32; var NotificationsBox = GObject.registerClass({ Signals: { 'wake-up-screen': {} }, }, class NotificationsBox extends St.BoxLayout { _init() { super._init({ vertical: true, name: 'unlockDialogNotifications', style_class: 'unlock-dialog-notifications-container', }); this._scrollView = new St.ScrollView({ hscrollbar_policy: St.PolicyType.NEVER }); this._notificationBox = new St.BoxLayout({ vertical: true, style_class: 'unlock-dialog-notifications-container', }); this._scrollView.add_actor(this._notificationBox); this.add_child(this._scrollView); this._settings = new Gio.Settings({ schema_id: 'org.gnome.desktop.notifications', }); this._sources = new Map(); Main.messageTray.getSources().forEach(source => { this._sourceAdded(Main.messageTray, source, true); }); this._updateVisibility(); this._sourceAddedId = Main.messageTray.connect('source-added', this._sourceAdded.bind(this)); this.connect('destroy', this._onDestroy.bind(this)); } _onDestroy() { if (this._sourceAddedId) { Main.messageTray.disconnect(this._sourceAddedId); this._sourceAddedId = 0; } let items = this._sources.entries(); for (let [source, obj] of items) this._removeSource(source, obj); } _updateVisibility() { this._notificationBox.visible = this._notificationBox.get_children().some(a => a.visible); this.visible = this._notificationBox.visible; } _makeNotificationSource(source, box) { let sourceActor = new MessageTray.SourceActor(source, SUMMARY_ICON_SIZE); box.add_child(sourceActor); let textBox = new St.BoxLayout({ x_expand: true, y_expand: true, y_align: Clutter.ActorAlign.CENTER, }); box.add_child(textBox); let title = new St.Label({ text: source.title, style_class: 'unlock-dialog-notification-label', x_expand: true, x_align: Clutter.ActorAlign.START, }); textBox.add(title); let count = source.unseenCount; let countLabel = new St.Label({ text: count.toString(), visible: count > 1, style_class: 'unlock-dialog-notification-count-text', }); textBox.add(countLabel); box.visible = count !== 0; return [title, countLabel]; } _makeNotificationDetailedSource(source, box) { let sourceActor = new MessageTray.SourceActor(source, SUMMARY_ICON_SIZE); let sourceBin = new St.Bin({ child: sourceActor }); box.add(sourceBin); let textBox = new St.BoxLayout({ vertical: true }); box.add_child(textBox); let title = new St.Label({ text: source.title.replace(/\n/g, ' '), style_class: 'unlock-dialog-notification-label', }); textBox.add(title); let visible = false; for (let i = 0; i < source.notifications.length; i++) { let n = source.notifications[i]; if (n.acknowledged) continue; let body = ''; if (n.bannerBodyText) { const bodyText = n.bannerBodyText.replace(/\n/g, ' '); body = n.bannerBodyMarkup ? bodyText : GLib.markup_escape_text(bodyText, -1); } let label = new St.Label({ style_class: 'unlock-dialog-notification-count-text' }); label.clutter_text.set_markup('%s %s'.format(n.title, body)); textBox.add(label); visible = true; } box.visible = visible; return [title, null]; } _shouldShowDetails(source) { return source.policy.detailsInLockScreen || source.narrowestPrivacyScope === MessageTray.PrivacyScope.SYSTEM; } _updateSourceBoxStyle(source, obj, box) { let hasCriticalNotification = source.notifications.some(n => n.urgency === MessageTray.Urgency.CRITICAL); if (hasCriticalNotification !== obj.hasCriticalNotification) { obj.hasCriticalNotification = hasCriticalNotification; if (hasCriticalNotification) box.add_style_class_name('critical'); else box.remove_style_class_name('critical'); } } _showSource(source, obj, box) { if (obj.detailed) [obj.titleLabel, obj.countLabel] = this._makeNotificationDetailedSource(source, box); else [obj.titleLabel, obj.countLabel] = this._makeNotificationSource(source, box); box.visible = obj.visible && (source.unseenCount > 0); this._updateSourceBoxStyle(source, obj, box); } _wakeUpScreenForSource(source) { if (!this._settings.get_boolean('show-banners')) return; const obj = this._sources.get(source); if (obj?.sourceBox.visible) this.emit('wake-up-screen'); } _sourceAdded(tray, source, initial) { let obj = { visible: source.policy.showInLockScreen, detailed: this._shouldShowDetails(source), sourceDestroyId: 0, sourceCountChangedId: 0, sourceTitleChangedId: 0, sourceUpdatedId: 0, sourceBox: null, titleLabel: null, countLabel: null, hasCriticalNotification: false, }; obj.sourceBox = new St.BoxLayout({ style_class: 'unlock-dialog-notification-source', x_expand: true, }); this._showSource(source, obj, obj.sourceBox); this._notificationBox.add_child(obj.sourceBox); obj.sourceCountChangedId = source.connect('notify::count', () => { this._countChanged(source, obj); }); obj.sourceTitleChangedId = source.connect('notify::title', () => { this._titleChanged(source, obj); }); obj.policyChangedId = source.policy.connect('notify', (policy, pspec) => { if (pspec.name === 'show-in-lock-screen') this._visibleChanged(source, obj); else this._detailedChanged(source, obj); }); obj.sourceDestroyId = source.connect('destroy', () => { this._onSourceDestroy(source, obj); }); this._sources.set(source, obj); if (!initial) { // block scrollbars while animating, if they're not needed now let boxHeight = this._notificationBox.height; if (this._scrollView.height >= boxHeight) this._scrollView.vscrollbar_policy = St.PolicyType.NEVER; let widget = obj.sourceBox; let [, natHeight] = widget.get_preferred_height(-1); widget.height = 0; widget.ease({ height: natHeight, mode: Clutter.AnimationMode.EASE_OUT_QUAD, duration: 250, onComplete: () => { this._scrollView.vscrollbar_policy = St.PolicyType.AUTOMATIC; widget.set_height(-1); }, }); this._updateVisibility(); this._wakeUpScreenForSource(source); } } _titleChanged(source, obj) { obj.titleLabel.text = source.title; } _countChanged(source, obj) { // A change in the number of notifications may change whether we show // details. let newDetailed = this._shouldShowDetails(source); let oldDetailed = obj.detailed; obj.detailed = newDetailed; if (obj.detailed || oldDetailed !== newDetailed) { // A new notification was pushed, or a previous notification was destroyed. // Give up, and build the list again. obj.sourceBox.destroy_all_children(); obj.titleLabel = obj.countLabel = null; this._showSource(source, obj, obj.sourceBox); } else { let count = source.unseenCount; obj.countLabel.text = count.toString(); obj.countLabel.visible = count > 1; } obj.sourceBox.visible = obj.visible && (source.unseenCount > 0); this._updateVisibility(); this._wakeUpScreenForSource(source); } _visibleChanged(source, obj) { if (obj.visible === source.policy.showInLockScreen) return; obj.visible = source.policy.showInLockScreen; obj.sourceBox.visible = obj.visible && source.unseenCount > 0; this._updateVisibility(); this._wakeUpScreenForSource(source); } _detailedChanged(source, obj) { let newDetailed = this._shouldShowDetails(source); if (obj.detailed === newDetailed) return; obj.detailed = newDetailed; obj.sourceBox.destroy_all_children(); obj.titleLabel = obj.countLabel = null; this._showSource(source, obj, obj.sourceBox); } _onSourceDestroy(source, obj) { this._removeSource(source, obj); this._updateVisibility(); } _removeSource(source, obj) { obj.sourceBox.destroy(); obj.sourceBox = obj.titleLabel = obj.countLabel = null; source.disconnect(obj.sourceDestroyId); source.disconnect(obj.sourceCountChangedId); source.disconnect(obj.sourceTitleChangedId); source.policy.disconnect(obj.policyChangedId); this._sources.delete(source); } }); var Clock = GObject.registerClass( class UnlockDialogClock extends St.BoxLayout { _init() { super._init({ style_class: 'unlock-dialog-clock', vertical: true }); this._time = new St.Label({ style_class: 'unlock-dialog-clock-time', x_align: Clutter.ActorAlign.CENTER, }); this._date = new St.Label({ style_class: 'unlock-dialog-clock-date', x_align: Clutter.ActorAlign.CENTER, }); this._hint = new St.Label({ style_class: 'unlock-dialog-clock-hint', x_align: Clutter.ActorAlign.CENTER, opacity: 0, }); this.add_child(this._time); this.add_child(this._date); this.add_child(this._hint); this._wallClock = new GnomeDesktop.WallClock({ time_only: true }); this._wallClock.connect('notify::clock', this._updateClock.bind(this)); this._seat = Clutter.get_default_backend().get_default_seat(); this._touchModeChangedId = this._seat.connect('notify::touch-mode', this._updateHint.bind(this)); this._monitorManager = Meta.MonitorManager.get(); this._powerModeChangedId = this._monitorManager.connect( 'power-save-mode-changed', () => (this._hint.opacity = 0)); this._idleMonitor = global.backend.get_core_idle_monitor(); this._idleWatchId = this._idleMonitor.add_idle_watch(HINT_TIMEOUT * 1000, () => { this._hint.ease({ opacity: 255, duration: CROSSFADE_TIME, }); }); this._updateClock(); this._updateHint(); this.connect('destroy', this._onDestroy.bind(this)); } _updateClock() { this._time.text = this._wallClock.clock; let date = new Date(); /* Translators: This is a time format for a date in long format */ let dateFormat = Shell.util_translate_time_string(N_('%A %B %-d')); this._date.text = date.toLocaleFormat(dateFormat); } _updateHint() { this._hint.text = this._seat.touch_mode ? _('Swipe up to unlock') : _('Click or press a key to unlock'); } _onDestroy() { this._wallClock.run_dispose(); this._seat.disconnect(this._touchModeChangedId); this._idleMonitor.remove_watch(this._idleWatchId); this._monitorManager.disconnect(this._powerModeChangedId); } }); var UnlockDialogLayout = GObject.registerClass( class UnlockDialogLayout extends Clutter.LayoutManager { _init(stack, notifications, switchUserButton) { super._init(); this._stack = stack; this._notifications = notifications; this._switchUserButton = switchUserButton; } vfunc_get_preferred_width(container, forHeight) { return this._stack.get_preferred_width(forHeight); } vfunc_get_preferred_height(container, forWidth) { return this._stack.get_preferred_height(forWidth); } vfunc_allocate(container, box) { let [width, height] = box.get_size(); let tenthOfHeight = height / 10.0; let thirdOfHeight = height / 3.0; let [, , stackWidth, stackHeight] = this._stack.get_preferred_size(); let [, , notificationsWidth, notificationsHeight] = this._notifications.get_preferred_size(); let columnWidth = Math.max(stackWidth, notificationsWidth); let columnX1 = Math.floor((width - columnWidth) / 2.0); let actorBox = new Clutter.ActorBox(); // Notifications let maxNotificationsHeight = Math.min( notificationsHeight, height - tenthOfHeight - stackHeight); actorBox.x1 = columnX1; actorBox.y1 = height - maxNotificationsHeight; actorBox.x2 = columnX1 + columnWidth; actorBox.y2 = actorBox.y1 + maxNotificationsHeight; this._notifications.allocate(actorBox); // Authentication Box let stackY = Math.min( thirdOfHeight, height - stackHeight - maxNotificationsHeight); actorBox.x1 = columnX1; actorBox.y1 = stackY; actorBox.x2 = columnX1 + columnWidth; actorBox.y2 = stackY + stackHeight; this._stack.allocate(actorBox); // Switch User button if (this._switchUserButton.visible) { let [, , natWidth, natHeight] = this._switchUserButton.get_preferred_size(); const textDirection = this._switchUserButton.get_text_direction(); if (textDirection === Clutter.TextDirection.RTL) actorBox.x1 = box.x1 + natWidth; else actorBox.x1 = box.x2 - (natWidth * 2); actorBox.y1 = box.y2 - (natHeight * 2); actorBox.x2 = actorBox.x1 + natWidth; actorBox.y2 = actorBox.y1 + natHeight; this._switchUserButton.allocate(actorBox); } } }); var UnlockDialog = GObject.registerClass({ Signals: { 'failed': {}, 'wake-up-screen': {}, }, }, class UnlockDialog extends St.Widget { _init(parentActor) { super._init({ accessible_role: Atk.Role.WINDOW, style_class: 'unlock-dialog', visible: false, reactive: true, }); parentActor.add_child(this); this._gdmClient = new Gdm.Client(); try { this._gdmClient.set_enabled_extensions([ Gdm.UserVerifierChoiceList.interface_info().name, ]); } catch (e) { } this._adjustment = new St.Adjustment({ actor: this, lower: 0, upper: 2, page_size: 1, page_increment: 1, }); this._adjustment.connect('notify::value', () => { this._setTransitionProgress(this._adjustment.value); }); this._swipeTracker = new SwipeTracker.SwipeTracker(this, Clutter.Orientation.VERTICAL, Shell.ActionMode.UNLOCK_SCREEN); this._swipeTracker.connect('begin', this._swipeBegin.bind(this)); this._swipeTracker.connect('update', this._swipeUpdate.bind(this)); this._swipeTracker.connect('end', this._swipeEnd.bind(this)); this.connect('scroll-event', (o, event) => { if (this._swipeTracker.canHandleScrollEvent(event)) return Clutter.EVENT_PROPAGATE; let direction = event.get_scroll_direction(); if (direction === Clutter.ScrollDirection.UP) this._showClock(); else if (direction === Clutter.ScrollDirection.DOWN) this._showPrompt(); return Clutter.EVENT_STOP; }); this._activePage = null; let tapAction = new Clutter.TapAction(); tapAction.connect('tap', this._showPrompt.bind(this)); this.add_action(tapAction); // Background this._backgroundGroup = new Clutter.Actor(); this.add_child(this._backgroundGroup); this._bgManagers = []; const themeContext = St.ThemeContext.get_for_stage(global.stage); this._scaleChangedId = themeContext.connect('notify::scale-factor', () => this._updateBackgroundEffects()); this._updateBackgrounds(); this._monitorsChangedId = Main.layoutManager.connect('monitors-changed', this._updateBackgrounds.bind(this)); this._userManager = AccountsService.UserManager.get_default(); this._userName = GLib.get_user_name(); this._user = this._userManager.get_user(this._userName); // Authentication & Clock stack this._stack = new Shell.Stack(); this._promptBox = new St.BoxLayout({ vertical: true }); this._promptBox.set_pivot_point(0.5, 0.5); this._promptBox.hide(); this._stack.add_child(this._promptBox); this._clock = new Clock(); this._clock.set_pivot_point(0.5, 0.5); this._stack.add_child(this._clock); this._showClock(); this.allowCancel = false; Main.ctrlAltTabManager.addGroup(this, _('Unlock Window'), 'dialog-password-symbolic'); // Notifications this._notificationsBox = new NotificationsBox(); this._notificationsBox.connect('wake-up-screen', () => this.emit('wake-up-screen')); // Switch User button this._otherUserButton = new St.Button({ style_class: 'modal-dialog-button button switch-user-button', accessible_name: _('Log in as another user'), button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE, reactive: false, opacity: 0, x_align: Clutter.ActorAlign.END, y_align: Clutter.ActorAlign.END, child: new St.Icon({ icon_name: 'system-users-symbolic' }), }); this._otherUserButton.set_pivot_point(0.5, 0.5); this._otherUserButton.connect('clicked', this._otherUserClicked.bind(this)); this._screenSaverSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.screensaver' }); this._userSwitchEnabledId = this._screenSaverSettings.connect('changed::user-switch-enabled', this._updateUserSwitchVisibility.bind(this)); this._lockdownSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.lockdown' }); this._lockdownSettings.connect('changed::disable-user-switching', this._updateUserSwitchVisibility.bind(this)); this._userLoadedId = this._user.connect('notify::is-loaded', this._updateUserSwitchVisibility.bind(this)); this._updateUserSwitchVisibility(); // Main Box let mainBox = new St.Widget(); mainBox.add_constraint(new Layout.MonitorConstraint({ primary: true })); mainBox.add_child(this._stack); mainBox.add_child(this._notificationsBox); mainBox.add_child(this._otherUserButton); mainBox.layout_manager = new UnlockDialogLayout( this._stack, this._notificationsBox, this._otherUserButton); this.add_child(mainBox); this._idleMonitor = global.backend.get_core_idle_monitor(); this._idleWatchId = this._idleMonitor.add_idle_watch(IDLE_TIMEOUT * 1000, this._escape.bind(this)); this.connect('destroy', this._onDestroy.bind(this)); } vfunc_key_press_event(keyEvent) { if (this._activePage === this._promptBox || (this._promptBox && this._promptBox.visible)) return Clutter.EVENT_PROPAGATE; const { keyval } = keyEvent; if (keyval === Clutter.KEY_Shift_L || keyval === Clutter.KEY_Shift_R || keyval === Clutter.KEY_Shift_Lock || keyval === Clutter.KEY_Caps_Lock) return Clutter.EVENT_PROPAGATE; let unichar = keyEvent.unicode_value; this._showPrompt(); if (GLib.unichar_isgraph(unichar)) this._authPrompt.addCharacter(unichar); return Clutter.EVENT_PROPAGATE; } _createBackground(monitorIndex) { let monitor = Main.layoutManager.monitors[monitorIndex]; let widget = new St.Widget({ style_class: 'screen-shield-background', x: monitor.x, y: monitor.y, width: monitor.width, height: monitor.height, effect: new Shell.BlurEffect({ name: 'blur' }), }); let bgManager = new Background.BackgroundManager({ container: widget, monitorIndex, controlPosition: false, }); this._bgManagers.push(bgManager); this._backgroundGroup.add_child(widget); } _updateBackgroundEffects() { const themeContext = St.ThemeContext.get_for_stage(global.stage); for (const widget of this._backgroundGroup) { const effect = widget.get_effect('blur'); if (effect) { effect.set({ brightness: BLUR_BRIGHTNESS, sigma: BLUR_SIGMA * themeContext.scale_factor, }); } } } _updateBackgrounds() { for (let i = 0; i < this._bgManagers.length; i++) this._bgManagers[i].destroy(); this._bgManagers = []; this._backgroundGroup.destroy_all_children(); for (let i = 0; i < Main.layoutManager.monitors.length; i++) this._createBackground(i); this._updateBackgroundEffects(); } _ensureAuthPrompt() { if (!this._authPrompt) { this._authPrompt = new AuthPrompt.AuthPrompt(this._gdmClient, AuthPrompt.AuthPromptMode.UNLOCK_ONLY); this._authPrompt.connect('failed', this._fail.bind(this)); this._authPrompt.connect('cancelled', this._fail.bind(this)); this._authPrompt.connect('reset', this._onReset.bind(this)); this._promptBox.add_child(this._authPrompt); } this._authPrompt.reset(); this._authPrompt.updateSensitivity(true); } _maybeDestroyAuthPrompt() { let focus = global.stage.key_focus; if (focus === null || (this._authPrompt && this._authPrompt.contains(focus)) || (this._otherUserButton && focus === this._otherUserButton)) this.grab_key_focus(); if (this._authPrompt) { this._authPrompt.destroy(); this._authPrompt = null; } } _showClock() { if (this._activePage === this._clock) return; this._activePage = this._clock; this._adjustment.ease(0, { duration: CROSSFADE_TIME, mode: Clutter.AnimationMode.EASE_OUT_QUAD, onComplete: () => this._maybeDestroyAuthPrompt(), }); } _showPrompt() { this._ensureAuthPrompt(); if (this._activePage === this._promptBox) return; this._activePage = this._promptBox; this._adjustment.ease(1, { duration: CROSSFADE_TIME, mode: Clutter.AnimationMode.EASE_OUT_QUAD, }); } _setTransitionProgress(progress) { this._promptBox.visible = progress > 0; this._clock.visible = progress < 1; this._otherUserButton.set({ reactive: progress > 0, can_focus: progress > 0, }); const { scaleFactor } = St.ThemeContext.get_for_stage(global.stage); this._promptBox.set({ opacity: 255 * progress, scale_x: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * progress, scale_y: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * progress, translation_y: FADE_OUT_TRANSLATION * (1 - progress) * scaleFactor, }); this._clock.set({ opacity: 255 * (1 - progress), scale_x: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * (1 - progress), scale_y: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * (1 - progress), translation_y: -FADE_OUT_TRANSLATION * progress * scaleFactor, }); this._otherUserButton.set({ opacity: 255 * progress, scale_x: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * progress, scale_y: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * progress, }); } _fail() { this._showClock(); this.emit('failed'); } _onReset(authPrompt, beginRequest) { let userName; if (beginRequest == AuthPrompt.BeginRequestType.PROVIDE_USERNAME) { this._authPrompt.setUser(this._user); userName = this._userName; } else { userName = null; } this._authPrompt.begin({ userName }); } _escape() { if (this._authPrompt && this.allowCancel) this._authPrompt.cancel(); } _swipeBegin(tracker, monitor) { if (monitor !== Main.layoutManager.primaryIndex) return; this._adjustment.remove_transition('value'); this._ensureAuthPrompt(); let progress = this._adjustment.value; tracker.confirmSwipe(this._stack.height, [0, 1], progress, Math.round(progress)); } _swipeUpdate(tracker, progress) { this._adjustment.value = progress; } _swipeEnd(tracker, duration, endProgress) { this._activePage = endProgress ? this._promptBox : this._clock; this._adjustment.ease(endProgress, { mode: Clutter.AnimationMode.EASE_OUT_CUBIC, duration, onComplete: () => { if (this._activePage === this._clock) this._maybeDestroyAuthPrompt(); }, }); } _otherUserClicked() { Gdm.goto_login_session_sync(null); this._authPrompt.cancel(); } _onDestroy() { this.popModal(); if (this._idleWatchId) { this._idleMonitor.remove_watch(this._idleWatchId); this._idleWatchId = 0; } if (this._monitorsChangedId) { Main.layoutManager.disconnect(this._monitorsChangedId); delete this._monitorsChangedId; } let themeContext = St.ThemeContext.get_for_stage(global.stage); if (this._scaleChangedId) { themeContext.disconnect(this._scaleChangedId); delete this._scaleChangedId; } if (this._gdmClient) { this._gdmClient = null; delete this._gdmClient; } if (this._userLoadedId) { this._user.disconnect(this._userLoadedId); this._userLoadedId = 0; } if (this._userSwitchEnabledId) { this._screenSaverSettings.disconnect(this._userSwitchEnabledId); this._userSwitchEnabledId = 0; } } _updateUserSwitchVisibility() { this._otherUserButton.visible = this._userManager.can_switch() && this._screenSaverSettings.get_boolean('user-switch-enabled') && !this._lockdownSettings.get_boolean('disable-user-switching'); } cancel() { if (this._authPrompt) this._authPrompt.cancel(); } finish(onComplete) { if (!this._authPrompt) { onComplete(); return; } this._authPrompt.finish(onComplete); } open(timestamp) { this.show(); if (this._isModal) return true; let modalParams = { timestamp, actionMode: Shell.ActionMode.UNLOCK_SCREEN, }; let grab = Main.pushModal(this, modalParams); if (grab.get_seat_state() !== Clutter.GrabState.ALL) { Main.popModal(grab); return false; } this._grab = grab; this._isModal = true; return true; } activate() { this._showPrompt(); } popModal(timestamp) { if (this._isModal) { Main.popModal(this._grab, timestamp); this._grab = null; this._isModal = false; } } });