Sitic has uploaded a new change for review. https://gerrit.wikimedia.org/r/222222
Change subject: Add cross-wiki notifications ...................................................................... Add cross-wiki notifications Shows notifications if there are new ones. Bug: T103678 Change-Id: Ifb06262bfc6916884301e028675f84daee7085be --- M backend/celery/api.py M backend/celery/tasks.py M backend/server/__init__.py M frontend/src/app/index.css M frontend/src/app/index.js M frontend/src/app/main/main.html M frontend/src/app/runBlock.js M frontend/src/app/services.js A frontend/src/components/notifications/notifications.controller.js A frontend/src/components/notifications/notifications.html M frontend/src/components/watchlist/watchlist.controller.js M frontend/src/components/watchlist/watchlist.html M frontend/src/i18n/locale-en.json 13 files changed, 271 insertions(+), 45 deletions(-) git pull ssh://gerrit.wikimedia.org:29418/labs/tools/crosswatch refs/changes/22/222222/1 diff --git a/backend/celery/api.py b/backend/celery/api.py index 987c4b6..3ed8643 100644 --- a/backend/celery/api.py +++ b/backend/celery/api.py @@ -55,19 +55,22 @@ time = now + delta return time.strftime("%Y%m%d%H%M%S") + def handle_response(self, response): + if 'error' in response: + logger.error(response['error']) + if response['error']['code'] == "mwoauth-invalid-authorization": + raise Exception("OAuth authentication failed") + + raise Exception(str(response['error'])) + if 'warnings' in response: + logger.warn("API-request warning: " + str(response['warnings'])) + def query(self, params): params['format'] = "json" response = requests.get(self.api_url, params=params, auth=self.auth, headers=self.headers).json() - if 'error' in response: - logger.error(response['error']['code']) - if response['error']['code'] == "mwoauth-invalid-authorization": - raise Exception("OAuth authentication failed") - - raise Exception(str(response['error']['code'])) - if 'warnings' in response: - logger.warn("API-request warning: " + str(response['warnings'])) + self.handle_response(response) return response def query_gen(self, params): @@ -91,6 +94,29 @@ break last_continue = response['continue'] + def post(self, params, payload, token_type='csrf'): + params['format'] = "json" + token = self.get_token(token_type) + payload['token'] = token + + print params + print payload + response = requests.post(self.api_url, + params=params, + data=payload, + auth=self.auth, + headers=self.headers) + + self.handle_response(json.loads(response.text)) + + def get_token(self, type='csrf'): + params = {'action': "query", + 'meta': "tokens", + 'type': type} + r = self.query(params) + token = r['query']['tokens'][type + 'token'] + return token + def get_username(self): try: params = { diff --git a/backend/celery/tasks.py b/backend/celery/tasks.py index 86e5a7a..d51fee4 100644 --- a/backend/celery/tasks.py +++ b/backend/celery/tasks.py @@ -39,6 +39,7 @@ for project in preload_projects: obj['wiki'] = wikis[project] watchlistgetter.delay(obj) + notificationgetter.delay(obj) db = MySQLdb.connect( host='centralauth.labsdb', @@ -88,6 +89,7 @@ if result and int(result[0]) >= 1: obj['wiki'] = wiki watchlistgetter.delay(obj) + notificationgetter.delay(obj) db.close() @@ -130,14 +132,14 @@ for item in response['watchlist']: item['project'] = obj['wiki']['dbname'] item['projecturl'] = obj['wiki']['url'] + item['projectgroup'] = obj['wiki']['group'] + item['projectlang'] = obj['wiki']['lang'] + item['projectlangname'] = obj['wiki']['langname'] if 'commenthidden' in item: item['parsedcomment'] = "<s>edit summary removed</s>" item['parsedcomment'] = fix_urls(item['parsedcomment'], obj['wiki']['url']) - item['projectgroup'] = obj['wiki']['group'] - item['projectlang'] = obj['wiki']['lang'] - item['projectlangname'] = obj['wiki']['langname'] if 'bot' in item: item['bot'] = "b" if 'minor' in item: @@ -156,5 +158,68 @@ mw.publish(message) +@app.task +def notificationgetter(obj): + """ + Get the echo notifications for a wiki + :param obj: dict with wiki and connection information + """ + project = obj['wiki']['dbname'] + + # Now, accessing the API on behalf of a user + logger.info("Reading notifications for " + project) + mw = MediaWiki(host=obj['wiki']['url'], + access_token=obj['access_token'], + redis_channel=obj['redis_channel']) + params = { + 'action': "query", + 'meta': "notifications", + 'notprop': "list", + 'notformat': "html", + 'notalertunreadfirst': "", + 'notmessagecontinue': "" + } + response = mw.query(params) + + result = response['query']['notifications']['list'] + if not result: + return + + event = { + 'msgtype': 'notification', + 'project': obj['wiki']['dbname'], + 'projecturl': obj['wiki']['url'], + 'projectgroup': obj['wiki']['group'], + 'projectlang': obj['wiki']['lang'], + 'projectlangname': obj['wiki']['langname'] + } + for item in result.values(): + if 'read' in item: + continue + + event['id'] = item['id'] + # random id + event['uuid'] = uuid4().hex[:8] + + event['comment'] = fix_urls(item['*'], obj['wiki']['url']) + event['timestamp'] = item['timestamp']['utcunix'] + + mw.publish(event) + + +@app.task +def notifications_mark_read(obj): + mw = MediaWiki(access_token=obj['access_token']) + wikis = mw.get_wikis() + params = {'action': "echomarkread"} + + for project, notifications in obj['notifications'].iteritems(): + projecturl = wikis[project]['url'] + mw = MediaWiki(host=projecturl, access_token=obj['access_token']) + + payload = {'list': notifications} + mw.post(params, payload) + + if __name__ == '__main__': app.start() diff --git a/backend/server/__init__.py b/backend/server/__init__.py index 79ee85c..29979d9 100644 --- a/backend/server/__init__.py +++ b/backend/server/__init__.py @@ -56,6 +56,9 @@ data['redis_channel'] = redis_channel celery_app.send_task('backend.celery.tasks.initial_task', (data, ), expires=60) + elif data[u'action'] == u'notifications_mark_read': + celery_app.send_task('backend.celery.tasks.notifications_mark_read', + (data, ), expires=60) class NoChacheStaticFileHandler(StaticFileHandler): diff --git a/frontend/src/app/index.css b/frontend/src/app/index.css index 6434707..626e68f 100644 --- a/frontend/src/app/index.css +++ b/frontend/src/app/index.css @@ -110,12 +110,14 @@ .watchlist-list-item-oneline { min-height: 32px; } + watchlist-entry { overflow: hidden; width: 100%; } -watchlist-entry a{ +watchlist-entry a, +.notifications a{ color:#0645ad; } @@ -123,7 +125,12 @@ min-width: 8em; } -watchlist-entry a.project { +.notifications .left { + min-width: 6em; +} + +watchlist-entry a.project, +.notifications a.project{ color: rgba(0, 0, 0, 0.87); } @@ -135,7 +142,9 @@ outline:none; } -#watchlist:last-child md-divider { +.watchlist:last-child md-divider, +.notifications:last-child md-divider +{ border-top: 0; } @@ -161,6 +170,42 @@ font-weight: bold; } +.mw-echo-notification{ + list-style: none none; +} +.mw-echo-notifications{ + margin:0; + padding:0; + list-style: none none; + overflow:auto; + background-color: rgb(238, 238,238); +} +.mw-echo-notification-wrapper { + display: block; + background-color: rgb(241, 241, 241); + border-bottom: 1px solid rgb(221, 221, 221); + padding: 15px 40px 10px 10px; + white-space: normal; + font-size: 13px; + line-height: 16px; + color: inherit; + text-decoration: inherit; +} +.mw-echo-icon { + width: 30px; + height: 30px; + float: left; + margin-right: 10px; + margin-left: 10px; +} +.mw-echo-content { + font-size: 13px; + font-weight:normal; + line-height: 16px; + overflow: hidden; +} + + /** material design icons **/ @font-face { font-family: 'Material Icons'; diff --git a/frontend/src/app/index.js b/frontend/src/app/index.js index aa7d01a..e839e98 100644 --- a/frontend/src/app/index.js +++ b/frontend/src/app/index.js @@ -51,6 +51,7 @@ function socketFactory (socketFactory, $browser, $location) { var baseHref = $browser.baseHref(); var sockjsUrl = baseHref + 'sockjs'; + if ($location.host() === 'localhost') { // debug – use tools backend when developing sockjsUrl = 'https://tools.wmflabs.org/crosswatch/sockjs' } diff --git a/frontend/src/app/main/main.html b/frontend/src/app/main/main.html index cac7489..42ff85a 100644 --- a/frontend/src/app/main/main.html +++ b/frontend/src/app/main/main.html @@ -1,6 +1,7 @@ <header ng-include="'components/navbar/navbar.html'"></header> <div layout="column" flex class="md-padding" role="main"> <div ng-include="'components/settings/settings.html'"></div> + <div ng-include="'components/notifications/notifications.html'"></div> <div ng-include="'components/watchlist/watchlist.html'"></div> </div> diff --git a/frontend/src/app/runBlock.js b/frontend/src/app/runBlock.js index 0965138..f52f1de 100644 --- a/frontend/src/app/runBlock.js +++ b/frontend/src/app/runBlock.js @@ -56,6 +56,8 @@ var data = angular.fromJson(msg.data); if (data.msgtype === 'watchlist') { dataService.addWatchlistEntries(data.entires); + } else if (data.msgtype === 'notification') { + dataService.addNotificationEntries(data) } else if (data.msgtype === 'loginerror') { $log.error('login failed!'); diff --git a/frontend/src/app/services.js b/frontend/src/app/services.js index bcb7c83..8fcd7eb 100644 --- a/frontend/src/app/services.js +++ b/frontend/src/app/services.js @@ -50,6 +50,12 @@ vm.watchlist.loading = true; /** + * Array that contains new echo notifications. + * @type {Array} + */ + vm.notifications = []; + + /** * Initialize user settings */ vm.defaultconfig = { @@ -163,6 +169,35 @@ }; /** + * Process a new echo entry. + * @param entry + */ + vm.addNotificationEntries = function (entry) { + vm.notifications.push(entry); + }; + + /** + * Mark all notifications as read. + */ + vm.markNotificationsRead = function () { + var notifications = {}; + for (var i=0; i<vm.notifications.length; i++) { + var not = vm.notifications[i]; + if (!notifications.hasOwnProperty(not.project)) { + notifications[not.project] = []; + } + + notifications[not.project].push(not.id); + } + var query = { + action: 'notifications_mark_read', + access_token: authService.tokens(), + notifications: notifications + }; + socket.send(angular.toJson(query)); + }; + + /** * Save user config to local storage */ vm.saveConfig = function() { @@ -193,6 +228,34 @@ } } }; + + vm.icons = {}; + vm.icons['wikibooks'] = "//upload.wikimedia.org/wikipedia/commons/f/fa/Wikibooks-logo.svg"; + vm.icons['wiktionary'] = "//upload.wikimedia.org/wikipedia/commons/e/ef/Wikitionary.svg"; + vm.icons['wikiquote'] = "//upload.wikimedia.org/wikipedia/commons/f/fa/Wikiquote-logo.svg"; + vm.icons['wikipedia'] = "//upload.wikimedia.org/wikipedia/commons/8/80/Wikipedia-logo-v2.svg"; + vm.icons['wikinews'] = "//upload.wikimedia.org/wikipedia/commons/2/24/Wikinews-logo.svg"; + vm.icons['wikivoyage'] = "//upload.wikimedia.org/wikipedia/commons/8/8a/Wikivoyage-logo.svg"; + vm.icons['wikisource'] = "//upload.wikimedia.org/wikipedia/commons/4/4c/Wikisource-logo.svg"; + vm.icons['wikiversity'] = "//upload.wikimedia.org/wikipedia/commons/9/91/Wikiversity-logo.svg"; + vm.icons['foundation'] = "//upload.wikimedia.org/wikipedia/commons/c/c4/Wikimedia_Foundation_RGB_logo_with_text.svg"; + vm.icons['mediawiki'] = "//upload.wikimedia.org/wikipedia/commons/3/3d/Mediawiki-logo.png"; + vm.icons['meta'] = "//upload.wikimedia.org/wikipedia/commons/7/75/Wikimedia_Community_Logo.svg"; + vm.icons['wikidata'] = "//upload.wikimedia.org/wikipedia/commons/f/ff/Wikidata-logo.svg"; + vm.icons['commons'] = "//upload.wikimedia.org/wikipedia/commons/4/4a/Commons-logo.svg"; + vm.icons['species'] = "//upload.wikimedia.org/wikipedia/en/b/bf/Wikispecies-logo-35px.png"; + vm.icons['incubator'] = "//upload.wikimedia.org/wikipedia/commons/e/e3/Incubator-logo.svg"; + vm.icons['test'] = "//upload.wikimedia.org/wikipedia/commons/4/4a/Wikipedia_logo_v2_%28black%29.svg"; + + vm.flags = ["ad", "ae", "af", "ag", "ai", "al", "am", "an", "ao", "ar", "as", "at", "au", "aw", "ax", "az", "ba", "bb", "bd", "be", "bf", "bg", "bh", "bi", "bj", "bm", "bn", "bo", "br", "bs", "bt", "bv", "bw", "by", "bz", "ca", "cc", "cd", "cf", "cg", "ch", "ci", "ck", "cl", "cm", "cn", "co", "cr", "cs", "cu", "cv", "cx", "cy", "cz", "da", "de", "dj", "dk", "dm", "do", "dz", "ec", "ee", "eg", "eh", "en", "er", "es", "et", "fam", "fi", "fj", "fk", "fm", "fo", "fr", "ga", "gb", "gd", "ge", "gf", "gh", "gi", "gl", "gm", "gn", "gp", "gq", "gr", "gs", "gt", "gu", "gw", "gy", "he", "hk", "hm", "hn", "hr", "ht", "hu", "id", "ie", "il", "in", "io", "iq", "ir", "is", "it", "jm", "jo", "jp", "ke", "kg", "kh", "ki", "km", "kn", "kp", "kr", "kw", "ky", "kz", "la", "lb", "lc", "li", "lk", "lr", "ls", "lt", "lu", "lv", "ly", "ma", "mc", "md", "me", "mg", "mh", "mk", "ml", "mm", "mn", "mo", "mp", "mq", "mr", "ms", "mt", "mu", "mv", "mw", "mx", "my", "mz", "na", "nc", "ne", "nf", "ng", "ni", "nl", "no", "np", "nr", "nu", "nz", "om", "pa", "pe", "pf", "pg", "ph", "pk", "pl", "pm", "pn", "pr", "ps", "pt", "pw", "py", "qa", "re", "ro", "rs", "ru", "rw", "sa", "sb", "scotland", "sc", "sd", "se", "sg", "sh", "si", "sj", "sk", "sl", "sm", "sn", "so", "sr", "st", "sv", "sy", "sz", "tc", "td", "tf", "tg", "th", "tj", "tk", "tl", "tm", "tn", "to", "tr", "tt", "tv", "tw", "tz", "ua", "ug", "um", "us", "uy", "uz", "va", "vc", "ve", "vg", "vi", "vn", "vu", "wales", "wf", "ws", "ye", "yt", "za", "zh", "zm", "zw"]; + + vm.flagurl = function (lang) { + if (vm.flags.indexOf(lang) >= 0) { + return "assets/images/flags/png/" + lang + ".png"; + } else { + return false; + } + }; } // Debounce function diff --git a/frontend/src/components/notifications/notifications.controller.js b/frontend/src/components/notifications/notifications.controller.js new file mode 100644 index 0000000..c280f77 --- /dev/null +++ b/frontend/src/components/notifications/notifications.controller.js @@ -0,0 +1,11 @@ +'use strict'; + +angular.module('crosswatch') + .controller('NotificationsCtrl', function ($translate, $log, dataService) { + var vm = this; + vm.icons = dataService.icons; + vm.flagurl = dataService.flagurl; + vm.notifications = dataService.notifications; + vm.config = dataService.config; + vm.markNotificationsRead = dataService.markNotificationsRead; + }); diff --git a/frontend/src/components/notifications/notifications.html b/frontend/src/components/notifications/notifications.html new file mode 100644 index 0000000..012acba --- /dev/null +++ b/frontend/src/components/notifications/notifications.html @@ -0,0 +1,32 @@ +<div ng-controller="NotificationsCtrl as ctrl" layout="row" layout-align="center center" class="md-padding"> + <md-whiteframe class="md-whiteframe-z5 whitebox" ng-if="ctrl.notifications.length"> + <md-toolbar class='md-small-tall'> + <div class="md-toolbar-tools"> + <h1> + <span translate="NOTIFICATIONS"></span> + </h1> + <div flex></div> + <md-button class="md-raised md-accent" translate="MARKALLREAD" + ng-click="read = !read; ctrl.markNotificationsRead()" ng-disabled="read"> + </md-button> + </div> + </md-toolbar> + <md-list> + <md-list-item layout="row" class="notifications" style="padding: 6px 16px;" + ng-repeat="event in ctrl.notifications | orderBy:'-timestamp' track by event.uuid"> + <div class="left"> + <a href="{{::event.projecturl}}/wiki/Special:Notifications" class="project" + title="{{::event.project}}" target="_blank"> + <span ng-if="::event.projectlang"> + <span ng-if="!ctrl.config.flagsenable || !ctrl.flagurl(event.projectlang)">{{::event.projectlangname}} </span> + <img ng-if="ctrl.config.flagsenable && ctrl.flagurl(event.projectlang)" height="12px" ng-src="{{::ctrl.flagurl(event.projectlang)}}"> + </span> + <img height="16px" ng-src="{{::ctrl.icons[event.projectgroup]}}" alt="{{::event.projectgroup}}"> + </a> + </div> + <div ng-bind-html="::event.comment"></div> + <md-divider></md-divider> + </md-list-item> + </md-list> + </md-whiteframe> +</div> diff --git a/frontend/src/components/watchlist/watchlist.controller.js b/frontend/src/components/watchlist/watchlist.controller.js index 4461c87..0e967de 100644 --- a/frontend/src/components/watchlist/watchlist.controller.js +++ b/frontend/src/components/watchlist/watchlist.controller.js @@ -1,41 +1,15 @@ 'use strict'; angular.module('crosswatch') - .controller('WatchlistCtrl', function ($translate, socket, $log, dataService, $rootScope, $timeout, $scope) { + .controller('WatchlistCtrl', function ($log, dataService) { var vm = this; + vm.icons = dataService.icons; + vm.flagurl = dataService.flagurl; vm.watchlist = dataService.watchlist; vm.config = dataService.config; vm.moreWatchlistEntries = dataService.moreWatchlistEntries; vm.search = function (text) { dataService.filterWatchlist(text); - }; - - vm.icons = {}; - vm.icons['wikibooks'] = "//upload.wikimedia.org/wikipedia/commons/f/fa/Wikibooks-logo.svg"; - vm.icons['wiktionary'] = "//upload.wikimedia.org/wikipedia/commons/e/ef/Wikitionary.svg"; - vm.icons['wikiquote'] = "//upload.wikimedia.org/wikipedia/commons/f/fa/Wikiquote-logo.svg"; - vm.icons['wikipedia'] = "//upload.wikimedia.org/wikipedia/commons/8/80/Wikipedia-logo-v2.svg"; - vm.icons['wikinews'] = "//upload.wikimedia.org/wikipedia/commons/2/24/Wikinews-logo.svg"; - vm.icons['wikivoyage'] = "//upload.wikimedia.org/wikipedia/commons/8/8a/Wikivoyage-logo.svg"; - vm.icons['wikisource'] = "//upload.wikimedia.org/wikipedia/commons/4/4c/Wikisource-logo.svg"; - vm.icons['wikiversity'] = "//upload.wikimedia.org/wikipedia/commons/9/91/Wikiversity-logo.svg"; - vm.icons['foundation'] = "//upload.wikimedia.org/wikipedia/commons/c/c4/Wikimedia_Foundation_RGB_logo_with_text.svg"; - vm.icons['mediawiki'] = "//upload.wikimedia.org/wikipedia/commons/3/3d/Mediawiki-logo.png"; - vm.icons['meta'] = "//upload.wikimedia.org/wikipedia/commons/7/75/Wikimedia_Community_Logo.svg"; - vm.icons['wikidata'] = "//upload.wikimedia.org/wikipedia/commons/f/ff/Wikidata-logo.svg"; - vm.icons['commons'] = "//upload.wikimedia.org/wikipedia/commons/4/4a/Commons-logo.svg"; - vm.icons['species'] = "//upload.wikimedia.org/wikipedia/en/b/bf/Wikispecies-logo-35px.png"; - vm.icons['incubator'] = "//upload.wikimedia.org/wikipedia/commons/e/e3/Incubator-logo.svg"; - vm.icons['test'] = "//upload.wikimedia.org/wikipedia/commons/4/4a/Wikipedia_logo_v2_%28black%29.svg"; - - vm.flags = ["ad", "ae", "af", "ag", "ai", "al", "am", "an", "ao", "ar", "as", "at", "au", "aw", "ax", "az", "ba", "bb", "bd", "be", "bf", "bg", "bh", "bi", "bj", "bm", "bn", "bo", "br", "bs", "bt", "bv", "bw", "by", "bz", "ca", "cc", "cd", "cf", "cg", "ch", "ci", "ck", "cl", "cm", "cn", "co", "cr", "cs", "cu", "cv", "cx", "cy", "cz", "da", "de", "dj", "dk", "dm", "do", "dz", "ec", "ee", "eg", "eh", "en", "er", "es", "et", "fam", "fi", "fj", "fk", "fm", "fo", "fr", "ga", "gb", "gd", "ge", "gf", "gh", "gi", "gl", "gm", "gn", "gp", "gq", "gr", "gs", "gt", "gu", "gw", "gy", "he", "hk", "hm", "hn", "hr", "ht", "hu", "id", "ie", "il", "in", "io", "iq", "ir", "is", "it", "jm", "jo", "jp", "ke", "kg", "kh", "ki", "km", "kn", "kp", "kr", "kw", "ky", "kz", "la", "lb", "lc", "li", "lk", "lr", "ls", "lt", "lu", "lv", "ly", "ma", "mc", "md", "me", "mg", "mh", "mk", "ml", "mm", "mn", "mo", "mp", "mq", "mr", "ms", "mt", "mu", "mv", "mw", "mx", "my", "mz", "na", "nc", "ne", "nf", "ng", "ni", "nl", "no", "np", "nr", "nu", "nz", "om", "pa", "pe", "pf", "pg", "ph", "pk", "pl", "pm", "pn", "pr", "ps", "pt", "pw", "py", "qa", "re", "ro", "rs", "ru", "rw", "sa", "sb", "scotland", "sc", "sd", "se", "sg", "sh", "si", "sj", "sk", "sl", "sm", "sn", "so", "sr", "st", "sv", "sy", "sz", "tc", "td", "tf", "tg", "th", "tj", "tk", "tl", "tm", "tn", "to", "tr", "tt", "tv", "tw", "tz", "ua", "ug", "um", "us", "uy", "uz", "va", "vc", "ve", "vg", "vi", "vn", "vu", "wales", "wf", "ws", "ye", "yt", "za", "zh", "zm", "zw"]; - - vm.flagurl = function (lang) { - if (vm.flags.indexOf(lang) >= 0) { - return "assets/images/flags/png/" + lang + ".png"; - } else { - return false; - } }; }); diff --git a/frontend/src/components/watchlist/watchlist.html b/frontend/src/components/watchlist/watchlist.html index 2d164d5..0380ad8 100644 --- a/frontend/src/components/watchlist/watchlist.html +++ b/frontend/src/components/watchlist/watchlist.html @@ -24,7 +24,8 @@ </md-list> <md-list infinite-scroll="ctrl.moreWatchlistEntries()" infinite-scroll-immediate-check="false" infinite-scroll-distance="1"> - <md-list-item layout="row" id="watchlist" ng-class="(ctrl.config.oneline) ? 'watchlist-list-item-oneline' : ''" + <md-list-item layout="row" id="watchlist" class="watchlist" + ng-class="(ctrl.config.oneline) ? 'watchlist-list-item-oneline' : ''" ng-repeat="event in ctrl.watchlist.active track by event.id"> <watchlist-entry ng-click="event.clicked = !event.clicked" md-ink-ripple></watchlist-entry> <md-divider></md-divider> diff --git a/frontend/src/i18n/locale-en.json b/frontend/src/i18n/locale-en.json index 63b8ce4..6b6b241 100644 --- a/frontend/src/i18n/locale-en.json +++ b/frontend/src/i18n/locale-en.json @@ -66,5 +66,7 @@ "NS_OTHER": "Other namespaces", "HISTORY": "history", "CONTRIBS": "contribs", - "ONELINE": "Traditional watchlist layout" + "ONELINE": "Traditional watchlist layout", + "NOTIFICATIONS": "Notifications", + "MARKALLREAD": "mark all as read" } -- To view, visit https://gerrit.wikimedia.org/r/222222 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: newchange Gerrit-Change-Id: Ifb06262bfc6916884301e028675f84daee7085be Gerrit-PatchSet: 1 Gerrit-Project: labs/tools/crosswatch Gerrit-Branch: master Gerrit-Owner: Sitic <jan.leb...@online.de> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits