diff --git a/webroot/static/scripts/bug.js b/webroot/static/scripts/bug.js index 1860f96..009096f 100644 --- a/webroot/static/scripts/bug.js +++ b/webroot/static/scripts/bug.js @@ -197,6 +197,37 @@ BzDeck.bug.set_bug_tooltips = function ($bug, bug) { } }; +BzDeck.bug.update = function ($bug, bug, changes) { + let $timeline = $bug.querySelector('.bug-timeline'); + + if ($timeline) { + let $parent = $timeline.querySelector('section, .scrollable-area-content'), + $entry = BzDeck.timeline.create_entry($timeline.id, changes); + + if (BzDeck.data.prefs['ui.timeline.sort.order'] === 'descending') { + $parent.insertBefore($entry, $timeline.querySelector('[itemprop="comment"]')); + } else { + $parent.appendChild($entry); + } + } + + if (changes.has('attachment') && $bug.querySelector('[data-field="attachments"]')) { + BzDeck.DetailsPage.attachments.render($bug, [changes.get('attachment')], true); + } + + if (changes.has('history') && $bug.querySelector('[data-field="history"]')) { + let _bug = { 'id': bug.id, '_update_needed': true }; + + // Prep partial data + for (let change in changes.get('history').changes) { + _bug[change.field_name] = bug[change.field_name]; + } + + BzDeck.bug.fill_data($bug, _bug, true); + BzDeck.DetailsPage.history.render($bug, [changes.get('history')], true); + } +}; + /* ---------------------------------------------------------------------------------------------- * Timeline * ---------------------------------------------------------------------------------------------- */ @@ -518,3 +549,136 @@ BzDeck.timeline.handle_keydown = function (event) { return FlareTail.util.event.ignore(event); }; + +/* ---------------------------------------------------------------------------------------------- + * Bugzilla Push Notifications support + * https://wiki.mozilla.org/BMO/ChangeNotificationSystem + * ---------------------------------------------------------------------------------------------- */ + +BzDeck.bugzfeed = { + subscription: new Set() +}; + +BzDeck.bugzfeed.connect = function () { + let endpoint = BzDeck.options.api.endpoints.websocket; + + if (!endpoint || !navigator.onLine) { + return; + } + + this.websocket = new WebSocket(endpoint); + + this.websocket.addEventListener('open', event => { + if (this.reconnector) { + window.clearInterval(this.reconnector); + } + + // Subscribe bugs once (re)connected + if (this.subscription.size) { + this.subscribe([...this.subscription]); + } + }); + + this.websocket.addEventListener('close', event => { + // Try to reconnect every 30 seconds when unexpectedly disconnected + if (event.code !== 1000) { + this.reconnector = window.setInterval(() => this.connect(), 30000); + } + }); + + this.websocket.addEventListener('message', event => { + let message = JSON.parse(event.data) + + if (message.command === 'update') { + this.get_changes(message); + } + }); +}; + +BzDeck.bugzfeed.send = function (command, bugs) { + if (this.websocket.readyState === 1) { + this.websocket.send(JSON.stringify({ 'command': command, 'bugs': bugs })); + } +}; + +BzDeck.bugzfeed.subscribe = function (bugs) { + for (let bug of bugs) { + this.subscription.add(bug); + } + + this.send('subscribe', bugs); +}; + +BzDeck.bugzfeed.unsubscribe = function (bugs) { + for (let bug of bugs) { + this.subscription.delete(bug); + } + + this.send('unsubscribe', bugs); +}; + +BzDeck.bugzfeed.get_changes = function (message) { + let api = BzDeck.options.api, + id = message.bug, + time = new Date(message.when), + params = new URLSearchParams(); + + params.append('include_fields', [...api.default_fields, ...api.extra_fields].join()); + params.append('exclude_fields', 'attachments.data'); + + BzDeck.core.request('GET', 'bug/' + id, params, null, bug => { + if (!bug || !bug.comments) { + return; + } + + let get_change = (field, time_field = 'creation_time') => + [for (item of bug[field] || []) if (new Date(item[time_field]) - time === 0) item][0], + changes = new Map(), + comment = get_change('comments'), + attachment = get_change('attachments'), + history = get_change('history', 'change_time'); + + if (comment) { + changes.set('comment', comment); + } + + if (attachment) { + changes.set('attachment', attachment); + } + + if (history) { + changes.set('history', history); + } + + this.save_changes(bug, changes); + + FlareTail.util.event.dispatch(window, 'bug:updated', { 'detail': { + 'bug': bug, + 'changes': changes + }}); + }); +}; + +BzDeck.bugzfeed.save_changes = function (bug, changes) { + BzDeck.model.get_bug_by_id(bug.id, cache => { + if (changes.has('comment')) { + cache.comments.push(changes.get('comment')); + } + + if (changes.has('attachment')) { + cache.attachments = cache.attachments || []; + cache.attachments.push(changes.get('attachment')); + } + + if (changes.has('history')) { + cache.history = cache.history || []; + cache.history.push(changes.get('history')); + + for (let change in changes.get('history').changes) { + cache[change.field_name] = bug[change.field_name]; + } + } + + BzDeck.model.save_bug(cache); + }); +}; diff --git a/webroot/static/scripts/bzdeck.js b/webroot/static/scripts/bzdeck.js index 542b9eb..05bc1e6 100644 --- a/webroot/static/scripts/bzdeck.js +++ b/webroot/static/scripts/bzdeck.js @@ -19,7 +19,10 @@ BzDeck.data = {}; BzDeck.options = { api: { - endpoint: 'https://api-dev.bugzilla.mozilla.org/latest/', + endpoints: { + rest: 'https://api-dev.bugzilla.mozilla.org/latest/', + websocket: 'ws://bugzfeed.mozilla.org/' + }, extra_fields: [ 'attachments', 'blocks', 'cc', 'comments', 'depends_on', 'dupe_of', 'flags', 'groups', 'history', 'is_cc_accessible', 'is_confirmed', 'is_creator_accessible', 'see_also', @@ -401,6 +404,9 @@ BzDeck.bootstrap.finish = function () { // Register the app for an activity on Firefox OS BzDeck.global.register_activity_handler(); + // Connect to the push notification server + BzDeck.bugzfeed.connect(); + BzDeck.global.show_status('Loading complete.'); // l10n BzDeck.session.login(); this.processing = false; @@ -753,7 +759,7 @@ BzDeck.core.request = function (method, path, params, data, callback, auth = fal } let xhr = new XMLHttpRequest(), - url = new URL(BzDeck.options.api.endpoint); + url = new URL(BzDeck.options.api.endpoints.rest); params = params || new URLSearchParams(); diff --git a/webroot/static/scripts/details.js b/webroot/static/scripts/details.js index b0d2841..30963d7 100644 --- a/webroot/static/scripts/details.js +++ b/webroot/static/scripts/details.js @@ -42,6 +42,8 @@ BzDeck.DetailsPage = function (id, bug_list = []) { this.open(bug); }); + + BzDeck.bugzfeed.subscribe([id]); }; BzDeck.DetailsPage.prototype.open = function (bug, bug_list = []) { @@ -77,6 +79,12 @@ BzDeck.DetailsPage.prototype.open = function (bug, bug_list = []) { $tabpanel.querySelector('[role="checkbox"][data-field="_starred"]') .setAttribute('aria-checked', event.detail.ids.has(bug.id)); }); + + window.addEventListener('bug:updated', event => { + if ($tabpanel && this.data.id === event.detail.bug.id) { + BzDeck.bug.update(this.view.$bug, event.detail.bug, event.detail.changes); + } + }); }; BzDeck.DetailsPage.prototype.prep_tabpanel = function (bug) { diff --git a/webroot/static/scripts/home.js b/webroot/static/scripts/home.js index fe3d6c1..f7c7db6 100644 --- a/webroot/static/scripts/home.js +++ b/webroot/static/scripts/home.js @@ -189,6 +189,8 @@ BzDeck.HomePage = function () { FlareTail.util.event.async(() => { this.show_preview(oldval, newval); }); + + BzDeck.bugzfeed.subscribe([newval]); } obj[prop] = newval; @@ -213,6 +215,13 @@ BzDeck.HomePage = function () { $row.setAttribute('data-unread', event.detail.ids.has(Number.parseInt($row.dataset.id))); } }); + + window.addEventListener('bug:updated', event => { + if (this.data.preview_id === event.detail.bug.id) { + BzDeck.bug.update(document.querySelector('#home-preview-bug'), + event.detail.bug, event.detail.changes); + } + }); }; BzDeck.HomePage.prototype.show_preview = function (oldval, newval) { diff --git a/webroot/static/scripts/search.js b/webroot/static/scripts/search.js index 93fd601..c08c277 100644 --- a/webroot/static/scripts/search.js +++ b/webroot/static/scripts/search.js @@ -51,10 +51,14 @@ BzDeck.SearchPage = function () { return; } - if (prop === 'preview_id' && !FlareTail.util.device.type.startsWith('mobile')) { - FlareTail.util.event.async(() => { - this.show_preview(oldval, newval); - }); + if (prop === 'preview_id') { + if (!FlareTail.util.device.type.startsWith('mobile')) { + FlareTail.util.event.async(() => { + this.show_preview(oldval, newval); + }); + } + + BzDeck.bugzfeed.subscribe([newval]); } obj[prop] = newval; @@ -91,6 +95,12 @@ BzDeck.SearchPage = function () { $tabpanel.querySelector('[role="article"] [role="checkbox"][data-field="_starred"]') .setAttribute('aria-checked', event.detail.ids.has(this.data.preview_id)); }); + + window.addEventListener('bug:updated', event => { + if ($tabpanel && this.data.preview_id === event.detail.bug.id) { + BzDeck.bug.update($tabpanel.querySelector('article'), event.detail.bug, event.detail.changes); + } + }); }; BzDeck.SearchPage.prototype.setup_toolbar = function () {